From 68d6f69d373bce3e9db5ad656b62b473ffd40b02 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 6 Sep 2026 17:32:48 +0200 Subject: [PATCH] [SQUASHED] edit-diff-line-with-modified-click --- README.md | 4 +- docs-master/Config.md | 19 +- docs-master/dev/Codebase_Guide.md | 1 - docs-master/keybindings/Keybindings_en.md | 60 +- docs-master/keybindings/Keybindings_ja.md | 74 +- docs-master/keybindings/Keybindings_ko.md | 60 +- docs-master/keybindings/Keybindings_nl.md | 60 +- docs-master/keybindings/Keybindings_pl.md | 74 +- docs-master/keybindings/Keybindings_pt.md | 60 +- docs-master/keybindings/Keybindings_ru.md | 62 +- docs-master/keybindings/Keybindings_zh-CN.md | 74 +- docs-master/keybindings/Keybindings_zh-TW.md | 74 +- pkg/app/entry_point.go | 3 - pkg/cheatsheet/generate.go | 7 - pkg/commands/git.go | 11 +- pkg/commands/git_commands/commit.go | 6 +- pkg/commands/git_commands/commit_test.go | 2 +- pkg/commands/git_commands/diff.go | 145 ++- pkg/commands/git_commands/diff_mode.go | 35 + .../git_commands/git_command_builder.go | 15 +- pkg/commands/git_commands/patch.go | 85 ++ pkg/commands/git_commands/stash.go | 6 +- pkg/commands/git_commands/stash_test.go | 2 +- pkg/commands/git_commands/working_tree.go | 30 +- .../git_commands/working_tree_test.go | 30 +- pkg/commands/patch/hunk.go | 5 + pkg/commands/patch/parse.go | 37 +- pkg/commands/patch/patch.go | 83 +- pkg/commands/patch/patch_builder.go | 220 ++++- pkg/commands/patch/patch_builder_test.go | 140 +++ pkg/commands/patch/patch_test.go | 217 +++-- pkg/config/app_config.go | 11 +- pkg/config/app_config_test.go | 16 + pkg/config/user_config.go | 18 +- pkg/config/user_config_validation.go | 4 - pkg/config/user_config_validation_test.go | 4 + pkg/gocui/block_events_test.go | 54 +- pkg/gocui/escape.go | 117 ++- pkg/gocui/escape_test.go | 1 + pkg/gocui/flush_test.go | 27 +- pkg/gocui/gui.go | 213 +++-- pkg/gocui/suspend_test.go | 13 +- pkg/gocui/tcell_driver.go | 14 +- pkg/gocui/tcell_driver_test.go | 40 +- pkg/gocui/view.go | 529 +++++++++- pkg/gocui/view_test.go | 320 +++++++ pkg/gui/context.go | 29 +- pkg/gui/context/base_context.go | 47 +- pkg/gui/context/commit_files_context.go | 23 +- pkg/gui/context/context.go | 100 +- pkg/gui/context/local_commits_context.go | 11 +- pkg/gui/context/main_context.go | 69 +- pkg/gui/context/patch_explorer_context.go | 154 --- pkg/gui/context/reflog_commits_context.go | 9 +- pkg/gui/context/setup.go | 42 - pkg/gui/context/stash_context.go | 9 +- pkg/gui/context/sub_commits_context.go | 11 +- pkg/gui/context/working_tree_context.go | 9 +- pkg/gui/controllers.go | 43 +- pkg/gui/controllers/attach.go | 2 +- pkg/gui/controllers/base_controller.go | 4 +- pkg/gui/controllers/commit_diff_actions.go | 467 +++++++++ .../controllers/commits_files_controller.go | 76 +- .../controllers/context_lines_controller.go | 33 +- .../custom_patch_options_menu_action.go | 20 +- pkg/gui/controllers/diff_copy.go | 43 + pkg/gui/controllers/files_controller.go | 89 +- pkg/gui/controllers/global_controller.go | 4 + .../controllers/helpers/app_status_helper.go | 28 +- .../helpers/custom_patch_helper.go | 25 + pkg/gui/controllers/helpers/diff_helper.go | 39 +- .../controllers/helpers/diff_line_helper.go | 175 ++++ .../controllers/helpers/diff_line_parser.go | 316 ++++++ .../helpers/diff_line_parser_test.go | 293 ++++++ .../helpers/diff_line_plain_text.go | 99 ++ .../controllers/helpers/diff_line_queries.go | 457 +++++++++ .../helpers/diff_line_queries_test.go | 104 ++ .../helpers/diff_line_raw_fallback.go | 109 +++ .../controllers/helpers/diff_line_restore.go | 607 ++++++++++++ .../helpers/diff_line_selection.go | 200 ++++ pkg/gui/controllers/helpers/files_helper.go | 12 +- pkg/gui/controllers/helpers/helpers.go | 8 +- pkg/gui/controllers/helpers/mode_helper.go | 10 +- .../helpers/patch_building_helper.go | 115 --- pkg/gui/controllers/helpers/refresh_helper.go | 62 +- pkg/gui/controllers/helpers/refs_helper.go | 1 - pkg/gui/controllers/helpers/staging_helper.go | 127 --- .../helpers/window_arrangement_helper.go | 51 +- .../helpers/window_arrangement_helper_test.go | 2 +- .../controllers/local_commits_controller.go | 29 +- pkg/gui/controllers/main_view_controller.go | 901 +++++++++++++++++- pkg/gui/controllers/options_menu_action.go | 2 +- .../controllers/patch_building_controller.go | 277 ------ .../controllers/patch_explorer_controller.go | 416 -------- .../controllers/reflog_commits_controller.go | 28 +- pkg/gui/controllers/staging_controller.go | 358 ------- pkg/gui/controllers/stash_controller.go | 7 +- pkg/gui/controllers/sub_commits_controller.go | 1 + pkg/gui/controllers/submodules_controller.go | 4 +- .../switch_to_diff_files_controller.go | 49 +- .../switch_to_focused_main_view_controller.go | 46 +- .../controllers/toggle_whitespace_action.go | 26 +- .../controllers/view_selection_controller.go | 100 -- .../controllers/working_tree_diff_actions.go | 394 ++++++++ pkg/gui/global_handlers.go | 25 +- pkg/gui/gui.go | 14 +- pkg/gui/gui_common.go | 6 +- pkg/gui/main_panels.go | 402 +++++++- pkg/gui/options_map.go | 6 +- pkg/gui/patch_exploring/focus.go | 47 - pkg/gui/patch_exploring/focus_test.go | 129 --- pkg/gui/patch_exploring/state.go | 438 --------- pkg/gui/presentation/files_test.go | 3 +- pkg/gui/pty.go | 17 +- pkg/gui/tasks_adapter.go | 41 +- pkg/gui/types/common.go | 8 +- pkg/gui/types/context.go | 124 ++- pkg/gui/types/diff_line_info.go | 62 ++ pkg/gui/types/diff_select.go | 28 + pkg/gui/types/keybindings.go | 12 + pkg/gui/types/refresh.go | 2 - pkg/gui/types/rendering.go | 38 +- pkg/gui/types/views.go | 10 +- pkg/gui/view_helpers.go | 10 +- pkg/gui/views.go | 41 +- pkg/i18n/english.go | 51 +- pkg/integration/components/view_driver.go | 70 +- pkg/integration/components/views.go | 22 - .../tests/commit/discard_old_file_changes.go | 2 +- .../tests/commit/stage_range_of_lines.go | 7 +- pkg/integration/tests/commit/staged.go | 25 +- .../tests/commit/staged_without_hooks.go | 15 +- pkg/integration/tests/commit/unstaged.go | 16 +- .../conflicts/resolve_without_trailing_lf.go | 2 +- pkg/integration/tests/demo/custom_patch.go | 4 +- pkg/integration/tests/demo/stage_lines.go | 2 +- .../tests/diff/diff_and_apply_patch.go | 3 +- .../tests/diff/diff_renderer_metadata.go | 50 + .../file/directory_diff_with_renamed_files.go | 6 +- .../pane_shown_again_starts_at_the_top.go | 61 ++ .../pane_taking_over_starts_at_the_top.go | 85 ++ .../rename_similarity_threshold_change.go | 2 +- .../file/staged_changes_in_lower_pane.go | 81 ++ .../tests/filter_and_search/nested_filter.go | 14 +- ...vance_after_staging_shifts_line_numbers.go | 45 + .../apply_custom_patch.go} | 22 +- .../apply_custom_patch_in_reverse.go} | 22 +- ..._custom_patch_in_reverse_with_conflict.go} | 42 +- .../apply_custom_patch_with_modified_file.go} | 21 +- ...stom_patch_with_modified_file_conflict.go} | 19 +- .../build_patch_from_a_commits_diff.go | 104 ++ .../build_patch_from_a_reflog_entry.go | 54 ++ .../build_patch_from_a_whole_commits_diff.go | 75 ++ .../build_patch_with_mixed_selections.go | 114 +++ ...hange_context_size_while_building_patch.go | 54 ++ .../change_screen_mode_in_focused_diff.go} | 26 +- .../main_view/click_selects_diff_line.go | 40 + .../tests/main_view/commit_from_main_view.go | 67 ++ .../main_view/copy_selected_diff_lines.go | 93 ++ ...om_patch_goes_through_the_diff_renderer.go | 50 + .../discard_all_changes.go | 27 +- .../tests/main_view/discard_diff_lines.go | 70 ++ ...a_commit_only_where_it_can_be_rewritten.go | 63 ++ ...discard_line_from_added_file_in_commit.go} | 38 +- .../main_view/discard_lines_from_a_commit.go | 62 ++ .../main_view/drag_range_with_autoscroll.go | 43 + .../main_view/drag_selects_diff_line_range.go | 55 ++ .../edit_historical_diff_line.go} | 11 +- .../main_view/edit_hunk_in_focused_diff.go | 59 ++ .../main_view/edit_selected_diff_line.go | 45 + .../enter_and_double_click_focus_file_diff.go | 69 ++ .../main_view/escape_dismisses_selection.go | 59 ++ ...cus_follows_a_pane_emptied_from_outside.go | 52 + .../focus_follows_into_a_pane_taking_over.go | 74 ++ .../main_view/focus_follows_staged_side.go | 54 ++ .../main_view/focus_follows_when_pane_goes.go | 47 + ...focus_leaves_an_always_split_empty_pane.go | 53 ++ .../focus_returns_when_split_collapses.go | 56 ++ .../hide_selection_when_changes_vanish.go | 47 + ..._wrapped_line_covered_across_a_rerender.go | 60 ++ .../keep_both_halves_of_a_change_selected.go | 68 ++ ...ition_by_the_visible_end_of_a_selection.go | 102 ++ ...n_both_panes_when_changing_context_size.go | 71 ++ ..._in_both_panes_when_ignoring_whitespace.go | 70 ++ ...oth_panes_when_switching_diff_renderers.go | 78 ++ ...eep_position_when_changing_context_size.go | 97 ++ .../keep_position_when_ignoring_whitespace.go | 85 ++ ...ion_when_ignoring_whitespace_removes_it.go | 81 ++ ..._position_when_switching_diff_renderers.go | 78 ++ ...sition_when_the_selection_is_off_screen.go | 65 ++ .../keep_scroll_when_the_diff_cant_be_read.go | 58 ++ ...lected_range_when_changing_context_size.go | 123 +++ .../keep_selection_after_moving_patch_out.go | 75 ++ ...eep_selection_visible_when_diff_shrinks.go | 59 ++ .../move_multi_file_range_to_index.go} | 41 +- ..._part_of_adjacent_added_lines_to_index.go} | 32 +- .../main_view/move_partial_patch_to_index.go | 84 ++ .../move_partial_patch_to_later_commit.go} | 35 +- .../move_partial_patch_to_new_commit.go} | 29 +- ...atch_from_added_file_to_earlier_commit.go} | 27 +- ...from_added_file_to_index_with_conflict.go} | 18 +- ...ve_patch_from_added_file_to_new_commit.go} | 17 +- ..._patch_from_deleted_file_to_new_commit.go} | 18 +- .../move_patch_to_earlier_commit.go} | 25 +- .../move_patch_to_index.go} | 42 +- .../move_patch_to_index_with_conflict.go} | 17 +- ...patch_to_index_with_custom_diff_config.go} | 24 +- ...move_patch_to_index_with_modified_file.go} | 25 +- .../move_patch_to_later_commit.go} | 25 +- .../move_patch_to_new_commit.go} | 26 +- .../move_patch_to_new_commit_before.go} | 22 +- ..._patch_to_new_commit_in_stacked_branch.go} | 17 +- .../main_view/navigate_by_hunk_and_file.go | 88 ++ .../no_selection_over_a_binary_diff.go | 56 ++ .../no_selection_over_a_commit_log.go | 48 + .../main_view/no_selection_when_no_changes.go | 31 + .../patch_marks_follow_a_renderer_switch.go | 58 ++ ...ch_marks_show_while_the_diff_is_focused.go | 61 ++ .../main_view/range_select_diff_lines.go | 59 ++ .../raw_fallback_under_an_external_diff.go | 63 ++ ...emove_content_change_from_renamed_file.go} | 66 +- .../remove_lines_from_the_custom_patch.go | 90 ++ .../remove_part_of_added_file_from_commit.go} | 33 +- .../remove_patch_from_commit.go} | 35 +- ...reset_a_patch_built_from_a_commits_diff.go | 49 + .../main_view/reset_patch_with_escape.go | 49 + ...eset_the_patch_from_the_pane_showing_it.go | 49 + .../search_collapses_the_selection.go | 51 + .../main_view/search_follows_the_selection.go | 44 + .../select_below_a_long_commit_message.go | 64 ++ .../main_view/select_below_a_long_diffstat.go | 61 ++ .../tests/main_view/select_diff_lines.go | 54 ++ .../select_hunk_below_last_change.go | 37 + .../tests/main_view/select_hunk_in_diff.go | 59 ++ .../select_hunk_on_focusing_main_view.go | 67 ++ .../select_in_a_diff_read_in_part.go | 36 + ...select_line_when_whole_file_is_one_hunk.go | 43 + .../select_next_change_after_staging.go | 54 ++ .../select_next_change_after_unstaging.go | 45 + .../select_next_deletion_after_staging_one.go | 43 + ...ct_visible_change_on_focusing_main_view.go | 77 ++ ...lect_visible_hunk_on_focusing_main_view.go | 93 ++ ...ection_command_tooltips_follow_the_diff.go | 62 ++ ...election_commands_only_where_they_apply.go | 58 ++ .../selection_over_the_custom_patch.go | 47 + .../tests/main_view/stage_deleted_file.go | 80 ++ .../tests/main_view/stage_diff_lines.go | 70 ++ .../stage_hunks_with_rapid_keypresses.go | 54 ++ ...ge_partial_block_of_changes_first_lines.go | 8 +- ...age_partial_block_of_changes_last_lines.go | 8 +- ...e_partial_block_of_changes_middle_lines.go | 8 +- .../main_view/stage_range_spanning_files.go | 55 ++ .../stage_under_conforming_diff_renderer.go | 55 ++ .../stage_under_unsupported_diff_renderer.go | 71 ++ .../start_patch_from_another_commit.go | 71 ++ .../tests/main_view/unstage_diff_lines.go | 57 ++ .../patch_building/move_to_index_partial.go | 96 -- .../patch_building/renamed_file_whole.go | 11 +- .../tests/patch_building/reset_with_escape.go | 43 - .../patch_building/specific_selection.go | 159 ---- .../tests/patch_building/start_new_patch.go | 62 -- .../tests/staging/diff_context_change.go | 123 --- pkg/integration/tests/staging/search.go | 42 - ...ext_line_after_staging_in_two_hunk_diff.go | 61 -- ..._line_after_staging_isolated_added_line.go | 51 - pkg/integration/tests/staging/stage_hunks.go | 120 --- .../stage_hunks_with_rapid_keypresses.go | 50 - pkg/integration/tests/staging/stage_lines.go | 122 --- pkg/integration/tests/staging/stage_ranges.go | 108 --- .../tests/stash/stash_staged_partial_file.go | 5 +- pkg/integration/tests/submodule/add.go | 4 +- pkg/integration/tests/submodule/remove.go | 2 +- .../tests/submodule/remove_nested.go | 2 +- pkg/integration/tests/test_list.go | 162 +++- pkg/integration/tests/ui/range_select.go | 21 +- .../tests/ui/range_select_with_autoscroll.go | 12 +- pkg/tasks/tasks.go | 232 ++++- pkg/tasks/tasks_test.go | 269 ++++++ schema-master/config.json | 38 +- 279 files changed, 14374 insertions(+), 5149 deletions(-) create mode 100644 pkg/commands/git_commands/diff_mode.go create mode 100644 pkg/commands/patch/patch_builder_test.go delete mode 100644 pkg/gui/context/patch_explorer_context.go create mode 100644 pkg/gui/controllers/commit_diff_actions.go create mode 100644 pkg/gui/controllers/diff_copy.go create mode 100644 pkg/gui/controllers/helpers/custom_patch_helper.go create mode 100644 pkg/gui/controllers/helpers/diff_line_helper.go create mode 100644 pkg/gui/controllers/helpers/diff_line_parser.go create mode 100644 pkg/gui/controllers/helpers/diff_line_parser_test.go create mode 100644 pkg/gui/controllers/helpers/diff_line_plain_text.go create mode 100644 pkg/gui/controllers/helpers/diff_line_queries.go create mode 100644 pkg/gui/controllers/helpers/diff_line_queries_test.go create mode 100644 pkg/gui/controllers/helpers/diff_line_raw_fallback.go create mode 100644 pkg/gui/controllers/helpers/diff_line_restore.go create mode 100644 pkg/gui/controllers/helpers/diff_line_selection.go delete mode 100644 pkg/gui/controllers/helpers/patch_building_helper.go delete mode 100644 pkg/gui/controllers/helpers/staging_helper.go delete mode 100644 pkg/gui/controllers/patch_building_controller.go delete mode 100644 pkg/gui/controllers/patch_explorer_controller.go delete mode 100644 pkg/gui/controllers/staging_controller.go delete mode 100644 pkg/gui/controllers/view_selection_controller.go create mode 100644 pkg/gui/controllers/working_tree_diff_actions.go delete mode 100644 pkg/gui/patch_exploring/focus.go delete mode 100644 pkg/gui/patch_exploring/focus_test.go delete mode 100644 pkg/gui/patch_exploring/state.go create mode 100644 pkg/gui/types/diff_line_info.go create mode 100644 pkg/gui/types/diff_select.go create mode 100644 pkg/integration/tests/diff/diff_renderer_metadata.go create mode 100644 pkg/integration/tests/file/pane_shown_again_starts_at_the_top.go create mode 100644 pkg/integration/tests/file/pane_taking_over_starts_at_the_top.go create mode 100644 pkg/integration/tests/file/staged_changes_in_lower_pane.go create mode 100644 pkg/integration/tests/main_view/advance_after_staging_shifts_line_numbers.go rename pkg/integration/tests/{patch_building/apply.go => main_view/apply_custom_patch.go} (74%) rename pkg/integration/tests/{patch_building/apply_in_reverse.go => main_view/apply_custom_patch_in_reverse.go} (69%) rename pkg/integration/tests/{patch_building/apply_in_reverse_with_conflict.go => main_view/apply_custom_patch_in_reverse_with_conflict.go} (70%) rename pkg/integration/tests/{patch_building/apply_with_modified_file_no_conflict.go => main_view/apply_custom_patch_with_modified_file.go} (76%) rename pkg/integration/tests/{patch_building/apply_with_modified_file_conflict.go => main_view/apply_custom_patch_with_modified_file_conflict.go} (78%) create mode 100644 pkg/integration/tests/main_view/build_patch_from_a_commits_diff.go create mode 100644 pkg/integration/tests/main_view/build_patch_from_a_reflog_entry.go create mode 100644 pkg/integration/tests/main_view/build_patch_from_a_whole_commits_diff.go create mode 100644 pkg/integration/tests/main_view/build_patch_with_mixed_selections.go create mode 100644 pkg/integration/tests/main_view/change_context_size_while_building_patch.go rename pkg/integration/tests/{staging/diff_change_screen_mode.go => main_view/change_screen_mode_in_focused_diff.go} (58%) create mode 100644 pkg/integration/tests/main_view/click_selects_diff_line.go create mode 100644 pkg/integration/tests/main_view/commit_from_main_view.go create mode 100644 pkg/integration/tests/main_view/copy_selected_diff_lines.go create mode 100644 pkg/integration/tests/main_view/custom_patch_goes_through_the_diff_renderer.go rename pkg/integration/tests/{staging => main_view}/discard_all_changes.go (64%) create mode 100644 pkg/integration/tests/main_view/discard_diff_lines.go create mode 100644 pkg/integration/tests/main_view/discard_from_a_commit_only_where_it_can_be_rewritten.go rename pkg/integration/tests/{patch_building/discard_lines_from_commit.go => main_view/discard_line_from_added_file_in_commit.go} (55%) create mode 100644 pkg/integration/tests/main_view/discard_lines_from_a_commit.go create mode 100644 pkg/integration/tests/main_view/drag_range_with_autoscroll.go create mode 100644 pkg/integration/tests/main_view/drag_selects_diff_line_range.go rename pkg/integration/tests/{patch_building/edit_line_in_patch_building_panel.go => main_view/edit_historical_diff_line.go} (78%) create mode 100644 pkg/integration/tests/main_view/edit_hunk_in_focused_diff.go create mode 100644 pkg/integration/tests/main_view/edit_selected_diff_line.go create mode 100644 pkg/integration/tests/main_view/enter_and_double_click_focus_file_diff.go create mode 100644 pkg/integration/tests/main_view/escape_dismisses_selection.go create mode 100644 pkg/integration/tests/main_view/focus_follows_a_pane_emptied_from_outside.go create mode 100644 pkg/integration/tests/main_view/focus_follows_into_a_pane_taking_over.go create mode 100644 pkg/integration/tests/main_view/focus_follows_staged_side.go create mode 100644 pkg/integration/tests/main_view/focus_follows_when_pane_goes.go create mode 100644 pkg/integration/tests/main_view/focus_leaves_an_always_split_empty_pane.go create mode 100644 pkg/integration/tests/main_view/focus_returns_when_split_collapses.go create mode 100644 pkg/integration/tests/main_view/hide_selection_when_changes_vanish.go create mode 100644 pkg/integration/tests/main_view/keep_a_wrapped_line_covered_across_a_rerender.go create mode 100644 pkg/integration/tests/main_view/keep_both_halves_of_a_change_selected.go create mode 100644 pkg/integration/tests/main_view/keep_position_by_the_visible_end_of_a_selection.go create mode 100644 pkg/integration/tests/main_view/keep_position_in_both_panes_when_changing_context_size.go create mode 100644 pkg/integration/tests/main_view/keep_position_in_both_panes_when_ignoring_whitespace.go create mode 100644 pkg/integration/tests/main_view/keep_position_in_both_panes_when_switching_diff_renderers.go create mode 100644 pkg/integration/tests/main_view/keep_position_when_changing_context_size.go create mode 100644 pkg/integration/tests/main_view/keep_position_when_ignoring_whitespace.go create mode 100644 pkg/integration/tests/main_view/keep_position_when_ignoring_whitespace_removes_it.go create mode 100644 pkg/integration/tests/main_view/keep_position_when_switching_diff_renderers.go create mode 100644 pkg/integration/tests/main_view/keep_position_when_the_selection_is_off_screen.go create mode 100644 pkg/integration/tests/main_view/keep_scroll_when_the_diff_cant_be_read.go create mode 100644 pkg/integration/tests/main_view/keep_selected_range_when_changing_context_size.go create mode 100644 pkg/integration/tests/main_view/keep_selection_after_moving_patch_out.go create mode 100644 pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go rename pkg/integration/tests/{patch_building/move_range_to_index.go => main_view/move_multi_file_range_to_index.go} (73%) rename pkg/integration/tests/{patch_building/move_to_index_part_of_adjacent_added_lines.go => main_view/move_part_of_adjacent_added_lines_to_index.go} (67%) create mode 100644 pkg/integration/tests/main_view/move_partial_patch_to_index.go rename pkg/integration/tests/{patch_building/move_to_later_commit_partial_hunk.go => main_view/move_partial_patch_to_later_commit.go} (71%) rename pkg/integration/tests/{patch_building/move_to_new_commit_partial_hunk.go => main_view/move_partial_patch_to_new_commit.go} (73%) rename pkg/integration/tests/{patch_building/move_to_earlier_commit_from_added_file.go => main_view/move_patch_from_added_file_to_earlier_commit.go} (79%) rename pkg/integration/tests/{patch_building/move_to_index_from_added_file_with_conflict.go => main_view/move_patch_from_added_file_to_index_with_conflict.go} (82%) rename pkg/integration/tests/{patch_building/move_to_new_commit_from_added_file.go => main_view/move_patch_from_added_file_to_new_commit.go} (81%) rename pkg/integration/tests/{patch_building/move_to_new_commit_from_deleted_file.go => main_view/move_patch_from_deleted_file_to_new_commit.go} (81%) rename pkg/integration/tests/{patch_building/move_to_earlier_commit.go => main_view/move_patch_to_earlier_commit.go} (80%) rename pkg/integration/tests/{patch_building/move_to_index.go => main_view/move_patch_to_index.go} (71%) rename pkg/integration/tests/{patch_building/move_to_index_with_conflict.go => main_view/move_patch_to_index_with_conflict.go} (83%) rename pkg/integration/tests/{patch_building/move_to_index_works_even_if_noprefix_is_set.go => main_view/move_patch_to_index_with_custom_diff_config.go} (73%) rename pkg/integration/tests/{patch_building/move_to_index_with_modified_file.go => main_view/move_patch_to_index_with_modified_file.go} (67%) rename pkg/integration/tests/{patch_building/move_to_later_commit.go => main_view/move_patch_to_later_commit.go} (80%) rename pkg/integration/tests/{patch_building/move_to_new_commit.go => main_view/move_patch_to_new_commit.go} (82%) rename pkg/integration/tests/{patch_building/move_to_new_commit_before.go => main_view/move_patch_to_new_commit_before.go} (81%) rename pkg/integration/tests/{patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go => main_view/move_patch_to_new_commit_in_stacked_branch.go} (80%) create mode 100644 pkg/integration/tests/main_view/navigate_by_hunk_and_file.go create mode 100644 pkg/integration/tests/main_view/no_selection_over_a_binary_diff.go create mode 100644 pkg/integration/tests/main_view/no_selection_over_a_commit_log.go create mode 100644 pkg/integration/tests/main_view/no_selection_when_no_changes.go create mode 100644 pkg/integration/tests/main_view/patch_marks_follow_a_renderer_switch.go create mode 100644 pkg/integration/tests/main_view/patch_marks_show_while_the_diff_is_focused.go create mode 100644 pkg/integration/tests/main_view/range_select_diff_lines.go create mode 100644 pkg/integration/tests/main_view/raw_fallback_under_an_external_diff.go rename pkg/integration/tests/{patch_building/renamed_file_partial.go => main_view/remove_content_change_from_renamed_file.go} (52%) create mode 100644 pkg/integration/tests/main_view/remove_lines_from_the_custom_patch.go rename pkg/integration/tests/{patch_building/remove_parts_of_added_file.go => main_view/remove_part_of_added_file_from_commit.go} (63%) rename pkg/integration/tests/{patch_building/remove_from_commit.go => main_view/remove_patch_from_commit.go} (67%) create mode 100644 pkg/integration/tests/main_view/reset_a_patch_built_from_a_commits_diff.go create mode 100644 pkg/integration/tests/main_view/reset_patch_with_escape.go create mode 100644 pkg/integration/tests/main_view/reset_the_patch_from_the_pane_showing_it.go create mode 100644 pkg/integration/tests/main_view/search_collapses_the_selection.go create mode 100644 pkg/integration/tests/main_view/search_follows_the_selection.go create mode 100644 pkg/integration/tests/main_view/select_below_a_long_commit_message.go create mode 100644 pkg/integration/tests/main_view/select_below_a_long_diffstat.go create mode 100644 pkg/integration/tests/main_view/select_diff_lines.go create mode 100644 pkg/integration/tests/main_view/select_hunk_below_last_change.go create mode 100644 pkg/integration/tests/main_view/select_hunk_in_diff.go create mode 100644 pkg/integration/tests/main_view/select_hunk_on_focusing_main_view.go create mode 100644 pkg/integration/tests/main_view/select_in_a_diff_read_in_part.go create mode 100644 pkg/integration/tests/main_view/select_line_when_whole_file_is_one_hunk.go create mode 100644 pkg/integration/tests/main_view/select_next_change_after_staging.go create mode 100644 pkg/integration/tests/main_view/select_next_change_after_unstaging.go create mode 100644 pkg/integration/tests/main_view/select_next_deletion_after_staging_one.go create mode 100644 pkg/integration/tests/main_view/select_visible_change_on_focusing_main_view.go create mode 100644 pkg/integration/tests/main_view/select_visible_hunk_on_focusing_main_view.go create mode 100644 pkg/integration/tests/main_view/selection_command_tooltips_follow_the_diff.go create mode 100644 pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go create mode 100644 pkg/integration/tests/main_view/selection_over_the_custom_patch.go create mode 100644 pkg/integration/tests/main_view/stage_deleted_file.go create mode 100644 pkg/integration/tests/main_view/stage_diff_lines.go create mode 100644 pkg/integration/tests/main_view/stage_hunks_with_rapid_keypresses.go rename pkg/integration/tests/{staging => main_view}/stage_partial_block_of_changes_first_lines.go (91%) rename pkg/integration/tests/{staging => main_view}/stage_partial_block_of_changes_last_lines.go (91%) rename pkg/integration/tests/{staging => main_view}/stage_partial_block_of_changes_middle_lines.go (93%) create mode 100644 pkg/integration/tests/main_view/stage_range_spanning_files.go create mode 100644 pkg/integration/tests/main_view/stage_under_conforming_diff_renderer.go create mode 100644 pkg/integration/tests/main_view/stage_under_unsupported_diff_renderer.go create mode 100644 pkg/integration/tests/main_view/start_patch_from_another_commit.go create mode 100644 pkg/integration/tests/main_view/unstage_diff_lines.go delete mode 100644 pkg/integration/tests/patch_building/move_to_index_partial.go delete mode 100644 pkg/integration/tests/patch_building/reset_with_escape.go delete mode 100644 pkg/integration/tests/patch_building/specific_selection.go delete mode 100644 pkg/integration/tests/patch_building/start_new_patch.go delete mode 100644 pkg/integration/tests/staging/diff_context_change.go delete mode 100644 pkg/integration/tests/staging/search.go delete mode 100644 pkg/integration/tests/staging/select_next_line_after_staging_in_two_hunk_diff.go delete mode 100644 pkg/integration/tests/staging/select_next_line_after_staging_isolated_added_line.go delete mode 100644 pkg/integration/tests/staging/stage_hunks.go delete mode 100644 pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go delete mode 100644 pkg/integration/tests/staging/stage_lines.go delete mode 100644 pkg/integration/tests/staging/stage_ranges.go diff --git a/README.md b/README.md index 5d5e47e11..14637a880 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Lazygit is not my fulltime job but it is a hefty part time job so if you want to ### Stage individual lines -Press space on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to select the entirety of the current hunk. +Press `` on a changed file to focus its diff in the main view. Press `` on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to switch to hunk selection mode. When a file has both staged and unstaged changes, use `` to move between the two diff panes; the same actions stage or unstage the selection depending on the pane. ![stage_lines](../assets/demo/stage_lines-compressed.gif) @@ -195,7 +195,7 @@ You can create worktrees to have multiple branches going at once without the nee You can build a custom patch from an old commit and then remove the patch from the commit, split out a new commit, apply the patch in reverse to the index, and more. -In this example we have a redundant comment that we want to remove from an old commit. We hit `` on the commit to view its files, then `` on a file to focus the patch, then `` to add the comment line to our custom patch, and then `ctrl+p` to view the custom patch options; selecting to remove the patch from the current commit. +In this example we have a redundant comment that we want to remove from an old commit. We hit `` on the commit to view its files, then `` on a file to focus its diff. From there, `` adds the selected comment line to the custom patch and `ctrl+p` opens the custom patch options, where we choose to remove the patch from the original commit. Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs). diff --git a/docs-master/Config.md b/docs-master/Config.md index 857a4e359..8e63b6c1e 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -78,7 +78,7 @@ gui: # If true, do not show a warning when amending a commit. skipAmendWarning: false - # If true, do not show a warning when discarding changes in the staging view. + # If true, do not show a warning when discarding changes from a focused diff. skipDiscardChangeWarning: false # If true, do not show warning when applying/popping the stash @@ -148,14 +148,13 @@ gui: # - 'top': split the window vertically (side panel on top, main view below) enlargedSideViewLocation: left - # If true, wrap lines in the staging view to the width of the view. This makes - # it much easier to work with diffs that have long lines, e.g. paragraphs of + # If true, wrap lines in focused diffs to the width of the view. This makes it + # much easier to work with diffs that have long lines, e.g. paragraphs of # markdown text. - wrapLinesInStagingView: true + wrapLinesInDiffView: true - # If true, hunk selection mode will be enabled by default when entering the - # staging view. - useHunkModeInStagingView: true + # If true, hunk selection mode will be enabled by default when focusing a diff. + useHunkModeInDiffView: true # One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' # | 'ru' | 'pt' @@ -817,6 +816,8 @@ keybinding: main: prevHunk: [, h] nextHunk: [, l] + prevFile: "N" + nextFile: "n" toggleSelectHunk: a pickBothHunks: b editSelectHunk: E @@ -907,7 +908,7 @@ It is used, for example, when pasting a commit message into the commit message p ## Configuring File Editing -There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file if you invoke it from the staging panel, for example. +There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file when you invoke it from a focused diff. To tell lazygit which editor to use for the `e` command, the easiest way to do that is to provide an editPreset config, e.g. @@ -970,7 +971,7 @@ When the selected line gets close to the bottom of the window and you hit down-a That's the behavior when `gui.scrollOffBehavior` is set to "margin" (the default). If you set `gui.scrollOffBehavior` to "jump", then upon reaching the last line of a view and hitting down-arrow the view will scroll by half a page so that the selection ends up in the middle of the view. This may feel a little jarring because the cursor jumps around when continuously moving down, but it has the advantage that the view doesn't scroll as often. -This setting applies both to all list views (e.g. commits and branches etc), and to the staging view. +This setting applies both to all list views (e.g. commits and branches etc), and to focused diffs. ## Filtering diff --git a/docs-master/dev/Codebase_Guide.md b/docs-master/dev/Codebase_Guide.md index 1692be33e..5ecff6e9b 100644 --- a/docs-master/dev/Codebase_Guide.md +++ b/docs-master/dev/Codebase_Guide.md @@ -31,7 +31,6 @@ * `pkg/gui/keybindings`: Contains code for mapping between keybindings and their labels * `pkg/gui/mergeconflicts`: Contains code relating to the handling of merge conflicts * `pkg/gui/modes`: Contains code relating to the state of different modes e.g. cherry picking mode, rebase mode. -* `pkg/gui/patch_exploring`: Contains code relating to the state of patch-oriented views like the staging view. * `pkg/gui/popup`: Contains code that lets you easily raise popups * `pkg/gui/presentation`: Contains presentation code i.e. code concerned with rendering content inside views * `pkg/gui/services/custom_commands`: Contains code related to user-defined custom commands. diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 3ec731bf2..edfe27106 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -65,7 +65,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | -| `` `` | Enter file / Toggle directory collapsed | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | @@ -149,7 +149,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | View stash options | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | Stage all | Toggle staged/unstaged for all files in working tree. | -| `` `` | Stage lines / Collapse directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | +| `` `` | Focus file diff / Collapse directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. | | `` d `` | Discard | View options for discarding changes to the selected file. | | `` g `` | View upstream reset options | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | @@ -222,42 +222,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` (fn+up) `` | Scroll down | | | `` (fn+down) `` | Scroll up | | -| `` `` | Switch view | Switch to other view (staged/unstaged changes). | -| `` `` | Exit back to side panel | | -| `` / `` | Search the current view by text | | - -## Main panel (patch building) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Go to previous hunk | | -| `` , l `` | Go to next hunk | | -| `` v `` | Toggle range select | | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Copy selected text to clipboard | | -| `` o `` | Open file | Open file in default application. | +| `` v `` | Toggle range select | | | `` e `` | Edit file | Open file in external editor. | -| `` `` | Toggle lines in patch | | -| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | -| `` `` | Exit custom patch builder | | -| `` / `` | Search the current view by text | | - -## Main panel (staging) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Go to previous hunk | | -| `` , l `` | Go to next hunk | | -| `` v `` | Toggle range select | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Copy selected text to clipboard | | | `` `` | Stage | Toggle selection staged / unstaged. | | `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | -| `` o `` | Open file | Open file in default application. | -| `` e `` | Edit file | Open file in external editor. | -| `` `` | Return to files panel | | -| `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | +| `` `` | Copy selected text to clipboard | | +| `` , h `` | Go to previous hunk | | +| `` , l `` | Go to next hunk | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | +| `` `` | Exit back to side panel | | | `` c `` | Commit | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | | `` C `` | Commit changes using git editor | | @@ -327,8 +304,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Switch view | Switch to other view (staged/unstaged changes). | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Toggle range select | | +| `` e `` | Edit file | Open file in external editor. | +| `` `` | Stage | Toggle selection staged / unstaged. | +| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | +| `` E `` | Edit hunk | Edit selected hunk in external editor. | +| `` `` | Copy selected text to clipboard | | +| `` , h `` | Go to previous hunk | | +| `` , l `` | Go to next hunk | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | +| `` c `` | Commit | Commit staged changes. | +| `` w `` | Commit changes without pre-commit hook | | +| `` C `` | Commit changes using git editor | | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | Search the current view by text | | ## Stash diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 6a3d9b5c1..4aef4bd13 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -114,7 +114,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 外部差分ツールを開く(git difftool) | | | `` `` | パッチに含めるファイルを切り替え | ファイルがカスタムパッチに含まれるかどうかを切り替えます。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 | | `` a `` | すべてのファイルを切り替え | コミットのすべてのファイルをカスタムパッチに追加/削除します。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 | -| `` `` | ファイルに入る / ディレクトリの折りたたみを切り替える | ファイルが選択されている場合、そのファイルに入ってカスタムパッチに個々の行を追加/削除できます。ディレクトリが選択されている場合、ディレクトリを切り替えます。 | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | ファイルツリービューを切り替え | ファイル表示をフラット表示とツリー表示で切り替えます。フラット表示はすべてのファイルパスを一覧で表示し、ツリー表示はディレクトリごとにファイルをグループ化します。

デフォルトは設定ファイル内の 'gui.showFileTree' キーで変更できます。 | | `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます | | `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します | @@ -191,8 +191,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 範囲選択を切り替え | | +| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | +| `` `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 | +| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 | +| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 | +| `` `` | 選択したテキストをクリップボードにコピー | | +| `` , h `` | 前のハンクに移動 | | +| `` , l `` | 次のハンクに移動 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | サイドパネルに戻る | | +| `` c `` | コミット | ステージされた変更をコミットします。 | +| `` w `` | pre-commitフックなしで変更をコミット | | +| `` C `` | Gitエディタを使用して変更をコミット | | +| `` `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: | | `` / `` | 現在のビューをテキストで検索 | | ## タグ @@ -244,44 +259,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 0 `` | メインビューにフォーカス | | | `` / `` | 現在のビューをテキストでフィルタリング | | -## メインパネル(ステージング) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | 前のハンクに移動 | | -| `` , l `` | 次のハンクに移動 | | -| `` v `` | 範囲選択を切り替え | | -| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 選択したテキストをクリップボードにコピー | | -| `` `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 | -| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 | -| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | -| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | -| `` `` | ファイルパネルに戻る | | -| `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | -| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 | -| `` c `` | コミット | ステージされた変更をコミットします。 | -| `` w `` | pre-commitフックなしで変更をコミット | | -| `` C `` | Gitエディタを使用して変更をコミット | | -| `` `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: | -| `` / `` | 現在のビューをテキストで検索 | | - -## メインパネル(パッチ作成) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | 前のハンクに移動 | | -| `` , l `` | 次のハンクに移動 | | -| `` v `` | 範囲選択を切り替え | | -| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 選択したテキストをクリップボードにコピー | | -| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | -| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | -| `` `` | パッチ内の行を切り替え | | -| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | -| `` `` | カスタムパッチビルダーを終了 | | -| `` / `` | 現在のビューをテキストで検索 | | - ## メインパネル(マージ中) | Key | Action | Info | @@ -304,8 +281,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` (fn+up) `` | 下にスクロール | | | `` (fn+down) `` | 上にスクロール | | -| `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 範囲選択を切り替え | | +| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | +| `` `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 | +| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 | +| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 | +| `` `` | 選択したテキストをクリップボードにコピー | | +| `` , h `` | 前のハンクに移動 | | +| `` , l `` | 次のハンクに移動 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | サイドパネルに戻る | | +| `` c `` | コミット | ステージされた変更をコミットします。 | +| `` w `` | pre-commitフックなしで変更をコミット | | +| `` C `` | Gitエディタを使用して変更をコミット | | +| `` `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: | | `` / `` | 現在のビューをテキストで検索 | | ## メニュー diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index a0e5d84dc..680e9cf30 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -83,8 +83,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 드래그 선택 전환 | | +| `` e `` | 파일 편집 | Open file in external editor. | +| `` `` | Staged 전환 | 선택한 행을 staged / unstaged | +| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | +| `` E `` | Edit hunk | Edit selected hunk in external editor. | +| `` `` | 선택한 텍스트를 클립보드에 복사 | | +| `` , h `` | 이전 hunk를 선택 | | +| `` , l `` | 다음 hunk를 선택 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | +| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. | +| `` w `` | Commit changes without pre-commit hook | | +| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | 검색 시작 | | ## Stash @@ -161,42 +176,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` (fn+up) `` | 아래로 스크롤 | | | `` (fn+down) `` | 위로 스크롤 | | -| `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | -| `` `` | Exit back to side panel | | -| `` / `` | 검색 시작 | | - -## 메인 패널 (Patch Building) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | 이전 hunk를 선택 | | -| `` , l `` | 다음 hunk를 선택 | | -| `` v `` | 드래그 선택 전환 | | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 선택한 텍스트를 클립보드에 복사 | | -| `` o `` | 파일 닫기 | Open file in default application. | +| `` v `` | 드래그 선택 전환 | | | `` e `` | 파일 편집 | Open file in external editor. | -| `` `` | Line(s)을 패치에 추가/삭제 | | -| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | -| `` `` | Exit custom patch builder | | -| `` / `` | 검색 시작 | | - -## 메인 패널 (Staging) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | 이전 hunk를 선택 | | -| `` , l `` | 다음 hunk를 선택 | | -| `` v `` | 드래그 선택 전환 | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 선택한 텍스트를 클립보드에 복사 | | | `` `` | Staged 전환 | 선택한 행을 staged / unstaged | | `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | -| `` o `` | 파일 닫기 | Open file in default application. | -| `` e `` | 파일 편집 | Open file in external editor. | -| `` `` | 파일 목록으로 돌아가기 | | -| `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | +| `` `` | 선택한 텍스트를 클립보드에 복사 | | +| `` , h `` | 이전 hunk를 선택 | | +| `` , l `` | 다음 hunk를 선택 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | +| `` `` | Exit back to side panel | | | `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. | | `` w `` | Commit changes without pre-commit hook | | | `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | | @@ -345,7 +337,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files included in patch | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | -| `` `` | Enter file to add selected lines to the patch (or toggle directory collapsed) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | @@ -395,7 +387,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | Stash 옵션 보기 | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | 모든 변경을 Staged/unstaged으로 전환 | Toggle staged/unstaged for all files in working tree. | -| `` `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | +| `` `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. | | `` d `` | View 'discard changes' options | View options for discarding changes to the selected file. | | `` g `` | View upstream reset options | | | `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 0f01dea7c..d8b40dbfa 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -72,7 +72,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | Bekijk stash opties | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | Toggle staged alle | Toggle staged/unstaged for all files in working tree. | -| `` `` | Stage individuele hunks/lijnen | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | +| `` `` | Stage individuele hunks/lijnen | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. | | `` d `` | Bekijk 'veranderingen ongedaan maken' opties | View options for discarding changes to the selected file. | | `` g `` | Bekijk upstream reset opties | | | `` D `` | Resetten | View reset options for working tree (e.g. nuking the working tree). | @@ -144,7 +144,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open externe diff applicatie (git difftool) | | | `` `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | -| `` `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit | @@ -230,24 +230,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` (fn+up) `` | Scroll omlaag | | | `` (fn+down) `` | Scroll omhoog | | -| `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | -| `` `` | Exit back to side panel | | -| `` / `` | Start met zoeken | | - -## Patch bouwen - -| Key | Action | Info | -|-----|--------|-------------| +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | +| `` v `` | Toggle drag selecteer | | +| `` e `` | Verander bestand | Open bestand in externe editor. | +| `` `` | Toggle staged | Toggle lijnen staged / unstaged | +| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | +| `` E `` | Edit hunk | Edit selected hunk in external editor. | +| `` `` | Copy selected text to clipboard | | | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | -| `` v `` | Toggle drag selecteer | | -| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | -| `` `` | Copy selected text to clipboard | | -| `` o `` | Open bestand | Open bestand in standaardapplicatie. | -| `` e `` | Verander bestand | Open bestand in externe editor. | -| `` `` | Voeg toe/verwijder lijn(en) in patch | | -| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | -| `` `` | Sluit lijn-bij-lijn modus | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | +| `` `` | Exit back to side panel | | +| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | +| `` w `` | Commit veranderingen zonder pre-commit hook | | +| `` C `` | Commit veranderingen met de git editor | | +| `` `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: | | `` / `` | Start met zoeken | | ## Reflog @@ -305,26 +304,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | -| `` `` | Exit back to side panel | | -| `` / `` | Start met zoeken | | - -## Staging - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Selecteer de vorige hunk | | -| `` , l `` | Selecteer de volgende hunk | | -| `` v `` | Toggle drag selecteer | | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | | `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | -| `` `` | Copy selected text to clipboard | | +| `` v `` | Toggle drag selecteer | | +| `` e `` | Verander bestand | Open bestand in externe editor. | | `` `` | Toggle staged | Toggle lijnen staged / unstaged | | `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | -| `` o `` | Open bestand | Open bestand in standaardapplicatie. | -| `` e `` | Verander bestand | Open bestand in externe editor. | -| `` `` | Ga terug naar het bestanden paneel | | -| `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | +| `` `` | Copy selected text to clipboard | | +| `` , h `` | Selecteer de vorige hunk | | +| `` , l `` | Selecteer de volgende hunk | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | +| `` `` | Exit back to side panel | | | `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` C `` | Commit veranderingen met de git editor | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index ba46f7c46..a612f44ab 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -98,8 +98,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Przełącz zaznaczenie zakresu | | +| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | +| `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | +| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. | +| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. | +| `` `` | Kopiuj zaznaczony tekst do schowka | | +| `` , h `` | Idź do poprzedniego fragmentu | | +| `` , l `` | Idź do następnego fragmentu | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | +| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | +| `` w `` | Zatwierdź zmiany bez hooka pre-commit | | +| `` C `` | Zatwierdź zmiany używając edytora git | | +| `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Drzewa pracy @@ -132,22 +147,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Pokaż commity | | | `` / `` | Filtruj bieżący widok po tekście | | -## Główny panel (budowanie łatki) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Idź do poprzedniego fragmentu | | -| `` , l `` | Idź do następnego fragmentu | | -| `` v `` | Przełącz zaznaczenie zakresu | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Kopiuj zaznaczony tekst do schowka | | -| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | -| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | -| `` `` | Przełącz linie w łatce | | -| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | -| `` `` | Wyjdź z budowniczego niestandardowej łatki | | -| `` / `` | Szukaj w bieżącym widoku po tekście | | - ## Input prompt | Key | Action | Info | @@ -200,8 +199,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` (fn+up) `` | Przewiń w dół | | | `` (fn+down) `` | Przewiń w górę | | -| `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Przełącz zaznaczenie zakresu | | +| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | +| `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | +| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. | +| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. | +| `` `` | Kopiuj zaznaczony tekst do schowka | | +| `` , h `` | Idź do poprzedniego fragmentu | | +| `` , l `` | Idź do następnego fragmentu | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | +| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | +| `` w `` | Zatwierdź zmiany bez hooka pre-commit | | +| `` C `` | Zatwierdź zmiany używając edytora git | | +| `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Panel główny (scalanie) @@ -220,28 +234,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Wróć do panelu plików | | -## Panel główny (zatwierdzanie) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Idź do poprzedniego fragmentu | | -| `` , l `` | Idź do następnego fragmentu | | -| `` v `` | Przełącz zaznaczenie zakresu | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Kopiuj zaznaczony tekst do schowka | | -| `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | -| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. | -| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | -| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | -| `` `` | Wróć do panelu plików | | -| `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | -| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. | -| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | -| `` w `` | Zatwierdź zmiany bez hooka pre-commit | | -| `` C `` | Zatwierdź zmiany używając edytora git | | -| `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | -| `` / `` | Szukaj w bieżącym widoku po tekście | | - ## Panel potwierdzenia | Key | Action | Info | @@ -296,7 +288,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` `` | Przełącz plik włączony w łatkę | Przełącz, czy plik jest włączony w niestandardową łatkę. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Przełącz wszystkie pliki | Dodaj/usuń wszystkie pliki commita do niestandardowej łatki. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | -| `` `` | Wejdź do pliku / Przełącz zwiń katalog | Jeśli plik jest wybrany, wejdź do pliku, aby móc dodawać/usuwać poszczególne linie do niestandardowej łatki. Jeśli wybrany jest katalog, przełącz katalog. | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | Przełącz widok drzewa plików | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 3071613be..252c72060 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -148,7 +148,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` `` | Alternar entre o arquivo incluído no patch | Alternar se o arquivo está incluído no patch personalizado. Veja https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Alternar todos os arquivos | Adicionar/remover todos os arquivos de commit para atualização personalizada. Consulte https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | -| `` `` | Insira o arquivo / Alternar diretório recolhido | Se um arquivo estiver selecionado, insira o arquivo para que você possa adicionar/remover linhas individuais no patch personalizado. Se um diretório for selecionado, ative o diretório. | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | Alternar exibição de árvore de arquivo | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos | | `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo | @@ -234,26 +234,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` (fn+up) `` | Rolar para baixo | | | `` (fn+down) `` | Rolar para cima | | -| `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | -| `` `` | Exit back to side panel | | -| `` / `` | Pesquisar na visualização atual por texto | | - -## Painel Principal (preparação) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Ir para o local anterior | | -| `` , l `` | Ir para o próximo trecho | | -| `` v `` | Toggle range select | | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | -| `` `` | Copiar texto selecionado para área de transferência | | +| `` v `` | Toggle range select | | +| `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` `` | Etapa | Ativar/desativar seleção em staged/unstaged | | `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. | -| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | -| `` e `` | Editar arquivo | Abrir arquivo no editor externo. | -| `` `` | Retornar ao painel de arquivos | | -| `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` E `` | Editar hunk | Editar o local selecionado no editor externo. | +| `` `` | Copiar texto selecionado para área de transferência | | +| `` , h `` | Ir para o local anterior | | +| `` , l `` | Ir para o próximo trecho | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | +| `` `` | Exit back to side panel | | | `` c `` | Commit | Submeter mudanças em staging | | `` w `` | Fazer commit de alterações sem pré-commit | | | `` C `` | Enviar alteração usando um editor Git | | @@ -284,22 +277,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Retornar ao painel de arquivos | | -## Painel principal (patch build) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Ir para o local anterior | | -| `` , l `` | Ir para o próximo trecho | | -| `` v `` | Toggle range select | | -| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | -| `` `` | Copiar texto selecionado para área de transferência | | -| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | -| `` e `` | Editar arquivo | Abrir arquivo no editor externo. | -| `` `` | Alternar linhas no caminho | | -| `` d `` | Remover linhas do commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | -| `` `` | Sair do construtor de patch personalizado | | -| `` / `` | Pesquisar na visualização atual por texto | | - ## Reflog | Key | Action | Info | @@ -336,8 +313,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | +| `` v `` | Toggle range select | | +| `` e `` | Editar arquivo | Abrir arquivo no editor externo. | +| `` `` | Etapa | Ativar/desativar seleção em staged/unstaged | +| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. | +| `` E `` | Editar hunk | Editar o local selecionado no editor externo. | +| `` `` | Copiar texto selecionado para área de transferência | | +| `` , h `` | Ir para o local anterior | | +| `` , l `` | Ir para o próximo trecho | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | +| `` c `` | Commit | Submeter mudanças em staging | +| `` w `` | Fazer commit de alterações sem pré-commit | | +| `` C `` | Enviar alteração usando um editor Git | | +| `` `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado
Veja a documentação:
| | `` / `` | Pesquisar na visualização atual por texto | | ## Stash diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 3d03f9ca6..b93a4164c 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -73,26 +73,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | -| `` `` | Exit back to side panel | | -| `` / `` | Найти | | - -## Главная панель (Индексирование) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Выбрать предыдущую часть | | -| `` , l `` | Выбрать следующую часть | | -| `` v `` | Переключить выборку перетаскивания | | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Скопировать выделенный текст в буфер обмена | | +| `` v `` | Переключить выборку перетаскивания | | +| `` e `` | Редактировать файл | Open file in external editor. | | `` `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные | | `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | -| `` o `` | Открыть файл | Open file in default application. | -| `` e `` | Редактировать файл | Open file in external editor. | -| `` `` | Вернуться к панели файлов | | -| `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` E `` | Изменить эту часть | Edit selected hunk in external editor. | +| `` `` | Скопировать выделенный текст в буфер обмена | | +| `` , h `` | Выбрать предыдущую часть | | +| `` , l `` | Выбрать следующую часть | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | +| `` `` | Exit back to side panel | | | `` c `` | Сохранить изменения | Commit staged changes. | | `` w `` | Закоммитить изменения без предварительного хука коммита | | | `` C `` | Сохранить изменения с помощью редактора git | | @@ -105,8 +98,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` (fn+up) `` | Прокрутить вниз | | | `` (fn+down) `` | Прокрутить вверх | | -| `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Переключить выборку перетаскивания | | +| `` e `` | Редактировать файл | Open file in external editor. | +| `` `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные | +| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | +| `` E `` | Изменить эту часть | Edit selected hunk in external editor. | +| `` `` | Скопировать выделенный текст в буфер обмена | | +| `` , h `` | Выбрать предыдущую часть | | +| `` , l `` | Выбрать следующую часть | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | +| `` c `` | Сохранить изменения | Commit staged changes. | +| `` w `` | Закоммитить изменения без предварительного хука коммита | | +| `` C `` | Сохранить изменения с помощью редактора git | | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | Найти | | ## Главная панель (Слияние) @@ -125,22 +133,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Вернуться к панели файлов | | -## Главная панель (сборка патчей) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | Выбрать предыдущую часть | | -| `` , l `` | Выбрать следующую часть | | -| `` v `` | Переключить выборку перетаскивания | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Скопировать выделенный текст в буфер обмена | | -| `` o `` | Открыть файл | Open file in default application. | -| `` e `` | Редактировать файл | Open file in external editor. | -| `` `` | Добавить/удалить строку(и) для патча | | -| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | -| `` `` | Выйти из сборщика пользовательских патчей | | -| `` / `` | Найти | | - ## Журнал ссылок (Reflog) | Key | Action | Info | @@ -304,7 +296,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` `` | Переключить файлы включённые в патч | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Переключить все файлы, включённые в патч | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | -| `` `` | Введите файл, чтобы добавить выбранные строки в патч (или свернуть каталог переключения) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | @@ -389,7 +381,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | | `` S `` | Просмотреть параметры хранилища | View stash options (e.g. stash all, stash staged, stash unstaged). | | `` a `` | Все проиндексированные/непроиндексированные | Toggle staged/unstaged for all files in working tree. | -| `` `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | +| `` `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. | | `` d `` | Просмотреть параметры «отмены изменении» | View options for discarding changes to the selected file. | | `` g `` | Просмотреть параметры сброса upstream-ветки | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 9819cb982..773e2e8e9 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -178,7 +178,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 使用外部差异比较工具(git difftool) | | | `` `` | 补丁中包含的切换文件 | 切换文件是否包含在自定义补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | | `` a `` | 操作所有文件 | 添加或删除所有提交中的文件到自定义的补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | -| `` `` | 输入文件以将所选行添加到补丁中(或切换目录折叠) | 如果已选择一个文件,则Enter进入该文件,以便您可以向自定义补丁添加/删除单独的行。如果选择了目录,则切换目录。 | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | 切换文件树视图 | 在平面布局和树布局之间切换文件视图。平面布局在单个列表中显示所有文件路径,树布局按目录分组文件。

可以在配置文件中使用 'gui.showFileTree' 键更改默认设置。 | | `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 | | `` = `` | 展开全部文件 | 展开文件树中的全部目录 | @@ -249,22 +249,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 查看提交 | | | `` / `` | 通过文本过滤当前视图 | | -## 构建补丁中 - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | 选择上一个区块 | | -| `` , l `` | 选择下一个区块 | | -| `` v `` | 切换拖动选择 | | -| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | -| `` `` | 复制选中文本到剪贴板 | | -| `` o `` | 打开文件 | 使用默认程序打开该文件 | -| `` e `` | 编辑文件 | 使用外部编辑器打开文件 | -| `` `` | 添加/移除 行到补丁 | | -| `` d `` | 从提交中移除行 | 从本次提交中移除所选行。此操作会在后台运行交互式变基,因此如果后续提交也修改了这些行,您可能会遇到合并冲突。 | -| `` `` | 退出逐行模式 | | -| `` / `` | 开始搜索 | | - ## 标签 | Key | Action | Info | @@ -285,8 +269,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | +| `` v `` | 切换拖动选择 | | +| `` e `` | 编辑文件 | 使用外部编辑器打开文件 | +| `` `` | 切换暂存状态 | 切换行暂存状态 | +| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 | +| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 | +| `` `` | 复制选中文本到剪贴板 | | +| `` , h `` | 选择上一个区块 | | +| `` , l `` | 选择下一个区块 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | 退出回到侧边面板 | | +| `` c `` | 提交变更 | 提交暂存文件 | +| `` w `` | 提交变更而无需预先提交钩子 | | +| `` C `` | 使用 Git 编辑器提交变更 | | +| `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | | `` / `` | 开始搜索 | | ## 正在合并 @@ -305,36 +304,29 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` M `` | 查看合并冲突选项 | 查看用于解决合并冲突的选项。 | | `` `` | 返回文件面板 | | -## 正在暂存 - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | 选择上一个区块 | | -| `` , l `` | 选择下一个区块 | | -| `` v `` | 切换拖动选择 | | -| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | -| `` `` | 复制选中文本到剪贴板 | | -| `` `` | 切换暂存状态 | 切换行暂存状态 | -| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 | -| `` o `` | 打开文件 | 使用默认程序打开该文件 | -| `` e `` | 编辑文件 | 使用外部编辑器打开文件 | -| `` `` | 返回文件面板 | | -| `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | -| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 | -| `` c `` | 提交变更 | 提交暂存文件 | -| `` w `` | 提交变更而无需预先提交钩子 | | -| `` C `` | 使用 Git 编辑器提交变更 | | -| `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | -| `` / `` | 开始搜索 | | - ## 正常 | Key | Action | Info | |-----|--------|-------------| | `` (fn+up) `` | 向下滚动 | | | `` (fn+down) `` | 向上滚动 | | -| `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | +| `` v `` | 切换拖动选择 | | +| `` e `` | 编辑文件 | 使用外部编辑器打开文件 | +| `` `` | 切换暂存状态 | 切换行暂存状态 | +| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 | +| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 | +| `` `` | 复制选中文本到剪贴板 | | +| `` , h `` | 选择上一个区块 | | +| `` , l `` | 选择下一个区块 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | 退出回到侧边面板 | | +| `` c `` | 提交变更 | 提交暂存文件 | +| `` w `` | 提交变更而无需预先提交钩子 | | +| `` C `` | 使用 Git 编辑器提交变更 | | +| `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | | `` / `` | 开始搜索 | | ## 状态 diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 0e5debfdf..2a70fca04 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -52,30 +52,29 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` ] `` | 下一個索引標籤 | | | `` [ `` | 上一個索引標籤 | | -## 主面板 (補丁生成) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | 選擇上一段 | | -| `` , l `` | 選擇下一段 | | -| `` v `` | 切換拖曳選擇 | | -| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | -| `` `` | 複製所選文本至剪貼簿 | | -| `` o `` | 開啟檔案 | 使用預設軟體開啟 | -| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | -| `` `` | 向 (或從) 補丁中添加/刪除行 | | -| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 | -| `` `` | 退出自訂補丁建立器 | | -| `` / `` | 搜尋 | | - ## 主面板(一般) | Key | Action | Info | |-----|--------|-------------| | `` (fn+up) `` | 向下捲動 | | | `` (fn+down) `` | 向上捲動 | | -| `` `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | +| `` v `` | 切換拖曳選擇 | | +| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | +| `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | +| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 | +| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 | +| `` `` | 複製所選文本至剪貼簿 | | +| `` , h `` | 選擇上一段 | | +| `` , l `` | 選擇下一段 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | 退出回到側邊面板 | | +| `` c `` | 提交變更 | 提交暫存區變更 | +| `` w `` | 沒有預提交 hook 就提交更改 | | +| `` C `` | 使用 git 編輯器提交變更 | | +| `` `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件: | | `` / `` | 搜尋 | | ## 主面板(合併) @@ -94,28 +93,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 | | `` `` | 返回檔案面板 | | -## 主面板(預存) - -| Key | Action | Info | -|-----|--------|-------------| -| `` , h `` | 選擇上一段 | | -| `` , l `` | 選擇下一段 | | -| `` v `` | 切換拖曳選擇 | | -| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | -| `` `` | 複製所選文本至剪貼簿 | | -| `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | -| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 | -| `` o `` | 開啟檔案 | 使用預設軟體開啟 | -| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | -| `` `` | 返回檔案面板 | | -| `` `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | -| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 | -| `` c `` | 提交變更 | 提交暫存區變更 | -| `` w `` | 沒有預提交 hook 就提交更改 | | -| `` C `` | 使用 git 編輯器提交變更 | | -| `` `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件: | -| `` / `` | 搜尋 | | - ## 功能表 | Key | Action | Info | @@ -230,7 +207,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 開啟外部差異工具 (git difftool) | | | `` `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | | `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | -| `` `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案,則Enter進入該檔案,以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 | +| `` `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. | | `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。

可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 | | `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 | | `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 | @@ -355,8 +332,23 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | +| `` `` | Switch diff pane | Switch to the other focused diff pane. | +| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | +| `` v `` | 切換拖曳選擇 | | +| `` e `` | 編輯檔案 | 使用外部編輯器開啟 | +| `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | +| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 | +| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 | +| `` `` | 複製所選文本至剪貼簿 | | +| `` , h `` | 選擇上一段 | | +| `` , l `` | 選擇下一段 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | 退出回到側邊面板 | | +| `` c `` | 提交變更 | 提交暫存區變更 | +| `` w `` | 沒有預提交 hook 就提交更改 | | +| `` C `` | 使用 git 編輯器提交變更 | | +| `` `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件: | | `` / `` | 搜尋 | | ## 狀態 diff --git a/pkg/app/entry_point.go b/pkg/app/entry_point.go index a3225e311..ab9638b78 100644 --- a/pkg/app/entry_point.go +++ b/pkg/app/entry_point.go @@ -143,9 +143,6 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes if integrationTest != nil { integrationTest.SetupConfig(appConfig) - // Set this to true so that integration tests don't have to explicitly deal with the hunk - // staging hint: - appConfig.GetAppState().DidShowHunkStagingHint = true // Preserve the changes that the test setup just made to the config, so // they don't get lost when we reload the config while running the test diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index 5c5a94530..a14e91594 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -119,9 +119,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string { "prompt": tr.PromptTitle, "information": tr.InformationTitle, "main": tr.NormalTitle, - "patchBuilding": tr.PatchBuildingTitle, "mergeConflicts": tr.MergingTitle, - "staging": tr.StagingTitle, "menu": tr.MenuTitle, "search": tr.SearchTitle, "secondary": tr.SecondaryTitle, @@ -140,12 +138,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string { } func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*bindingSection { - excludedViews := []string{"stagingSecondary", "patchBuildingSecondary"} bindingsToDisplay := lo.Filter(bindings, func(binding *types.Binding, _ int) bool { - if lo.Contains(excludedViews, binding.ViewName) { - return false - } - return (binding.Description != "" || binding.Alternative != "") && len(binding.Keys) > 0 }) diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 69cddfa48..334f20315 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -135,8 +135,15 @@ func NewGitCommandAux( rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands) stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands) patchBuilder := patch.NewPatchBuilder(cmn.Log, - func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) { - return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain) + func(from string, to string, reverse bool, filename string, previousPath string) (string, error) { + // A patch is built from git's own diff: what a diff renderer would make of it + // is a picture of it, not something that can be applied. + return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, git_commands.DiffModePlain) + }, + func() (string, error) { + // Under lazygit's own temp dir, so that it honours the configured location + // and is cleaned up with everything else when we exit. + return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-") }) patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder) bisectCommands := git_commands.NewBisectCommands(gitCommon) diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index d067e9831..387a1fdc7 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -240,12 +240,12 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj { return self.cmd.New(cmdArgs) } -func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj { +func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, mode DiffMode) *oscommands.CmdObj { cmdArgs := NewGitCmd("show"). Config("diff.noprefix=false"). - AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode). Arg("--submodule"). - Arg("--color=" + self.diffRendererConfigManager.GetColorArg()). + Arg("--color=" + mode.colorArg(self.diffRendererConfigManager)). Arg("--stat"). Arg("--decorate"). Arg("-p"). diff --git a/pkg/commands/git_commands/commit_test.go b/pkg/commands/git_commands/commit_test.go index 9b2ddecfb..6f01b9827 100644 --- a/pkg/commands/git_commands/commit_test.go +++ b/pkg/commands/git_commands/commit_test.go @@ -341,7 +341,7 @@ func TestCommitShowCmdObj(t *testing.T) { } instance := buildCommitCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, runner: runner, repoPaths: &repoPaths}) - assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths).Run()) + assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths, DiffModeRendered).Run()) runner.CheckForMissingCalls() }) } diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index d532f1bbb..eb8b60a7a 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -2,10 +2,128 @@ package git_commands import ( "fmt" + "os" + "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/mgutz/str" ) +// metadataHandshake is the record a diff renderer that speaks the OSC 1717 protocol +// emits before anything else, to announce that it does: a version-only record, with +// none of the fields a line's record has. See ProbeDiffRendererEmitsMetadata, and +// gocui's escape interpreter for how it is kept off the screen on a real render. +const metadataHandshake = "\x1b]1717" + +// ProbeDiffRendererEmitsMetadata reports whether the configured diff renderer states +// which line of which file it is rendering, by running it on empty input and looking +// for the handshake. The answer decides whether a diff the renderer produced can be +// acted on at all, or has to be replaced by git's own when the user wants to act on it +// (see DiffLineHelper.MainViewDiffMode). +// +// Asking rather than watching a real render: the handshake is the renderer's first +// output whatever the diff, so the answer is a property of the renderer, known before +// we render anything — where watching would have to see a diff go by first, and would +// be fooled by a diff with no lines to describe. +// +// No terminal is needed. git only invokes a stdin filter when it thinks it is talking +// to one, but the renderer itself doesn't care: it announces itself whenever OSC1717 is +// set, so it can be run directly with empty input. +func (self *DiffCommands) ProbeDiffRendererEmitsMetadata() bool { + manager := self.diffRendererConfigManager + + switch manager.GetDiffRendererType() { + case config.DiffRendererType_StdinFilter: + if command := manager.GetStdinFilterCommand(0); command != "" { + return self.probeEmitsMetadata(self.cmd.NewShell(command, "")) + } + case config.DiffRendererType_ExtDiff: + // An empty command means git's own diff.external config, which picks a driver + // per file through .gitattributes: there is no one renderer to ask, and a single + // diff can be produced by several, so we take it that it says nothing. + if command := manager.GetExternalDiffCommand(3); command != "" { + return self.externalDiffEmitsMetadata(command) + } + case config.DiffRendererType_RawGit: + // git describes only the formats whose output can't be read back as a diff, and + // asked with the renderer's own arguments it answers for exactly the format + // those select: a handshake for a word diff, silence for a unified one. With no + // arguments there is nothing to fall back to anyway, since this already is git's + // own diff. + if args := manager.GetRawGitArgs(); len(args) > 0 { + return self.rawGitEmitsMetadata(args) + } + } + + return false +} + +// rawGitEmitsMetadata asks git itself, run with the diff renderer's own arguments. +func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool { + oldPath, newPath, cleanup, ok := self.probeFiles() + if !ok { + return false + } + defer cleanup() + + return self.probeEmitsMetadata(self.cmd.New( + NewGitCmd("diff"). + Arg("--no-index"). + Arg(rawGitArgs...). + Arg(oldPath, newPath). + ToArgv(), + )) +} + +// externalDiffEmitsMetadata asks an external diff command, invoking it the way git +// invokes one — with the seven positional arguments of git's diff.external convention — +// over two empty files, so that it announces itself without having a diff to render. +func (self *DiffCommands) externalDiffEmitsMetadata(externalDiffCommand string) bool { + oldPath, newPath, cleanup, ok := self.probeFiles() + if !ok { + return false + } + defer cleanup() + + args := append(str.ToArgv(externalDiffCommand), + "probe", oldPath, "0000000", "100644", newPath, "0000000", "100644") + return self.probeEmitsMetadata(self.cmd.New(args)) +} + +// probeFiles makes the two empty files a probe stands a diff up from, and the cleanup +// that removes them. Empty, because what the probe wants is for the renderer to announce +// itself, not for it to have anything to say. +func (self *DiffCommands) probeFiles() (string, string, func(), bool) { + tempDir := self.os.GetTempDir() + + oldFile, err := os.CreateTemp(tempDir, "lazygit-probe-old-*") + if err != nil { + return "", "", nil, false + } + oldFile.Close() + + newFile, err := os.CreateTemp(tempDir, "lazygit-probe-new-*") + if err != nil { + os.Remove(oldFile.Name()) + return "", "", nil, false + } + newFile.Close() + + return oldFile.Name(), newFile.Name(), func() { + os.Remove(oldFile.Name()) + os.Remove(newFile.Name()) + }, true +} + +func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool { + cmdObj.AddEnvVars("OSC1717=V1") + // A renderer may well object to being handed nothing to render. We want to know + // whatever it said before objecting, and that is captured either way. + output, _ := cmdObj.RunWithOutput() + return strings.Contains(output, metadataHandshake) +} + type DiffCommands struct { *GitCommon } @@ -18,19 +136,40 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands { // This is for generating diffs to be shown in the UI (e.g. rendering a range // diff to the main view). It uses a custom diff renderer if one is configured. -func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { +func (self *DiffCommands) DiffCmdObj(diffArgs []string, mode DiffMode) *oscommands.CmdObj { return self.cmd.New( NewGitCmd("diff"). Config("diff.noprefix=false"). - AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode). Arg("--submodule"). - Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())). + Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))). Arg(diffArgs...). Dir(self.repoPaths.worktreePath). ToArgv(), ) } +// CustomPatchDiffCmdObj is the command that renders the custom patch being built: a diff +// of the two trees the patch was materialized into (PatchCommands.WriteCustomPatchDiffTrees), +// under the directory holding them. It goes through the same wiring as any other diff we +// show, so the patch is rendered by whatever renders the rest of them, and git works out +// how much context to give it. +// +// git's own path prefixes are suppressed because the trees are named a and b themselves, +// which leaves the paths reading like an ordinary diff's over the repo's own paths. +func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *oscommands.CmdObj { + return self.cmd.New( + NewGitCmd("diff"). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode). + Arg("--no-index"). + Arg("--no-prefix"). + Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))). + Arg("a", "b"). + Dir(dir). + ToArgv(), + ) +} + // This is a basic generic diff command that can be used for any diff operation // (e.g. copying a diff to the clipboard). It will not use a custom diff renderer, // and does not use user configs such as ignore whitespace. diff --git a/pkg/commands/git_commands/diff_mode.go b/pkg/commands/git_commands/diff_mode.go new file mode 100644 index 000000000..ed3724ef1 --- /dev/null +++ b/pkg/commands/git_commands/diff_mode.go @@ -0,0 +1,35 @@ +package git_commands + +import ( + "github.com/jesseduffield/lazygit/pkg/config" +) + +// DiffMode says what a diff command's output is for. This decides whether the +// configured diff renderer produces it, and whether it is coloured. +type DiffMode int + +const ( + // DiffModeRendered is the diff as the user has arranged for it to look: through the + // diff renderer, with the renderer's own arguments and its preference about colour. + DiffModeRendered DiffMode = iota + // DiffModeRaw is git's own coloured diff, for showing a diff whose rendered form + // couldn't be acted on. + DiffModeRaw + // DiffModePlain is git's own uncoloured diff, for building patches from and copying + // text out of rather than for looking at. + DiffModePlain +) + +// colorArg returns the value to pass to git's --color for this mode. Rendered output is +// coloured however the renderer wants its input; a raw diff gets git's own colour, which +// is the point of it; a plain one is for reading as text, not for looking at. +func (self DiffMode) colorArg(diffRendererConfigManager *config.DiffRendererConfigManager) string { + switch self { + case DiffModeRendered: + return diffRendererConfigManager.GetColorArg() + case DiffModeRaw: + return "always" + default: + return "never" + } +} diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index f1a7c87b4..588b6f52c 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -123,18 +123,23 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand return self } -func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder { +func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, mode DiffMode) *GitCommandBuilder { contextSize := userConfig.Git.DiffContextSize extDiffCmd := diffRendererConfigManager.GetExternalDiffCommand(contextSize) - useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff + useExtDiff := mode == DiffModeRendered && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff return self. - ConfigIf(forUI && extDiffCmd != "", "diff.external="+extDiffCmd). + ConfigIf(mode == DiffModeRendered && extDiffCmd != "", "diff.external="+extDiffCmd). ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff"). Arg(fmt.Sprintf("--unified=%d", contextSize)). - ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). + // Ignoring whitespace is about what the user wants to see, so it holds for a raw + // diff as much as for a rendered one. Patches are built from a plain diff, + // where a diff that leaves changes out would apply to nothing. + ArgIf(mode != DiffModePlain && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)). - ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...) + // The renderer's own arguments to git — a word diff, say — are part of the + // rendering, so they go with it. + ArgIf(mode == DiffModeRendered, diffRendererConfigManager.GetRawGitArgs()...) } func (self *GitCommandBuilder) ToArgv() []string { diff --git a/pkg/commands/git_commands/patch.go b/pkg/commands/git_commands/patch.go index f40f7fa6f..1e814cd61 100644 --- a/pkg/commands/git_commands/patch.go +++ b/pkg/commands/git_commands/patch.go @@ -2,13 +2,16 @@ package git_commands import ( "fmt" + "os" "path/filepath" + "strings" "time" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" + "github.com/samber/lo" "github.com/stefanhaller/git-todo-parser/todo" ) @@ -20,6 +23,10 @@ type PatchCommands struct { stash *StashCommands PatchBuilder *patch.PatchBuilder + + // The version of the patch the diff trees were last written for, so that they are + // written again when, and only when, the patch has changed since. + treesWrittenForGeneration int } func NewPatchCommands( @@ -40,6 +47,84 @@ func NewPatchCommands( } } +// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no +// longer describes the patch. Call it before rendering the patch, which is often — every +// time the panel showing it re-renders — while the patch itself changes rarely. +func (self *PatchCommands) EnsureCustomPatchDiffTrees() error { + if self.PatchBuilder.Generation() == self.treesWrittenForGeneration { + return nil + } + if err := self.WriteCustomPatchDiffTrees(); err != nil { + return err + } + self.treesWrittenForGeneration = self.PatchBuilder.Generation() + return nil +} + +// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the +// directory the patch builder keeps for it: `a` holds each of the patch's files as it is +// before the patch, `b` as it is after. Diffing those two trees against each other +// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which +// can then be rendered exactly as any other diff is — through a diff renderer of any +// kind, and with git's own idea of how much context to show. +// +// The trees are named a and b so that the diff's paths, with git's own prefixes +// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real +// repo-relative paths. +func (self *PatchCommands) WriteCustomPatchDiffTrees() error { + dir := self.PatchBuilder.TempDir() + if dir == "" { + return nil + } + + before := filepath.Join(dir, "a") + after := filepath.Join(dir, "b") + for _, tree := range []string{before, after} { + if err := os.RemoveAll(tree); err != nil { + return err + } + if err := os.MkdirAll(tree, 0o700); err != nil { + return err + } + } + + for _, file := range self.PatchBuilder.FilesInPatch() { + content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.ContentPath).RunWithOutput() + // A file the patch adds has no content on the before side, so git has nothing to + // show for it. + added := err != nil + + // The before side holds an added file as an empty file rather than not at all, so + // that the diff pairs the two sides up and states the file's real path, instead of + // reporting a file that only one of the trees has. + if err := self.os.CreateFileWithContent(filepath.Join(before, file.Path), + lo.Ternary(added, "", content)); err != nil { + return err + } + // The after side is seeded with the same content, for the patch to change; a file + // the patch adds is left absent, for the patch to create. + if !added { + if err := self.os.CreateFileWithContent(filepath.Join(after, file.Path), content); err != nil { + return err + } + } + } + + // Write added files as creations rather than as diffs against an empty file: the + // patch is applied in one go, so a file it expects to be there already would make + // the whole of it fail. + patchText := self.PatchBuilder.PatchToApply(false, false) + if strings.TrimSpace(patchText) == "" { + // Nothing in the patch, so the two trees are alike and the diff is empty. + return nil + } + patchFilePath, err := self.SaveTemporaryPatch(patchText) + if err != nil { + return err + } + return self.cmd.New(NewGitCmd("apply").Arg(patchFilePath).Dir(after).ToArgv()).Run() +} + type ApplyPatchOpts struct { ThreeWay bool Cached bool diff --git a/pkg/commands/git_commands/stash.go b/pkg/commands/git_commands/stash.go index ea23c5141..f904a7ac4 100644 --- a/pkg/commands/git_commands/stash.go +++ b/pkg/commands/git_commands/stash.go @@ -80,14 +80,14 @@ func (self *StashCommands) Hash(index int) (string, error) { return strings.Trim(hash, "\r\n"), err } -func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { +func (self *StashCommands) ShowStashEntryCmdObj(index int, mode DiffMode) *oscommands.CmdObj { // "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason cmdArgs := NewGitCmd("stash").Arg("show"). - AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode). Arg("-p"). Arg("--stat"). Arg("-u"). - Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())). + Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))). Arg(fmt.Sprintf("refs/stash@{%d}", index)). Dir(self.repoPaths.worktreePath). ToArgv() diff --git a/pkg/commands/git_commands/stash_test.go b/pkg/commands/git_commands/stash_test.go index 8f5629c98..264d8fc57 100644 --- a/pkg/commands/git_commands/stash_test.go +++ b/pkg/commands/git_commands/stash_test.go @@ -174,7 +174,7 @@ func TestStashStashEntryCmdObj(t *testing.T) { } instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths}) - cmdStr := instance.ShowStashEntryCmdObj(s.index).Args() + cmdStr := instance.ShowStashEntryCmdObj(s.index, DiffModeRendered).Args() assert.Equal(t, s.expected, cmdStr) }) } diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 846b359b3..54d249d38 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -383,9 +383,9 @@ func (self *WorkingTreeCommands) Exclude(filename string) error { } // WorktreeFileDiff returns the diff of a file -func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string { +func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, mode DiffMode, cached bool) string { // for now we assume an error means the file was deleted - s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput() + s, _ := self.WorktreeFileDiffCmdObj(file, mode, cached, file.Names()).RunWithOutput() return s } @@ -393,18 +393,13 @@ func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, // in the working tree. node is the item they belong to; all it decides is // whether git has to compare against /dev/null, which is the case for a file // that isn't in the index yet. -func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj { - colorArg := self.diffRendererConfigManager.GetColorArg() - if plain { - colorArg = "never" - } - +func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode DiffMode, cached bool, paths []string) *oscommands.CmdObj { noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile() cmdArgs := NewGitCmd("diff"). - AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode). Arg("--submodule"). - Arg(fmt.Sprintf("--color=%s", colorArg)). + Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))). ArgIf(cached, "--cached"). ArgIf(noIndex, "--no-index"). Arg("--"). @@ -420,25 +415,20 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain // but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. // For a renamed file, previousPath is the path it was renamed from (empty otherwise); // both paths must be passed to git for the rename to be detected. -func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, plain bool) (string, error) { +func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, mode DiffMode) (string, error) { fileNames := []string{fileName} if previousPath != "" { fileNames = append(fileNames, previousPath) } - return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput() + return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, mode).RunWithOutput() } -func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj { - colorArg := self.diffRendererConfigManager.GetColorArg() - if plain { - colorArg = "never" - } - +func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, mode DiffMode) *oscommands.CmdObj { cmdArgs := NewGitCmd("diff"). Config("diff.noprefix=false"). - AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode). Arg("--submodule"). - Arg(fmt.Sprintf("--color=%s", colorArg)). + Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))). Arg(from). Arg(to). ArgIf(reverse, "-R"). diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index 5b87a1320..e9e5e48d2 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -197,7 +197,7 @@ func TestWorkingTreeDiff(t *testing.T) { type scenario struct { testName string file *models.File - plain bool + mode DiffMode cached bool ignoreWhitespace bool contextSize uint64 @@ -215,7 +215,7 @@ func TestWorkingTreeDiff(t *testing.T) { HasStagedChanges: false, Tracked: true, }, - plain: false, + mode: DiffModeRendered, cached: false, ignoreWhitespace: false, contextSize: 3, @@ -230,7 +230,7 @@ func TestWorkingTreeDiff(t *testing.T) { HasStagedChanges: false, Tracked: true, }, - plain: false, + mode: DiffModeRendered, cached: true, ignoreWhitespace: false, contextSize: 3, @@ -245,7 +245,7 @@ func TestWorkingTreeDiff(t *testing.T) { HasStagedChanges: false, Tracked: true, }, - plain: true, + mode: DiffModePlain, cached: false, ignoreWhitespace: false, contextSize: 3, @@ -260,7 +260,7 @@ func TestWorkingTreeDiff(t *testing.T) { HasStagedChanges: false, Tracked: false, }, - plain: false, + mode: DiffModeRendered, cached: false, ignoreWhitespace: false, contextSize: 3, @@ -275,7 +275,7 @@ func TestWorkingTreeDiff(t *testing.T) { HasStagedChanges: false, Tracked: true, }, - plain: false, + mode: DiffModeRendered, cached: false, ignoreWhitespace: true, contextSize: 3, @@ -290,7 +290,7 @@ func TestWorkingTreeDiff(t *testing.T) { HasStagedChanges: false, Tracked: true, }, - plain: false, + mode: DiffModeRendered, cached: false, ignoreWhitespace: false, contextSize: 17, @@ -305,7 +305,7 @@ func TestWorkingTreeDiff(t *testing.T) { HasStagedChanges: false, Tracked: true, }, - plain: false, + mode: DiffModeRendered, cached: false, ignoreWhitespace: false, contextSize: 3, @@ -326,7 +326,7 @@ func TestWorkingTreeDiff(t *testing.T) { } instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths}) - result := instance.WorktreeFileDiff(s.file, s.plain, s.cached) + result := instance.WorktreeFileDiff(s.file, s.mode, s.cached) assert.Equal(t, expectedResult, result) s.runner.CheckForMissingCalls() }) @@ -341,7 +341,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { reverse bool fileName string previousPath string - plain bool + mode DiffMode ignoreWhitespace bool contextSize uint64 runner *oscommands.FakeCmdObjRunner @@ -356,7 +356,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { to: "0987654321", reverse: false, fileName: "test.txt", - plain: false, + mode: DiffModeRendered, ignoreWhitespace: false, contextSize: 3, runner: oscommands.NewFakeRunner(t). @@ -368,7 +368,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { to: "0987654321", reverse: false, fileName: "test.txt", - plain: false, + mode: DiffModeRendered, ignoreWhitespace: false, contextSize: 123, runner: oscommands.NewFakeRunner(t). @@ -380,7 +380,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { to: "0987654321", reverse: false, fileName: "test.txt", - plain: false, + mode: DiffModeRendered, ignoreWhitespace: true, contextSize: 3, runner: oscommands.NewFakeRunner(t). @@ -393,7 +393,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { reverse: false, fileName: "new.txt", previousPath: "old.txt", - plain: false, + mode: DiffModeRendered, ignoreWhitespace: false, contextSize: 3, runner: oscommands.NewFakeRunner(t). @@ -412,7 +412,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths}) - result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain) + result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.mode) assert.NoError(t, err) assert.Equal(t, expectedResult, result) s.runner.CheckForMissingCalls() diff --git a/pkg/commands/patch/hunk.go b/pkg/commands/patch/hunk.go index 568b312a7..e539f3a47 100644 --- a/pkg/commands/patch/hunk.go +++ b/pkg/commands/patch/hunk.go @@ -16,6 +16,11 @@ type Hunk struct { newStart int // the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example) headerContext string + // the lengths declared in the header line ('2' and '3' in the above example), + // kept so that we can check the parsed body against them (see + // Patch.IsWellFormed). Only set by Parse. + declaredOldLength int + declaredNewLength int // the body of the hunk, excluding the header line bodyLines []*PatchLine } diff --git a/pkg/commands/patch/parse.go b/pkg/commands/patch/parse.go index fee7d2918..a26332869 100644 --- a/pkg/commands/patch/parse.go +++ b/pkg/commands/patch/parse.go @@ -7,7 +7,9 @@ import ( "github.com/jesseduffield/lazygit/pkg/utils" ) -var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`) +// Captures, in order: the old start, the old length (omitted by git when it is +// 1), the new start, the new length (likewise), and the trailing context. +var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`) func Parse(patchStr string) *Patch { // ignore trailing newline. @@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch { var currentHunk *Hunk for _, line := range lines { if strings.HasPrefix(line, "@@") { - oldStart, newStart, headerContext := headerInfo(line) + oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line) currentHunk = &Hunk{ - oldStart: oldStart, - newStart: newStart, - headerContext: headerContext, - bodyLines: []*PatchLine{}, + oldStart: oldStart, + newStart: newStart, + declaredOldLength: oldLength, + declaredNewLength: newLength, + headerContext: headerContext, + bodyLines: []*PatchLine{}, } hunks = append(hunks, currentHunk) } else if currentHunk != nil { @@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch { } } -func headerInfo(header string) (int, int, string) { +func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) { match := hunkHeaderRegexp.FindStringSubmatch(header) - oldStart := utils.MustConvertToInt(match[1]) - newStart := utils.MustConvertToInt(match[2]) - headerContext := match[3] + oldStart = utils.MustConvertToInt(match[1]) + oldLength = declaredLength(match[2]) + newStart = utils.MustConvertToInt(match[3]) + newLength = declaredLength(match[4]) + headerContext = match[5] - return oldStart, newStart, headerContext + return oldStart, oldLength, newStart, newLength, headerContext +} + +// declaredLength parses a length capture of a hunk header, which git omits when +// it is 1 (e.g. "@@ -0,0 +1 @@"). +func declaredLength(match string) int { + if match == "" { + return 1 + } + return utils.MustConvertToInt(match) } func newHunkLine(line string) *PatchLine { diff --git a/pkg/commands/patch/patch.go b/pkg/commands/patch/patch.go index fbbf3c935..767f3cd89 100644 --- a/pkg/commands/patch/patch.go +++ b/pkg/commands/patch/patch.go @@ -79,6 +79,44 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int { return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1 } +// IsWellFormed reports whether every hunk's body matches the lengths declared in +// its header. A faithful unified diff always satisfies this; a rendering that +// restructured the diff body does not — a diff renderer that puts line numbers in +// a gutter, say, shifts the +/- marker off the start of each line, so every body +// line reads as context and the computed lengths no longer match the header. That +// makes this the test for whether a rendered diff can be parsed as a unified diff +// at all, rather than trusting a mis-parse. Only meaningful for patches produced +// by Parse, which is where the declared lengths come from. +func (self *Patch) IsWellFormed() bool { + return self.isWellFormed(false) +} + +// IsWellFormedSoFar is IsWellFormed for a patch parsed from a diff we have only the +// beginning of. Its last hunk holds the first lines of a body that hasn't all arrived, +// so every hunk but the last has to match its header exactly, as before, while the last +// one only has to fit within what its header declares. +// +// The check exists to tell a faithful rendering from a restructured one, and it still +// does that. A rendering that moves the +/- marker off the start of the line makes us +// read a change as context, and a context line counts towards both lengths, so such a +// hunk comes out longer than its header declares rather than shorter. +func (self *Patch) IsWellFormedSoFar() bool { + return self.isWellFormed(true) +} + +func (self *Patch) isWellFormed(lastHunkMayBeIncomplete bool) bool { + for i, hunk := range self.hunks { + if lastHunkMayBeIncomplete && i == len(self.hunks)-1 { + return hunk.oldLength() <= hunk.declaredOldLength && + hunk.newLength() <= hunk.declaredNewLength + } + if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength { + return false + } + } + return true +} + func (self *Patch) ContainsChanges() bool { return lo.SomeBy(self.hunks, func(hunk *Hunk) bool { return hunk.containsChanges() @@ -114,6 +152,37 @@ func (self *Patch) LineNumberOfLine(idx int) int { return hunk.newStart + offset } +// Takes a line index in the patch and returns the line number in the old file. +// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives +// the line's position in the old file (additions get the position they sit at). +// If the line is a header line, returns 1. +// If the line is a hunk header line, returns the first old-file line number in that hunk. +// If the line is out of range below, returns the last old-file line number in the last hunk. +func (self *Patch) OldLineNumberOfLine(idx int) int { + if idx < len(self.header) || len(self.hunks) == 0 { + return 1 + } + + hunkIdx := self.HunkContainingLine(idx) + // cursor out of range, return last file line number + if hunkIdx == -1 { + lastHunk := self.hunks[len(self.hunks)-1] + return lastHunk.oldStart + lastHunk.oldLength() - 1 + } + + hunk := self.hunks[hunkIdx] + hunkStartIdx := self.HunkStartIdx(hunkIdx) + idxInHunk := idx - hunkStartIdx + + if idxInHunk == 0 { + return hunk.oldStart + } + + lines := hunk.bodyLines[:idxInHunk-1] + offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT}) + return hunk.oldStart + offset +} + // Returns hunk index containing the line at the given patch line index func (self *Patch) HunkContainingLine(idx int) int { for hunkIdx, hunk := range self.hunks { @@ -196,17 +265,3 @@ func (self *Patch) AdjustLineNumber(lineNumber int) int { return adjustedLineNumber } - -func (self *Patch) IsSingleHunkForWholeFile() bool { - if len(self.hunks) != 1 { - return false - } - - // We consider a patch to be a single hunk for the whole file if it has only additions or - // deletions but not both, and no context lines. This not quite correct, because it will also - // return true for a block of added or deleted lines if the diff context size is 0, but in this - // case you wouldn't be able to stage things anyway, so it doesn't matter. - bodyLines := self.hunks[0].bodyLines - return nLinesWithKind(bodyLines, []PatchLineKind{DELETION, CONTEXT}) == 0 || - nLinesWithKind(bodyLines, []PatchLineKind{ADDITION, CONTEXT}) == 0 -} diff --git a/pkg/commands/patch/patch_builder.go b/pkg/commands/patch/patch_builder.go index 0d5ca34f8..7b619e369 100644 --- a/pkg/commands/patch/patch_builder.go +++ b/pkg/commands/patch/patch_builder.go @@ -1,10 +1,12 @@ package patch import ( + "os" "sort" "strings" "github.com/jesseduffield/generics/maps" + "github.com/jesseduffield/generics/set" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" "github.com/sirupsen/logrus" @@ -33,7 +35,7 @@ type fileInfo struct { } type ( - loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) + loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string) (string, error) ) // PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility @@ -60,12 +62,27 @@ type PatchBuilder struct { // loadFileDiff loads the diff of a file, for a given to (typically a commit hash) loadFileDiff loadFileDiffFunc + + // newTempDir makes a directory for the current patch to be materialized into, as + // two file trees that can be diffed against each other and so rendered like any + // other diff (see PatchCommands.WriteCustomPatchDiffTrees). Its lifetime is the + // patch's: made when one is started, removed when it is given up. + newTempDir func() (string, error) + tempDir string + + // generation counts the changes made to the patch, so that whoever materializes it + // can tell whether what they last built still describes it — and rebuild only then, + // rather than on every render of it. + generation int } -func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder { +func NewPatchBuilder( + log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error), +) *PatchBuilder { return &PatchBuilder{ Log: log, loadFileDiff: loadFileDiff, + newTempDir: newTempDir, } } @@ -73,6 +90,9 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { p.mutex.Lock() defer p.mutex.Unlock() + p.generation++ + p.makeTempDir() + p.To = to p.From = from p.reverse = reverse @@ -91,6 +111,89 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo { return p.fileInfoMap } +// TempDir is the directory the patch is materialized into for rendering, and "" when +// there is none — no patch, or a directory we failed to make. +func (p *PatchBuilder) TempDir() string { + p.mutex.Lock() + defer p.mutex.Unlock() + + return p.tempDir +} + +// Generation says which version of the patch this is; see the field. +func (p *PatchBuilder) Generation() int { + p.mutex.Lock() + defer p.mutex.Unlock() + + return p.generation +} + +// makeTempDir replaces the directory the patch is materialized into with a fresh one. +// Only call this with the lock held. +func (p *PatchBuilder) makeTempDir() { + p.removeTempDir() + if p.newTempDir == nil { + return + } + dir, err := p.newTempDir() + if err != nil { + p.Log.Error(err) + return + } + p.tempDir = dir +} + +// removeTempDir takes the patch's materialized form away with the patch. Only call this +// with the lock held. +func (p *PatchBuilder) removeTempDir() { + if p.tempDir == "" { + return + } + if err := os.RemoveAll(p.tempDir); err != nil { + p.Log.Error(err) + } + p.tempDir = "" +} + +// PatchFile records what materializing the patch needs to know about one of its files: +// where the patch expects to find it, and where its content before the patch comes from. +type PatchFile struct { + // Path is the name the patch knows the file by: for a renamed file, the name it had + // before where the patch carries the rename, and the name it was renamed to where the + // patch keeps only a content change and leaves the rename behind. + Path string + // ContentPath is where the file's content before the patch is to be found in the + // commit the patch is built from — for a renamed file always the name it had there, + // whatever the patch calls it. + ContentPath string +} + +// FilesInPatch says which files the patch touches, in a stable order, and where each of +// them comes from. +func (p *PatchBuilder) FilesInPatch() []PatchFile { + fileInfoMap := p.snapshotFileInfoMap() + + filenames := maps.Keys(fileInfoMap) + sort.Strings(filenames) + + files := make([]PatchFile, 0, len(filenames)) + for _, filename := range filenames { + info := fileInfoMap[filename] + if info.mode == UNSELECTED { + continue + } + file := PatchFile{Path: filename, ContentPath: filename} + if info.previousPath != "" { + file.ContentPath = info.previousPath + if info.mode == WHOLE { + file.Path = info.previousPath + } + } + files = append(files, file) + } + return files +} + func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string { var patch strings.Builder @@ -135,6 +238,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error return err } + p.generation++ p.addFileWhole(info) return nil @@ -146,6 +250,7 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error { return err } + p.generation++ p.removeFile(info) return nil @@ -162,7 +267,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI return info, nil } - diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true) + diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath) if err != nil { return nil, err } @@ -182,6 +287,7 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li if err != nil { return err } + p.generation++ info.mode = PART info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices) @@ -193,6 +299,7 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string, if err != nil { return err } + p.generation++ info.mode = PART info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices) if len(info.includedLineIndices) == 0 { @@ -291,6 +398,110 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus return info.mode } +// LineIdentity says which change line of a file is meant — the line number it has on +// the side it belongs to, and whether it is a deletion — without reference to where +// that line sits in the file's parsed diff. +// +// It is how a diff shown in the main view speaks about its lines: what a rendered row +// resolves to is a line of a file, while the index of that line in the diff depends on +// how much of the diff is being shown and in what order a renderer laid it out. +type LineIdentity struct { + LineNumber int + IsDeletion bool +} + +// ChangeLineIndexByIdentity indexes a parsed diff's change lines by their identity. An +// addition is numbered in the new file and a deletion in the old one. Two consecutive +// deletions share the one new-file position between them, and numbering them in the +// old file keeps them apart. +func ChangeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int { + byIdentity := map[LineIdentity]int{} + for idx, line := range parsed.Lines() { + switch { + case line.IsAddition(): + byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx + case line.IsDeletion(): + byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx + } + } + return byIdentity +} + +// ChangeLineIndicesForLines maps the given change lines of a parsed diff to their +// indices in it. A line that names no change line of the diff — a context line, or a +// line that isn't in the diff at all — contributes nothing. +func ChangeLineIndicesForLines(parsed *Patch, lines []LineIdentity) []int { + byIdentity := ChangeLineIndexByIdentity(parsed) + indices := make([]int, 0, len(lines)) + for _, line := range lines { + if idx, ok := byIdentity[line]; ok { + indices = append(indices, idx) + } + } + return indices +} + +// PatchLineIndicesForLines maps change lines of filename to their indices in that +// file's diff; the patch is built in terms of those indices. everyChange reports +// whether the given lines cover all of the file's changes; this distinguishes acting +// on some of a file's lines from acting on the file itself. +func (p *PatchBuilder) PatchLineIndicesForLines( + filename string, previousPath string, lines []LineIdentity, +) (indices []int, everyChange bool, err error) { + info, err := p.getFileInfo(filename, previousPath) + if err != nil { + return nil, false, err + } + + parsed := Parse(info.diff) + selected := set.NewFromSlice(lines) + everyChange = lo.EveryBy(maps.Keys(ChangeLineIndexByIdentity(parsed)), + func(identity LineIdentity) bool { return selected.Includes(identity) }) + return ChangeLineIndicesForLines(parsed, lines), everyChange, nil +} + +// IncludedLineIdentities says which change lines of filename are in the patch, as the +// identities a diff of that file shown anywhere can be compared against. Empty for a +// file that is no part of the patch. +func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity { + info, ok := p.snapshotFileInfoMap()[filename] + if !ok || info.mode == UNSELECTED { + return nil + } + + included := set.NewFromSlice(info.includedLineIndices) + identities := []LineIdentity{} + for identity, idx := range ChangeLineIndexByIdentity(Parse(info.diff)) { + if included.Includes(idx) { + identities = append(identities, identity) + } + } + return identities +} + +// IncludedChangeLineIndices says which of filename's change lines are in the patch, as +// their indices in the file's diff and in the order the file has them. +// +// It is how a line of the patch as it is shown names the line of the diff it came from: +// all that can be said about a line of the patch is which of the file's changes it is, +// its line numbers being the patch's own — a patch that leaves an earlier addition out +// numbers everything after it differently from the diff it was built from. +func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int { + info, ok := p.snapshotFileInfoMap()[filename] + if !ok || info.mode == UNSELECTED { + return nil + } + + included := set.NewFromSlice(info.includedLineIndices) + indices := []int{} + for idx, line := range Parse(info.diff).Lines() { + if (line.IsAddition() || line.IsDeletion()) && included.Includes(idx) { + indices = append(indices, idx) + } + } + return indices +} + func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) { info, err := p.getFileInfo(filename, previousPath) if err != nil { @@ -304,6 +515,9 @@ func (p *PatchBuilder) Reset() { p.mutex.Lock() defer p.mutex.Unlock() + p.generation++ + p.removeTempDir() + p.To = "" p.fileInfoMap = map[string]*fileInfo{} } diff --git a/pkg/commands/patch/patch_builder_test.go b/pkg/commands/patch/patch_builder_test.go new file mode 100644 index 000000000..883c6761a --- /dev/null +++ b/pkg/commands/patch/patch_builder_test.go @@ -0,0 +1,140 @@ +package patch + +import ( + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +// newTestPatchBuilder returns a patch builder started for a dummy commit, in which +// every file's diff is the given one. +func newTestPatchBuilder(diff string) *PatchBuilder { + patchBuilder := NewPatchBuilder(logrus.New().WithField("test", "test"), + func(from string, to string, reverse bool, filename string, previousPath string) (string, error) { + return diff, nil + }, + // Nothing here renders the patch, so it needs no directory to be + // materialized into. + nil) + patchBuilder.Start("from", "to", false, true) + return patchBuilder +} + +// In simpleDiff the deletion "-orange" is line index 6 of the parsed diff (line 2 of +// the old file) and the addition "+grape" is index 7 (line 2 of the new file). +func TestPatchLineIndicesForLines(t *testing.T) { + patchBuilder := newTestPatchBuilder(simpleDiff) + + indices, everyChange, err := patchBuilder.PatchLineIndicesForLines("filename", "", []LineIdentity{ + {LineNumber: 2, IsDeletion: true}, // -orange + {LineNumber: 2, IsDeletion: false}, // +grape + {LineNumber: 1, IsDeletion: false}, // " apple", a context line + }) + assert.NoError(t, err) + assert.Equal(t, []int{6, 7}, indices, "the context line names no change line") + assert.True(t, everyChange, "the two changes are all the diff has") +} + +// A renamed file's rename header makes its change lines sit further down the diff, and +// its old-file line numbers are of the file under its previous name. +func TestPatchLineIndicesForLinesOfARenamedFile(t *testing.T) { + patchBuilder := newTestPatchBuilder(renameWithModificationDiff) + + indices, everyChange, err := patchBuilder.PatchLineIndicesForLines("newname", "oldname", []LineIdentity{ + {LineNumber: 2, IsDeletion: true}, // -orange + {LineNumber: 2, IsDeletion: false}, // +grape + }) + assert.NoError(t, err) + assert.Equal(t, []int{9, 10}, indices) + assert.True(t, everyChange) +} + +// everyChange is about the diff alone: whether anything the file changes was left out +// of the selection. What that then means for the patch is the caller's question. +func TestPatchLineIndicesForLinesEveryChange(t *testing.T) { + patchBuilder := newTestPatchBuilder(newFile) + + _, everyChange, err := patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{ + {LineNumber: 1}, + {LineNumber: 2}, + }) + assert.NoError(t, err) + assert.False(t, everyChange, "the file's third added line is left out") + + _, everyChange, err = patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{ + {LineNumber: 1}, + {LineNumber: 2}, + {LineNumber: 3}, + }) + assert.NoError(t, err) + assert.True(t, everyChange) + + // A line the diff doesn't have doesn't stand in for one it does. + patchBuilder = newTestPatchBuilder(deletedFile) + _, everyChange, err = patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{ + {LineNumber: 1, IsDeletion: true}, + {LineNumber: 2, IsDeletion: true}, + {LineNumber: 4, IsDeletion: true}, + }) + assert.NoError(t, err) + assert.False(t, everyChange) +} + +func TestIncludedLineIdentities(t *testing.T) { + patchBuilder := newTestPatchBuilder(simpleDiff) + + // A file no part of the patch has nothing included. + assert.Empty(t, patchBuilder.IncludedLineIdentities("filename")) + + // With only the deletion in, only its identity comes back. + assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6})) + assert.Equal(t, + []LineIdentity{{LineNumber: 2, IsDeletion: true}}, + patchBuilder.IncludedLineIdentities("filename")) + + // With the addition in as well, both do. + assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{7})) + assert.ElementsMatch(t, + []LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}}, + patchBuilder.IncludedLineIdentities("filename")) +} + +func TestFilesInPatch(t *testing.T) { + patchBuilder := newTestPatchBuilder(simpleDiff) + + // A file no part of the patch is no part of what the patch is materialized from. + assert.Empty(t, patchBuilder.FilesInPatch()) + + assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6})) + assert.Equal(t, + []PatchFile{{Path: "filename", ContentPath: "filename"}}, + patchBuilder.FilesInPatch()) +} + +// A renamed file's content is under the name it had before whatever the patch calls the +// file, and the patch calls it by the name it had before only where it carries the +// rename — a partial selection has the rename stripped and names the file by the new one. +func TestFilesInPatchOfARenamedFile(t *testing.T) { + patchBuilder := newTestPatchBuilder(renameWithModificationDiff) + + assert.NoError(t, patchBuilder.AddFileLineRange("newname", "oldname", []int{9})) + assert.Equal(t, + []PatchFile{{Path: "newname", ContentPath: "oldname"}}, + patchBuilder.FilesInPatch()) + + assert.NoError(t, patchBuilder.AddFileWhole("newname", "oldname")) + assert.Equal(t, + []PatchFile{{Path: "oldname", ContentPath: "oldname"}}, + patchBuilder.FilesInPatch()) +} + +// A file taken into the patch whole has every one of its change lines in it. +func TestIncludedLineIdentitiesOfAWholeFile(t *testing.T) { + patchBuilder := newTestPatchBuilder(simpleDiff) + + assert.NoError(t, patchBuilder.AddFileWhole("filename", "")) + assert.ElementsMatch(t, + []LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}}, + patchBuilder.IncludedLineIdentities("filename")) +} diff --git a/pkg/commands/patch/patch_test.go b/pkg/commands/patch/patch_test.go index 4f84041d6..e22876b19 100644 --- a/pkg/commands/patch/patch_test.go +++ b/pkg/commands/patch/patch_test.go @@ -120,6 +120,20 @@ index 9320895..6d79956 100644 lemon ` +// Two deletions with no line between them: they share a new-file line number +// (both sit at the same new-file position), so only their old-file line numbers +// tell them apart. +const consecutiveDeletions = `diff --git a/filename b/filename +index 9320895..6d79956 100644 +--- a/filename ++++ b/filename +@@ -1,4 +1,2 @@ + apple +-grape +-pear + lemon +` + const newFile = `diff --git a/newfile b/newfile new file mode 100644 index 0000000..4e680cc @@ -682,6 +696,148 @@ func TestLineNumberOfLine(t *testing.T) { } } +func TestIsWellFormed(t *testing.T) { + // The body of a diff as rendered with the +/- markers moved out of the text + // and into a gutter: every body line now reads as context, so the lengths no + // longer match the header. + const gutterMangled = `diff --git a/filename b/filename +index 9320895..6d79956 100644 +--- a/filename ++++ b/filename +@@ -1,4 +1,2 @@ + apple + grape + pear + lemon +` + + scenarios := []struct { + testName string + patchStr string + expected bool + }{ + {"simpleDiff", simpleDiff, true}, + {"renameWithModificationDiff", renameWithModificationDiff, true}, + {"addNewlineToEndOfFile", addNewlineToEndOfFile, true}, + {"twoHunks", twoHunks, true}, + {"consecutiveDeletions", consecutiveDeletions, true}, + {"newFile", newFile, true}, + {"deletedFile", deletedFile, true}, + {"addNewlineToPreviouslyEmptyFile", addNewlineToPreviouslyEmptyFile, true}, + {"exampleHunk", exampleHunk, true}, + {"gutterMangled", gutterMangled, false}, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormed()) + }) + } +} + +func TestIsWellFormedSoFar(t *testing.T) { + // A diff read only as far as the middle of its second hunk. + const cutShort = `diff --git a/filename b/filename +index e48a11c..b2ab81b 100644 +--- a/filename ++++ b/filename +@@ -1,5 +1,5 @@ + apple +-grape ++orange + ... + ... + ... +@@ -8,6 +8,8 @@ grape + ... + ... +` + + // The same diff cut short in its first hunk, so that the second is missing + // entirely rather than short. + const cutShortInTheFirstHunk = `diff --git a/filename b/filename +index e48a11c..b2ab81b 100644 +--- a/filename ++++ b/filename +@@ -1,5 +1,5 @@ + apple +-grape +` + + // A rendering with the +/- markers moved into a gutter, cut short: reading the + // changes as context makes the hunk longer than its header declares, not shorter, + // so it doesn't pass for a diff we only have the beginning of. + const gutterMangledAndCutShort = `diff --git a/filename b/filename +index 9320895..6d79956 100644 +--- a/filename ++++ b/filename +@@ -1,4 +1,2 @@ + apple + grape + pear + lemon + melon +` + + scenarios := []struct { + testName string + patchStr string + expected bool + }{ + {"simpleDiff", simpleDiff, true}, + {"twoHunks", twoHunks, true}, + {"cutShort", cutShort, true}, + {"cutShortInTheFirstHunk", cutShortInTheFirstHunk, true}, + {"gutterMangledAndCutShort", gutterMangledAndCutShort, false}, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormedSoFar()) + }) + } +} + +func TestOldLineNumberOfLine(t *testing.T) { + type scenario struct { + testName string + patchStr string + indexes []int + expecteds []int + } + + scenarios := []scenario{ + { + testName: "twoChangesInOneHunk", + patchStr: twoChangesInOneHunk, + indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000}, + expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5}, + }, + { + testName: "consecutiveDeletions", + patchStr: consecutiveDeletions, + indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000}, + expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4}, + }, + { + testName: "renameWithModificationDiff", + patchStr: renameWithModificationDiff, + indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000}, + expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + for i, idx := range s.indexes { + patch := Parse(s.patchStr) + result := patch.OldLineNumberOfLine(idx) + assert.Equal(t, s.expecteds[i], result) + } + }) + } +} + func TestGetNextStageableLineIndex(t *testing.T) { type scenario struct { testName string @@ -765,64 +921,3 @@ func TestAdjustLineNumber(t *testing.T) { }) } } - -func TestIsSingleHunkForWholeFile(t *testing.T) { - scenarios := []struct { - testName string - patchStr string - expectedResult bool - }{ - { - testName: "simpleDiff", - patchStr: simpleDiff, - expectedResult: false, - }, - { - testName: "addNewlineToEndOfFile", - patchStr: addNewlineToEndOfFile, - expectedResult: false, - }, - { - testName: "removeNewlinefromEndOfFile", - patchStr: removeNewlinefromEndOfFile, - expectedResult: false, - }, - { - testName: "twoHunks", - patchStr: twoHunks, - expectedResult: false, - }, - { - testName: "twoChangesInOneHunk", - patchStr: twoChangesInOneHunk, - expectedResult: false, - }, - { - testName: "newFile", - patchStr: newFile, - expectedResult: true, - }, - { - testName: "deletedFile", - patchStr: deletedFile, - expectedResult: true, - }, - { - testName: "addNewlineToPreviouslyEmptyFile", - patchStr: addNewlineToPreviouslyEmptyFile, - expectedResult: true, - }, - { - testName: "exampleHunk", - patchStr: exampleHunk, - expectedResult: false, - }, - } - - for _, s := range scenarios { - t.Run(s.testName, func(t *testing.T) { - patch := Parse(s.patchStr) - assert.Equal(t, s.expectedResult, patch.IsSingleHunkForWholeFile()) - }) - } -} diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index ade614f7d..7bb10927c 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -296,6 +296,8 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([] {[]string{"keybinding", "universal", "cyclePagers"}, "cycleDiffRenderers"}, {[]string{"keybinding", "universal", "cyclePagersReverse"}, "cycleDiffRenderersReverse"}, {[]string{"gui", "windowSize"}, "screenMode"}, + {[]string{"gui", "wrapLinesInStagingView"}, "wrapLinesInDiffView"}, + {[]string{"gui", "useHunkModeInStagingView"}, "useHunkModeInDiffView"}, {[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"}, } @@ -845,11 +847,10 @@ func (c *AppConfig) SaveGlobalUserConfig() { // AppState stores data between runs of the app like when the last update check // was performed and which other repos have been checked out type AppState struct { - LastUpdateCheck int64 - RecentRepos []string - StartupPopupVersion int - DidShowHunkStagingHint bool - LastVersion string // this is the last version the user was using, for the purpose of showing release notes + LastUpdateCheck int64 + RecentRepos []string + StartupPopupVersion int + LastVersion string // this is the last version the user was using, for the purpose of showing release notes // these are for shell commands typed in directly, not for custom commands in the lazygit config. // For backwards compatibility we keep the old name in yaml files. diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index 180f4b882..6bc1ec654 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -108,6 +108,22 @@ func TestMigrationOfRenamedKeys(t *testing.T) { "Renamed 'gui.windowSize' to 'screenMode'", }, }, + { + name: "Rename staging view options", + input: `gui: + wrapLinesInStagingView: false + useHunkModeInStagingView: true +`, + expected: `gui: + wrapLinesInDiffView: false + useHunkModeInDiffView: true +`, + expectedDidChange: true, + expectedChanges: []string{ + "Renamed 'gui.wrapLinesInStagingView' to 'wrapLinesInDiffView'", + "Renamed 'gui.useHunkModeInStagingView' to 'useHunkModeInDiffView'", + }, + }, } for _, s := range scenarios { diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 9738186d9..827ce368c 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -94,7 +94,7 @@ type GuiConfig struct { MouseEvents bool `yaml:"mouseEvents"` // If true, do not show a warning when amending a commit. SkipAmendWarning bool `yaml:"skipAmendWarning"` - // If true, do not show a warning when discarding changes in the staging view. + // If true, do not show a warning when discarding changes from a focused diff. SkipDiscardChangeWarning bool `yaml:"skipDiscardChangeWarning"` // If true, do not show warning when applying/popping the stash SkipStashWarning bool `yaml:"skipStashWarning"` @@ -129,10 +129,10 @@ type GuiConfig struct { // - 'left': split the window horizontally (side panel on the left, main view on the right) // - 'top': split the window vertically (side panel on top, main view below) EnlargedSideViewLocation string `yaml:"enlargedSideViewLocation"` - // If true, wrap lines in the staging view to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text. - WrapLinesInStagingView bool `yaml:"wrapLinesInStagingView"` - // If true, hunk selection mode will be enabled by default when entering the staging view. - UseHunkModeInStagingView bool `yaml:"useHunkModeInStagingView"` + // If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text. + WrapLinesInDiffView bool `yaml:"wrapLinesInDiffView"` + // If true, hunk selection mode will be enabled by default when focusing a diff. + UseHunkModeInDiffView bool `yaml:"useHunkModeInDiffView"` // One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' | 'pt' Language string `yaml:"language" jsonschema:"enum=auto,enum=en,enum=zh-TW,enum=zh-CN,enum=pl,enum=nl,enum=ja,enum=ko,enum=ru"` // Format used when displaying time e.g. commit time. @@ -660,6 +660,8 @@ type KeybindingCommitFilesConfig struct { type KeybindingMainConfig struct { PrevHunk Keybinding `yaml:"prevHunk"` NextHunk Keybinding `yaml:"nextHunk"` + PrevFile Keybinding `yaml:"prevFile"` + NextFile Keybinding `yaml:"nextFile"` ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"` PickBothHunks Keybinding `yaml:"pickBothHunks"` EditSelectHunk Keybinding `yaml:"editSelectHunk"` @@ -876,8 +878,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { }, MainPanelSplitMode: "flexible", EnlargedSideViewLocation: "left", - WrapLinesInStagingView: true, - UseHunkModeInStagingView: true, + WrapLinesInDiffView: true, + UseHunkModeInDiffView: true, Language: "auto", TimeFormat: "02 Jan 06", ShortTimeFormat: time.Kitchen, @@ -1170,6 +1172,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { Main: KeybindingMainConfig{ PrevHunk: Keybinding{"", "h"}, NextHunk: Keybinding{"", "l"}, + PrevFile: Keybinding{"N"}, + NextFile: Keybinding{"n"}, ToggleSelectHunk: Keybinding{"a"}, PickBothHunks: Keybinding{"b"}, EditSelectHunk: Keybinding{"E"}, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 3f836a21b..ca785f480 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -210,10 +210,6 @@ var ValidCustomCommandContexts = []string{ "stash", "normal", "normalSecondary", - "staging", - "stagingSecondary", - "patchBuilding", - "patchBuildingSecondary", "mergeConflicts", "menu", "confirmation", diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 7977e3e4c..4b2c1f2af 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -242,6 +242,10 @@ func TestUserConfigValidate_enums(t *testing.T) { {value: "commits,subCommits", valid: true}, {value: "invalid_value", valid: false}, {value: "commits, invalid_value", valid: false}, + // The staging and patch-building panels are gone, so a config that + // still names their contexts is reported rather than fatal. + {value: "staging", valid: false}, + {value: "patchBuilding", valid: false}, }, }, { diff --git a/pkg/gocui/block_events_test.go b/pkg/gocui/block_events_test.go index 277bac89a..e95b834ff 100644 --- a/pkg/gocui/block_events_test.go +++ b/pkg/gocui/block_events_test.go @@ -50,6 +50,22 @@ func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) { return GocuiEvent{Type: eventKey, Key: key}, &fired } +// runQueuedWork runs what the gui has queued for the following passes of the +// event loop, which is where EndBlockingEvents leaves the replay of the keys it +// buffered. +func runQueuedWork(t *testing.T, g *Gui) { + t.Helper() + + for { + ev, ok := g.userEvents.dequeue() + if !ok { + return + } + assert.NoError(t, ev.f(g)) + ev.task.Done() + } +} + func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) { g := newTestGui(t) keyEvent, fired := setupKeyRecorder(t, g) @@ -64,12 +80,37 @@ func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) { assert.NoError(t, g.handleEvent(&keyEvent)) assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking") - // Unblocking replays the buffered keys. - assert.NoError(t, g.EndBlockingEvents()) + // Unblocking queues the replay rather than dispatching from here. + g.EndBlockingEvents() + assert.Len(t, *fired, 1, "the replay must wait for the event loop") + + runQueuedWork(t, g) assert.Len(t, *fired, 3, "both buffered keys should replay on unblock") assert.Empty(t, g.bufferedKeyEvents) } +func TestBlockingEvents_KeysArrivingBeforeTheReplayGoBehindIt(t *testing.T) { + g := newTestGui(t) + keyEvent, fired := setupKeyRecorder(t, g) + other := GocuiEvent{Type: eventKey, Key: NewKeyRune('y')} + g.SetKeybinding("main", other.Key, func(*Gui, *View) error { + *fired = append(*fired, 0) + return nil + }) + + g.BeginBlockingEvents() + assert.NoError(t, g.handleEvent(&keyEvent)) + g.EndBlockingEvents() + + // A key pressed while the replay is still queued joins the end of the buffer: + // dispatching it now would put it ahead of the keys buffered before it. + assert.NoError(t, g.handleEvent(&other)) + assert.Empty(t, *fired) + + runQueuedWork(t, g) + assert.Equal(t, []int{1, 0}, *fired, "the keys should arrive in the order they were pressed") +} + func TestBlockingEvents_NestsWithCounter(t *testing.T) { g := newTestGui(t) keyEvent, fired := setupKeyRecorder(t, g) @@ -79,11 +120,13 @@ func TestBlockingEvents_NestsWithCounter(t *testing.T) { assert.NoError(t, g.handleEvent(&keyEvent)) // The inner block ending still leaves us blocked: no replay yet. - assert.NoError(t, g.EndBlockingEvents()) + g.EndBlockingEvents() + runQueuedWork(t, g) assert.Empty(t, *fired) // Only the outermost block ending replays. - assert.NoError(t, g.EndBlockingEvents()) + g.EndBlockingEvents() + runQueuedWork(t, g) assert.Len(t, *fired, 1) } @@ -94,5 +137,6 @@ func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) { click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)} assert.NoError(t, g.handleEvent(&click)) assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered") - assert.NoError(t, g.EndBlockingEvents()) + g.EndBlockingEvents() + runQueuedWork(t, g) } diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index 7f3de9e6e..16b8f1cc7 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -20,6 +20,25 @@ type escapeInterpreter struct { instruction instruction hyperlink strings.Builder + // the digits of the OSC number seen so far, while we don't yet know which + // OSC this is + oscNumber strings.Builder + // the payload of an OSC 1717 sequence, in which a diff renderer states + // which line of which file it is about to render; accumulated like + // hyperlink, and attached to the cells that follow it + metadata strings.Builder + // whether the payload currently in metadata has reached a cell, so that one + // that never does can be recognized and kept as an orphan + metadataConsumed bool + // OSC 1717 payloads that no cell took, because the next record followed + // with nothing rendered in between. A renderer emits records back to back + // wherever two diff lines share a rendered line — the deletion and the + // addition of a modification collapsed into one column, or a banner + // announcing a file and its first hunk at once. The write loop gives these + // cells of their own, so that a line keeps every record it was given rather + // than only the last. + orphanedMetadata []string + // ConPTY emits cursor-positioning escapes (CUP) to skip over blank // rows rather than emitting LFs for them. To convert those into row // advances the view can act on, we track where in the pseudo-terminal @@ -82,9 +101,9 @@ const ( stateParams stateCSIDiscard stateOSC - stateOSCWaitForParams stateOSCParams stateOSCHyperlink + stateOSCMetadata stateOSCEndEscape stateOSCSkipUnknown @@ -427,27 +446,42 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { } return true, nil case stateOSC: - if characterEquals(ch, '8') { - ei.state = stateOSCWaitForParams - ei.hyperlink.Reset() + // Accumulate the OSC number until the ';' that terminates it, then + // dispatch on the whole number rather than on a single digit. + switch { + case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9': + ei.oscNumber.WriteByte(ch[0]) + return true, nil + case characterEquals(ch, ';'): + switch ei.oscNumber.String() { + case "8": + ei.hyperlink.Reset() + ei.state = stateOSCParams + case "1717": + ei.orphanUnconsumedMetadata() + ei.state = stateOSCMetadata + default: + ei.state = stateOSCSkipUnknown + } + ei.oscNumber.Reset() + return true, nil + default: + // Not an OSC we understand — it has no number, or a character + // follows the number where the ';' should be. Rather than + // erroring, which would reset state mid-OSC and leak the rest of + // the sequence into the view as literal text, skip to its + // terminator, which this character may already be. + ei.oscNumber.Reset() + switch { + case characterEquals(ch, 0x07): + ei.state = stateNone + case characterEquals(ch, 0x1b): + ei.state = stateOSCEndEscape + default: + ei.state = stateOSCSkipUnknown + } return true, nil } - - ei.state = stateOSCSkipUnknown - return true, nil - case stateOSCWaitForParams: - if !characterEquals(ch, ';') { - // Malformed OSC 8 (expected ';' after '8'). Rather than - // erroring — which would reset state mid-OSC and cause the - // rest of the sequence to leak as literal text — treat the - // whole OSC as one we don't understand and skip to its - // terminator. - ei.state = stateOSCSkipUnknown - return true, nil - } - - ei.state = stateOSCParams - return true, nil case stateOSCParams: if characterEquals(ch, ';') { ei.state = stateOSCHyperlink @@ -463,6 +497,18 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.hyperlink.Write(ch) } return true, nil + case stateOSCMetadata: + switch { + case characterEquals(ch, 0x07): + ei.dropMetadataIfHandshake() + ei.state = stateNone + case characterEquals(ch, 0x1b): + ei.dropMetadataIfHandshake() + ei.state = stateOSCEndEscape + default: + ei.metadata.Write(ch) + } + return true, nil case stateOSCEndEscape: ei.state = stateNone return true, nil @@ -478,6 +524,37 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { return false, nil } +// orphanUnconsumedMetadata clears the metadata accumulator for a new OSC 1717 +// record, keeping the payload it held as an orphan if no cell took it (see +// orphanedMetadata). +func (ei *escapeInterpreter) orphanUnconsumedMetadata() { + if ei.metadata.Len() > 0 && !ei.metadataConsumed { + ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String()) + } + ei.metadata.Reset() + ei.metadataConsumed = false +} + +// takeOrphanedMetadata hands the accumulated orphaned payloads to the caller and +// clears the list. +func (ei *escapeInterpreter) takeOrphanedMetadata() []string { + result := ei.orphanedMetadata + ei.orphanedMetadata = nil + return result +} + +// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that +// carries nothing beyond the version. A diff renderer emits such a record ahead +// of everything else to announce that it speaks the protocol, so that a host can +// find that out by asking rather than by inspecting a rendering. It says nothing +// about any line, so it must not attach to the line that follows it; a per-line +// record always has fields, and is kept. +func (ei *escapeInterpreter) dropMetadataIfHandshake() { + if !strings.Contains(ei.metadata.String(), ";") { + ei.metadata.Reset() + } +} + func (ei *escapeInterpreter) outputCSI() error { n := len(ei.csiParam) for i := 0; i < n; { diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index 39ccbe908..0efef2a77 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -167,6 +167,7 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) { "\x1b[0 q", // intermediate byte after a param "\x1b[1;;m", // malformed SGR: empty middle param "\x1b]8bogus\x07", // OSC 8 missing ';' + "\x1b]1337;File=inline=1\x07", // OSC with a number we don't implement "\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap "\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params } diff --git a/pkg/gocui/flush_test.go b/pkg/gocui/flush_test.go index d4082fcf6..d5d6b585f 100644 --- a/pkg/gocui/flush_test.go +++ b/pkg/gocui/flush_test.go @@ -64,8 +64,8 @@ func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) { assert.True(t, status.IsTainted(), "status view should be tainted after SetContent") assert.False(t, main.IsTainted(), "main view should not be tainted (was not modified)") - // flushContentOnly should succeed and clear status tainted flag - assert.NoError(t, g.flushContentOnly(g.views)) + // flushContentOnly should clear status tainted flag + g.flushContentOnly(g.views) assert.False(t, status.IsTainted(), "status view should not be tainted after flushContentOnly") assert.False(t, main.IsTainted(), "main view should not be tainted after flushContentOnly") @@ -76,11 +76,28 @@ func TestFlushContentOnly_WritesCorrectContent(t *testing.T) { status, _ := setupViews(t, g) status.SetContent("Fetching |") - assert.NoError(t, g.flushContentOnly(g.views)) + g.flushContentOnly(g.views) assert.Equal(t, "Fetching |", status.Buffer()) } +func TestForceFlushViewsContentOnlyDrawsLineFlash(t *testing.T) { + g := newTestGui(t) + _, main := setupViews(t, g) + main.Highlight = true + main.SelBgColor = ColorBlue + main.SelectedLineColorWidth = 2 + main.FocusPoint(0, 0, false) + + main.SetLineFlash(0) + g.ForceFlushViewsContentOnly(g.Views()) + + for x := main.x0 + 1; x <= main.x0+2; x++ { + _, style, _ := Screen.Get(x, main.y0+1) + assert.True(t, style.HasReverse(), "selection-bar cell at x=%d should flash", x) + } +} + func TestProcessEvent_ContentOnlyEvent_SkipsTaintedCheck(t *testing.T) { g := newTestGui(t) status, main := setupViews(t, g) @@ -231,7 +248,7 @@ func TestFlushContentOnly_DoesNotOverdrawHigherZViews(t *testing.T) { assert.False(t, popup.IsTainted(), "popup should not be tainted") // flushContentOnly is what spinner ticks ultimately invoke. - assert.NoError(t, g.flushContentOnly(g.views)) + g.flushContentOnly(g.views) assert.Equal(t, "P", cellAt(21, 9), "popup region must still show popup content after flushContentOnly; "+ @@ -279,7 +296,7 @@ func TestFlushContentOnly_RedrawsTransitivelyOverlappingViews(t *testing.T) { assert.False(t, b.IsTainted()) assert.False(t, c.IsTainted()) - assert.NoError(t, g.flushContentOnly(g.views)) + g.flushContentOnly(g.views) // a redrawn (direct). assert.Equal(t, "X", cellAt(5, 5), "a should be redrawn (tainted)") diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 67fbd1aa2..255d4fa2c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -91,6 +91,14 @@ type ViewMouseBinding struct { // must be a mouse key Key KeyName + + // If true, this binding is dispatched before ShouldHandleMouseEvent is + // consulted, so it fires even when a popup panel is focused and the click + // lands on a view other than that panel (which is normally swallowed). This + // is the same early phase that hyperlink clicks are handled in; use it for + // clicks that must stay live behind a popup, e.g. opening a diff line in the + // editor from the main view behind the commit-message panel. + HandleWhenPopupPanelFocused bool } type ViewMouseBindingOpts struct { @@ -230,10 +238,13 @@ type Gui struct { // blockInputCount, when greater than zero, withholds keyboard input from // the handlers: key events are buffered into bufferedKeyEvents and replayed // once the count drops back to zero, while mouse clicks and hover are - // dropped outright. It's a counter so blocking can nest. Both fields are - // only touched on the UI thread. See BeginBlockingEvents. + // dropped outright. It's a counter so blocking can nest. replayPending says + // that the replay is queued but hasn't run yet, and input is withheld until + // it has. All three fields are only touched on the UI thread. See + // BeginBlockingEvents. blockInputCount int bufferedKeyEvents []GocuiEvent + replayPending bool } type NewGuiOpts struct { @@ -391,13 +402,12 @@ func (g *Gui) Size() (x, y int) { // corner of the terminal. It checks if the position is valid and applies // the given colors. // Should only be used if you know that the given rune is not part of a grapheme cluster. -func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) error { +func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) { if x < 0 || y < 0 || x >= g.maxX || y >= g.maxY { // swallowing error because it's not that big of a deal - return nil + return } tcellSetCell(x, y, string(ch), fgColor, bgColor, g.outputMode) - return nil } // SetView creates a new view with its top-left corner at (x0, y0) @@ -419,7 +429,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er v.y1 = y1 if sizeChanged { - v.ClearViewLines() + v.RewrapContent() if v.Editable { cursorX, cursorY := v.TextArea.GetCursorXY() @@ -888,12 +898,33 @@ func (g *Gui) BeginBlockingEvents() { // normal dispatch path, so they act on the now-current context (a key whose // binding no longer exists is simply ignored, just as if it had been pressed // now). Must be called on the UI thread. -func (g *Gui) EndBlockingEvents() error { +// +// The replay is queued rather than run here, so that the buffered keys arrive on +// a later pass of the event loop, as they would have if the user had pressed them +// then. Running them here dispatches them from the middle of whatever the caller +// was doing. If a caller ends the block partway through updating the screen, a +// handler then acts on state the caller has yet to finish writing. +func (g *Gui) EndBlockingEvents() { g.blockInputCount-- if g.blockInputCount > 0 { - return nil + return } + // Input stays withheld until the replay has run. Gui events are dispatched in + // preference to queued work (see processRemainingEvents), so a key pressed + // before the replay gets its turn would otherwise be handled ahead of the keys + // buffered before it. + g.replayPending = true + g.Update(func(*Gui) error { return g.replayBufferedKeys() }) +} + +// replayBufferedKeys dispatches the keys withheld while input was blocked, and +// lets input through again. One of their handlers may block input afresh, and +// then the keys after it are withheld in their turn, to be replayed when that +// block ends. +func (g *Gui) replayBufferedKeys() error { + g.replayPending = false + buffered := g.bufferedKeyEvents g.bufferedKeyEvents = nil for i := range buffered { @@ -1123,7 +1154,8 @@ func (g *Gui) processEvent() error { contentOnly = contentOnly && remainingContentOnly if contentOnly { - return g.flushContentOnly(g.views) + g.flushContentOnly(g.views) + return nil } return g.flush() } @@ -1164,7 +1196,7 @@ func (g *Gui) processRemainingEvents() (bool, error) { // handleEvent handles an event, based on its type (key-press, error, // etc.) func (g *Gui) handleEvent(ev *GocuiEvent) error { - if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) { + if g.withholdingInput() && eventWithheldWhileBlocking(ev) { if ev.Type == eventKey { // Buffer keys so they replay against fresh state on unblock. g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev) @@ -1193,6 +1225,13 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error { } } +// withholdingInput reports whether events are being kept from the handlers. They +// are while a block is in force, and on until the keys it buffered have been +// replayed. +func (g *Gui) withholdingInput() bool { + return g.blockInputCount > 0 || g.replayPending +} + // eventWithheldWhileBlocking reports whether an event must not reach the // handlers while input is blocked (see BeginBlockingEvents). Key events are // withheld (buffered for replay); mouse clicks and hover are withheld (dropped). @@ -1217,7 +1256,7 @@ func (g *Gui) onResize() { } // drawFrameEdges draws the horizontal and vertical edges of a view. -func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error { +func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) { runeH, runeV := '─', '│' if len(v.FrameRunes) >= 2 { runeH, runeV = v.FrameRunes[0], v.FrameRunes[1] @@ -1228,14 +1267,10 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error { continue } if v.y0 > -1 && v.y0 < g.maxY { - if err := g.SetRune(x, v.y0, runeH, fgColor, bgColor); err != nil { - return err - } + g.SetRune(x, v.y0, runeH, fgColor, bgColor) } if v.y1 > -1 && v.y1 < g.maxY { - if err := g.SetRune(x, v.y1, runeH, fgColor, bgColor); err != nil { - return err - } + g.SetRune(x, v.y1, runeH, fgColor, bgColor) } } @@ -1245,19 +1280,14 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error { continue } if v.x0 > -1 && v.x0 < g.maxX { - if err := g.SetRune(v.x0, y, runeV, fgColor, bgColor); err != nil { - return err - } + g.SetRune(v.x0, y, runeV, fgColor, bgColor) } if v.x1 > -1 && v.x1 < g.maxX { runeToPrint := calcScrollbarRune(showScrollbar, realScrollbarStart, realScrollbarEnd, y, runeV) - if err := g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor); err != nil { - return err - } + g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor) } } - return nil } func calcScrollbarRune( @@ -1357,17 +1387,13 @@ func corner(v *View, directions byte) rune { } // drawFrameCorners draws the corners of the view. -func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) error { +func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) { if v.y0 == v.y1 { if !g.SupportOverlaps && v.x0 >= 0 && v.x1 >= 0 && v.y0 >= 0 && v.x0 < g.maxX && v.x1 < g.maxX && v.y0 < g.maxY { - if err := g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor); err != nil { - return err - } - if err := g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor); err != nil { - return err - } + g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor) + g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor) } - return nil + return } runeTL, runeTR, runeBL, runeBR := '┌', '┐', '└', '┘' @@ -1388,18 +1414,15 @@ func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) error { for _, c := range corners { if c.x >= 0 && c.y >= 0 && c.x < g.maxX && c.y < g.maxY { - if err := g.SetRune(c.x, c.y, c.ch, fgColor, bgColor); err != nil { - return err - } + g.SetRune(c.x, c.y, c.ch, fgColor, bgColor) } } - return nil } // drawTitle draws the title of the view. -func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error { +func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) { if v.y0 < 0 || v.y0 >= g.maxY { - return nil + return } tabs := v.Tabs @@ -1435,9 +1458,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error { x := v.x0 + 2 for _, ch := range prefix { - if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil { - return err - } + g.SetRune(x, v.y0, ch, fgColor, bgColor) x += uniseg.StringWidth(string(ch)) } for i, ch := range str { @@ -1460,64 +1481,55 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error { currentFgColor &= ^AttrBold } } - if err := g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor); err != nil { - return err - } + g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor) x += uniseg.StringWidth(string(ch)) } - return nil } // drawSubtitle draws the subtitle of the view. -func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error { +func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) { if v.y0 < 0 || v.y0 >= g.maxY { - return nil + return } start := v.x1 - 5 - uniseg.StringWidth(v.Subtitle) if start < v.x0 { - return nil + return } x := start for _, ch := range v.Subtitle { if x >= v.x1 { break } - if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil { - return err - } + g.SetRune(x, v.y0, ch, fgColor, bgColor) x += uniseg.StringWidth(string(ch)) } - return nil } // drawListFooter draws the footer of a list view, showing something like '1 of 10' -func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error { +func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) { if len(v.buf.lines) == 0 { - return nil + return } message := v.Footer if v.y1 < 0 || v.y1 >= g.maxY { - return nil + return } start := v.x1 - 1 - uniseg.StringWidth(message) if start < v.x0 { - return nil + return } x := start for _, ch := range message { if x >= v.x1 { break } - if err := g.SetRune(x, v.y1, ch, fgColor, bgColor); err != nil { - return err - } + g.SetRune(x, v.y1, ch, fgColor, bgColor) x += uniseg.StringWidth(string(ch)) } - return nil } // flush updates the gui, re-drawing frames and buffers. @@ -1534,7 +1546,7 @@ func (g *Gui) flush() error { // if GUI's size has changed, we need to redraw all views if maxX != g.maxX || maxY != g.maxY { for _, v := range g.views { - v.ClearViewLines() + v.RewrapContent() } } g.maxX, g.maxY = maxX, maxY @@ -1545,40 +1557,35 @@ func (g *Gui) flush() error { } } for _, v := range g.views { - if err := g.draw(v); err != nil { - return err - } + g.draw(v) } Screen.Show() return nil } -// Redraws only tainted views and skips the layout pass. +// Redraws only dirty views and skips the layout pass. // tcell's cell-level dirty tracking ensures only // actually-changed cells are emitted to the terminal. -// Will also redraw any views that overlap tainted views -func (g *Gui) flushContentOnly(views []*View) error { +// Will also redraw any views that overlap dirty views. +func (g *Gui) flushContentOnly(views []*View) { // The screen must not be touched while suspended (see Suspend). if g.isSuspended() { - return nil + return } for _, v := range viewsToRedrawContentOnly(views) { - if err := g.draw(v); err != nil { - return err - } + g.draw(v) } Screen.Show() - return nil } func viewsToRedrawContentOnly(views []*View) []*View { redrawIndexes := set.New[int]() for i, v := range views { - if !v.IsTainted() && !redrawIndexes.Includes(i) { + if !v.NeedsRedraw() && !redrawIndexes.Includes(i) { continue } @@ -1608,11 +1615,11 @@ func (g *Gui) ForceLayoutAndRedraw() error { return g.flush() } -// Redraws only tainted views outside of the normal main +// Redraws only dirty views outside of the normal main // loop, without a layout pass. Useful during longer operations that block the // main thread, e.g. to update a spinner in a status view. -func (g *Gui) ForceFlushViewsContentOnly(views []*View) error { - return g.flushContentOnly(views) +func (g *Gui) ForceFlushViewsContentOnly(views []*View) { + g.flushContentOnly(views) } // hasFocus reports whether a view is drawn as focused. Views that are embedded @@ -1630,9 +1637,9 @@ func outermostView(v *View) *View { } // draw manages the cursor and calls the draw function of a view. -func (g *Gui) draw(v *View) error { +func (g *Gui) draw(v *View) { if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 { - return nil + return } if g.Cursor { @@ -1671,30 +1678,18 @@ func (g *Gui) draw(v *View) error { } } - if err := g.drawFrameEdges(v, frameColor, bgColor); err != nil { - return err - } - if err := g.drawFrameCorners(v, frameColor, bgColor); err != nil { - return err - } + g.drawFrameEdges(v, frameColor, bgColor) + g.drawFrameCorners(v, frameColor, bgColor) if v.Title != "" || len(v.Tabs) > 0 { - if err := g.drawTitle(v, fgColor, bgColor); err != nil { - return err - } + g.drawTitle(v, fgColor, bgColor) } if v.Subtitle != "" { - if err := g.drawSubtitle(v, fgColor, bgColor); err != nil { - return err - } + g.drawSubtitle(v, fgColor, bgColor) } if v.Footer != "" && g.ShowListFooter { - if err := g.drawListFooter(v, fgColor, bgColor); err != nil { - return err - } + g.drawListFooter(v, fgColor, bgColor) } } - - return nil } // onKey manages key-press events. A keybinding handler is called when @@ -1778,6 +1773,22 @@ func (g *Gui) onKey(ev *GocuiEvent) error { } } + var mouseOpts ViewMouseBindingOpts + if IsMouseKey(ev.Key) { + isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v) + mouseOpts = ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick} + + // Dispatch bindings that opt into firing while a popup panel is focused + // before the gate below gets a chance to reject the click. + matched, err := g.execMouseKeybindings(v, ev, mouseOpts, true) + if err != nil { + return err + } + if matched { + return nil + } + } + if g.ShouldHandleMouseEvent != nil { if !g.ShouldHandleMouseEvent(v, ev.Key.KeyName()) { // Give clients a chance to reject clicks, for example clicks in inactive views @@ -1827,9 +1838,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error { } if IsMouseKey(ev.Key) { - isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v) - opts := ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick} - matched, err := g.execMouseKeybindings(v, ev, opts) + matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false) if err != nil { return err } @@ -1893,11 +1902,12 @@ func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool { return isDoubleClick } -func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) { +func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts, handleWhenPopupPanelFocused bool) (bool, error) { isMatch := func(binding *ViewMouseBinding) bool { return binding.ViewName == view.Name() && ev.Key.KeyName() == binding.Key && - ev.Key.Mod() == binding.Modifier + ev.Key.Mod() == binding.Modifier && + binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused } // first pass looks for ones that match the focused view @@ -2066,6 +2076,9 @@ func (g *Gui) Suspend() error { return errors.New("Already suspended") } + for _, view := range g.views { + view.ClearLineFlash() + } g.suspended = true if err := g.screen.Suspend(); err != nil { diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go index ded220bea..28d51ffac 100644 --- a/pkg/gocui/suspend_test.go +++ b/pkg/gocui/suspend_test.go @@ -18,7 +18,7 @@ func TestFlushIsNoOpWhileSuspended(t *testing.T) { flush func(g *Gui) error }{ {"flush", func(g *Gui) error { return g.flush() }}, - {"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }}, + {"flushContentOnly", func(g *Gui) error { g.flushContentOnly(g.views); return nil }}, } for _, tc := range tests { @@ -67,3 +67,14 @@ func TestResumeSchedulesRedraw(t *testing.T) { assert.Equal(t, eventResize, ev.Type, "resuming must schedule a redraw; without one the screen stays blank until the next event arrives") } + +func TestSuspendClearsLineFlashes(t *testing.T) { + g := newTestGui(t) + v, err := g.SetView("main", 0, 0, 20, 10, 0) + assert.ErrorIs(t, err, ErrUnknownView) + v.SetLineFlash(3) + + assert.NoError(t, g.Suspend()) + assert.Equal(t, -1, v.lineFlashY) + assert.NoError(t, g.Resume()) +} diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 312d6d5a2..ab36becff 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -202,6 +202,7 @@ const ( var ( lastMouseKey tcell.ButtonMask = tcell.ButtonNone + lastMouseMod tcell.ModMask = tcell.ModNone dragState = NOT_DRAGGING lastX = 0 lastY = 0 @@ -370,6 +371,12 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone { newButtonPress = true lastMouseKey = button + // The keyboard modifiers held at press time apply to the whole gesture: + // the press, every drag event, and the release. Snapshotting them here + // keeps a modified press from producing events that match unmodified + // bindings, and ignores modifier changes while the button is held. + lastMouseMod = tev.Modifiers() + mouseMod = Modifier(lastMouseMod) switch button { case tcell.ButtonPrimary: mouseKey = MouseLeft @@ -395,7 +402,8 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { case tcell.ButtonMiddle: default: } - mouseMod = ModNone + mouseMod = Modifier(lastMouseMod) + lastMouseMod = tcell.ModNone lastMouseKey = tcell.ButtonNone } default: @@ -426,10 +434,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { // reaches drag bindings instead of being delivered with the // default MouseRelease key. dragState = DRAGGING - mouseMod = ModMotion + mouseMod = Modifier(lastMouseMod) | ModMotion mouseKey = MouseLeft case DRAGGING: - mouseMod = ModMotion + mouseMod = Modifier(lastMouseMod) | ModMotion mouseKey = MouseLeft } } diff --git a/pkg/gocui/tcell_driver_test.go b/pkg/gocui/tcell_driver_test.go index 9038e73ce..99a9f8966 100644 --- a/pkg/gocui/tcell_driver_test.go +++ b/pkg/gocui/tcell_driver_test.go @@ -36,21 +36,55 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) { assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName()) } -func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) { +func TestWholeGestureCarriesPressModifiers(t *testing.T) { t.Cleanup(resetMouseState) resetMouseState() - gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt)) - gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt)) + pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt)) + dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt)) releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt)) + assert.Equal(t, eventMouse, pressEvent.Type) + assert.Equal(t, MouseLeft, pressEvent.Key.KeyName()) + assert.Equal(t, ModAlt, pressEvent.Key.Mod()) + assert.Equal(t, eventMouse, dragEvent.Type) + assert.Equal(t, MouseLeft, dragEvent.Key.KeyName()) + assert.Equal(t, ModAlt|ModMotion, dragEvent.Key.Mod()) assert.Equal(t, eventMouse, releaseEvent.Type) assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName()) + assert.Equal(t, ModAlt, releaseEvent.Key.Mod()) +} + +func TestModifierChangesWhileButtonHeldAreIgnored(t *testing.T) { + t.Cleanup(resetMouseState) + resetMouseState() + + gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone)) + dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt)) + releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt)) + + assert.Equal(t, ModMotion, dragEvent.Key.Mod()) assert.Equal(t, ModNone, releaseEvent.Key.Mod()) } +func TestModifiedClickWithoutDragCarriesModifierOnPressAndRelease(t *testing.T) { + t.Cleanup(resetMouseState) + resetMouseState() + + pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModShift)) + releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonNone, tcell.ModShift)) + + assert.Equal(t, eventMouse, pressEvent.Type) + assert.Equal(t, MouseLeft, pressEvent.Key.KeyName()) + assert.Equal(t, ModShift, pressEvent.Key.Mod()) + assert.Equal(t, eventMouse, releaseEvent.Type) + assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName()) + assert.Equal(t, ModShift, releaseEvent.Key.Mod()) +} + func resetMouseState() { lastMouseKey = tcell.ButtonNone + lastMouseMod = tcell.ModNone dragState = NOT_DRAGGING lastX = 0 lastY = 0 diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 0a06d1981..b8356add2 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -79,12 +79,21 @@ type View struct { // a user starts a range select and then moves the cursor up. rangeSelectStartY int + // The view line whose selection-width bar is temporarily reversed. A value + // of -1 means that no line is flashing. + lineFlashY int + // readBuffer is used for storing unread bytes readBuffer []byte // tained is true if the viewLines must be updated tainted bool + // needsRedraw is true if the view's current state has not been drawn to the + // screen yet. A tainted view always needs a redraw, but draw-only state can + // require one without invalidating viewLines. + needsRedraw bool + // firstDirtyLine is the index of the lowest line in `lines` that has been // written to or highlighted since viewLines was last refreshed, and whose // cached wrapping (lineType.wrappedCells) may therefore be stale. Lines @@ -157,6 +166,23 @@ type View struct { // instead of Sel{Bg,Fg}Colors for highlighting selected lines. HighlightInactive bool + // If SelectedLineColorWidth is greater than zero, a highlighted line is painted + // in the selection colors on that many columns at its left edge only, rather + // than across its whole width, leaving the line's own colors to show through. + // For content that conveys meaning by color of its own. + SelectedLineColorWidth int + + // InclusionGutterMarker is the glyph the inclusion gutter draws on a marked line + // (see SetInclusionGutter), and InclusionGutterMarkerColor its color. Both are + // set once, when the view is created. + InclusionGutterMarker string + InclusionGutterMarkerColor Attribute + // showInclusionGutter reserves the gutter's columns at the left of every line, + // and inclusionGutterMarks, indexed by line of the content, says which lines get + // the marker. Set together, via SetInclusionGutter. + showInclusionGutter bool + inclusionGutterMarks []bool + // If Frame is true, a border will be drawn around the view. Frame bool @@ -245,23 +271,145 @@ type pos struct { x, y int } -// call this in the event of a view resize, or if you want to render new content -// without the chance of old content still appearing, or if you want to remove -// a line from the existing content +// call this if you want to render new content without the chance of old content +// still appearing, or if you want to remove a line from the existing content. For +// a view whose size has changed, whose content is the same but has to be wrapped +// afresh, call RewrapContent instead. func (v *View) clearViewLines() { - v.tainted = true + v.markViewLinesDirty() v.viewLines = nil v.clearHover() } -// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on -// the UI thread (the layout pass) that touch a view whose content a task -// goroutine may be writing concurrently: viewLines/tainted/hover are all -// buffer state that writeMutex protects. -func (v *View) ClearViewLines() { +// markViewLinesDirty records that the cached viewLines no longer represent the +// view's buffer or wrapping, so both rebuilding and redrawing are required. +func (v *View) markViewLinesDirty() { + v.tainted = true + v.needsRedraw = true +} + +// RewrapContent wraps the view's content for the size the view has now, and puts +// the positions into that content — the scroll offset, the cursor, a range's +// anchor — back on the lines they were on. They are all view lines, which count +// the segments each line is wrapped into, so wrapping the content at another +// width leaves every one of them pointing at a different line. +// +// Call it on the UI thread whenever the view's size changes; a task goroutine may +// be writing the content concurrently, and all of this is state writeMutex +// protects. +func (v *View) RewrapContent() { v.writeMutex.Lock() defer v.writeMutex.Unlock() + + v.refreshViewLinesIfNeeded() + origin := v.contentPosOf(v.oy) + cursor := v.contentPosOf(v.oy + v.cy) + anchor := v.contentPosOf(v.rangeSelectStartY) + cursorRow := v.cy + v.clearViewLines() + v.refreshViewLinesIfNeeded() + + if !origin.ok { + return + } + + cursorLine, cursorOk := v.viewLineOf(cursor) + if anchorLine, ok := v.viewLineOf(anchor); ok { + v.rangeSelectStartY = anchorLine + if cursorOk { + // A range covers lines of content, not the wrapped segments those + // lines are drawn as, so its ends go back on the outermost segments + // of their lines: a line that was covered whole stays covered whole. + cursorLine = v.viewLineOfRangeEnd(cursor, anchor) + v.rangeSelectStartY = v.viewLineOfRangeEnd(anchor, cursor) + } + } + + // The line the cursor is on keeps the row it was drawn on, so that it doesn't + // move under the user; with no cursor on screen the view keeps its own place + // in the content instead. + if v.Highlight && cursorOk && cursorRow >= 0 && cursorRow < v.InnerHeight() { + v.SetOriginY(cursorLine - cursorRow) + } else if originLine, ok := v.viewLineOf(origin); ok { + v.SetOriginY(originLine) + } + if cursorOk { + v.cy = cursorLine - v.oy + } +} + +// contentPos is a position in a view's content in terms that survive the content +// being wrapped again: which line of it, and which of that line's segments. +type contentPos struct { + line, segment int + ok bool +} + +// contentPosOf returns where the given view line sits in the content. Only call +// this with a lock on writeMutex, and with the view lines up to date. +func (v *View) contentPosOf(viewLine int) contentPos { + if viewLine < 0 || viewLine >= len(v.viewLines) { + return contentPos{} + } + return contentPos{ + line: v.viewLines[viewLine].linesY, + segment: v.viewLines[viewLine].linesX, + ok: true, + } +} + +// viewLineOf returns the view line drawing the given position in the content, +// on the nearest segment its line still has. Only call this with a lock on +// writeMutex, and with the view lines up to date. +func (v *View) viewLineOf(pos contentPos) (int, bool) { + first, last, ok := v.segmentSpanOf(pos) + if !ok { + return 0, false + } + return min(first+pos.segment, last), true +} + +// viewLineOfRangeEnd returns the view line for one end of a range selection: the +// outermost segment of its line, so that the range covers that line whole. other +// is the range's other end, which says which way is outward. Both ends have to be +// positions whose lines are drawn, which viewLineOf answers. +func (v *View) viewLineOfRangeEnd(pos contentPos, other contentPos) int { + first, last, _ := v.segmentSpanOf(pos) + if pos.line <= other.line { + return first + } + return last +} + +// segmentSpanOf returns the first and last view line drawing the given position's +// line of the content. ok is false when the position was never taken, or its line +// isn't drawn at all. +func (v *View) segmentSpanOf(pos contentPos) (int, int, bool) { + if !pos.ok { + return 0, 0, false + } + return v.viewLineSpanOfBufferLine(pos.line) +} + +// viewLineSpanOfBufferLine returns the first and last view line drawing the given +// buffer line, i.e. the first and last segment it is wrapped into. Both are the +// same view line when the line doesn't wrap. ok is false when the line isn't drawn +// at all. Only call this with a lock on writeMutex, and with the view lines up to +// date. +func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) { + first, last := -1, -1 + for i, vline := range v.viewLines { + if vline.linesY == bufferLine { + if first == -1 { + first = i + } + last = i + } else if first != -1 { + break + } + } + return first, last, first != -1 } type searcher struct { @@ -499,10 +647,23 @@ func (v *View) SetRangeSelectStart(rangeSelectStartY int) { v.rangeSelectStartY = rangeSelectStartY } +// RangeSelectStartY returns the view line the range selection is anchored on, +// or -1 when there is no range. +func (v *View) RangeSelectStartY() int { + return v.rangeSelectStartY +} + func (v *View) CancelRangeSelect() { v.rangeSelectStartY = -1 } +// HasRangeSelect reports whether a range selection is anchored, as opposed to the +// view showing a plain cursor. A range whose ends are on the same view line is still +// one, which SelectedLineRange alone can't tell you. +func (v *View) HasRangeSelect() bool { + return v.rangeSelectStartY != -1 +} + func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int { if viewHeight >= lineCount { return 0 @@ -588,6 +749,9 @@ type cell struct { width int // number of terminal cells occupied by chr (always 1 or 2) bgColor, fgColor Attribute hyperlink string + // the OSC 1717 payload in effect when the cell was written, i.e. what the + // diff renderer said about the diff line this cell is part of + metadata string } type cells []cell @@ -621,11 +785,13 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View { Frame: true, Editor: DefaultEditor, tainted: true, + needsRedraw: true, outMode: mode, buf: &viewBuffer{ei: newEscapeInterpreter(mode)}, searcher: &searcher{}, TextArea: &TextArea{}, rangeSelectStartY: -1, + lineFlashY: -1, TabWidth: 4, } @@ -699,6 +865,38 @@ func (v *View) Name() string { return v.name } +// SetInclusionGutter shows or hides a column reserved at the left of every line, in +// which marks — indexed by line of the content — say which lines get +// InclusionGutterMarker drawn, on every segment of a line the view wrapped. The +// content is drawn shifted past it. +// +// It is drawn over the content rather than written into it, so the content itself — +// and with it what each line of the view means, where a click lands, and how the +// lines wrap — is untouched but for the width the gutter takes. +func (v *View) SetInclusionGutter(show bool, marks []bool) { + v.writeMutex.Lock() + changed := v.showInclusionGutter != show + v.showInclusionGutter = show + v.inclusionGutterMarks = marks + v.writeMutex.Unlock() + + if changed { + // The gutter takes its columns from the content, so what is left of it wraps + // differently, and everything pointing into it has to come along. + v.RewrapContent() + } +} + +// inclusionGutterWidth is how many columns the inclusion gutter takes while it is +// shown — the marker plus a column of space before the content — and 0 while it is +// not. Only call this with a lock on writeMutex. +func (v *View) inclusionGutterWidth() int { + if !v.showInclusionGutter { + return 0 + } + return uniseg.StringWidth(v.InclusionGutterMarker) + 1 +} + // setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies // the specified colors, taking into account if the cell must be highlighted. Also, it checks if the // position is valid. @@ -721,7 +919,8 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW rangeSelectEnd = max(relativeRangeSelectStart, v.cy) } - if y >= rangeSelectStart && y <= rangeSelectEnd { + colorWidth := v.SelectedLineColorWidth + if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) { // this ensures we use the bright variant of a colour upon highlight fgColorComponent := fgColor & ^AttrAll if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 { @@ -749,6 +948,10 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW fgColor |= AttrUnderline } + if v.lineFlashY == v.oy+y && (v.SelectedLineColorWidth == 0 || x < v.SelectedLineColorWidth) { + fgColor ^= AttrReverse + } + // Don't display empty characters if ch == "" { ch = " " @@ -941,7 +1144,7 @@ func (v *View) write(p []byte) { return } - v.tainted = true + v.markViewLinesDirty() // write only ever touches lines from v.buf.wy onwards, so any cached wrapping // below that stays valid. v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy) @@ -962,6 +1165,19 @@ func (b *viewBuffer) write(v *View, p []byte) { finishLine := func() { b.autoRenderHyperlinksInCurrentLine(v) + // A record that reached the line's end without covering a cell still + // belongs to the line: an orphan (see escapeInterpreter.orphanedMetadata), + // or the record of a changed line that is empty, which a renderer emits + // with nothing but the newline after it. Give each a cell of its own, so + // that the line is still recognizable as the diff line it renders rather + // than as nothing at all. + for _, payload := range b.ei.takeOrphanedMetadata() { + b.writeCells([]cell{{metadata: payload}}) + } + if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed { + b.writeCells([]cell{{metadata: b.ei.metadata.String()}}) + b.ei.metadataConsumed = true + } } advanceToNextLine := func() { @@ -970,6 +1186,10 @@ func (b *viewBuffer) write(v *View, p []byte) { if b.wy >= len(b.lines) { b.lines = append(b.lines, lineType{}) } + // An OSC 1717 record describes the line it precedes and is never + // closed, so it stops applying at the line's end; a renderer emits a + // fresh one for each line it has something to say about. + b.ei.metadata.Reset() } if b.pendingNewline { @@ -1114,6 +1334,15 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo truncateLine := false isEscape, err := b.ei.parseOne(ch) + + // A record that the next one superseded before any cell took it still + // belongs to this line (see escapeInterpreter.orphanedMetadata); give each + // a cell of its own, in the order they were emitted, ahead of whatever this + // character produces. + for _, payload := range b.ei.takeOrphanedMetadata() { + cells = append(cells, cell{metadata: payload}) + } + if err != nil { for _, chr := range b.ei.characters() { c := cell{ @@ -1140,7 +1369,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo fg: b.ei.curFgColor, bg: b.ei.curBgColor, } - return truncateLine, []cell{} + return truncateLine, cells } else if cf, ok := b.ei.instruction.(cursorForward); ok { // emit `n` space cells under the parser-tracked SGR — used // to materialize ConPTY's compressed runs of spaces (which @@ -1150,8 +1379,12 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo ch = []byte{' '} width = 1 } else if isEscape { - // do not output anything - return truncateLine, nil + // the escape itself outputs nothing, but any cells carrying an + // orphaned record still need writing + if len(cells) == 0 { + return truncateLine, nil + } + return truncateLine, cells } else if characterEquals(ch, '\t') { // fill tab-sized space tabWidth := v.TabWidth @@ -1166,9 +1399,13 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo fgColor: b.ei.curFgColor, bgColor: b.ei.curBgColor, hyperlink: b.ei.hyperlink.String(), + metadata: b.ei.metadata.String(), chr: string(ch), width: width, } + if c.metadata != "" { + b.ei.metadataConsumed = true + } for range repeatCount { cells = append(cells, c) } @@ -1253,9 +1490,9 @@ func (v *View) CopyContent(from *View) { // A background task may be streaming output into the source view's buffer // via Write, so read it under its own lock. The source is always a - // different view than the destination (see the sole caller, - // moveMainContextToTop), and no other code holds two view write locks at - // once, so this can't deadlock. + // different view than the destination — its callers hand content from one + // view to another — and no other code holds two view write locks at once, so + // this can't deadlock. from.writeMutex.Lock() defer from.writeMutex.Unlock() @@ -1330,7 +1567,7 @@ func (v *View) SwapInOffscreenRender() { } v.buf = v.offscreen v.offscreen = nil - v.tainted = true + v.markViewLinesDirty() v.clearHover() } @@ -1494,6 +1731,12 @@ func (v *View) IsTainted() bool { return v.tainted } +func (v *View) NeedsRedraw() bool { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + return v.needsRedraw +} + // draw re-draws the view's contents. func (v *View) draw(isWindowFocused bool) { v.writeMutex.Lock() @@ -1502,6 +1745,7 @@ func (v *View) draw(isWindowFocused bool) { if !v.Visible { return } + defer func() { v.needsRedraw = false }() v.clearRunes() @@ -1533,6 +1777,8 @@ func (v *View) draw(isWindowFocused bool) { emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault} + gutterWidth := v.inclusionGutterWidth() + for y, vline := range v.viewLines[start:] { if y >= maxY { break @@ -1547,10 +1793,20 @@ func (v *View) draw(isWindowFocused bool) { trailingCell.bgColor = attrs.bg } + // The inclusion gutter is blank but for the marker on a marked line, and the + // content begins after it. The blanks go through setCharacter like everything + // else, so that a selection reaching the left edge covers the gutter too. + for gx := range gutterWidth { + v.setCharacter(gx, y, " ", v.FgColor, v.BgColor, isWindowFocused) + } + if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] { + v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused) + } + // x tracks the current x position in the view, and cellIdx tracks the // index of the cell. If we print a double-sized rune, we increment cellIdx // by one but x by two. - x := -v.ox + x := gutterWidth - v.ox cellIdx := 0 var c cell @@ -1564,7 +1820,7 @@ func (v *View) draw(isWindowFocused bool) { // no more characters to write so we're only going to be printing empty cells // past this point - x = 0 + x = gutterWidth } // if we're out of cells to write, we'll just print empty cells. @@ -1599,10 +1855,11 @@ func (v *View) refreshViewLinesIfNeeded() { return } - maxX := v.InnerWidth() wrap := 0 if v.Wrap { - wrap = maxX + // The inclusion gutter, while it is shown, takes its columns out of the width + // the content has to wrap in. + wrap = max(0, v.InnerWidth()-v.inclusionGutterWidth()) } lineIdx := 0 @@ -1744,6 +2001,152 @@ func (v *View) BufferLines() []string { return lines } +// MarkedLines returns the lines of the view's content that the inclusion gutter is +// marking (see SetInclusionGutter), in the order they appear. Empty while the gutter +// is hidden. +func (v *View) MarkedLines() []string { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + if !v.showInclusionGutter { + return nil + } + + lines := []string{} + for i, line := range v.buf.lines { + if i < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[i] { + lines = append(lines, line.cells.String()) + } + } + return lines +} + +// DiffLineContent holds what one line of a rendered diff offers to a reader trying +// to recover which line of which file it came from: the line's text, which can be +// parsed as a unified diff when the rendering preserves one, and the OSC 1717 +// records a diff renderer attached to it, which state the answer outright. +type DiffLineContent struct { + Text string + // The distinct OSC 1717 payloads carried by the line's cells, in + // left-to-right order. A single-column rendering tags every cell of a line + // with the same payload, so there is one; a side-by-side rendering tags + // each side separately, so a line showing a deletion beside the addition + // that replaces it carries both. + Metadata []string +} + +// DiffLineContents returns the per-line material a diff-line reader works from +// (see DiffLineContent), indexed by unwrapped buffer line. Text and records are +// snapshotted in a single locked pass, so they stay consistent with each other +// and with the buffer they came from even while a re-render rebuilds it. +func (v *View) DiffLineContents() []DiffLineContent { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + return diffLineContentsFrom(v.buf, 0) +} + +// OffscreenDiffLineContents is DiffLineContents for the content of a re-render in +// progress (see BeginOffscreenRender). A reader deciding where the new content +// should be shown has to work from this: it has to answer before the swap, since +// after the swap the content is already on screen. Returns nil when no re-render +// is underway. +func (v *View) OffscreenDiffLineContents() []DiffLineContent { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + if v.offscreen == nil { + return nil + } + return diffLineContentsFrom(v.offscreen, 0) +} + +// OffscreenDiffLineContentsFrom is OffscreenDiffLineContents restricted to the lines +// from index `from` on (so result[0] is buffer line `from`). It lets a reader that +// follows a re-render as it loads look at each line once, rather than snapshotting +// the whole buffer again on every line — the difference between an O(n) and an O(n²) +// scan of a large diff. Returns nil when no re-render is underway, or when `from` is +// past the lines read so far. +func (v *View) OffscreenDiffLineContentsFrom(from int) []DiffLineContent { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + if v.offscreen == nil || from < 0 || from >= len(v.offscreen.lines) { + return nil + } + return diffLineContentsFrom(v.offscreen, from) +} + +// OffscreenLineCount returns the number of unwrapped lines a re-render in progress +// has read so far, or 0 when none is underway. It tells a reader waiting for a +// particular line, cheaply, when a screenful below it has arrived too — so that the +// swap shows that line with content under it rather than at the bottom edge of a +// half-filled view. +func (v *View) OffscreenLineCount() int { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + if v.offscreen == nil { + return 0 + } + return len(v.offscreen.lines) +} + +func diffLineContentsFrom(buf *viewBuffer, from int) []DiffLineContent { + lines := buf.lines[from:] + contents := make([]DiffLineContent, len(lines)) + for i, line := range lines { + var metadata []string + for _, c := range line.cells { + if c.metadata != "" && !slices.Contains(metadata, c.metadata) { + metadata = append(metadata, c.metadata) + } + } + contents[i] = DiffLineContent{Text: line.cells.String(), Metadata: metadata} + } + return contents +} + +// BufferLineForViewLine maps a view line index (which counts wrapped lines) to +// the index of the corresponding line in the unwrapped internal buffer (as +// returned by BufferLines). Several view lines map to the same buffer line when +// that line wraps. Returns false if the view line is out of range. +func (v *View) BufferLineForViewLine(y int) (int, bool) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + return v.bufferLineForViewLine(y) +} + +// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the +// first view line that renders it — the inverse of BufferLineForViewLine, for +// turning a line found by examining the buffer into a line to scroll to or +// select. Returns false if the buffer line isn't rendered into any view line. +func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.refreshViewLinesIfNeeded() + + first, _, ok := v.viewLineSpanOfBufferLine(bufferLineIdx) + return first, ok +} + +// LastViewLineForBufferLine maps an unwrapped buffer line index to the index of +// the last view line that renders it, which for a line that doesn't wrap is the +// same as the first. It is where the far end of a range goes: a range is over +// buffer lines, so it has to cover the last one of them to its final segment +// rather than stopping where that line begins. +func (v *View) LastViewLineForBufferLine(bufferLineIdx int) (int, bool) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.refreshViewLinesIfNeeded() + + _, last, ok := v.viewLineSpanOfBufferLine(bufferLineIdx) + return last, ok +} + // Buffer returns a string with the contents of the view's internal // buffer. func (v *View) Buffer() string { @@ -1770,6 +2173,9 @@ func (v *View) ViewBufferLines() []string { // LinesHeight is the count of view lines (i.e. lines excluding wrapping) func (v *View) LinesHeight() int { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + return len(v.buf.lines) } @@ -1843,28 +2249,22 @@ func indexFunc(r rune) bool { return r == ' ' || r == 0 } -// SetHighlight toggles highlighting of separate lines, for custom lists -// or multiple selection in views. -func (v *View) SetHighlight(y int, on bool) { - if y < 0 || y >= len(v.buf.lines) { - return - } +// SetLineFlash temporarily marks a view line without moving or changing the +// selection. The caller owns the lifetime and clears it with ClearLineFlash. +func (v *View) SetLineFlash(viewLine int) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() - cells := make([]cell, 0, len(v.buf.lines[y].cells)) - for _, c := range v.buf.lines[y].cells { - if on { - c.bgColor = v.SelBgColor - c.fgColor = v.SelFgColor - } else { - c.bgColor = v.BgColor - c.fgColor = v.FgColor - } - cells = append(cells, c) - } - v.tainted = true - v.firstDirtyLine = min(v.firstDirtyLine, y) - v.buf.lines[y].cells = cells - v.clearHover() + v.lineFlashY = viewLine + v.needsRedraw = true +} + +func (v *View) ClearLineFlash() { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.lineFlashY = -1 + v.needsRedraw = true } func lineWrap(line []cell, columns int) [][]cell { @@ -1970,16 +2370,31 @@ func (v *View) SelectedLineIdx() int { return seletedLineIdx } +// IsLineVisible reports whether the given view line is one of those on screen. +func (v *View) IsLineVisible(viewLine int) bool { + return viewLine >= v.OriginY() && viewLine < v.OriginY()+v.InnerHeight() +} + +// MiddleVisibleLineIdx returns the view line halfway down the visible content. It +// stands in for a cursor in a view that has none: of the lines on screen, the one in +// the middle is the likeliest to be the one being read. +func (v *View) MiddleVisibleLineIdx() int { + top := v.OriginY() + bottom := min(top+v.InnerHeight(), v.ViewLinesHeight()) + return (top + bottom) / 2 +} + // expected to only be used in tests func (v *View) SelectedLine() string { v.writeMutex.Lock() defer v.writeMutex.Unlock() - if len(v.buf.lines) == 0 { + idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx()) + if !ok { return "" } - return v.lineContentAtIdx(v.SelectedLineIdx()) + return v.lineContentAtIdx(idx) } // expected to only be used in tests @@ -1994,8 +2409,17 @@ func (v *View) SelectedLines() []string { startIdx, endIdx := v.SelectedLineRange() lines := make([]string, 0, endIdx-startIdx+1) + previous := -1 for i := startIdx; i <= endIdx; i++ { - lines = append(lines, v.lineContentAtIdx(i)) + // The selection is in view lines, which count the segments a wrapped line + // is drawn as; a line the selection covers several segments of is still + // the one line it is. + idx, ok := v.bufferLineForViewLine(i) + if !ok || idx == previous { + continue + } + previous = idx + lines = append(lines, v.lineContentAtIdx(idx)) } return lines @@ -2005,6 +2429,19 @@ func (v *View) lineContentAtIdx(idx int) string { return v.buf.lines[idx].cells.String() } +// bufferLineForViewLine maps a view line index, which counts the wrapped +// segments of the lines it draws, to the index of the line of content it is a +// segment of. Only call this with a lock on writeMutex. +func (v *View) bufferLineForViewLine(y int) (int, bool) { + v.refreshViewLinesIfNeeded() + + if y < 0 || y >= len(v.viewLines) { + return 0, false + } + + return v.viewLines[y].linesY, true +} + func (v *View) SelectedPoint() (int, int) { cx, cy := v.Cursor() ox, oy := v.Origin() diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 2ee5eb4b8..b6dd6f401 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -158,6 +158,107 @@ func TestAutoRenderingHyperlinks(t *testing.T) { assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink) } +// osc1717 wraps an OSC 1717 payload in the sequence a diff renderer emits it in: +// the ESC ] introducer with the OSC number, and ESC \ as the terminator. +func osc1717(payload string) string { + return "\x1b]1717;" + payload + "\x1b\\" +} + +func TestDiffLineContents(t *testing.T) { + v := NewView("name", 0, 0, 80, 10, OutputNormal) + + // A diff renderer prefixes each line it renders with a record naming the + // file and the line's position in it: version;type;new-line;old-line;file. + v.writeString(strings.Join([]string{ + osc1717("1;c;1;;foo.txt") + "line1", + osc1717("1;d;2;2;foo.txt") + "old2", + osc1717("1;a;2;;foo.txt") + "new2", + "@@ a hunk header, which carries no record @@", + }, "\n")) + + assert.Equal(t, []DiffLineContent{ + {Text: "line1", Metadata: []string{"1;c;1;;foo.txt"}}, + {Text: "old2", Metadata: []string{"1;d;2;2;foo.txt"}}, + {Text: "new2", Metadata: []string{"1;a;2;;foo.txt"}}, + // The record of the line before doesn't bleed onto this one. + {Text: "@@ a hunk header, which carries no record @@"}, + }, v.DiffLineContents()) +} + +func TestDiffLineContentsWithSideBySideRecords(t *testing.T) { + v := NewView("name", 0, 0, 80, 10, OutputNormal) + + // A side-by-side renderer puts two diff lines on one rendered line, and so + // emits a record before each half. + v.writeString(strings.Join([]string{ + osc1717("1;c;1;;foo.txt") + "context " + osc1717("1;c;1;;foo.txt") + "context", + osc1717("1;d;2;2;foo.txt") + "old2 " + osc1717("1;a;2;;foo.txt") + "new2", + }, "\n")) + + assert.Equal(t, []DiffLineContent{ + // The two halves of a context line are the same diff line, stated twice. + {Text: "context context", Metadata: []string{"1;c;1;;foo.txt"}}, + {Text: "old2 new2", Metadata: []string{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"}}, + }, v.DiffLineContents()) +} + +func TestDiffLineContentsOfWrappedLine(t *testing.T) { + v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9 + v.Wrap = true + + // A line that gocui wraps is still one buffer line, so its record covers + // every view line it is displayed on. + v.writeString(osc1717("1;a;1;;foo.txt") + "a line too long to fit") + + assert.Equal(t, []DiffLineContent{ + {Text: "a line too long to fit", Metadata: []string{"1;a;1;;foo.txt"}}, + }, v.DiffLineContents()) + assert.Equal(t, 3, v.ViewLinesHeight()) + for viewLine := range 3 { + bufferLine, ok := v.BufferLineForViewLine(viewLine) + assert.True(t, ok) + assert.Equal(t, 0, bufferLine) + } +} + +func TestDiffLineContentsWithRecordsCoveringNoCell(t *testing.T) { + v := NewView("name", 0, 0, 80, 10, OutputNormal) + + v.writeString(strings.Join([]string{ + // A banner announcing a file and its first hunk at once carries both + // records back to back. + osc1717("1;f;;;foo.txt") + osc1717("1;h;5;;foo.txt") + "foo.txt --- Go", + // So does a modification whose deletion and addition are collapsed into + // a single rendered line. + osc1717("1;d;5;5;foo.txt") + osc1717("1;a;5;;foo.txt") + "595 new content", + // A changed line that is empty is rendered as its record and nothing else. + osc1717("1;a;6;;foo.txt"), + }, "\n") + "\n") + + assert.Equal(t, []DiffLineContent{ + {Text: "foo.txt --- Go", Metadata: []string{"1;f;;;foo.txt", "1;h;5;;foo.txt"}}, + {Text: "595 new content", Metadata: []string{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"}}, + {Text: "", Metadata: []string{"1;a;6;;foo.txt"}}, + }, v.DiffLineContents()) +} + +func TestDiffLineContentsSwallowsHandshake(t *testing.T) { + v := NewView("name", 0, 0, 80, 10, OutputNormal) + + // A diff renderer announces itself with a version-only record before the + // diff. It must leave no trace: no visible bytes, no line of its own, and + // above all no record on the line that follows it. + v.writeString(osc1717("1") + strings.Join([]string{ + "diff --git a/foo.txt b/foo.txt", + osc1717("1;a;1;;foo.txt") + "added", + }, "\n")) + + assert.Equal(t, []DiffLineContent{ + {Text: "diff --git a/foo.txt b/foo.txt"}, + {Text: "added", Metadata: []string{"1;a;1;;foo.txt"}}, + }, v.DiffLineContents()) +} + // An async re-render builds into an off-screen buffer and swaps it in once it // has enough to paint, so readers keep seeing the previous render — coherent and // consistent — until the new content appears in one step. See View.offscreen. @@ -204,6 +305,61 @@ func TestViewLinesTruncatedByShorterRender(t *testing.T) { assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines()) } +func TestBufferLineForViewLine(t *testing.T) { + v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9 + v.Wrap = true + + // Buffer line 0 is short (view line 0); buffer line 1 wraps into three view + // lines (1, 2, 3); buffer line 2 is short again (view line 4). + v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast") + + for viewLine, wantBufferLine := range []int{0, 1, 1, 1, 2} { + bufferLine, ok := v.BufferLineForViewLine(viewLine) + assert.True(t, ok) + assert.Equal(t, wantBufferLine, bufferLine) + } + + _, ok := v.BufferLineForViewLine(5) + assert.False(t, ok) + + _, ok = v.BufferLineForViewLine(-1) + assert.False(t, ok) +} + +func TestViewLineForBufferLine(t *testing.T) { + v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9 + v.Wrap = true + + // A wrapped buffer line maps to the first of the view lines it spans. + v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast") + + for bufferLine, wantViewLine := range []int{0, 1, 4} { + viewLine, ok := v.ViewLineForBufferLine(bufferLine) + assert.True(t, ok) + assert.Equal(t, wantViewLine, viewLine) + } + + _, ok := v.ViewLineForBufferLine(3) + assert.False(t, ok) +} + +func TestLastViewLineForBufferLine(t *testing.T) { + v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9 + v.Wrap = true + + // A wrapped buffer line maps to the last of the view lines it spans. + v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast") + + for bufferLine, wantViewLine := range []int{0, 3, 4} { + viewLine, ok := v.LastViewLineForBufferLine(bufferLine) + assert.True(t, ok) + assert.Equal(t, wantViewLine, viewLine) + } + + _, ok := v.LastViewLineForBufferLine(3) + assert.False(t, ok) +} + // While an async re-render loads, it swaps in only a partially-filled buffer at // its first paint and keeps appending lines afterwards. The scrollbar must keep // using the pre-load height until the load ends, so the thumb doesn't shrink and @@ -780,3 +936,167 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) { "trailing cell at (%d, 2) should have green bg", x) } } + +// A view that wraps draws one line of its content as several view lines, and the +// cursor and the range anchor count those. What is asked about a selection is +// which lines of the content it covers, so those are what it has to be reported +// in. +func TestSelectedLinesOfWrappedContent(t *testing.T) { + v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10 + v.Wrap = true + v.Highlight = true + + // "a line that wraps" takes two view lines, so the four lines of content are + // drawn as five: "one", "two", "a line th", "at wraps", "four". + v.writeString("one\ntwo\na line that wraps\nfour\n") + assert.Equal(t, 5, v.ViewLinesHeight()) + + // The cursor on the wrapped line's second half is on that line. + v.FocusPoint(0, 3, false) + assert.Equal(t, "a line that wraps", v.SelectedLine()) + + // A range over both halves of the wrapped line covers one line of content. + v.SetRangeSelectStart(2) + assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines()) +} + +func TestLineFlashReversesTheSelectionBarWithoutChangingSelection(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + v := NewView("name", 0, 0, 11, 5, OutputNormal) + v.Highlight = true + v.SelBgColor = ColorBlue + v.SelectedLineColorWidth = 2 + v.writeString("one\ntwo\nthree\n") + v.FocusPoint(0, 1, false) + v.SetLineFlash(1) + v.draw(true) + + for x := 1; x <= 2; x++ { + _, style, _ := Screen.Get(x, 2) + assert.True(t, style.HasReverse(), "selection-bar cell at (%d, 2) should flash", x) + } + _, style, _ := Screen.Get(3, 2) + assert.False(t, style.HasReverse(), "the flash should stop after the selection bar") + assert.Equal(t, "two", v.SelectedLine(), "flashing should not change the selection") + + v.ClearLineFlash() + v.draw(true) + _, style, _ = Screen.Get(1, 2) + assert.False(t, style.HasReverse(), "clearing should remove the flash") +} + +// Resizing a view throws away the wrapping of its content and wraps it again for +// the new width, which moves every line of it to a different view line. The +// positions into the view count view lines, so they all have to come along. +func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) { + g := &Gui{} + v, _ := g.SetView("name", 0, 0, 11, 10, 0) // InnerWidth 10 + v.Wrap = true + v.Highlight = true + + // Two wrapping lines, with a single line between them: eight view lines for + // five lines of content. + v.writeString("one\na line that wraps\ntwo\nanother wrapping line\nthree\n") + assert.Equal(t, 8, v.ViewLinesHeight()) + + // A range over the whole of the second wrapping line, which is drawn as view + // lines 4 to 6. + v.SetRangeSelectStart(4) + v.FocusPoint(0, 6, false) + assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines()) + + // Widen the view so that nothing wraps any more. + _, _ = g.SetView("name", 0, 0, 31, 10, 0) // InnerWidth 30 + assert.Equal(t, 5, v.ViewLinesHeight()) + + assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines()) +} + +// The inclusion gutter reserves columns at the left of every line, draws its marker +// on the marked lines only, and moves the content out of the way. +func TestInclusionGutter(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + // InnerWidth 10; the frame puts view x=0 at screen x=1. + v := NewView("name", 0, 0, 11, 5, OutputNormal) + v.Wrap = true + v.InclusionGutterMarker = "✓" + + v.writeString("aaa\nbbb\nccc\n") + + // The gutter is two columns wide — the marker and a space; mark the middle line. + v.SetInclusionGutter(true, []bool{false, true, false}) + v.draw(true) + + chr, _, _ := Screen.Get(1, 1) + assert.Equal(t, " ", chr, "an unmarked line has no marker") + chr, _, _ = Screen.Get(1, 2) + assert.Equal(t, "✓", chr, "a marked line has one") + chr, _, _ = Screen.Get(1, 3) + assert.Equal(t, " ", chr, "an unmarked line has no marker") + + // The content begins after the gutter: view x=2, i.e. screen x=3. + chr, _, _ = Screen.Get(3, 1) + assert.Equal(t, "a", chr) + chr, _, _ = Screen.Get(3, 2) + assert.Equal(t, "b", chr) + chr, _, _ = Screen.Get(3, 3) + assert.Equal(t, "c", chr) + + // Hiding the gutter puts the content back at the left edge. + v.SetInclusionGutter(false, nil) + v.draw(true) + chr, _, _ = Screen.Get(1, 1) + assert.Equal(t, "a", chr) +} + +// A marked line the view wraps is marked on every segment it is drawn as, so that +// the mark doesn't look like it belongs to the first part of the line alone. The +// gutter takes its columns out of the width the content wraps in. +func TestInclusionGutterMarksEverySegmentOfAWrappedLine(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10 + v.Wrap = true + v.InclusionGutterMarker = "✓" + + // Ten cells, wrapping at eight once the two-column gutter is shown. + v.writeString("0123456789\n") + v.SetInclusionGutter(true, []bool{true}) + v.draw(true) + + chr, _, _ := Screen.Get(1, 1) + assert.Equal(t, "✓", chr) + chr, _, _ = Screen.Get(3, 1) + assert.Equal(t, "0", chr) + chr, _, _ = Screen.Get(10, 1) + assert.Equal(t, "7", chr, "the content wraps at the width the gutter leaves it") + + chr, _, _ = Screen.Get(1, 2) + assert.Equal(t, "✓", chr, "the line's second segment is marked too") + chr, _, _ = Screen.Get(3, 2) + assert.Equal(t, "8", chr) +} + +// Showing the gutter narrows the content, so the content wraps again — and the +// positions into it, which count the segments lines are drawn as, have to come +// along, as they do for any other change of width. +func TestShowingTheInclusionGutterKeepsThePlaceInTheContent(t *testing.T) { + v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10 + v.Wrap = true + v.Highlight = true + v.InclusionGutterMarker = "✓" + + v.writeString("one\ntwo\nthree\nsomethingfartoolong\n") + assert.Equal(t, 5, v.ViewLinesHeight()) + + v.FocusPoint(0, 2, false) + assert.Equal(t, "three", v.SelectedLine()) + + // With eight columns left for the content, the last line wraps into three + // segments rather than two. + v.SetInclusionGutter(true, []bool{false, false, true, false}) + assert.Equal(t, 6, v.ViewLinesHeight()) + assert.Equal(t, "three", v.SelectedLine()) +} diff --git a/pkg/gui/context.go b/pkg/gui/context.go index d83b144f2..04ca5d316 100644 --- a/pkg/gui/context.go +++ b/pkg/gui/context.go @@ -196,12 +196,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) { self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == "" - self.updateSelectionHighlights() + self.UpdateSelectionHighlights() c.HandleFocus(opts) } -// updateSelectionHighlights re-derives which views draw a selection, and which of +// UpdateSelectionHighlights re-derives which views draw a selection, and which of // them draw theirs as the active one: a view shows a selection while its context is // on the stack and has something to select, and the context the user is in shows the // active selection while the ones behind it show inactive ones. @@ -210,7 +210,7 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) { // every change to the stack goes through; after a refresh, since that is when the // contents of a list change; and from whoever tells a context that its content has // gained or lost something to select. -func (self *ContextMgr) updateSelectionHighlights() { +func (self *ContextMgr) UpdateSelectionHighlights() { self.RLock() defer self.RUnlock() @@ -350,18 +350,6 @@ func (self *ContextMgr) AllList() []types.IListContext { return listContexts } -func (self *ContextMgr) AllPatchExplorer() []types.IPatchExplorerContext { - var listContexts []types.IPatchExplorerContext - - for _, context := range self.allContexts.Flatten() { - if listContext, ok := context.(types.IPatchExplorerContext); ok { - listContexts = append(listContexts, listContext) - } - } - - return listContexts -} - func (self *ContextMgr) ContextForKey(key types.ContextKey) types.Context { self.RLock() defer self.RUnlock() @@ -399,3 +387,14 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context { panic("context not in stack") } + +// IsInStack reports whether the given context is on the stack at all, for callers +// that can't otherwise know and would make NextInStack panic. +func (self *ContextMgr) IsInStack(c types.Context) bool { + self.RLock() + defer self.RUnlock() + + return lo.ContainsBy(self.ContextStack, func(other types.Context) bool { + return other.GetKey() == c.GetKey() + }) +} diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index b5fbf76ce..67135d772 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -13,15 +13,15 @@ type BaseContext struct { windowName string onGetOptionsMap func() map[string]string - keybindingsFns []types.KeybindingsFn - mouseKeybindingsFns []types.MouseKeybindingsFn - onDoubleClickFn func() error - onClickFn func(opts gocui.ViewMouseBindingOpts) error - onClickFocusedMainViewFn onClickFocusedMainViewFn - onRenderToMainFn func() - onFocusFns []onFocusFn - onFocusLostFns []onFocusLostFn - onQuitFns []func() + keybindingsFns []types.KeybindingsFn + mouseKeybindingsFns []types.MouseKeybindingsFn + onDoubleClickFn func() error + onClickFn func(opts gocui.ViewMouseBindingOpts) error + focusedMainViewDiffSource types.FocusedMainViewDiffSource + onRenderToMainFn func() + onFocusFns []onFocusFn + onFocusLostFns []onFocusLostFn + onQuitFns []func() focusable bool transient bool @@ -34,9 +34,8 @@ type BaseContext struct { } type ( - onFocusFn = func(types.OnFocusOpts) - onFocusLostFn = func(types.OnFocusLostOpts) - onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error + onFocusFn = func(types.OnFocusOpts) + onFocusLostFn = func(types.OnFocusLostOpts) ) var _ types.IBaseContext = &BaseContext{} @@ -122,6 +121,14 @@ func (self *BaseContext) HasSelectableContent() bool { return self.hasSelectableContent } +// SetHasSelectableContent is for the contexts whose answer isn't fixed and isn't a +// list length either: the main panes, which can only tell by reading the diff they +// have rendered. Whoever sets it re-derives the highlights that follow from it (see +// ContextMgr.UpdateSelectionHighlights). +func (self *BaseContext) SetHasSelectableContent(value bool) { + self.hasSelectableContent = value +} + func (self *BaseContext) GetKey() types.ContextKey { return self.key } @@ -153,7 +160,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() { self.onQuitFns = nil self.onDoubleClickFn = nil self.onClickFn = nil - self.onClickFocusedMainViewFn = nil + self.focusedMainViewDiffSource = nil self.onRenderToMainFn = nil } @@ -175,12 +182,12 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e } } -func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) { - if fn != nil { - if self.onClickFocusedMainViewFn != nil { - panic("only one controller is allowed to set an onClickFocusedMainViewFn") +func (self *BaseContext) AddFocusedMainViewDiffSource(source types.FocusedMainViewDiffSource) { + if source != nil { + if self.focusedMainViewDiffSource != nil { + panic("only one controller is allowed to set the focused main view diff source") } - self.onClickFocusedMainViewFn = fn + self.focusedMainViewDiffSource = source } } @@ -192,8 +199,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro return self.onClickFn } -func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn { - return self.onClickFocusedMainViewFn +func (self *BaseContext) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self.focusedMainViewDiffSource } func (self *BaseContext) AddOnRenderToMainFn(fn func()) { diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index f819a2eb4..4ad7334ef 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -19,11 +19,16 @@ type CommitFilesContext struct { } var ( - _ types.IListContext = (*CommitFilesContext)(nil) - _ types.DiffableContext = (*CommitFilesContext)(nil) - _ types.IFilterableContext = (*CommitFilesContext)(nil) + _ types.IListContext = (*CommitFilesContext)(nil) + _ types.DiffableContext = (*CommitFilesContext)(nil) + _ types.IFilterableContext = (*CommitFilesContext)(nil) + _ types.DiffMainViewContext = (*CommitFilesContext)(nil) ) +func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType { + return types.DiffMainViewTypePatchBuilding +} + func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext { viewModel := filetree.NewCommitFileTreeViewModel( func() []*models.CommitFile { return c.Model().CommitFiles }, @@ -80,10 +85,16 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string { } func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) { - if refs := self.GetRefRange(); refs != nil { - return refs.From.ParentRefName(), refs.To.RefName() + return FromAndToForDiff(self.GetRef(), self.GetRefRange()) +} + +// FromAndToForDiff gives the two ends to diff for a ref, or for a range of them: a +// range runs from the parent of its first ref to its last, a single ref from its own +// parent to itself. +func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) { + if refRange != nil { + return refRange.From.ParentRefName(), refRange.To.RefName() } - ref := self.GetRef() return ref.ParentRefName(), ref.RefName() } diff --git a/pkg/gui/context/context.go b/pkg/gui/context/context.go index 8af05e36f..280f45642 100644 --- a/pkg/gui/context/context.go +++ b/pkg/gui/context/context.go @@ -8,27 +8,23 @@ const ( // used as a nil value when passing a context key as an arg NO_CONTEXT types.ContextKey = "none" - GLOBAL_CONTEXT_KEY types.ContextKey = "global" - STATUS_CONTEXT_KEY types.ContextKey = "status" - SNAKE_CONTEXT_KEY types.ContextKey = "snake" - FILES_CONTEXT_KEY types.ContextKey = "files" - LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches" - REMOTES_CONTEXT_KEY types.ContextKey = "remotes" - WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees" - REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches" - TAGS_CONTEXT_KEY types.ContextKey = "tags" - LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits" - REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits" - SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits" - COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles" - STASH_CONTEXT_KEY types.ContextKey = "stash" - NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal" - NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary" - STAGING_MAIN_CONTEXT_KEY types.ContextKey = "staging" - STAGING_SECONDARY_CONTEXT_KEY types.ContextKey = "stagingSecondary" - PATCH_BUILDING_MAIN_CONTEXT_KEY types.ContextKey = "patchBuilding" - PATCH_BUILDING_SECONDARY_CONTEXT_KEY types.ContextKey = "patchBuildingSecondary" - MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts" + GLOBAL_CONTEXT_KEY types.ContextKey = "global" + STATUS_CONTEXT_KEY types.ContextKey = "status" + SNAKE_CONTEXT_KEY types.ContextKey = "snake" + FILES_CONTEXT_KEY types.ContextKey = "files" + LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches" + REMOTES_CONTEXT_KEY types.ContextKey = "remotes" + WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees" + REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches" + TAGS_CONTEXT_KEY types.ContextKey = "tags" + LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits" + REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits" + SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits" + COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles" + STASH_CONTEXT_KEY types.ContextKey = "stash" + NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal" + NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary" + MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts" // these shouldn't really be needed for anything but I'm giving them unique keys nonetheless OPTIONS_CONTEXT_KEY types.ContextKey = "options" @@ -66,10 +62,6 @@ var AllContextKeys = []types.ContextKey{ STASH_CONTEXT_KEY, NORMAL_MAIN_CONTEXT_KEY, NORMAL_SECONDARY_CONTEXT_KEY, - STAGING_MAIN_CONTEXT_KEY, - STAGING_SECONDARY_CONTEXT_KEY, - PATCH_BUILDING_MAIN_CONTEXT_KEY, - PATCH_BUILDING_SECONDARY_CONTEXT_KEY, MERGE_CONFLICTS_CONTEXT_KEY, MENU_CONTEXT_KEY, @@ -83,35 +75,31 @@ var AllContextKeys = []types.ContextKey{ } type ContextTree struct { - Global types.Context - Status types.Context - Snake types.Context - Files *WorkingTreeContext - Menu *MenuContext - Branches *BranchesContext - Tags *TagsContext - LocalCommits *LocalCommitsContext - CommitFiles *CommitFilesContext - Remotes *RemotesContext - Worktrees *WorktreesContext - Submodules *SubmodulesContext - RemoteBranches *RemoteBranchesContext - ReflogCommits *ReflogCommitsContext - SubCommits *SubCommitsContext - Stash *StashContext - Suggestions *SuggestionsContext - Normal *MainContext - NormalSecondary *MainContext - Staging *PatchExplorerContext - StagingSecondary *PatchExplorerContext - CustomPatchBuilder *PatchExplorerContext - CustomPatchBuilderSecondary types.Context - MergeConflicts *MergeConflictsContext - Confirmation *ConfirmationContext - Prompt *PromptContext - CommitMessage *CommitMessageContext - CommitDescription types.Context - CommandLog types.Context + Global types.Context + Status types.Context + Snake types.Context + Files *WorkingTreeContext + Menu *MenuContext + Branches *BranchesContext + Tags *TagsContext + LocalCommits *LocalCommitsContext + CommitFiles *CommitFilesContext + Remotes *RemotesContext + Worktrees *WorktreesContext + Submodules *SubmodulesContext + RemoteBranches *RemoteBranchesContext + ReflogCommits *ReflogCommitsContext + SubCommits *SubCommitsContext + Stash *StashContext + Suggestions *SuggestionsContext + Normal *MainContext + NormalSecondary *MainContext + MergeConflicts *MergeConflictsContext + Confirmation *ConfirmationContext + Prompt *PromptContext + CommitMessage *CommitMessageContext + CommitDescription types.Context + CommandLog types.Context // display contexts AppStatus types.Context @@ -149,10 +137,6 @@ func (self *ContextTree) Flatten() []types.Context { self.CommitDescription, self.MergeConflicts, - self.StagingSecondary, - self.Staging, - self.CustomPatchBuilderSecondary, - self.CustomPatchBuilder, self.NormalSecondary, self.Normal, diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 4a99259fd..8bab33801 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -31,11 +31,16 @@ type commitDropIndicator struct { } var ( - _ types.IListContext = (*LocalCommitsContext)(nil) - _ types.DiffableContext = (*LocalCommitsContext)(nil) - _ types.ISearchableContext = (*LocalCommitsContext)(nil) + _ types.IListContext = (*LocalCommitsContext)(nil) + _ types.DiffableContext = (*LocalCommitsContext)(nil) + _ types.ISearchableContext = (*LocalCommitsContext)(nil) + _ types.DiffMainViewContext = (*LocalCommitsContext)(nil) ) +func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType { + return types.DiffMainViewTypePatchBuilding +} + func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { dropIndicator := &commitDropIndicator{insertionIndex: -1} viewModel := NewLocalCommitsViewModel( diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index 692c6dd5c..92ce65d37 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -8,9 +8,62 @@ import ( type MainContext struct { *SimpleContext *SearchTrait + + diffSelect types.DiffSelectState + // dragAnchorViewLine is the view line a mouse-down landed on, remembered so that a + // drag that follows can anchor its range there. The click may have selected a whole + // hunk, whose range anchor is the block's far end, so the clicked line can't be + // read back from the view. + dragAnchorViewLine int + // selectableContentRenderKey names the render whose content HasSelectableContent + // was worked out from. What there is to select is a property of the content, so an + // answer about the content of another render says nothing about this one. + selectableContentRenderKey string } -var _ types.ISearchableContext = (*MainContext)(nil) +var ( + _ types.ISearchableContext = (*MainContext)(nil) + _ types.DiffPaneContext = (*MainContext)(nil) +) + +// DiffSelectState returns the focused main view's selection mode state, for the +// controllers to read and mutate directly. +func (self *MainContext) DiffSelectState() *types.DiffSelectState { + return &self.diffSelect +} + +// ResetDiffSelectMode returns the pane's selection to the default mode — a single +// line, no range — for whenever it is established from scratch rather than moved. The +// view's range anchor is cleared too, so the next render highlights the cursor line +// only. +func (self *MainContext) ResetDiffSelectMode() { + self.diffSelect.Mode = types.DiffSelectModeLine + self.diffSelect.RangeIsSticky = false + self.diffSelect.UserEnabledHunkMode = false + self.GetView().CancelRangeSelect() +} + +// SetDragAnchorViewLine records the view line a mouse-down landed on, so that a drag +// that follows can anchor its range there (see dragAnchorViewLine). +func (self *MainContext) SetDragAnchorViewLine(viewLine int) { + self.dragAnchorViewLine = viewLine +} + +// DragAnchorViewLine returns the view line the last mouse-down landed on. +func (self *MainContext) DragAnchorViewLine() int { + return self.dragAnchorViewLine +} + +// SelectableContentRenderKey returns the render HasSelectableContent describes (see +// selectableContentRenderKey). +func (self *MainContext) SelectableContentRenderKey() string { + return self.selectableContentRenderKey +} + +// SetSelectableContentRenderKey records which render HasSelectableContent describes. +func (self *MainContext) SetSelectableContentRenderKey(key string) { + self.selectableContentRenderKey = key +} func NewMainContext( view *gocui.View, @@ -21,12 +74,11 @@ func NewMainContext( ctx := &MainContext{ SimpleContext: NewSimpleContext( NewBaseContext(NewBaseContextOpts{ - Kind: types.MAIN_CONTEXT, - View: view, - WindowName: windowName, - Key: key, - Focusable: true, - HasSelectableContent: false, + Kind: types.MAIN_CONTEXT, + View: view, + WindowName: windowName, + Key: key, + Focusable: true, })), SearchTrait: NewSearchTrait(c), } @@ -38,5 +90,8 @@ func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool return nil } +// When selecting a search result, collapse a range selection (whether sticky or not) +// or a hunk selection to just the matching line. func (self *MainContext) OnSearchSelect(int) { + self.ResetDiffSelectMode() } diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go deleted file mode 100644 index 434de6e58..000000000 --- a/pkg/gui/context/patch_explorer_context.go +++ /dev/null @@ -1,154 +0,0 @@ -package context - -import ( - "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" - "github.com/jesseduffield/lazygit/pkg/gui/types" - deadlock "github.com/sasha-s/go-deadlock" -) - -type PatchExplorerContext struct { - *SimpleContext - *SearchTrait - - state *patch_exploring.State - viewTrait *ViewTrait - getIncludedLineIndices func() []int - c *ContextCommon - mutex deadlock.Mutex - - // true if we're inside the OnSelectItem callback; in that case we don't want to update the - // search result index. - inOnSelectItemCallback bool -} - -var ( - _ types.IPatchExplorerContext = (*PatchExplorerContext)(nil) - _ types.ISearchableContext = (*PatchExplorerContext)(nil) -) - -func NewPatchExplorerContext( - view *gocui.View, - windowName string, - key types.ContextKey, - - getIncludedLineIndices func() []int, - - c *ContextCommon, -) *PatchExplorerContext { - ctx := &PatchExplorerContext{ - state: nil, - viewTrait: NewViewTrait(view), - c: c, - getIncludedLineIndices: getIncludedLineIndices, - SimpleContext: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ - View: view, - WindowName: windowName, - Key: key, - Kind: types.MAIN_CONTEXT, - Focusable: true, - HasSelectableContent: true, - NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES, - })), - SearchTrait: NewSearchTrait(c), - } - - ctx.SetHandleRenderFunc(ctx.OnViewWidthChanged) - - return ctx -} - -func (self *PatchExplorerContext) IsPatchExplorerContext() {} - -func (self *PatchExplorerContext) GetState() *patch_exploring.State { - return self.state -} - -func (self *PatchExplorerContext) SetState(state *patch_exploring.State) { - self.state = state -} - -func (self *PatchExplorerContext) GetViewTrait() types.IViewTrait { - return self.viewTrait -} - -func (self *PatchExplorerContext) GetIncludedLineIndices() []int { - return self.getIncludedLineIndices() -} - -func (self *PatchExplorerContext) RenderAndFocus() { - self.setContent() - - self.FocusSelection() - self.c.Render() -} - -func (self *PatchExplorerContext) Render() { - self.setContent() - - self.c.Render() -} - -func (self *PatchExplorerContext) setContent() { - self.GetView().SetContent(self.GetContentToRender()) -} - -func (self *PatchExplorerContext) FocusSelection() { - view := self.GetView() - state := self.GetState() - bufferHeight := view.InnerHeight() - _, origin := view.Origin() - numLines := view.ViewLinesHeight() - - newOriginY := state.CalculateOrigin(origin, bufferHeight, numLines) - - view.SetOriginY(newOriginY) - - startIdx, endIdx := state.SelectedViewRange() - // As far as the view is concerned, we are always selecting a range - view.SetRangeSelectStart(startIdx) - view.SetCursorY(endIdx - newOriginY) - - if !self.inOnSelectItemCallback { - view.SetNearestSearchPosition() - } -} - -func (self *PatchExplorerContext) GetContentToRender() string { - if self.GetState() == nil { - return "" - } - - return self.GetState().RenderForLineIndices(self.GetIncludedLineIndices()) -} - -func (self *PatchExplorerContext) NavigateTo(selectedLineIdx int) { - self.GetState().SetLineSelectMode() - self.GetState().SelectLine(selectedLineIdx) - - self.RenderAndFocus() -} - -func (self *PatchExplorerContext) GetMutex() *deadlock.Mutex { - return &self.mutex -} - -func (self *PatchExplorerContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { - return nil -} - -func (self *PatchExplorerContext) OnSearchSelect(selectedLineIdx int) { - self.GetMutex().Lock() - defer self.GetMutex().Unlock() - self.inOnSelectItemCallback = true - self.NavigateTo(selectedLineIdx) - self.inOnSelectItemCallback = false -} - -func (self *PatchExplorerContext) OnViewWidthChanged() { - if state := self.GetState(); state != nil { - state.OnViewWidthChanged(self.GetView()) - self.setContent() - self.RenderAndFocus() - } -} diff --git a/pkg/gui/context/reflog_commits_context.go b/pkg/gui/context/reflog_commits_context.go index 6358fbbb0..eedf065cd 100644 --- a/pkg/gui/context/reflog_commits_context.go +++ b/pkg/gui/context/reflog_commits_context.go @@ -14,10 +14,15 @@ type ReflogCommitsContext struct { } var ( - _ types.IListContext = (*ReflogCommitsContext)(nil) - _ types.DiffableContext = (*ReflogCommitsContext)(nil) + _ types.IListContext = (*ReflogCommitsContext)(nil) + _ types.DiffableContext = (*ReflogCommitsContext)(nil) + _ types.DiffMainViewContext = (*ReflogCommitsContext)(nil) ) +func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType { + return types.DiffMainViewTypePatchBuilding +} + func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext { viewModel := NewFilteredListViewModel( func() []*models.Commit { return c.Model().FilteredReflogCommits }, diff --git a/pkg/gui/context/setup.go b/pkg/gui/context/setup.go index ef1211313..04d035388 100644 --- a/pkg/gui/context/setup.go +++ b/pkg/gui/context/setup.go @@ -41,48 +41,6 @@ func NewContextTree(c *ContextCommon) *ContextTree { Suggestions: NewSuggestionsContext(c), Normal: NewMainContext(c.Views().Main, "main", NORMAL_MAIN_CONTEXT_KEY, c), NormalSecondary: NewMainContext(c.Views().Secondary, "secondary", NORMAL_SECONDARY_CONTEXT_KEY, c), - Staging: NewPatchExplorerContext( - c.Views().Staging, - "main", - STAGING_MAIN_CONTEXT_KEY, - func() []int { return nil }, - c, - ), - StagingSecondary: NewPatchExplorerContext( - c.Views().StagingSecondary, - "secondary", - STAGING_SECONDARY_CONTEXT_KEY, - func() []int { return nil }, - c, - ), - CustomPatchBuilder: NewPatchExplorerContext( - c.Views().PatchBuilding, - "main", - PATCH_BUILDING_MAIN_CONTEXT_KEY, - func() []int { - file := commitFilesContext.GetSelectedFile() - if file == nil { - return nil - } - includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath) - if err != nil { - c.Log.Error(err) - return nil - } - - return includedLineIndices - }, - c, - ), - CustomPatchBuilderSecondary: NewSimpleContext( - NewBaseContext(NewBaseContextOpts{ - Kind: types.MAIN_CONTEXT, - View: c.Views().PatchBuildingSecondary, - WindowName: "secondary", - Key: PATCH_BUILDING_SECONDARY_CONTEXT_KEY, - Focusable: false, - }), - ), MergeConflicts: NewMergeConflictsContext( c, ), diff --git a/pkg/gui/context/stash_context.go b/pkg/gui/context/stash_context.go index 2014de9f3..99919868a 100644 --- a/pkg/gui/context/stash_context.go +++ b/pkg/gui/context/stash_context.go @@ -12,10 +12,15 @@ type StashContext struct { } var ( - _ types.IListContext = (*StashContext)(nil) - _ types.DiffableContext = (*StashContext)(nil) + _ types.IListContext = (*StashContext)(nil) + _ types.DiffableContext = (*StashContext)(nil) + _ types.DiffMainViewContext = (*StashContext)(nil) ) +func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType { + return types.DiffMainViewTypePatchBuilding +} + func NewStashContext( c *ContextCommon, ) *StashContext { diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index b0bcee30a..14a6e49ca 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -21,11 +21,16 @@ type SubCommitsContext struct { } var ( - _ types.IListContext = (*SubCommitsContext)(nil) - _ types.DiffableContext = (*SubCommitsContext)(nil) - _ types.ISearchableContext = (*SubCommitsContext)(nil) + _ types.IListContext = (*SubCommitsContext)(nil) + _ types.DiffableContext = (*SubCommitsContext)(nil) + _ types.ISearchableContext = (*SubCommitsContext)(nil) + _ types.DiffMainViewContext = (*SubCommitsContext)(nil) ) +func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType { + return types.DiffMainViewTypePatchBuilding +} + func NewSubCommitsContext( c *ContextCommon, ) *SubCommitsContext { diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index d82037e44..706bed3b6 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -15,10 +15,15 @@ type WorkingTreeContext struct { } var ( - _ types.IListContext = (*WorkingTreeContext)(nil) - _ types.IFilterableContext = (*WorkingTreeContext)(nil) + _ types.IListContext = (*WorkingTreeContext)(nil) + _ types.IFilterableContext = (*WorkingTreeContext)(nil) + _ types.DiffMainViewContext = (*WorkingTreeContext)(nil) ) +func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType { + return types.DiffMainViewTypeStaging +} + func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { viewModel := filetree.NewFileTreeViewModel( func() []*models.File { return c.Model().Files }, diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index f21fb607f..67ff1c810 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -52,8 +52,7 @@ func (gui *Gui) resetHelpersAndControllers() { gpgHelper := helpers.NewGpgHelper(helperCommon) viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts) - patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon) - stagingHelper := helpers.NewStagingHelper(helperCommon) + customPatchHelper := helpers.NewCustomPatchHelper(helperCommon) mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon) searchHelper := helpers.NewSearchHelper(helperCommon) @@ -61,13 +60,12 @@ func (gui *Gui) resetHelpersAndControllers() { helperCommon, refsHelper, rebaseHelper, - patchBuildingHelper, - stagingHelper, mergeConflictsHelper, worktreeHelper, searchHelper, ) - diffHelper := helpers.NewDiffHelper(helperCommon) + diffLineHelper := helpers.NewDiffLineHelper(helperCommon) + diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper) cherryPickHelper := helpers.NewCherryPickHelper( helperCommon, rebaseHelper, @@ -77,7 +75,7 @@ func (gui *Gui) resetHelpersAndControllers() { modeHelper := helpers.NewModeHelper( helperCommon, diffHelper, - patchBuildingHelper, + customPatchHelper, cherryPickHelper, rebaseHelper, bisectHelper, @@ -91,8 +89,7 @@ func (gui *Gui) resetHelpersAndControllers() { gui.helpers = &helpers.Helpers{ Refs: refsHelper, Host: helpers.NewHostHelper(helperCommon), - PatchBuilding: patchBuildingHelper, - Staging: stagingHelper, + CustomPatch: customPatchHelper, Bisect: bisectHelper, Suggestions: suggestionsHelper, Files: helpers.NewFilesHelper(helperCommon), @@ -110,6 +107,7 @@ func (gui *Gui) resetHelpersAndControllers() { SuspendResume: helpers.NewSuspendResumeHelper(helperCommon), Snake: helpers.NewSnakeHelper(helperCommon), Diff: diffHelper, + DiffLine: diffLineHelper, Repos: reposHelper, RecordDirectory: recordDirectoryHelper, Update: helpers.NewUpdateHelper(helperCommon, gui.Updater), @@ -176,18 +174,13 @@ func (gui *Gui) resetHelpersAndControllers() { contextLinesController := controllers.NewContextLinesController(common) renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common) verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common) - viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common) branchesController := controllers.NewBranchesController(common) gitFlowController := controllers.NewGitFlowController(common) stashController := controllers.NewStashController(common) commitFilesController := controllers.NewCommitFilesController(common) - patchExplorerControllerFactory := controllers.NewPatchExplorerControllerFactory(common) - stagingController := controllers.NewStagingController(common, gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, false) - stagingSecondaryController := controllers.NewStagingController(common, gui.State.Contexts.StagingSecondary, gui.State.Contexts.Staging, true) mainViewController := controllers.NewMainViewController(common, gui.State.Contexts.Normal, gui.State.Contexts.NormalSecondary) secondaryViewController := controllers.NewMainViewController(common, gui.State.Contexts.NormalSecondary, gui.State.Contexts.Normal) - patchBuildingController := controllers.NewPatchBuildingController(common) snakeController := controllers.NewSnakeController(common) reflogCommitsController := controllers.NewReflogCommitsController(common) subCommitsController := controllers.NewSubCommitsController(common) @@ -284,28 +277,6 @@ func (gui *Gui) resetHelpersAndControllers() { ) // TODO: add scroll controllers for main panels (need to bring some more functionality across for that e.g. reading more from the currently displayed git command) - controllers.AttachControllers(gui.State.Contexts.Staging, - stagingController, - patchExplorerControllerFactory.Create(gui.State.Contexts.Staging), - verticalScrollControllerFactory.Create(gui.State.Contexts.Staging), - ) - - controllers.AttachControllers(gui.State.Contexts.StagingSecondary, - stagingSecondaryController, - patchExplorerControllerFactory.Create(gui.State.Contexts.StagingSecondary), - verticalScrollControllerFactory.Create(gui.State.Contexts.StagingSecondary), - ) - - controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilder, - patchBuildingController, - patchExplorerControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder), - verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder), - ) - - controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilderSecondary, - verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilderSecondary), - ) - controllers.AttachControllers(gui.State.Contexts.MergeConflicts, mergeConflictsController, ) @@ -313,13 +284,11 @@ func (gui *Gui) resetHelpersAndControllers() { controllers.AttachControllers(gui.State.Contexts.Normal, mainViewController, verticalScrollControllerFactory.Create(gui.State.Contexts.Normal), - viewSelectionControllerFactory.Create(gui.State.Contexts.Normal), ) controllers.AttachControllers(gui.State.Contexts.NormalSecondary, secondaryViewController, verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary), - viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary), ) controllers.AttachControllers(gui.State.Contexts.Files, diff --git a/pkg/gui/controllers/attach.go b/pkg/gui/controllers/attach.go index c9ef5d4b0..f3c36da43 100644 --- a/pkg/gui/controllers/attach.go +++ b/pkg/gui/controllers/attach.go @@ -8,7 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController) context.AddMouseKeybindingsFn(controller.GetMouseKeybindings) context.AddOnDoubleClickFn(controller.GetOnDoubleClick()) context.AddOnClickFn(controller.GetOnClick()) - context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView()) + context.AddFocusedMainViewDiffSource(controller.GetFocusedMainViewDiffSource()) context.AddOnRenderToMainFn(controller.GetOnRenderToMain()) context.AddOnFocusFn(controller.GetOnFocus()) context.AddOnFocusLostFn(controller.GetOnFocusLost()) diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go index f91f0b4cc..5ef03e4fd 100644 --- a/pkg/gui/controllers/base_controller.go +++ b/pkg/gui/controllers/base_controller.go @@ -19,11 +19,11 @@ func (self *baseController) GetOnDoubleClick() func() error { return nil } -func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { +func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error { return nil } -func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error { +func (self *baseController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { return nil } diff --git a/pkg/gui/controllers/commit_diff_actions.go b/pkg/gui/controllers/commit_diff_actions.go new file mode 100644 index 000000000..ceb13cddd --- /dev/null +++ b/pkg/gui/controllers/commit_diff_actions.go @@ -0,0 +1,467 @@ +package controllers + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/patch" + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" +) + +// CommitDiffActions implements what a panel showing a commit's diff offers on that diff +// in the focused main view. Five panels do: the commit files panel shows the diff of one +// file of a commit, and the commits, sub-commits, stash and reflog panels the whole diff +// of whatever they have selected. They all offer the same thing and differ only in which +// diff they show, so they share this, each saying which diff that is. +type CommitDiffActions struct { + c *ControllerCommon + + // The panel this belongs to, and what it is showing the diff of — nil when it has + // nothing selected, and so no diff. + panel types.Context + target func() *commitDiffTarget +} + +// commitDiffTarget is the diff a panel is showing: the two ends of it, and whether it +// belongs to a commit lazygit may rewrite. +type commitDiffTarget struct { + from string + to string + canRebase bool +} + +var _ types.FocusedMainViewActions = &CommitDiffActions{} + +func NewCommitDiffActions( + c *ControllerCommon, panel types.Context, target func() *commitDiffTarget, +) *CommitDiffActions { + return &CommitDiffActions{c: c, panel: panel, target: target} +} + +// PlainDiff hands out the diff the asking pane is showing, for the given files — the +// commit's diff as in the main view, only without the commit's message and stat above it, +// or the diff the custom patch is previewed as, whose lines are the patch's own rather +// than the commit's. +// +// The patch's own diff is handed out whole: it is only ever as big as the patch, and it +// names its files under the trees the patch was materialized into rather than under the +// paths asked for. +func (self *CommitDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string { + if self.showsCustomPatch(pane) { + return self.customPatchDiff() + } + + target := self.target() + if target == nil { + return "" + } + return self.c.Helpers().Diff.PlainDiffBetweenRefs(target.from, target.to, paths) +} + +// customPatchDiff is the diff the custom patch is previewed as, as git writes it — the +// diff behind what the pane previewing the patch shows, in which the lines shown there +// can be found again. +func (self *CommitDiffActions) customPatchDiff() string { + treesDir := self.c.Git().Patch.PatchBuilder.TempDir() + if treesDir == "" { + return "" + } + // An error means the two trees differ, as they do for any patch with something in + // it. We are after the diff itself either way. + diff, _ := self.c.Git().Diff. + CustomPatchDiffCmdObj(treesDir, git_commands.DiffModePlain). + RunWithOutput() + return diff +} + +// PrimaryAction takes the selected lines into the custom patch being built from this +// diff, or back out of it when the first of them is already in — the same toggling the +// commit files panel does to a whole file at a time. +// +// The commit is not touched, so the diff stays as it is: what changes is the patch +// beside it, and which of its lines are marked as being in that patch. +func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error { + // In the pane showing the patch, the lines are the patch's own, so there they only + // come back out of it. + if self.showsCustomPatch(pane) { + return self.removePatchLines(pane, firstLineIdx, lastLineIdx) + } + + if self.c.UserConfig().Git.DiffContextSize == 0 { + return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch, + self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) + } + + target := self.target() + if target == nil { + return nil + } + lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx) + if len(lines) == 0 { + return nil + } + + patchBuilder := self.c.Git().Patch.PatchBuilder + from, reverse := self.patchEndpoints(target) + // A patch is built from one diff, so building from another one means giving up the + // patch there is — which the user is asked about, as entering the patch builder asks. + mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, target.to, reverse) + return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ + Title: self.c.Tr.DiscardPatch, + Prompt: self.c.Tr.DiscardPatchConfirm, + HandleConfirm: func() error { + if mustDiscardPatch { + patchBuilder.Reset() + } + if !patchBuilder.Active() { + patchBuilder.Start(from, target.to, reverse, target.canRebase) + } + + if err := self.togglePatchLines(lines); err != nil { + return err + } + // Taking the last line back out ends the patch rather than leaving an empty + // one, so that the pane previewing it and the marks over the diff go with it. + if patchBuilder.IsEmpty() { + patchBuilder.Reset() + } + + // The diff on screen is the one the marks belong to, so they can be brought up + // to date at once rather than waiting for the render below. + self.c.Helpers().DiffLine.RefreshInclusionGutter() + + // The selection moves on past the lines just toggled, to the next change of + // the diff — which is still there, a toggle leaving the diff as it was, so + // hold input back until it has moved: a second press meanwhile would toggle + // the same lines straight back. + self.c.GocuiGui().BeginBlockingEvents() + self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, len(lines), + self.c.GocuiGui().EndBlockingEvents) + + // The panel's own render, which is all that is needed: the marks over the diff + // and the patch previewed beside it have changed, while the commit has not. + self.c.PostRefreshUpdate(self.panel) + return nil + }, + }) +} + +// removePatchLines takes the selected lines of the custom patch out of it. The primary +// action does this in the pane showing the patch: everything shown there is in the patch +// already, so there is nothing else it could mean. +// +// A line of the patch is named by its position among its file's changes, counted in the +// diff the patch is shown as. That position is the same one the line has among the +// changes the patch holds for that file. Line numbers would not do: a patch that leaves +// an earlier addition out numbers everything after it differently from the commit's diff. +func (self *CommitDiffActions) removePatchLines( + pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int, +) error { + lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx) + if len(lines) == 0 { + return nil + } + + patchBuilder := self.c.Git().Patch.PatchBuilder + files := self.filesInDiff() + for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) { + filename := self.patchBuilderPath(path) + if filename == "" { + continue + } + included := patchBuilder.IncludedChangeLineIndices(filename) + indices := []int{} + for _, ordinal := range ordinals { + if ordinal < len(included) { + indices = append(indices, included[ordinal]) + } + } + if len(indices) == 0 { + continue + } + if err := patchBuilder.RemoveFileLineRange(filename, files.previousPath(filename), indices); err != nil { + return err + } + } + // Taking the last line out ends the patch rather than leaving an empty one, as it does + // in the diff beside this pane. + if patchBuilder.IsEmpty() { + patchBuilder.Reset() + } + + self.c.Helpers().DiffLine.RefreshInclusionGutter() + + // The lines are gone from the patch, so the selection carries on from where they were, + // as unstaging leaves it. Input is held until it has moved, so that a second press acts + // on the patch as it now is. + self.c.GocuiGui().BeginBlockingEvents() + self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, 0, + self.c.GocuiGui().EndBlockingEvents) + + self.c.PostRefreshUpdate(self.panel) + return nil +} + +// DiscardSelection takes the selected lines out of the commit they are part of, by +// building a patch of exactly those lines and removing that patch from the commit. It is +// a rebase, so a later commit that touches the same lines can conflict with it. +// +// The patch it needs is its own, so a patch being built is given up first — which the +// prompt says, there being no way to get it back. +func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error { + target := self.target() + if target == nil { + return nil + } + lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx) + if len(lines) == 0 { + return nil + } + commitIndex := self.indexOfTargetCommit(target) + if commitIndex == -1 { + return nil + } + + patchBuilder := self.c.Git().Patch.PatchBuilder + prompt := lo.Ternary(patchBuilder.IsEmpty(), + self.c.Tr.DiscardLinesFromCommitPrompt, + self.c.Tr.DiscardLinesFromCommitPromptWithReset) + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.DiscardLinesFromCommitTitle, + Prompt: prompt, + HandleConfirm: func() error { + from, reverse := self.patchEndpoints(target) + patchBuilder.Reset() + patchBuilder.Start(from, target.to, reverse, target.canRebase) + if err := self.togglePatchLines(lines); err != nil { + return err + } + if patchBuilder.IsEmpty() { + return nil + } + + // The rebase runs on a worker, which may not read the model, so the commits + // it rewrites are taken here. + commits := self.c.Model().Commits + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) + err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) + }) + }, + }) + return nil +} + +// DiscardSelectionDisabledReason says why the selected lines can't be taken out of the +// commit: doing so rewrites it, which is only ours to do for a commit of the branch we +// are on, and not while a rebase is already under way. In the pane previewing the custom +// patch there is nothing to discard from — the lines there are the patch's, and space +// takes them back out of it. +func (self *CommitDiffActions) DiscardSelectionDisabledReason(pane types.DiffPaneContext) *types.DisabledReason { + if self.showsCustomPatch(pane) { + return &types.DisabledReason{Text: self.c.Tr.CannotDiscardFromCustomPatchView, ShowErrorInPanel: true} + } + target := self.target() + if target == nil || !target.canRebase { + return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true} + } + if self.c.Git().Status.WorkingTreeState().Any() { + return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true} + } + if self.c.UserConfig().Git.DiffContextSize == 0 { + return &types.DisabledReason{ + Text: fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines, + self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView), + ShowErrorInPanel: true, + } + } + return nil +} + +// PatchInclusion says which lines of the commit's diff are in the custom patch being +// built from it. nil when there is no such patch: none is being built at all, or the one +// being built is of another diff, whose lines are not these however alike they look. +func (self *CommitDiffActions) PatchInclusion() func(types.DiffLineInfo) bool { + patchBuilder := self.c.Git().Patch.PatchBuilder + target := self.target() + if !patchBuilder.Active() || target == nil { + return nil + } + from, reverse := self.patchEndpoints(target) + if patchBuilder.NewPatchRequired(from, target.to, reverse) { + return nil + } + + // Which lines of a file are in the patch is asked of the patch builder per file, and + // a diff can span many, so each is asked about when a line of it first comes up. + includedByPath := map[string]*set.Set[patch.LineIdentity]{} + return func(info types.DiffLineInfo) bool { + path := self.patchBuilderPath(info.Path) + if path == "" { + return false + } + included, asked := includedByPath[path] + if !asked { + included = set.NewFromSlice(patchBuilder.IncludedLineIdentities(path)) + includedByPath[path] = included + } + return included.Includes(info.PatchLineIdentity()) + } +} + +// togglePatchLines takes the given lines of the commit's diff into the custom patch, or +// out of it. The first line of the selection decides which of the two happens, once for +// the whole selection: pointing at a line that is already in the patch takes the whole +// selection out of it, as toggling a selection of files in the commit files panel does. +func (self *CommitDiffActions) togglePatchLines(lines []types.DiffLineInfo) error { + patchBuilder := self.c.Git().Patch.PatchBuilder + + // The files the selection covers, in the order the diff shows them, and per file the + // lines of it that are selected: a patch is built a file at a time, while a selection + // can span several of them. + paths := []string{} + linesByPath := map[string][]patch.LineIdentity{} + for _, line := range lines { + path := self.patchBuilderPath(line.Path) + if path == "" { + continue + } + if _, seen := linesByPath[path]; !seen { + paths = append(paths, path) + } + linesByPath[path] = append(linesByPath[path], line.PatchLineIdentity()) + } + if len(paths) == 0 { + return nil + } + + files := self.filesInDiff() + indicesByPath := map[string][]int{} + wholeFileByPath := map[string]bool{} + for _, path := range paths { + indices, everyChange, err := patchBuilder.PatchLineIndicesForLines( + path, files.previousPath(path), linesByPath[path]) + if err != nil { + return err + } + indicesByPath[path] = indices + + // Selecting every change of a file the commit adds or deletes is selecting the + // file: what the commit did to it is not something its content lines can carry, + // so a patch of those alone would move the content and leave the file behind. + wholeFileByPath[path] = everyChange && files.isWholeFileOperation(path) + } + + included, err := patchBuilder.GetFileIncLineIndices(paths[0], files.previousPath(paths[0])) + if err != nil { + return err + } + removing := len(indicesByPath[paths[0]]) > 0 && lo.Contains(included, indicesByPath[paths[0]][0]) + + for _, path := range paths { + if len(indicesByPath[path]) == 0 { + continue + } + previousPath := files.previousPath(path) + var err error + switch { + case wholeFileByPath[path] && removing: + err = patchBuilder.RemoveFile(path, previousPath) + case wholeFileByPath[path]: + err = patchBuilder.AddFileWhole(path, previousPath) + case removing: + err = patchBuilder.RemoveFileLineRange(path, previousPath, indicesByPath[path]) + default: + err = patchBuilder.AddFileLineRange(path, previousPath, indicesByPath[path]) + } + if err != nil { + return err + } + } + return nil +} + +// commitDiffFiles records what the diff a patch is being built from says about each of +// the files it covers, by the path the diff shows them under. +type commitDiffFiles map[string]*models.CommitFile + +// previousPath says what a file of the diff was called before, and is empty for one +// that wasn't renamed. A renamed file's diff only comes out as a rename when git is +// asked about both of its paths, and its lines are numbered in the file under its old +// name, so the patch builder has to be told the old path along with them. +func (self commitDiffFiles) previousPath(path string) string { + if file, ok := self[path]; ok { + return file.PreviousPath + } + return "" +} + +// isWholeFileOperation reports whether what the commit did to this file is something +// its diff's content lines don't say: creating it or deleting it, which the file +// header carries and a patch built from lines alone would leave out. +func (self commitDiffFiles) isWholeFileOperation(path string) bool { + file, ok := self[path] + return ok && (file.Added() || file.Deleted()) +} + +// filesInDiff asks git which files the diff a patch is being built from covers, and +// what it does to each. +func (self *CommitDiffActions) filesInDiff() commitDiffFiles { + target := self.target() + if target == nil { + return nil + } + from, reverse := self.patchEndpoints(target) + files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, target.to, reverse) + if err != nil { + return nil + } + + return lo.SliceToMap(files, func(file *models.CommitFile) (string, *models.CommitFile) { + return file.Path, file + }) +} + +// patchEndpoints gives the two ends of the diff a patch is built from. They are the ends +// of the diff shown, except in diffing mode, where what is shown is a diff against +// another ref, possibly the other way around. +func (self *CommitDiffActions) patchEndpoints(target *commitDiffTarget) (string, bool) { + return self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(target.from) +} + +// patchBuilderPath turns the absolute path a diff line carries into the repo-relative +// one the patch builder keys a file by, and "" for a path that is no file of this repo. +func (self *CommitDiffActions) patchBuilderPath(path string) string { + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path) + if err != nil || strings.HasPrefix(relativePath, "..") { + return "" + } + return filepath.ToSlash(relativePath) +} + +// indexOfTargetCommit finds the commit the diff belongs to among the commits of the +// branch we are on, which is how a rebase is told which commit to rewrite. -1 when it +// isn't one of them, in which case there is nothing we can rewrite. +func (self *CommitDiffActions) indexOfTargetCommit(target *commitDiffTarget) int { + return lo.IndexOf( + lo.Map(self.c.Model().Commits, func(commit *models.Commit, _ int) string { return commit.Hash() }), + target.to) +} + +// showsCustomPatch reports whether the given main pane is the one previewing the custom +// patch being built, rather than the commit's diff — which for a commit's diff is always +// the lower one. +func (self *CommitDiffActions) showsCustomPatch(pane types.DiffPaneContext) bool { + return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey() +} diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 4aa46a28c..c6b0fb293 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -23,6 +23,9 @@ type CommitFilesController struct { baseController *ListControllerTrait[*filetree.CommitFileNode] c *ControllerCommon + + // what this panel offers on the diff it shows in the focused main view + diffActions *CommitDiffActions } var _ types.IController = &CommitFilesController{} @@ -30,7 +33,7 @@ var _ types.IController = &CommitFilesController{} func NewCommitFilesController( c *ControllerCommon, ) *CommitFilesController { - return &CommitFilesController{ + controller := &CommitFilesController{ baseController: baseController{}, c: c, ListControllerTrait: NewListControllerTrait( @@ -40,6 +43,18 @@ func NewCommitFilesController( c.Contexts().CommitFiles.GetSelectedItems, ), } + controller.diffActions = NewCommitDiffActions(c, c.Contexts().CommitFiles, controller.diffTarget) + return controller +} + +// diffTarget is the commit whose files this panel is showing. Its main view shows the +// diff of that commit. +func (self *CommitFilesController) diffTarget() *commitDiffTarget { + if self.context().GetRef() == nil && self.context().GetRefRange() == nil { + return nil + } + from, to := self.context().GetFromAndToForDiff() + return &commitDiffTarget{from: from, to: to, canRebase: self.context().GetCanRebase()} } func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { @@ -109,8 +124,8 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) [] Keys: opts.GetKeys(opts.Config.Universal.GoInto), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), - Description: self.c.Tr.EnterCommitFile, - Tooltip: self.c.Tr.EnterCommitFileTooltip, + Description: self.c.Tr.FocusCommitFileDiff, + Tooltip: self.c.Tr.FocusCommitFileDiffTooltip, }, { Keys: opts.GetKeys(opts.Config.Files.ToggleTreeView), @@ -175,9 +190,10 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + mode := self.c.Helpers().DiffLine.MainViewDiffMode() paths := self.pathsForDiff(node) - cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false) - task := types.NewRunPtyTask(cmdObj.GetCmd()) + cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, mode) + task := types.NewMainViewDiffTask(cmdObj.GetCmd(), mode) self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, @@ -191,11 +207,15 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { } } +func (self *CommitFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self.diffActions +} + func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true) + cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModePlain) diff, err := cmdObj.RunWithOutput() if err != nil { return err @@ -537,41 +557,15 @@ func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (strin } func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error { - return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) -} - -func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error { if node.File == nil { return self.handleToggleCommitFileDirCollapsed(node) } - if self.c.UserConfig().Git.DiffContextSize == 0 { - return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch, - self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) - } + return focusMainView(self.c, self.context(), -1) +} - from, to, reverse := self.currentFromToReverseForPatchBuilding() - mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) - return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ - Title: self.c.Tr.DiscardPatch, - Prompt: self.c.Tr.DiscardPatchConfirm, - HandleConfirm: func() error { - if mustDiscardPatch { - self.c.Git().Patch.PatchBuilder.Reset() - } - - if !self.c.Git().Patch.PatchBuilder.Active() { - if err := self.startPatchBuilder(); err != nil { - return err - } - } - - self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts) - self.c.Helpers().PatchBuilding.ShowHunkStagingHint() - - return nil - }, - }) +func (self *CommitFilesController) GetOnDoubleClick() func() error { + return self.withItemGraceful(self.enter) } func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error { @@ -606,16 +600,6 @@ func (self *CommitFilesController) expandAll() error { return nil } -func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { - return func(mainViewName string, clickedLineIdx int) error { - node := self.getSelectedItem() - if node != nil && node.File != nil { - return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx}) - } - return nil - } -} - func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string { return diffPathsForNode( node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering()) diff --git a/pkg/gui/controllers/context_lines_controller.go b/pkg/gui/controllers/context_lines_controller.go index 022364c07..366429a50 100644 --- a/pkg/gui/controllers/context_lines_controller.go +++ b/pkg/gui/controllers/context_lines_controller.go @@ -1,11 +1,9 @@ package controllers import ( - "errors" "fmt" "math" - "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -51,10 +49,6 @@ func (self *ContextLinesController) Context() types.Context { } func (self *ContextLinesController) Increase() error { - if err := self.checkCanChangeContext(); err != nil { - return err - } - if self.c.UserConfig().Git.DiffContextSize < math.MaxUint64 { self.c.UserConfig().Git.DiffContextSize++ } @@ -62,10 +56,6 @@ func (self *ContextLinesController) Increase() error { } func (self *ContextLinesController) Decrease() error { - if err := self.checkCanChangeContext(); err != nil { - return err - } - if self.c.UserConfig().Git.DiffContextSize > 0 { self.c.UserConfig().Git.DiffContextSize-- } @@ -76,22 +66,11 @@ func (self *ContextLinesController) applyChange() error { self.c.Toast(fmt.Sprintf(self.c.Tr.DiffContextSizeChanged, self.c.UserConfig().Git.DiffContextSize)) currentContext := self.c.Context().CurrentSide() - switch currentContext.GetKey() { - // we make an exception for our staging and patch building contexts because they actually need to refresh their state afterwards. - case context.PATCH_BUILDING_MAIN_CONTEXT_KEY: - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.PATCH_BUILDING}}) - case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY: - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}}) - default: - currentContext.HandleRenderToMain() - } - return nil -} - -func (self *ContextLinesController) checkCanChangeContext() error { - if self.c.Git().Patch.PatchBuilder.Active() { - return errors.New(self.c.Tr.CantChangeContextSizeError) - } - + // The diff is about to be rendered again with more or less context around + // each change, which reads as the lines you were looking at moving up or down + // the view; keep them where they are instead. + self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView()) + self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView()) + currentContext.HandleRenderToMain() return nil } diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index 2882ab808..dc95c00df 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -30,7 +30,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error { { Label: self.c.Tr.ResetPatch, Tooltip: self.c.Tr.ResetPatchTooltip, - OnPress: self.c.Helpers().PatchBuilding.Reset, + OnPress: self.c.Helpers().CustomPatch.Reset, Keys: menuKey('c'), }, { @@ -123,15 +123,7 @@ func (self *CustomPatchOptionsMenuAction) getPatchCommitIndex() int { return -1 } -func (self *CustomPatchOptionsMenuAction) returnFocusFromPatchExplorerIfNecessary() { - if self.c.Context().Current().GetKey() == self.c.Contexts().CustomPatchBuilder.GetKey() { - self.c.Helpers().PatchBuilding.Escape() - } -} - func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error { - self.returnFocusFromPatchExplorerIfNecessary() - commits := self.c.Model().Commits commitIndex := self.getPatchCommitIndex() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { @@ -142,8 +134,6 @@ func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error { } func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() error { - self.returnFocusFromPatchExplorerIfNecessary() - commits := self.c.Model().Commits commitIndex := self.getPatchCommitIndex() toCommitIndex := self.c.Contexts().LocalCommits.GetSelectedLineIdx() @@ -155,8 +145,6 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() erro } func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error { - self.returnFocusFromPatchExplorerIfNecessary() - mustStash := self.c.Helpers().WorkingTree.IsWorkingTreeDirtyExceptSubmodules() return self.c.ConfirmIf(mustStash, types.ConfirmOpts{ Title: self.c.Tr.MustStashTitle, @@ -174,8 +162,6 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error } func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error { - self.returnFocusFromPatchExplorerIfNecessary() - commitIndex := self.getPatchCommitIndex() self.c.Helpers().Commits.OpenCommitMessagePanel( &helpers.OpenCommitMessagePanelOpts{ @@ -209,8 +195,6 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error { } func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() error { - self.returnFocusFromPatchExplorerIfNecessary() - commitIndex := self.getPatchCommitIndex() self.c.Helpers().Commits.OpenCommitMessagePanel( &helpers.OpenCommitMessagePanelOpts{ @@ -244,8 +228,6 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e } func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error { - self.returnFocusFromPatchExplorerIfNecessary() - affectedUnstagedFiles := self.getAffectedUnstagedFiles() mustStageFiles := len(affectedUnstagedFiles) > 0 diff --git a/pkg/gui/controllers/diff_copy.go b/pkg/gui/controllers/diff_copy.go new file mode 100644 index 000000000..f2e75b097 --- /dev/null +++ b/pkg/gui/controllers/diff_copy.go @@ -0,0 +1,43 @@ +package controllers + +import ( + "strings" + + "github.com/samber/lo" +) + +// Removes '+' or '-' from the beginning of each line in the diff string, except +// when both '+' and '-' lines are present, or diff header lines, in which case +// the diff is returned unchanged. This is useful for copying parts of diffs to +// the clipboard in order to paste them into code. +func dropDiffPrefix(diff string) string { + lines := strings.Split(strings.TrimRight(diff, "\n"), "\n") + + const ( + PLUS int = iota + MINUS + CONTEXT + OTHER + ) + + linesByType := lo.GroupBy(lines, func(line string) int { + switch { + case strings.HasPrefix(line, "+"): + return PLUS + case strings.HasPrefix(line, "-"): + return MINUS + case strings.HasPrefix(line, " "): + return CONTEXT + } + return OTHER + }) + + hasLinesOfType := func(lineType int) bool { return len(linesByType[lineType]) > 0 } + + keepPrefix := hasLinesOfType(OTHER) || (hasLinesOfType(PLUS) && hasLinesOfType(MINUS)) + if keepPrefix { + return diff + } + + return strings.Join(lo.Map(lines, func(line string, _ int) string { return line[1:] + "\n" }), "") +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index e61e1409c..28560c5e1 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -21,6 +21,9 @@ type FilesController struct { baseController *ListControllerTrait[*filetree.FileNode] c *ControllerCommon + + // what this panel offers on the diff it shows in the focused main view + diffActions *WorkingTreeDiffActions } var _ types.IController = &FilesController{} @@ -36,6 +39,7 @@ func NewFilesController( c.Contexts().Files.GetSelected, c.Contexts().Files.GetSelectedItems, ), + diffActions: NewWorkingTreeDiffActions(c), } } @@ -348,7 +352,7 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) { message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr)) if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" { - cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}) + cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}, git_commands.DiffModeRendered) prefix := message + "\n\n" if node.File.ShortStatus == "DU" { prefix += self.c.Tr.MergeConflictIncomingDiff @@ -366,56 +370,52 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) { func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { self.c.Helpers().MergeConflicts.ResetMergeState() - split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) - mainShowsStaged := !split && node.GetHasStagedChanges() + // The unstaged side of a file's diff is shown in the main pane and the staged side + // in the secondary one, each only where there is a side to show — so a side is + // always in the same place, whatever the file happens to have. A file with nothing + // unstaged therefore shows its staged changes in the secondary pane, which then has + // the whole section to itself. Configured to always split, both panes are shown + // whether or not there is anything on either side. + alwaysSplit := self.c.UserConfig().Gui.SplitDiff == "always" + showStaged := node.GetHasStagedChanges() || alwaysSplit + showUnstaged := node.GetHasUnstagedChanges() || alwaysSplit || !showStaged + + // While the main view is focused to act on this diff, it may have to be git's own + // rather than the diff renderer's; both panes have to agree about that. + mode := self.c.Helpers().DiffLine.MainViewDiffMode() paths := self.pathsForDiff(node) - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths) - title := self.c.Tr.UnstagedChanges - if mainShowsStaged { - title = self.c.Tr.StagedChanges - } - refreshOpts := types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Task: types.NewRunPtyTask(cmdObj.GetCmd()), - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Title: title, - }, - } + refreshOpts := types.RefreshMainOpts{Pair: self.c.MainViewPairs().Normal} - if split { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths) - - title := self.c.Tr.StagedChanges - if mainShowsStaged { - title = self.c.Tr.UnstagedChanges + if showUnstaged { + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, false, paths) + refreshOpts.Main = &types.ViewUpdateOpts{ + Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode), + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Title: self.c.Tr.UnstagedChanges, + NothingToActOn: !node.GetHasUnstagedChanges(), } + } + if showStaged { + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, true, paths) refreshOpts.Secondary = &types.ViewUpdateOpts{ - Title: title, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRunPtyTask(cmdObj.GetCmd()), + Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode), + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Title: self.c.Tr.StagedChanges, + NothingToActOn: !node.GetHasStagedChanges(), } } self.c.RenderToMainViews(refreshOpts) } -func (self *FilesController) GetOnDoubleClick() func() error { - return self.withItemGraceful(func(node *filetree.FileNode) error { - return self.press([]*filetree.FileNode{node}) - }) +func (self *FilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self.diffActions } -func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { - return func(mainViewName string, clickedLineIdx int) error { - node := self.getSelectedItem() - if node != nil && node.File != nil { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx}) - } - return nil - } +func (self *FilesController) GetOnDoubleClick() func() error { + return self.enter } // if we are dealing with a status for which there is no key in this map, @@ -692,7 +692,7 @@ func (self *FilesController) getSelectedFile() *models.File { } func (self *FilesController) enter() error { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) + return self.enterFile(-1) } func (self *FilesController) collapseAll() error { @@ -711,7 +711,10 @@ func (self *FilesController) expandAll() error { return nil } -func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { +// enterFile focuses the diff of the selected file, which is where the commands that +// act on its lines live. clickedViewLineIdx is the row of the diff a click landed on, +// or -1 when the diff wasn't clicked. +func (self *FilesController) enterFile(clickedViewLineIdx int) error { node := self.context().GetSelected() if node == nil { return nil @@ -737,11 +740,7 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { return self.switchToMerge() } - context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging) - self.c.Context().Push(context, opts) - self.c.Helpers().PatchBuilding.ShowHunkStagingHint() - - return nil + return focusMainView(self.c, self.context(), clickedViewLineIdx) } // conflictResolutionHint formats a conflict description for the main view, @@ -1532,7 +1531,7 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro } func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y}) + return self.enterFile(opts.Y) } func (self *FilesController) fetch() error { diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index b50ec6d1d..44e26798a 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -190,6 +190,10 @@ func (self *GlobalController) onDiffRenderersChanged() { if currentSide.GetKey() == currentKey || currentKey == context.NORMAL_MAIN_CONTEXT_KEY || currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY { + // The new renderer lays the same diff out its own way, so the line you were + // looking at ends up elsewhere in the view; keep it in front of you. + self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView()) + self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView()) currentSide.HandleRenderToMain() } diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index 44de70546..20e5824e9 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -99,21 +99,27 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingSt self.modeHelper.SetSuppressWorkingTreeStateMode(true) } self.c.OnWorker(func(task gocui.Task) error { - // End the block and restore the mode indicator once the operation and its - // refresh have applied their UI updates: OnUIThread queues this after the - // refresh's model bounces and Then (which RefreshFromWorker has already - // enqueued by the time f returns), so the replayed keys act on the - // refreshed state and any resulting working tree state shows correctly. - defer self.c.OnUIThread(func() error { - if opts.HideWorkingTreeState { - self.modeHelper.SetSuppressWorkingTreeStateMode(false) - } - return self.c.GocuiGui().EndBlockingEvents() - }) + defer self.endBlockingInput(opts.HideWorkingTreeState) return self.WithWaitingStatusImpl(opts.Message, f, task) }) } +// endBlockingInput lets input through again once the operation and its refresh +// have applied their UI updates, and restores the mode indicator with it. +// OnUIThread queues this after the refresh's model bounces and Then (which +// RefreshFromWorker has already enqueued by the time the operation returns), so +// the replayed keys act on the refreshed state and any resulting working tree +// state shows correctly. +func (self *AppStatusHelper) endBlockingInput(hideWorkingTreeState bool) { + self.c.OnUIThread(func() error { + if hideWorkingTreeState { + self.modeHelper.SetSuppressWorkingTreeStateMode(false) + } + self.c.GocuiGui().EndBlockingEvents() + return nil + }) +} + func (self *AppStatusHelper) HasStatus() bool { return self.statusMgr().HasStatus() } diff --git a/pkg/gui/controllers/helpers/custom_patch_helper.go b/pkg/gui/controllers/helpers/custom_patch_helper.go new file mode 100644 index 000000000..0c05c635d --- /dev/null +++ b/pkg/gui/controllers/helpers/custom_patch_helper.go @@ -0,0 +1,25 @@ +package helpers + +import "github.com/jesseduffield/lazygit/pkg/gui/types" + +type CustomPatchHelper struct { + c *HelperCommon +} + +func NewCustomPatchHelper(c *HelperCommon) *CustomPatchHelper { + return &CustomPatchHelper{c: c} +} + +func (self *CustomPatchHelper) Reset() error { + self.c.Git().Patch.PatchBuilder.Reset() + self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.COMMIT_FILES}, + }) + + // Render again so that the pane that was previewing the patch goes with it. The + // panel asked to do that is the side panel rather than whichever context has the + // focus. Both main panes are rendered by the panel beneath them, so a reset from + // within the focused main view has to go through that panel too. + self.c.PostRefreshUpdate(self.c.Context().CurrentSide()) + return nil +} diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 6af3b2b5c..da79ac479 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -15,11 +15,15 @@ import ( type DiffHelper struct { c *HelperCommon + // diffLineHelper says how a diff for the main view is to be produced, which depends + // on whether the focused main view could act on what a diff renderer would make of it. + diffLineHelper *DiffLineHelper } -func NewDiffHelper(c *HelperCommon) *DiffHelper { +func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper) *DiffHelper { return &DiffHelper{ - c: c, + c: c, + diffLineHelper: diffLineHelper, } } @@ -53,6 +57,8 @@ func (self *DiffHelper) DiffArgs() []string { // either there's no range, or it can't be diffed for some reason), then we want // to fall back to rendering the diff for the single commit. func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange) types.UpdateTask { + mode := self.diffLineHelper.MainViewDiffMode() + if refRange != nil { from, to := refRange.From, refRange.To args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"} @@ -72,13 +78,26 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm args = append(args, filterPath) } } - cmdObj := self.c.Git().Diff.DiffCmdObj(args) + cmdObj := self.c.Git().Diff.DiffCmdObj(args, mode) prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName()) - return types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix) + return types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, mode) } - cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit)) - return types.NewRunPtyTask(cmdObj.GetCmd()) + cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), mode) + return types.NewMainViewDiffTask(cmdObj.GetCmd(), mode) +} + +// PlainDiffBetweenRefs returns the diff of the given files between two refs as git +// writes it, without colour or a diff renderer's involvement — what a panel showing +// a commit's diff hands out as the diff behind its rendering (see +// types.FocusedMainViewDiffSource). It honours diffing mode, so that the diff is of +// the same two ends the main view is showing. +func (self *DiffHelper) PlainDiffBetweenRefs(from string, to string, paths []string) string { + from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + // An error means there is no diff to be had, which for our purposes is the same + // as an empty one. + diff, _ := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModePlain).RunWithOutput() + return diff } func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string { @@ -100,7 +119,7 @@ func (self *DiffHelper) ExitDiffMode() error { func (self *DiffHelper) RenderDiff() { args := self.DiffArgs() - cmdObj := self.c.Git().Diff.DiffCmdObj(args) + cmdObj := self.c.Git().Diff.DiffCmdObj(args, git_commands.DiffModeRendered) prefix := style.FgMagenta.Sprintf( "%s %s\n\n", self.c.Tr.ShowingGitDiff, @@ -192,7 +211,7 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error { // AdjustLineNumber is used to adjust a line number in the diff that's currently // being viewed, so that it corresponds to the line number in the actual working // copy state of the file. It is used when clicking on a delta hyperlink in a -// diff, or when pressing `e` in the staging or patch building panels. It works +// diff, or when pressing `e` in a focused diff. It works // by getting a diff of what's being viewed in the main view against the working // copy, and then using that diff to adjust the line number. // path is the file path of the file being viewed @@ -203,7 +222,7 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error { func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname string) int { switch viewname { - case "main", "patchBuilding": + case "main": if diffableContext, ok := self.c.Context().CurrentSide().(types.DiffableContext); ok { ref := diffableContext.RefForAdjustingLineNumberInDiff() if len(ref) != 0 { @@ -214,7 +233,7 @@ func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname s // unstaged changes view of the Files panel; no need to adjust line // numbers in this case - case "secondary", "stagingSecondary": + case "secondary": return self.adjustLineNumber(linenumber, "--", path) } diff --git a/pkg/gui/controllers/helpers/diff_line_helper.go b/pkg/gui/controllers/helpers/diff_line_helper.go new file mode 100644 index 000000000..c6672fdfc --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_helper.go @@ -0,0 +1,175 @@ +package helpers + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type DiffLineHelper struct { + c *HelperCommon + + // What the probe said about the diff renderer that rendererSignature names, or nil + // before it has been asked about any (see diffRendererEmitsMetadata). + rendererEmitsMetadata *bool + rendererSignature string +} + +func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper { + return &DiffLineHelper{c: c} +} + +// GetDiffLineInfo recovers the identity — file, kind, and old/new line number — +// of the diff row at the given (wrapped) view line of the given view. It is the +// seam every consumer of a diff row goes through, so that how we recover that +// identity can change without them noticing. +// +// There are two ways. A diff renderer that speaks the OSC 1717 protocol states +// the identity of each line it renders, which is the only way to recover it from +// a rendering that doesn't look like a diff any more — columns, or +/- markers +// replaced by colour. Otherwise we parse the view's contents as a unified diff, +// which works for the renderings that keep a diff's structure (no renderer, `git +// diff --color`, a renderer that only colorizes) and fails for the rest. +// +// ok is false when the row's identity can't be recovered, in which case the +// caller must not act on the line at all. +func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) { + identities, ok := self.diffLineIdentitiesAt(view, viewLineIdx) + if !ok { + return types.DiffLineInfo{}, false + } + return identities[0], true +} + +// diffLineIdentitiesAt recovers every diff line the row at the given (wrapped) view +// line shows, left to right. It is GetDiffLineInfo's form for a reader that can't +// settle for the line the row leads with: an end of a selection covers its whole +// row, so where a rendering puts a modification's two halves side by side it covers +// both of them. ok is false when the row's identity can't be recovered at all. +func (self *DiffLineHelper) diffLineIdentitiesAt( + view *gocui.View, viewLineIdx int, +) ([]types.DiffLineInfo, bool) { + // The cursor and clicks land on a view line, which counts wrapped segments; + // the contents are indexed by unwrapped buffer line. + bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx) + if !ok { + return nil, false + } + + contents := view.DiffLineContents() + if bufferLineIdx >= len(contents) { + return nil, false + } + + if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 { + return self.inRepoTerms(view, identities), true + } + + parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx) + if !ok { + return nil, false + } + + return self.inRepoTerms(view, []types.DiffLineInfo{self.diffLineInfo(parsed)}), true +} + +// diffLineInfoFromRecords recovers a row's identity from the records the diff +// renderer stated for it. These take precedence over the buffer parse. ok is false +// when the row carries no record we understand, leaving the caller to parse. +// +// A row can carry more than one record, when the rendering puts two diff lines on it +// (a side-by-side row shows a deletion and the addition replacing it); the leftmost +// is the one a reader would call the row's own, so it is the row's identity. +func (self *DiffLineHelper) diffLineInfoFromRecords(metadata []string) (types.DiffLineInfo, bool) { + identities := self.diffLineIdentitiesFromRecords(metadata) + if len(identities) == 0 { + return types.DiffLineInfo{}, false + } + return identities[0], true +} + +// diffLineIdentitiesFromRecords recovers the identity of every diff line the row's +// records state, left to right. Which of them a reader is after depends on the +// reader: the one the row leads with is the row's own identity (see +// diffLineInfoFromRecords), while a reader looking for a particular line has to +// consider them all, since which of a modification's two halves leads a row is up to +// the rendering. +func (self *DiffLineHelper) diffLineIdentitiesFromRecords(metadata []string) []types.DiffLineInfo { + identities := make([]types.DiffLineInfo, 0, len(metadata)) + for _, record := range metadata { + if parsed, ok := parseDiffLineMetadata(record); ok { + identities = append(identities, self.diffLineInfo(parsed)) + } + } + return identities +} + +// resolvedDiffLine is one rendered row's recovered identity, plus whether it could +// be recovered at all — the element of the table resolveDiffLines produces. +type resolvedDiffLine struct { + info types.DiffLineInfo + ok bool +} + +// resolveDiffLines recovers the identity of every row of a rendered diff in one +// pass, indexed 1:1 with contents. It is the batch form of GetDiffLineInfo, for the +// whole-buffer scans (which change lines are where, which file each row belongs +// to). Resolving row by row would re-run the buffer parser's whole-section parse +// once per row — O(n²) on a large single-file diff — so the buffer parser runs once +// for the whole buffer and the per-row metadata takes precedence on top. +func (self *DiffLineHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine { + bufferParsed := parseAllDiffLinesFromBuffer(diffLineTexts(contents)) + resolved := make([]resolvedDiffLine, len(contents)) + for i, content := range contents { + if info, ok := self.diffLineInfoFromRecords(content.Metadata); ok { + resolved[i] = resolvedDiffLine{info, true} + } else if bufferParsed[i].ok { + resolved[i] = resolvedDiffLine{self.diffLineInfo(bufferParsed[i].parsed), true} + } + } + return resolved +} + +// resolveDiffLineIdentities recovers every diff line each row of a rendered diff +// shows, in one pass, indexed 1:1 with contents. It is resolveDiffLines' form for the +// readers that can't settle for the line a row leads with: looking for a remembered +// line in a new rendering has to consider both halves of a modification, since a +// side-by-side row leads with the deletion whose addition was what got remembered +// under a unified one. +func (self *DiffLineHelper) resolveDiffLineIdentities(contents []gocui.DiffLineContent) [][]types.DiffLineInfo { + bufferParsed := parseAllDiffLinesFromBuffer(diffLineTexts(contents)) + identities := make([][]types.DiffLineInfo, len(contents)) + for i, content := range contents { + if fromRecords := self.diffLineIdentitiesFromRecords(content.Metadata); len(fromRecords) > 0 { + identities[i] = fromRecords + } else if bufferParsed[i].ok { + identities[i] = []types.DiffLineInfo{self.diffLineInfo(bufferParsed[i].parsed)} + } + } + return identities +} + +// diffLineInfo turns a parser's result into the absolute-path identity consumers +// work with. The path arrives repo-relative from the diff header, but a renderer +// states it however it likes, absolute paths included. +func (self *DiffLineHelper) diffLineInfo(parsed parsedDiffLine) types.DiffLineInfo { + return diffLineInfoIn(self.c.Git().RepoPaths.WorktreePath(), parsed) +} + +// diffLineInfoIn is diffLineInfo against a given worktree, for the callers that can't +// ask which repo we are in where they run: a repo switch replaces it, so only the UI +// thread may read it. +func diffLineInfoIn(worktreePath string, parsed parsedDiffLine) types.DiffLineInfo { + path := parsed.Path + if !filepath.IsAbs(path) { + path = filepath.Join(worktreePath, path) + } + + return types.DiffLineInfo{ + Path: path, + Type: parsed.Type, + NewLine: parsed.NewLine, + OldLine: parsed.OldLine, + } +} diff --git a/pkg/gui/controllers/helpers/diff_line_parser.go b/pkg/gui/controllers/helpers/diff_line_parser.go new file mode 100644 index 000000000..4409e0425 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_parser.go @@ -0,0 +1,316 @@ +package helpers + +import ( + "strconv" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/patch" + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// diffFilePrefix marks the start of a file's section in a (possibly multi-file) +// unified diff. +const diffFilePrefix = "diff --git " + +// parsedDiffLine is what the parser recovers about a row of a rendered diff. +// Path is the path as the diff header spells it, i.e. relative to the repo root; +// the caller turns it into the absolute path of types.DiffLineInfo. +type parsedDiffLine struct { + Path string + Type types.DiffLineType + NewLine int + OldLine int +} + +// bufferLineParse is the parser's result for one buffer line: the recovered +// identity, and whether the line could be resolved at all (false for a line in +// an unparseable section, or outside any file section). +type bufferLineParse struct { + parsed parsedDiffLine + ok bool +} + +// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by +// parsing the view's decolorized contents. +// +// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to +// resolve. A commit's diff spans several files, so we isolate the file section +// containing targetIdx and parse just that one (see parseFileSection). Use this +// for a single line, e.g. the one under the cursor; to resolve every line of a +// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once. +// +// ok is false when the buffer isn't a parseable unified diff at targetIdx, +// because the diff renderer restructured it, so that the caller can fall back. +func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) { + if targetIdx < 0 || targetIdx >= len(bufferLines) { + return parsedDiffLine{}, false + } + start, end := fileSectionBounds(bufferLines, targetIdx) + if start == -1 { + return parsedDiffLine{}, false + } + r := parseFileSection(bufferLines[start:end], end == len(bufferLines))[targetIdx-start] + return r.parsed, r.ok +} + +// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file) +// diff buffer in one pass, parsing each file section exactly once. It is the +// batch form of parseDiffLineFromBuffer, for callers that scan a whole buffer: +// resolving line by line would re-parse a section once per line of it — O(n²) on +// a large single-file diff — whereas this is O(n). The result is indexed 1:1 +// with bufferLines; a line in an unparseable section, or before the first +// "diff --git", is left ok=false. +func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse { + result := make([]bufferLineParse, len(bufferLines)) + for i := 0; i < len(bufferLines); { + if !strings.HasPrefix(bufferLines[i], diffFilePrefix) { + i++ // not in a file section yet; leave it unresolved + continue + } + _, end := fileSectionBounds(bufferLines, i) + copy(result[i:end], parseFileSection(bufferLines[i:end], end == len(bufferLines))) + i = end + } + return result +} + +// diffLineTexts extracts the text of each rendered row — the material the buffer +// parser works on. +func diffLineTexts(contents []gocui.DiffLineContent) []string { + texts := make([]string, len(contents)) + for i, content := range contents { + texts[i] = content.Text + } + return texts +} + +// fileSectionBounds returns the half-open range [start, end) of the file section +// containing targetIdx: the nearest "diff --git" at or above it, up to the next +// one (or the end of the buffer). start is -1 when targetIdx is before the first +// file section. +func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) { + start = -1 + for i := targetIdx; i >= 0; i-- { + if strings.HasPrefix(bufferLines[i], diffFilePrefix) { + start = i + break + } + } + if start == -1 { + return -1, -1 + } + end = len(bufferLines) + for i := start + 1; i < len(bufferLines); i++ { + if strings.HasPrefix(bufferLines[i], diffFilePrefix) { + end = i + break + } + } + return start, end +} + +// parseFileSection parses one file's diff section (fileLines, starting at its +// "diff --git" line) a single time and returns the identity of each of its +// lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with the +// section's buffer lines, so the type and the old/new line numbers fall out of +// the patch arithmetic. Every line is left ok=false when the section has no +// recoverable path or isn't a well-formed unified diff — the rendering +// restructured it, and acting on a mis-parse would land us on the wrong line, so +// the caller should fall back. +// +// endsTheBuffer says the section runs to the end of what we were given. That is +// where a diff we have only part of breaks off. A long one is read a screenful +// at a time and the rest as the user scrolls, so its last hunk holds fewer lines +// than its header declares until the reading is done. Insisting on the whole +// hunk there would leave every line of the file unresolved while the diff is the +// one on screen, so a section in that position is held to what has arrived. +func parseFileSection(fileLines []string, endsTheBuffer bool) []bufferLineParse { + result := make([]bufferLineParse, len(fileLines)) + + relPath := pathFromDiffHeader(fileLines) + if relPath == "" { + return result + } + p := patch.Parse(strings.Join(fileLines, "\n")) + isWellFormed := p.IsWellFormed + if endsTheBuffer { + isWellFormed = p.IsWellFormedSoFar + } + if !isWellFormed() { + return result + } + patchLines := p.Lines() + for i := range fileLines { + if i >= len(patchLines) { + break + } + parsed := parsedDiffLine{ + Path: relPath, + Type: diffLineTypeForKind(patchLines[i].Kind), + NewLine: p.LineNumberOfLine(i), + } + if parsed.Type == types.DiffLineDeleted { + parsed.OldLine = p.OldLineNumberOfLine(i) + } + result[i] = bufferLineParse{parsed, true} + } + return result +} + +func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType { + switch kind { + case patch.PATCH_HEADER: + return types.DiffLineFileHeader + case patch.HUNK_HEADER: + return types.DiffLineHunkHeader + case patch.ADDITION: + return types.DiffLineAdded + case patch.DELETION: + return types.DiffLineDeleted + case patch.CONTEXT: + return types.DiffLineContext + default: + return types.DiffLineOther + } +} + +// pathFromDiffHeader extracts the new-file path of a single file's diff section. +// It prefers the "+++ b/" line, falling back to "--- a/" when the +// new path is /dev/null (a deleted file), and to the "diff --git" line when +// there are no such lines at all (a pure rename, which has no hunks). +func pathFromDiffHeader(fileLines []string) string { + var oldPath, newPath string + for _, line := range fileLines { + if strings.HasPrefix(line, "@@") { + break // past the header + } + switch { + case strings.HasPrefix(line, "+++ "): + newPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "+++ ")) + case strings.HasPrefix(line, "--- "): + oldPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "--- ")) + } + } + + if newPath != "" && newPath != "/dev/null" { + return newPath + } + if oldPath != "" && oldPath != "/dev/null" { + return oldPath + } + return pathFromDiffGitLine(fileLines[0]) +} + +// pathFromDiffHeaderField decodes one path field of a diff header — the part +// after "--- " or "+++ ", or one of the two paths on the "diff --git" line — +// into the repo-relative path it names. +// +// git spells such a field in three ways: plain; terminated by a tab, when the +// path contains a space; or C-quoted as a whole, when the path contains +// characters git won't print raw — which, with core.quotePath enabled (the +// default), includes every non-ASCII byte, so `café` arrives as +// `"b/caf\303\251"`. The quoting is Go's string syntax, octal escapes included, +// so strconv decodes it for us. +// +// Returns "" for a quoted field we can't decode: better to resolve nothing than +// to point a consumer at a path that doesn't exist. +func pathFromDiffHeaderField(field string) string { + field = strings.TrimSuffix(field, "\t") + + if strings.HasPrefix(field, `"`) { + unquoted, err := strconv.Unquote(field) + if err != nil { + return "" + } + field = unquoted + } + + return stripDiffPathPrefix(field) +} + +// stripDiffPathPrefix removes the a/ or b/ prefix git puts on the paths in a +// diff header. We ask git for these prefixes explicitly (diff.noprefix=false), +// so they are always there. +func stripDiffPathPrefix(path string) string { + if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") { + return path[2:] + } + return path +} + +// parseDiffLineMetadata parses the payload of an OSC 1717 record, in which a +// diff renderer states which line of which file it is rendering. The v1 payload +// is positional and ';'-delimited: +// +// version;type;new-line;old-line;file +// +// The file comes last so that it may itself contain a ';'. The old-file line is +// empty unless the line is a deletion, the only kind that needs it, and the +// new-file line is empty on a file header, the one kind that has no line. +// +// ok is false for a payload of an unknown version or shape, so that the caller +// can fall back to reading the rendered text. +func parseDiffLineMetadata(payload string) (parsedDiffLine, bool) { + fields := strings.SplitN(payload, ";", 5) + if len(fields) < 5 || fields[0] != "1" { + return parsedDiffLine{}, false + } + + lineType, ok := diffLineTypeFromMetadata(fields[1]) + if !ok { + return parsedDiffLine{}, false + } + + newLine := 0 + if fields[2] != "" { + var err error + if newLine, err = strconv.Atoi(fields[2]); err != nil { + return parsedDiffLine{}, false + } + } else if lineType != types.DiffLineFileHeader { + return parsedDiffLine{}, false + } + + oldLine := 0 + if fields[3] != "" { + var err error + if oldLine, err = strconv.Atoi(fields[3]); err != nil { + return parsedDiffLine{}, false + } + } + + return parsedDiffLine{Path: fields[4], Type: lineType, NewLine: newLine, OldLine: oldLine}, true +} + +func diffLineTypeFromMetadata(typeField string) (types.DiffLineType, bool) { + switch typeField { + case "c": + return types.DiffLineContext, true + case "a": + return types.DiffLineAdded, true + case "d": + return types.DiffLineDeleted, true + case "f": + return types.DiffLineFileHeader, true + case "h": + return types.DiffLineHunkHeader, true + default: + return types.DiffLineOther, false + } +} + +// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X" +// line, where the two paths are separated by a space and either may be quoted. +// A path containing " b/" (or ` "b/`) would defeat this, but the +++/--- lines +// are unambiguous and we only get here when they are absent. +func pathFromDiffGitLine(line string) string { + rest := strings.TrimPrefix(line, diffFilePrefix) + if idx := strings.LastIndex(rest, ` "b/`); idx != -1 { + return pathFromDiffHeaderField(rest[idx+1:]) + } + if idx := strings.LastIndex(rest, " b/"); idx != -1 { + return pathFromDiffHeaderField(rest[idx+1:]) + } + return "" +} diff --git a/pkg/gui/controllers/helpers/diff_line_parser_test.go b/pkg/gui/controllers/helpers/diff_line_parser_test.go new file mode 100644 index 000000000..c29b251e0 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_parser_test.go @@ -0,0 +1,293 @@ +package helpers + +import ( + "slices" + "strings" + "testing" + + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/stretchr/testify/assert" +) + +// A two-file commit diff as it appears (decolorized) in the main view. file1 has +// two consecutive deletions (grape, pear) that share a new-file line number; +// file2 has two consecutive additions. +const twoFileDiff = `diff --git a/file1.go b/file1.go +index 1111111..2222222 100644 +--- a/file1.go ++++ b/file1.go +@@ -1,4 +1,2 @@ + apple +-grape +-pear + lemon +diff --git a/dir/file2.go b/dir/file2.go +index 3333333..4444444 100644 +--- a/dir/file2.go ++++ b/dir/file2.go +@@ -10,2 +9,4 @@ func foo() { + ctx ++added1 ++added2 + ctx2` + +func TestParseDiffLineFromBuffer(t *testing.T) { + bufferLines := strings.Split(twoFileDiff, "\n") + + scenarios := []struct { + name string + targetIdx int + expected parsedDiffLine + expectOk bool + }{ + {"file header", 0, parsedDiffLine{Path: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true}, + {"hunk header", 4, parsedDiffLine{Path: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true}, + {"context line", 5, parsedDiffLine{Path: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true}, + // The two deletions share new-file line 2 but have distinct old-file lines. + {"first deletion", 6, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true}, + {"second deletion", 7, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true}, + // The second file: its path comes from the second "diff --git" section, + // and its additions get distinct new-file line numbers. + {"first addition", 15, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true}, + {"second addition", 16, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true}, + {"out of range", 999, parsedDiffLine{}, false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx) + assert.Equal(t, s.expectOk, ok) + if s.expectOk { + assert.Equal(t, s.expected, result) + } + }) + } +} + +func TestParseDiffLineFromBufferRename(t *testing.T) { + // A rename with no content change has no hunks and no +++/--- lines, so the + // path has to come from the "diff --git" line; a rename with a content + // change has them, and they carry the new path. + pureRename := strings.Split(`diff --git a/old.go b/new.go +similarity index 100% +rename from old.go +rename to new.go`, "\n") + + result, ok := parseDiffLineFromBuffer(pureRename, 2) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result) + + renameWithModification := strings.Split(`diff --git a/old.go b/new.go +similarity index 62% +rename from old.go +rename to new.go +index 1111111..2222222 100644 +--- a/old.go ++++ b/new.go +@@ -1,2 +1,2 @@ + apple +-grape ++kiwi`, "\n") + + result, ok = parseDiffLineFromBuffer(renameWithModification, 10) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineAdded, NewLine: 2}, result) +} + +func TestParseDiffLineFromBufferDeletedFile(t *testing.T) { + // The new path is /dev/null, so the identity comes from the old path. + deletedFile := strings.Split(`diff --git a/gone.go b/gone.go +deleted file mode 100644 +index 1111111..0000000 +--- a/gone.go ++++ /dev/null +@@ -1,2 +0,0 @@ +-apple +-grape`, "\n") + + result, ok := parseDiffLineFromBuffer(deletedFile, 7) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{Path: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result) +} + +func TestParseDiffLineFromBufferNotADiff(t *testing.T) { + // A rendering with no "diff --git" line can't be parsed, so the caller falls + // back rather than acting on the line. + bufferLines := []string{"some", "lines", "that", "are not a diff"} + _, ok := parseDiffLineFromBuffer(bufferLines, 2) + assert.False(t, ok) +} + +func TestParseDiffLineFromBufferGutterMangled(t *testing.T) { + // A diff renderer that moves the line numbers into a gutter keeps the diff + // and hunk headers but pushes the +/- markers off the start of each body + // line, so every line reads as context. The body no longer matches the hunk + // header, so we refuse to parse rather than return a confident mis-parse. + mangled := strings.Split(`diff --git a/file1.txt b/file1.txt +index 1111111..2222222 100644 +--- a/file1.txt ++++ b/file1.txt +@@ -1,5 +1,3 @@ + 1 ⋮ 1 │ apple + 2 ⋮ │-grape + 3 ⋮ │-pear + 4 ⋮ 2 │ lemon + 5 ⋮ 3 │ mango`, "\n") + + _, ok := parseDiffLineFromBuffer(mangled, 6) + assert.False(t, ok) +} + +func TestParseDiffLineFromBufferReadInPart(t *testing.T) { + lines := strings.Split(twoFileDiff, "\n") + + // A long diff is read a screenful at a time, so the buffer breaks off part way + // through a hunk. The lines that did arrive are resolved all the same, since + // holding out for the whole hunk would leave the diff on screen with nothing to + // act on. + cutShort := lines[:len(lines)-1] + result, ok := parseDiffLineFromBuffer(cutShort, 15) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, result) + + // Only the section the buffer breaks off in is read that way. One that another + // section follows is all there, so a hunk short of what its header declares means + // the rendering restructured the diff, and none of it is resolved. + shortFirstSection := append(slices.Clone(lines[:8]), lines[9:]...) + _, ok = parseDiffLineFromBuffer(shortFirstSection, 5) + assert.False(t, ok) +} + +func TestPathFromDiffHeaderField(t *testing.T) { + scenarios := []struct { + name string + field string + expected string + }{ + {"new side", "b/file.go", "file.go"}, + {"old side", "a/file.go", "file.go"}, + {"a missing file", "/dev/null", "/dev/null"}, + // git terminates the field with a tab when the path has a space in it. + {"path with a space", "b/with space.go\t", "with space.go"}, + // With core.quotePath enabled (the default) every non-ASCII byte is + // escaped, and the field is quoted as a whole, prefix included. + {"non-ASCII path", `"b/caf\303\251.go"`, "café.go"}, + {"non-ASCII path with a space", "\"b/caf\\303\\251 x.go\"\t", "café x.go"}, + {"path with a double quote", `"b/we\"ird.go"`, `we"ird.go`}, + {"path with a backslash", `"b/back\\slash.go"`, `back\slash.go`}, + {"path with a tab", `"b/tab\there.go"`, "tab\there.go"}, + {"undecodable", `"b/unterminated`, ""}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, pathFromDiffHeaderField(s.field)) + }) + } +} + +func TestParseDiffLineFromBufferQuotedPath(t *testing.T) { + // A rename of a file whose name needs quoting, with a content change: the + // path is quoted on the "diff --git" line and on both of the +++/--- lines. + renamed := []string{ + `diff --git "a/caf\303\251 old.go" "b/caf\303\251 new.go"`, + "similarity index 62%", + `rename from "caf\303\251 old.go"`, + `rename to "caf\303\251 new.go"`, + "index 1111111..2222222 100644", + "--- \"a/caf\\303\\251 old.go\"\t", + "+++ \"b/caf\\303\\251 new.go\"\t", + "@@ -1,2 +1,2 @@", + " apple", + "-grape", + "+kiwi", + } + + result, ok := parseDiffLineFromBuffer(renamed, 10) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineAdded, NewLine: 2}, result) + + // The same rename without a content change has no +++/--- lines, so the path + // comes from the "diff --git" line, where both paths are quoted. + result, ok = parseDiffLineFromBuffer(renamed[:4], 2) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result) +} + +func TestParseAllDiffLinesFromBuffer(t *testing.T) { + // Some decoration above the diff, which belongs to no file section: a commit + // message and a diffstat, as `git show` renders them. + bufferLines := append( + []string{"commit 1234567", "", " do a thing", "", " file1.go | 2 --", ""}, + strings.Split(twoFileDiff, "\n")..., + ) + + all := parseAllDiffLinesFromBuffer(bufferLines) + + // The batch parse resolves each file section once, and has to agree with + // resolving the lines one at a time. + assert.Len(t, all, len(bufferLines)) + for i := range bufferLines { + parsed, ok := parseDiffLineFromBuffer(bufferLines, i) + assert.Equal(t, bufferLineParse{parsed, ok}, all[i], "line %d: %q", i, bufferLines[i]) + } + + // The lines above the first file section are left unresolved. + for i := range 6 { + assert.False(t, all[i].ok) + } + assert.True(t, all[6].ok) +} + +func TestParseDiffLineMetadata(t *testing.T) { + scenarios := []struct { + name string + payload string + expected parsedDiffLine + expectOk bool + }{ + {"context", "1;c;1;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true}, + {"added", "1;a;3;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineAdded, NewLine: 3}, true}, + // A deletion carries both numbers; two consecutive deletions share the + // new-file line and differ only in the old-file one. + {"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true}, + {"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true}, + // A whole-file deletion has new-file position 0 and the old path. + {"deleted file", "1;d;0;1;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true}, + // The path is the last field, so a ';' within it survives. + {"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{Path: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true}, + // A renderer may state the path absolutely; the parser keeps it verbatim + // and leaves resolving it to the caller. + {"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{Path: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true}, + // A file header has no line number; a hunk header carries the new-file + // line of the hunk's first line (0 for a whole-file deletion, mirroring + // `@@ -1,N +0,0 @@`). + {"file header", "1;f;;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader}, true}, + {"hunk header", "1;h;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true}, + {"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true}, + // A file header's line number is always empty, but a renderer that fills + // it in anyway is taken at its word rather than rejected. + {"file header with a line number", "1;f;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader, NewLine: 10}, true}, + + {"unknown version", "2;c;1;;foo.txt", parsedDiffLine{}, false}, + {"unknown type", "1;x;1;;foo.txt", parsedDiffLine{}, false}, + {"too few fields", "1;c;1", parsedDiffLine{}, false}, + {"non-numeric new-line", "1;c;x;;foo.txt", parsedDiffLine{}, false}, + {"non-numeric old-line", "1;d;2;y;foo.txt", parsedDiffLine{}, false}, + // Only a file header may omit the new-file line; on any other kind the + // record is malformed, and rejecting it falls the row back to the diff + // text rather than acting on a line number we don't have. + {"empty new-line on a content line", "1;c;;;foo.txt", parsedDiffLine{}, false}, + {"empty new-line on a hunk header", "1;h;;;foo.txt", parsedDiffLine{}, false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, ok := parseDiffLineMetadata(s.payload) + assert.Equal(t, s.expectOk, ok) + if s.expectOk { + assert.Equal(t, s.expected, result) + } + }) + } +} diff --git a/pkg/gui/controllers/helpers/diff_line_plain_text.go b/pkg/gui/controllers/helpers/diff_line_plain_text.go new file mode 100644 index 000000000..91e1e2c43 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_plain_text.go @@ -0,0 +1,99 @@ +package helpers + +import ( + "path/filepath" + "strings" + + "github.com/jesseduffield/lazygit/pkg/gocui" +) + +// Reading a rendering back to the diff it came from. What a diff view shows is a diff +// renderer's picture of a diff, and a picture is not what you want on your clipboard, +// or in a patch — so the lines of interest are located by identity in the diff itself, +// which the panel that rendered it hands out (types.FocusedMainViewDiffSource). + +// PlainDiffOfSelection returns the text of the diff behind the rows selected in view: +// per file the selection touches, the run of diff lines from the first of its selected +// lines to the last, with the files in the order the selection meets them. +// +// A run, rather than the matched lines alone, so that what comes out reads as a diff: +// the lines between two selected ones come along even when the rendering didn't show +// them (difftastic leaves out whitespace-only changes) or showed them in another order +// (a side-by-side rendering groups the deletions of a hunk before its additions). +// +// plainDiff fetches the diff of the given repo-relative files, and is asked only for +// the files the selection touches, so that copying three lines of a commit's diff +// doesn't fetch the whole of it. It returns "" when no selected row could be found in +// the diff, e.g. because the selection covers nothing but a renderer's decoration. +func (self *DiffLineHelper) PlainDiffOfSelection( + view *gocui.View, first int, last int, plainDiff func(paths []string) string, +) string { + worktreePath := self.c.Git().RepoPaths.WorktreePath() + + // The files in the order they are shown, and per file the lines to look for. Only + // content lines: a header names no line of the file, so it can't be looked for, and + // the headers within a run come along with it anyway. + paths := []string{} + selected := map[string]map[patchLine]bool{} + for _, info := range self.DiffLinesInViewRange(view, first, last) { + if !info.IsContent() { + continue + } + if _, ok := selected[info.Path]; !ok { + paths = append(paths, info.Path) + selected[info.Path] = map[patchLine]bool{} + } + selected[info.Path][patchLineOf(info)] = true + } + + relPaths := repoRelativePaths(worktreePath, paths) + if len(relPaths) == 0 { + return "" + } + + diffLines := strings.Split(strings.TrimSuffix(plainDiff(relPaths), "\n"), "\n") + runs := map[string][2]int{} + for i, parsed := range parseAllDiffLinesFromBuffer(diffLines) { + if !parsed.ok { + continue + } + info := diffLineInfoIn(worktreePath, parsed.parsed) + if !selected[info.Path][patchLineOf(info)] { + continue + } + if run, ok := runs[info.Path]; ok { + runs[info.Path] = [2]int{run[0], i} + } else { + runs[info.Path] = [2]int{i, i} + } + } + + text := strings.Builder{} + for _, path := range paths { + run, ok := runs[path] + if !ok { + continue + } + for _, line := range diffLines[run[0] : run[1]+1] { + text.WriteString(line) + text.WriteString("\n") + } + } + return text.String() +} + +// repoRelativePaths turns the absolute paths a diff line's identity carries into the +// repo-relative ones git speaks, dropping any that lies outside the worktree — a diff +// renderer states the path however it likes, and one we can't place is one we can't +// ask git about. +func repoRelativePaths(worktreePath string, paths []string) []string { + relPaths := make([]string, 0, len(paths)) + for _, path := range paths { + relPath, err := filepath.Rel(worktreePath, path) + if err != nil || strings.HasPrefix(relPath, "..") { + continue + } + relPaths = append(relPaths, filepath.ToSlash(relPath)) + } + return relPaths +} diff --git a/pkg/gui/controllers/helpers/diff_line_queries.go b/pkg/gui/controllers/helpers/diff_line_queries.go new file mode 100644 index 000000000..02af32efa --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_queries.go @@ -0,0 +1,457 @@ +package helpers + +import ( + "path/filepath" + "strings" + + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" +) + +// The questions a diff view can be asked about what it is showing — where the change +// lines are, which block or file a row belongs to — answered in the view-line terms a +// cursor and a click speak. They are all built on the identities recovered in +// diff_line_helper.go, which is where the answering stops and the recovering starts. + +// DiffLinesInViewRange returns the identity of every diff line shown by the rows in +// the inclusive view-line range [first, last] of view's rendered diff, in display +// order. Rows whose identity can't be recovered are left out, as are the wrapped +// segments of a row already counted. +// +// A row can show more than one diff line — a side-by-side rendering puts a deletion +// beside the addition replacing it — and all of them are reported: what the user +// pointed at is the row, so everything on it is selected. +func (self *DiffLineHelper) DiffLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo { + identities := self.resolveDiffLineIdentities(view.DiffLineContents()) + + infos := []types.DiffLineInfo{} + previousBufferLine := -1 + for viewLine := first; viewLine <= last; viewLine++ { + bufferLine, ok := view.BufferLineForViewLine(viewLine) + if !ok || bufferLine == previousBufferLine || bufferLine >= len(identities) { + continue + } + previousBufferLine = bufferLine + infos = append(infos, identities[bufferLine]...) + } + return self.inRepoTerms(view, infos) +} + +// ChangeLineOrdinals says, for each of the given change lines, which of its file's +// changes it is in the given diff — its place among them, counted from the top of the +// file — keyed by file. Lines the diff doesn't have are left out. +// +// It is how a line is named in something built out of a diff rather than being that diff: +// the custom patch holds the lines it was given in the order the file has them, so a +// place among a file's changes is a line of the patch. +func (self *DiffLineHelper) ChangeLineOrdinals( + diff string, infos []types.DiffLineInfo, +) map[string][]int { + ordinals := map[patchLine]int{} + counts := map[string]int{} + for _, parsed := range parseAllDiffLinesFromBuffer(strings.Split(diff, "\n")) { + if !parsed.ok { + continue + } + info := self.diffLineInfo(parsed.parsed) + if !info.IsChange() { + continue + } + ordinals[patchLineOf(info)] = counts[info.Path] + counts[info.Path]++ + } + + ordinalsByPath := map[string][]int{} + for _, info := range infos { + if ordinal, ok := ordinals[patchLineOf(info)]; ok { + ordinalsByPath[info.Path] = append(ordinalsByPath[info.Path], ordinal) + } + } + return ordinalsByPath +} + +// inRepoTerms brings the paths of lines recovered from a view into the repo's terms. +// +// They are in them already for a diff of the repo's own files. The pane previewing the +// custom patch, though, shows a diff of the two trees the patch was materialized into: a +// diff renderer states the path it was handed there, which is under the tree's own name, +// while the diff's text names the trees where an ordinary diff has git's a/ and b/ +// prefixes and so needs nothing. +func (self *DiffLineHelper) inRepoTerms(view *gocui.View, infos []types.DiffLineInfo) []types.DiffLineInfo { + if !self.ShowsCustomPatch(view) { + return infos + } + + worktreePath := self.c.Git().RepoPaths.WorktreePath() + treesDir := self.c.Git().Patch.PatchBuilder.TempDir() + return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo { + info.Path = repoPathOfTreePath(info.Path, treesDir, worktreePath) + return info + }) +} + +// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized +// into to the file of the repo it stands for: the path is the tree's name followed by the +// file's own, stated either against the directory holding the trees or against the repo, +// depending on how the renderer that stated it was given it. +func repoPathOfTreePath(path string, treesDir string, worktreePath string) string { + root := worktreePath + if treesDir != "" && strings.HasPrefix(path, treesDir+string(filepath.Separator)) { + root = treesDir + } + relativePath, err := filepath.Rel(root, path) + if err != nil { + return path + } + + segments := strings.Split(filepath.ToSlash(relativePath), "/") + if len(segments) > 1 && (segments[0] == "a" || segments[0] == "b") { + relativePath = filepath.Join(segments[1:]...) + } + return filepath.Join(worktreePath, relativePath) +} + +// ChangeLinesInViewRange returns the change lines — the additions and deletions — +// among the diff lines shown by the rows in the inclusive view-line range. Those are +// the lines a patch is built from: a patch carries whatever context it needs around +// them by itself, so a selection contributes only its changes. +func (self *DiffLineHelper) ChangeLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo { + return lo.Filter(self.DiffLinesInViewRange(view, first, last), + func(info types.DiffLineInfo, _ int) bool { return info.IsChange() }) +} + +// changeLines resolves view's rendered diff to one flag per buffer line: whether +// that row is a change line (an addition or a deletion), as opposed to context, a +// header, or a row whose identity couldn't be recovered. Those are the rows a +// selection is anchored on and navigation moves between. +func (self *DiffLineHelper) changeLines(view *gocui.View) []bool { + resolved := self.resolveDiffLines(view.DiffLineContents()) + isChange := make([]bool, len(resolved)) + for i, r := range resolved { + isChange[i] = r.ok && r.info.IsChange() + } + return isChange +} + +// FirstChangeLineInView returns the view line of the first change line on screen. It +// is where the selection goes when the main view is focused by keyboard: focusing a +// diff you are reading points at something in it without moving it, so the search +// stops at the bottom of the viewport rather than going after a change further down. +// ok is false when the viewport holds no change line — scrolled into a long stretch +// of context, or past the last change. +func (self *DiffLineHelper) FirstChangeLineInView(view *gocui.View) (int, bool) { + top, bottom, ok := visibleBufferLines(view) + if !ok { + return 0, false + } + + isChange := self.changeLines(view) + for i := top; i <= min(bottom, len(isChange)-1); i++ { + if isChange[i] { + return view.ViewLineForBufferLine(i) + } + } + return 0, false +} + +// FirstChangeBlockInView returns the view line of the first change block on screen: +// the first one that *begins* in the viewport, and failing that the one that reaches +// into the viewport from above, whose start is off screen. Hunk mode wants that order +// for the block it offers up on focus: preferably a block whose beginning the user can +// see, rather than the tail of one they have scrolled past the start of. The block +// bleeding in from above is kept as the answer for a change too long to fit on screen, +// where there is no other. ok is false when the viewport shows no change line. +func (self *DiffLineHelper) FirstChangeBlockInView(view *gocui.View) (int, bool) { + top, bottom, ok := visibleBufferLines(view) + if !ok { + return 0, false + } + + isChange := self.changeLines(view) + for i := top; i <= min(bottom, len(isChange)-1); i++ { + if isChange[i] && (i == 0 || !isChange[i-1]) { + return view.ViewLineForBufferLine(i) + } + } + // A block covering the top line is one that began above it: nothing else can put a + // change there once no block starts on screen. + if top < len(isChange) && isChange[top] { + return view.ViewLineForBufferLine(top) + } + return 0, false +} + +// visibleBufferLines returns the first and last line of view's content that the +// viewport shows any part of, for the queries that only care about what the user can +// see. The last line is the one at the bottom edge, or the content's last when the +// content ends above it. ok is false for a view showing no content at all. +func visibleBufferLines(view *gocui.View) (int, int, bool) { + top, ok := view.BufferLineForViewLine(view.OriginY()) + if !ok { + return 0, 0, false + } + + lastVisible := min(view.OriginY()+view.InnerHeight(), view.ViewLinesHeight()) - 1 + bottom, ok := view.BufferLineForViewLine(lastVisible) + if !ok { + return top, top, true + } + return top, bottom, true +} + +// ViewHasChangeLines reports whether view's rendered diff holds any change line at +// all, i.e. whether there is anything to select. It is false over a non-diff +// placeholder, and over a diff with nothing in it — an empty commit, a binary file — +// which are the cases where the focused main view shows no selection. +func (self *DiffLineHelper) ViewHasChangeLines(view *gocui.View) bool { + return lo.Contains(self.changeLines(view), true) +} + +// IsChangeLine reports whether the given view line of view's rendered diff is a +// change line rather than context, a header, or an unresolvable row — i.e. whether +// pointing at it points at something a patch could be built from. +func (self *DiffLineHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool { + info, ok := self.GetDiffLineInfo(view, viewLineIdx) + return ok && info.IsChange() +} + +// IsSingleHunkForWholeFile reports whether the file the given change line belongs to +// is shown as one solid block of changes — every row of its diff a change of the same +// kind, no context — which is what a newly added or deleted file looks like. That is +// the case where widening the selection to the change block would select the file +// entire, so hunk mode drops to a single line there instead. +// +// It says false while the diff is still being read in, since the rows that would +// answer otherwise — a context line, a change of the other kind — may not have +// arrived yet. That errs towards hunk mode, which is what the user asked for. +func (self *DiffLineHelper) IsSingleHunkForWholeFile(view *gocui.View, changeViewLine int) bool { + if manager := self.c.GetViewBufferManagerForView(view); manager != nil && manager.IsLoading() { + return false + } + + anchor, ok := view.BufferLineForViewLine(changeViewLine) + if !ok { + return false + } + resolved := self.resolveDiffLines(view.DiffLineContents()) + if anchor >= len(resolved) || !resolved[anchor].ok { + return false + } + + // The question is per file: a commit's diff may hold a newly added file next to an + // edited one. + path := resolved[anchor].info.Path + kind := resolved[anchor].info.Type + for _, row := range resolved { + if !row.ok || row.info.Path != path { + continue + } + if row.info.Type == types.DiffLineContext { + return false + } + if row.info.IsChange() && row.info.Type != kind { + return false + } + } + return true +} + +// ChangeBlockBounds returns the inclusive view-line range of the change block to +// select in hunk mode around anchorViewLine. A change block is lazygit's notion of a +// hunk — a run of consecutive added or deleted lines bounded by context, of which a +// single git @@ hunk may hold several. When the anchor is context, the block used is +// the first at or below it, or — with nothing below, the cursor sitting past the last +// change — the nearest above, so that hunk mode always has a block to select. ok is +// false only when the diff holds no change line at all. +func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) { + anchor, ok := view.BufferLineForViewLine(anchorViewLine) + if !ok { + return 0, 0, false + } + + isChange := self.changeLines(view) + start := anchor + for start < len(isChange) && !isChange[start] { + start++ + } + if start >= len(isChange) { + for start = min(anchor, len(isChange)-1); start >= 0 && !isChange[start]; start-- { + } + if start < 0 { + return 0, 0, false + } + } + end := start + for start > 0 && isChange[start-1] { + start-- + } + for end < len(isChange)-1 && isChange[end+1] { + end++ + } + + startView, startOk := view.ViewLineForBufferLine(start) + // The block's last line goes to its last view line, so that a line the view + // wrapped is highlighted to its end rather than only where it begins. + endView, endOk := view.LastViewLineForBufferLine(end) + if !startOk || !endOk { + return 0, 0, false + } + return startView, endView, true +} + +// AdjacentChangeBlock returns the view line to move to for next/previous change-block +// navigation in view's rendered diff, starting from anchorViewLine. A change block is +// lazygit's notion of a hunk (see ChangeBlockBounds). forward=true targets the start +// of the next block, forward=false the start of the previous one — from mid-block that +// means the previous block, rather than the one we are in. ok is false when there's no +// further block, so the caller leaves the view where it is. +func (self *DiffLineHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) { + anchor, ok := view.BufferLineForViewLine(anchorViewLine) + if !ok { + return 0, false + } + + target, ok := changeBlockStart(self.changeLines(view), anchor, forward) + if !ok { + return 0, false + } + return view.ViewLineForBufferLine(target) +} + +// AdjacentFile returns the view line to move to for next/previous file navigation in +// view's (possibly multi-file) rendered diff, starting from anchorViewLine: the first +// located row of the neighbouring file, found where the rows' file changes. ok is +// false at the first or last file. +func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) { + anchor, ok := view.BufferLineForViewLine(anchorViewLine) + if !ok { + return 0, false + } + + target, ok := fileStart(self.filePaths(view), anchor, forward) + if !ok { + return 0, false + } + return view.ViewLineForBufferLine(target) +} + +// filePaths resolves view's rendered diff to the path each buffer line belongs to, +// empty for a row whose identity couldn't be recovered. +func (self *DiffLineHelper) filePaths(view *gocui.View) []string { + resolved := self.resolveDiffLines(view.DiffLineContents()) + paths := make([]string, len(resolved)) + for i, row := range resolved { + if row.ok { + paths[i] = row.info.Path + } + } + return paths +} + +// fileStart finds, in a diff whose lines carry the file path they belong to (empty for +// a row no backend could place), the first located row of the file adjacent to `from` +// in the given direction — the row file navigation lands on. It is the pure index +// arithmetic behind AdjacentFile. +// +// A file is identified by its path, so we look for where the path changes, skipping +// rows that carry none: those are the blank separator rows between files, or the +// header rows of a diff renderer that doesn't state which file its headers belong to. +// So the landing row is the file's header wherever the source says so — a parseable +// buffer, or a renderer that tags its headers — and the file's first content line +// otherwise, which is an accepted degradation. +func fileStart(paths []string, from int, forward bool) (int, bool) { + anchorPath, ok := anchorFilePath(paths, from) + if !ok { + return 0, false + } + + if forward { + for i := from; i < len(paths); i++ { + if paths[i] != "" && paths[i] != anchorPath { + return i, true + } + } + return 0, false + } + + // Walk back past the current file (its rows and any unlocated ones) to the previous + // file's last located row, then back over that whole file, landing on its first. + i := from + for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) { + i-- + } + if i < 0 { + return 0, false + } + prevPath := paths[i] + for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) { + i-- + } + for paths[i] != prevPath { + i++ + } + return i, true +} + +// anchorFilePath returns the path of the file the anchor sits in: the first row at or +// below it that carries a path — the file whose content is at or below the top of the +// view — falling back to the nearest above when there is nothing below. Scanning down +// first matters because the anchor is often a file-header row that carries no path of +// its own, whose nearest tagged row above is the *previous* file's content; taking +// that would make next-file navigation jump back into the file just left, so a second +// press wouldn't advance. ok is false when no row carries a path. +func anchorFilePath(paths []string, from int) (string, bool) { + if from < 0 { + return "", false + } + for i := from; i < len(paths); i++ { + if paths[i] != "" { + return paths[i], true + } + } + for i := min(from, len(paths)) - 1; i >= 0; i-- { + if paths[i] != "" { + return paths[i], true + } + } + return "", false +} + +// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first +// line of the change block adjacent to `from` in the given direction. It is the pure +// index arithmetic behind AdjacentChangeBlock. +func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) { + if from < 0 || from >= len(isChange) { + return 0, false + } + + if forward { + i := from + for i < len(isChange) && isChange[i] { // leave the current block + i++ + } + for i < len(isChange) && !isChange[i] { // skip the separating context + i++ + } + if i == len(isChange) { + return 0, false + } + return i, true + } + + i := from + for i >= 0 && isChange[i] { // leave the current block + i-- + } + for i >= 0 && !isChange[i] { // skip context, landing on the previous block's last line + i-- + } + if i < 0 { + return 0, false + } + for i > 0 && isChange[i-1] { // walk back to that block's first line + i-- + } + return i, true +} diff --git a/pkg/gui/controllers/helpers/diff_line_queries_test.go b/pkg/gui/controllers/helpers/diff_line_queries_test.go new file mode 100644 index 000000000..47769ad98 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_queries_test.go @@ -0,0 +1,104 @@ +package helpers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChangeBlockStart(t *testing.T) { + // A diff with three change blocks separated by context: + // 0 file header 1 hunk header 2 context + // 3 + 4 + (block A) + // 5 context + // 6 - (block B) + // 7 context + // 8 + (block C) + isChange := []bool{false, false, false, true, true, false, true, false, true} + + scenarios := []struct { + name string + from int + forward bool + expected int + found bool + }{ + {"forward from a header lands on the first block", 0, true, 3, true}, + {"forward from separating context lands on the next block", 5, true, 6, true}, + {"forward from the start of a block skips to the next", 3, true, 6, true}, + {"forward from inside a block skips the rest of it", 4, true, 6, true}, + {"forward from the last block finds nothing", 8, true, 0, false}, + {"backward from a later block lands on the previous one's start", 8, false, 6, true}, + {"backward from a block start lands on the previous block's start", 6, false, 3, true}, + {"backward from inside the first block finds nothing", 4, false, 0, false}, + {"backward from the first block's start finds nothing", 3, false, 0, false}, + {"backward from context lands on the preceding block's start", 7, false, 6, true}, + {"an anchor past the end finds nothing", 9, true, 0, false}, + {"a negative anchor finds nothing", -1, true, 0, false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + got, found := changeBlockStart(isChange, s.from, s.forward) + assert.Equal(t, s.found, found) + if s.found { + assert.Equal(t, s.expected, got) + } + }) + } +} + +func TestFileStart(t *testing.T) { + // A parseable two-file diff: every row carries its file's path, headers included, + // as the buffer parser reports it. + parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"} + + // The same diff as a renderer that doesn't say which file its headers belong to + // emits it: only content lines carry the path, so navigation can land no higher + // than each file's first content line. + contentOnly := []string{"", "", "a", "a", "", "", "b", "b"} + + // Three such files, to exercise navigating from one file's untagged header to the + // next: the row just above b's header is a's content, so the anchor's file has to + // be found by scanning down (b) rather than up (a) — otherwise next-file would + // jump back into b and a second press couldn't advance. + contentOnlyThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"} + + // A renderer that does tag its header rows: the file header and the hunk-header box + // carry the file's path, but the blank separator rows around them carry nothing. + // Navigation must land on the header's first row, not the blank line above it. + // 0 blank 1-2 file hdr 3 blank 4-6 hunk hdr box 7 content + // 8 blank 9-10 file hdr 11 blank 12-13 hunk hdr box 14 content + headerTagged := []string{"", "a", "a", "", "a", "a", "a", "a", "", "b", "b", "", "b", "b", "b"} + + scenarios := []struct { + name string + paths []string + from int + forward bool + expected int + found bool + }{ + {"forward lands on the next file's header", parseable, 1, true, 4, true}, + {"forward from the last file finds nothing", parseable, 5, true, 0, false}, + {"backward lands on the previous file's header", parseable, 5, false, 0, true}, + {"backward from the first file finds nothing", parseable, 1, false, 0, false}, + {"forward lands on the next file's first content line", contentOnly, 2, true, 6, true}, + {"backward lands on the previous file's first content line", contentOnly, 7, false, 2, true}, + {"forward from an untagged header advances past it", contentOnly, 0, true, 6, true}, + {"a second forward press advances again", contentOnlyThree, 4, true, 10, true}, + {"forward lands on a tagged file header", headerTagged, 7, true, 9, true}, + {"backward lands on a tagged file header", headerTagged, 14, false, 1, true}, + {"a diff with no located rows finds nothing", []string{"", ""}, 0, true, 0, false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + got, found := fileStart(s.paths, s.from, s.forward) + assert.Equal(t, s.found, found) + if s.found { + assert.Equal(t, s.expected, got) + } + }) + } +} diff --git a/pkg/gui/controllers/helpers/diff_line_raw_fallback.go b/pkg/gui/controllers/helpers/diff_line_raw_fallback.go new file mode 100644 index 000000000..c147b274a --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_raw_fallback.go @@ -0,0 +1,109 @@ +package helpers + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/tasks" +) + +// Falling back to git's own diff when the configured one can't be acted on. +// +// A diff renderer is free to lay a diff out however it likes, and once it has, we can +// only tell which line of which file a row shows if the renderer says so. A renderer +// that doesn't produces a diff that can be read but not staged, edited or copied from, +// so when the user focuses the main view to act on it, we show git's own diff instead. +// Browsing keeps the renderer's version; only acting on it needs one we can follow. + +// MainViewDiffMode says how a side panel should produce the diff it renders into the +// main view: as the user configured it, or as git's own — while the main view holds +// focus and what the renderer would produce couldn't be acted on. +// +// Every panel that renders a diff into the main view asks, so that a re-render while +// focused — after staging a hunk, say — stays with git's own diff rather than flipping +// back to the renderer's. +func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode { + if self.mainViewIsFocused() && self.diffNeedsMetadata() && !self.diffRendererEmitsMetadata() { + return git_commands.DiffModeRaw + } + return git_commands.DiffModeRendered +} + +// RenderFocusedMainViewAgain has the panel beneath the focused main view render its +// diff again — which, the main view now holding focus, is git's own diff rather than +// the renderer's — and calls place once that is on screen. +// +// The whole diff is read before it is shown, rather than the first screenful: place +// looks at what is there to decide where to put the selection, and a change line +// further down would otherwise be missed. +// +// It is the same diff of the same files, so the view keeps the scroll position it has +// rather than starting from the top: git lays the changes out its own way and the line +// the user was on is somewhere else now, but the offset still puts them among the same +// part of the file — and the selection is then established from what that leaves on +// screen. +func (self *DiffLineHelper) RenderFocusedMainViewAgain(view *gocui.View, sidePanel types.Context, place func()) { + manager := self.c.GetOrCreateViewBufferManagerForView(view) + if manager == nil { + return + } + + manager.SetKeepScrollPositionForNextTask() + manager.SetRestoreForNextTask(&tasks.RenderRestore{ + FirstPaintReady: func() bool { return false }, + Apply: func(swapIn func()) { + swapIn() + place() + }, + }) + + sidePanel.HandleRenderToMain() +} + +func (self *DiffLineHelper) mainViewIsFocused() bool { + current := self.c.Context().CurrentStatic().GetKey() + return current == self.c.Contexts().Normal.GetKey() || + current == self.c.Contexts().NormalSecondary.GetKey() +} + +// diffNeedsMetadata reports whether the diff we would show is one whose rows can only +// be placed in the file by the records the renderer states. Any custom renderer may +// restructure the diff; so may git itself, once the renderer's arguments ask for a word +// diff, whose markup is inline. Plain git output describes itself, and needs no records. +func (self *DiffLineHelper) diffNeedsMetadata() bool { + manager := self.c.State().GetDiffRendererConfigManager() + if manager.GetDiffRendererType() != config.DiffRendererType_RawGit { + return true + } + return len(manager.GetRawGitArgs()) > 0 +} + +// diffRendererEmitsMetadata is the probed verdict about the current diff renderer, asked +// once and remembered until the renderer changes — the user cycling to another one, or a +// changed config being reloaded. +func (self *DiffLineHelper) diffRendererEmitsMetadata() bool { + signature := self.diffRendererSignature() + if self.rendererEmitsMetadata == nil || signature != self.rendererSignature { + verdict := self.c.Git().Diff.ProbeDiffRendererEmitsMetadata() + self.rendererEmitsMetadata = &verdict + self.rendererSignature = signature + } + return *self.rendererEmitsMetadata +} + +// diffRendererSignature identifies the current diff renderer, so that the remembered +// verdict is dropped when it stops describing the renderer we have. The width a command +// is asked for is no part of its identity, so a fixed one is used. +func (self *DiffLineHelper) diffRendererSignature() string { + manager := self.c.State().GetDiffRendererConfigManager() + index, _ := manager.CurrentDiffRendererIndex() + return fmt.Sprintf("%d\x00%s\x00%s\x00%s", + index, + manager.GetExternalDiffCommand(3), + manager.GetStdinFilterCommand(0), + strings.Join(manager.GetRawGitArgs(), "\x00")) +} diff --git a/pkg/gui/controllers/helpers/diff_line_restore.go b/pkg/gui/controllers/helpers/diff_line_restore.go new file mode 100644 index 000000000..e3c72d933 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_restore.go @@ -0,0 +1,607 @@ +package helpers + +import ( + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/tasks" + "github.com/samber/lo" +) + +// Keeping a diff view where it is when the same diff is rendered again differently. +// The line the user is on is remembered by identity (diff_line_helper.go), because a +// new rendering puts it on a different line of the view — and may not have it at all, +// which is what the fallbacks below are for. + +// diffLineAnchor is a line for a restore to land on: the identity to find it by in +// the new rendering, and the screen row it was on, so that it can be put back there. +type diffLineAnchor struct { + identity types.DiffLineInfo + row int +} + +// PreserveDiffPositionOnRerender remembers where a diff view is and puts it back +// there as it next re-renders, instead of leaving the user at the top of a new +// rendering of the diff they were already reading. Call it on the view about to be +// re-rendered, right before triggering the re-render — on both panes of the main +// window where both are being rendered again, since either of them may hold the diff +// being read; a pane that isn't showing is left alone. +// +// The line to keep is the end of the selection that is on screen, and the middle +// visible line when there is no selection or the whole of it has been scrolled out of +// sight — what the user is looking at, rather than the view's top edge or a selection +// they have long since left behind. It may not survive the re-render: a context line +// goes when the context size shrinks, and a whole hunk or file goes when whitespace +// stops counting. So the lines around it come along as fallbacks and the view lands on +// the nearest one that is still there, put back on the screen row it was on. With none +// of them left — and with a renderer that says nothing about its rows there is nothing +// to look for in the first place — the view keeps the scroll offset it had, which is +// still nearer to what was being read than the top of the diff. +// +// An off-screen selection is still put back on the diff line it was on, wherever the +// new rendering has that; it is only the view that stays where it is. +// +// A range or hunk selection has a second end, which is remembered the same way, so +// that it still covers the same lines of the diff afterwards. +func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) { + // A view that isn't the one its window is currently showing — the merge-conflicts + // view takes the main window over — isn't the one about to be re-rendered, so a + // restore installed on it would sit there and claim a later render instead. + if !view.Visible { + return + } + + // The re-render is produced by a different command from the one behind what is on + // screen — another context size, another renderer — so without being told otherwise + // it would be taken for content the user has never seen and shown from the top. + // Whether or not a line of the old rendering can be found in the new one, the offset + // into it is nearer to where they were reading than the top is. + if manager := self.c.GetViewBufferManagerForView(view); manager != nil { + manager.SetKeepScrollPositionForNextTask() + } + + showSelection := view.Highlight + anchorViewLine := view.MiddleVisibleLineIdx() + farEnd, hasFarEnd := types.DiffLineInfo{}, false + // A cursor that has been scrolled away from is put back by its own lines rather + // than by the anchor's, so that it comes out on the same line of the diff without + // the view having to go there. + var cursorCandidates []diffLineAnchor + if showSelection { + farEnd, hasFarEnd = self.selectionFarEndIdentity(view) + if end, ok := visibleSelectionEnd(view); ok { + anchorViewLine = end + } + if anchorViewLine != view.SelectedLineIdx() { + cursorCandidates = self.nearbyDiffLines(view, view.SelectedLineIdx()) + } + } + + self.restoreDiffLinePositionOnRerender(view, self.nearbyDiffLines(view, anchorViewLine), + func(anchor diffLineAnchor, viewLine int) { + // Put the line back on the screen row it was on, clamped into the view for + // the fallback lines, which can come from off screen. + row := lo.Clamp(anchor.row, 0, max(0, view.InnerHeight()-1)) + view.SetOrigin(0, max(0, viewLine-row)) + if showSelection { + // Put the far end back before the cursor, so that the selection covers + // the same lines again; a selection whose far end didn't survive the + // re-render is left as the single line we landed on. The origin is + // already where it should be, so moving the cursor mustn't scroll. + view.CancelRangeSelect() + cursorViewLine := self.selectionLine(view, cursorCandidates, viewLine) + if hasFarEnd { + if farEndViewLine, ok := self.findDiffLine(view, farEnd); ok { + cursorViewLine, farEndViewLine = coverWholeLines(view, cursorViewLine, farEndViewLine) + view.SetRangeSelectStart(farEndViewLine) + } + } + view.FocusPoint(0, cursorViewLine, false) + } + }) +} + +// coverWholeLines moves the two ends of a restored selection out to the edges of the +// diff lines they are on, so that the selection covers those lines whole. Both ends +// arrive on the first view line of their diff line, which is where looking one up by +// identity lands, and the view draws a line it wraps as several — of which a +// selection of that line means all. +func coverWholeLines(view *gocui.View, cursorViewLine int, farEndViewLine int) (int, int) { + if cursorViewLine <= farEndViewLine { + return cursorViewLine, lastViewLineOfSameDiffLine(view, farEndViewLine) + } + return lastViewLineOfSameDiffLine(view, cursorViewLine), farEndViewLine +} + +// lastViewLineOfSameDiffLine returns the last view line showing the same line of the +// diff as the given one, which is that line itself unless the view wrapped it. +func lastViewLineOfSameDiffLine(view *gocui.View, viewLine int) int { + bufferLine, ok := view.BufferLineForViewLine(viewLine) + if !ok { + return viewLine + } + if last, ok := view.LastViewLineForBufferLine(bufferLine); ok { + return last + } + return viewLine +} + +// visibleSelectionEnd returns the end of the selection to keep in place across a +// re-render: the selected line when it is on screen, and the range's other end when +// that is and the selected line isn't — a range can be long enough for the user to be +// looking at one end of it with the other far away. ok is false when the whole +// selection is off screen, and there is nothing of it to keep in place. +func visibleSelectionEnd(view *gocui.View) (int, bool) { + if view.IsLineVisible(view.SelectedLineIdx()) { + return view.SelectedLineIdx(), true + } + if farEnd, _, ok := selectionFarEndViewLine(view); ok && view.IsLineVisible(farEnd) { + return farEnd, true + } + return 0, false +} + +// selectionLine returns the line to put the cursor on once a re-render is on screen: +// the line the position anchor landed on, which is the selected one whenever it was +// on screen, and otherwise the nearest surviving line to where the selection was — +// found among its own candidates, since the anchor's are a search of the diff from +// somewhere else entirely. +func (self *DiffLineHelper) selectionLine( + view *gocui.View, candidates []diffLineAnchor, anchorViewLine int, +) int { + if len(candidates) == 0 { + return anchorViewLine + } + _, bufferLine := self.nearestSurvivingCandidate(view.DiffLineContents(), candidates) + if bufferLine == -1 { + return anchorViewLine + } + if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok { + return viewLine + } + return anchorViewLine +} + +// selectionFarEndIdentity returns the identity of the end of a range or hunk +// selection the cursor isn't on, so that a re-render can put it back. ok is false for +// a selection that is only a cursor, where restoring that is the whole job, and for +// an end that resolves to no diff line. +// +// An end covers the whole of its row, so where the row shows more than one diff line +// — a rendering that puts a modification's two halves side by side, or a word diff +// that puts both on the one line it changed — the end takes the outermost of them: +// the last for the range's lower end and the first for its upper one. Otherwise a +// rendering that splits them apart again would get back only the half the row led +// with, and half a change selected where a whole one was. +func (self *DiffLineHelper) selectionFarEndIdentity(view *gocui.View) (types.DiffLineInfo, bool) { + farEnd, isLowerEnd, ok := selectionFarEndViewLine(view) + if !ok { + return types.DiffLineInfo{}, false + } + identities, ok := self.diffLineIdentitiesAt(view, farEnd) + if !ok { + return types.DiffLineInfo{}, false + } + if isLowerEnd { + return identities[len(identities)-1], true + } + return identities[0], true +} + +// selectionFarEndViewLine returns the view line of the end of a range or hunk +// selection the cursor isn't on, and whether that is the lower of the two ends. ok +// is false when there is no range at all, only a cursor. +// +// A range whose two ends are on the same view line still has one, and is not the +// same thing as a cursor sitting there: it covers everything that row shows, which +// may be two lines of the diff at once. +func selectionFarEndViewLine(view *gocui.View) (int, bool, bool) { + if !view.HasRangeSelect() { + return 0, false, false + } + first, last := view.SelectedLineRange() + if view.SelectedLineIdx() == first { + return last, true, true + } + return first, false, true +} + +// findDiffLine returns the view line showing the given diff line in what view is +// displaying now, for placing a remembered line once the re-render is on screen. +func (self *DiffLineHelper) findDiffLine(view *gocui.View, identity types.DiffLineInfo) (int, bool) { + bufferLine, ok := self.patchLineRows(view.DiffLineContents())[patchLineOf(identity)] + if !ok { + return 0, false + } + return view.ViewLineForBufferLine(bufferLine) +} + +// restoreDiffLinePositionOnRerender arranges for view's next re-render to land on the +// first of the given candidate lines the new rendering still has, calling place with +// that candidate and the view line it ended up on. The candidates are in priority +// order (see nearbyDiffLines); if the rendering has none of them, place isn't called +// and the view re-renders as it otherwise would. +// +// The nearest candidate is looked for as the content loads, so that the re-render can +// be revealed at the right position as soon as that line and a screenful below it +// have arrived. Only the nearest one, because the candidates aren't in load order: a +// farther one can load first, and landing on it while a nearer one is still on its +// way would be settling for worse. The rest are considered together once the whole +// rendering is there. +func (self *DiffLineHelper) restoreDiffLinePositionOnRerender( + view *gocui.View, candidates []diffLineAnchor, place func(anchor diffLineAnchor, viewLine int), +) { + if len(candidates) == 0 { + return + } + + // The search of the loading content runs on the task's own goroutine, where the + // repo we are in may not be read — a repo switch replaces it — so take it here, on + // the UI thread, for the search to work from. + worktreePath := self.c.Git().RepoPaths.WorktreePath() + + // Which candidate the search settled on, for place to put back where it was. + found := diffLineAnchor{} + + self.installDiffLineRestore(view, + func(rows []gocui.DiffLineContent, offset int) (int, bool) { + for i, row := range rows { + if rowShowsDiffLine(row, worktreePath, candidates[0].identity) { + found = candidates[0] + return offset + i, true + } + } + return 0, false + }, + func(contents []gocui.DiffLineContent) (int, bool) { + anchor, bufferLine := self.nearestSurvivingCandidate(contents, candidates) + if bufferLine == -1 { + return 0, false + } + found = anchor + return bufferLine, true + }, + func(viewLine int) { place(found, viewLine) }, + nil, + ) +} + +// ChangeLineOrdinal returns how many change lines of view's rendered diff come before +// the one at the given view line — that line's place in the sequence of changes. ok is +// false when the view line belongs to no row of the content. +// +// It is how a place in a diff is remembered across acting on it: an action consumes +// the lines it acted on, so the identity of the line the user was on is gone, but the +// place it left behind is the same one that identity used to have. +func (self *DiffLineHelper) ChangeLineOrdinal(view *gocui.View, viewLine int) (int, bool) { + bufferLine, ok := view.BufferLineForViewLine(viewLine) + if !ok { + return 0, false + } + + ordinal := 0 + for i, row := range self.resolveDiffLines(view.DiffLineContents()) { + if i >= bufferLine { + break + } + if row.ok && row.info.IsChange() { + ordinal++ + } + } + return ordinal, true +} + +// RevealChangeLineAtOrdinal arranges for view's next re-render to be shown with the +// change line at the given ordinal placed by place — the diff having changed under the +// user, this is where what they were doing carries on. When the new diff has fewer +// changes than that, because the ones acted on were its last, it lands on the last +// change left. +// +// done is called once the selection is where it belongs, or once it turns out that no +// render is coming to put it there, for a caller that must not let the user act again +// in between. +func (self *DiffLineHelper) RevealChangeLineAtOrdinal( + view *gocui.View, ordinal int, place func(viewLine int), done func(), +) { + // How many change lines the incremental search has passed, so that it can carry on + // counting where it left off. + seen := 0 + + self.installDiffLineRestore(view, + func(rows []gocui.DiffLineContent, offset int) (int, bool) { + for i, row := range rows { + if info, ok := self.diffLineInfoFromRecords(row.Metadata); ok && info.IsChange() { + if seen == ordinal { + return offset + i, true + } + seen++ + } + } + return 0, false + }, + func(contents []gocui.DiffLineContent) (int, bool) { + last, count := -1, 0 + for i, row := range self.resolveDiffLines(contents) { + if !row.ok || !row.info.IsChange() { + continue + } + if count == ordinal { + return i, true + } + count++ + last = i + } + return last, last != -1 + }, + place, + done, + ) +} + +// installDiffLineRestore is what the restores are built on: it arranges for view's +// next re-render to be revealed with the row a search finds in it placed by place, +// instead of from the top. +// +// The search comes in two halves, because the content arrives a line at a time. +// findEarly is given the rows that have loaded since it last looked, so that the +// re-render can be revealed as soon as the row is there rather than waiting for the +// rest of a long diff; it can only go by what the renderer states about a row, a +// partly-loaded diff being unparseable. findComplete is given the whole rendering at +// the swap, for a target the incremental search couldn't settle on. Either returns the +// buffer line it found, and place is not called at all when neither does. +func (self *DiffLineHelper) installDiffLineRestore( + view *gocui.View, + findEarly func(rows []gocui.DiffLineContent, offset int) (int, bool), + findComplete func(contents []gocui.DiffLineContent) (int, bool), + place func(viewLine int), + done func(), +) { + // Get-or-create, because the pane may not have rendered anything yet: a file whose + // diff has only just become split has a second pane whose first render is the one + // this restore is for. + manager := self.c.GetOrCreateViewBufferManagerForView(view) + if manager == nil { + if done != nil { + done() + } + return + } + + // The readiness check below runs on the task's own goroutine, which may not read + // the view's dimensions, so take them here, on the UI thread. + viewHeight := view.InnerHeight() + + // What the search of the loading content has found, and how far it has looked, so + // that each line is looked at once. + foundLine := -1 + scanned := 0 + + manager.SetRestoreForNextTask(&tasks.RenderRestore{ + FirstPaintReady: func() bool { + if foundLine == -1 { + rows := view.OffscreenDiffLineContentsFrom(scanned) + if bufferLine, ok := findEarly(rows, scanned); ok { + foundLine = bufferLine + } + scanned += len(rows) + if foundLine == -1 { + return false + } + } + // Wait for a screenful below the line as well, so that the re-render isn't + // revealed with it stranded at the bottom of a half-filled view. + return view.OffscreenLineCount() >= foundLine+viewHeight + }, + Apply: func(swapIn func()) { + bufferLine := foundLine + if bufferLine == -1 { + if line, ok := findComplete(view.OffscreenDiffLineContents()); ok { + bufferLine = line + } + } + + swapIn() + + if bufferLine == -1 { + return + } + if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok { + place(viewLine) + } + }, + Done: done, + }) +} + +// nearbyDiffLines collects the lines of view's rendered diff as candidates for a +// restore to land on, ordered by proximity to the anchor line — the anchor itself +// first, then outward, preferring at-or-below on ties — each tagged with the screen +// row it is on. A restore lands on the first of them its re-render still has, so this +// order makes it land as near as possible to where the user was. +// +// The walk covers the whole diff rather than stopping at the change lines on either +// side of the anchor, which a context-size change always keeps: ignoring whitespace +// keeps nothing in particular, and can take a hunk or a whole file out of the diff, +// leaving the nearest surviving line in a neighbouring file. +func (self *DiffLineHelper) nearbyDiffLines(view *gocui.View, anchorViewLine int) []diffLineAnchor { + anchor, ok := view.BufferLineForViewLine(anchorViewLine) + if !ok { + return nil + } + resolved := self.resolveDiffLines(view.DiffLineContents()) + if anchor >= len(resolved) { + return nil + } + rows := screenRows(view, len(resolved)) + + candidates := make([]diffLineAnchor, 0, len(resolved)) + collect := func(bufferLine int) { + if line := resolved[bufferLine]; line.ok { + candidates = append(candidates, diffLineAnchor{identity: line.info, row: rows[bufferLine]}) + } + } + collect(anchor) + for below, above := anchor+1, anchor-1; below < len(resolved) || above >= 0; below, above = below+1, above-1 { + if below < len(resolved) { + collect(below) + } + if above >= 0 { + collect(above) + } + } + return candidates +} + +// screenRows maps each line of view's content to the screen row it is drawn on. The +// lines above the visible ones get -1 and those below them the view's height, so that +// putting one of them back where it was lands it at the top or bottom edge. +func screenRows(view *gocui.View, bufferLineCount int) []int { + height := view.InnerHeight() + originY := view.OriginY() + + rows := make([]int, bufferLineCount) + for i := range rows { + rows[i] = -1 + } + lastVisible := -1 + for y := originY; y < min(originY+height, view.ViewLinesHeight()); y++ { + bufferLine, ok := view.BufferLineForViewLine(y) + if !ok || bufferLine >= bufferLineCount { + continue + } + if rows[bufferLine] == -1 { + rows[bufferLine] = y - originY + } + lastVisible = bufferLine + } + for i := lastVisible + 1; i < bufferLineCount; i++ { + rows[i] = height + } + return rows +} + +// nearestSurvivingCandidate returns the first of the candidates that the given +// rendering still shows, and the line of it that does. The rendering is indexed +// first, rather than searched once per candidate: the candidate list is as long as +// the diff, and so is the rendering. +func (self *DiffLineHelper) nearestSurvivingCandidate( + contents []gocui.DiffLineContent, candidates []diffLineAnchor, +) (diffLineAnchor, int) { + rows := self.patchLineRows(contents) + for _, candidate := range candidates { + if line, ok := rows[patchLineOf(candidate.identity)]; ok { + return candidate, line + } + } + return diffLineAnchor{}, -1 +} + +// patchLineRows indexes a rendering by the diff lines it shows: for each of them, the +// first of its rows that does. A row can show more than one, and each is then a way +// of finding that row again. +func (self *DiffLineHelper) patchLineRows(contents []gocui.DiffLineContent) map[patchLine]int { + rows := map[patchLine]int{} + for i, identities := range self.resolveDiffLineIdentities(contents) { + for _, identity := range identities { + if _, seen := rows[patchLineOf(identity)]; !seen { + rows[patchLineOf(identity)] = i + } + } + } + return rows +} + +// rowShowsDiffLine reports whether the given row of a rendering shows the given diff +// line — among any others it shows, since a side-by-side rendering puts a deletion +// beside the addition replacing it. It only knows what the renderer states about the +// row, since the alternative, parsing the rendering as a diff, needs whole hunks and +// this is asked of content that is still loading. It takes the repo's worktree path +// rather than reading it, being asked off the UI thread. +func rowShowsDiffLine(row gocui.DiffLineContent, worktreePath string, target types.DiffLineInfo) bool { + return lo.SomeBy(row.Metadata, func(record string) bool { + parsed, ok := parseDiffLineMetadata(record) + return ok && patchLineOf(diffLineInfoIn(worktreePath, parsed)) == patchLineOf(target) + }) +} + +// patchLine records what stays the same about a diff line when the same diff is +// rendered again differently: which file it belongs to, the line number that +// identifies it on the side it belongs to, and what kind of line it is. +type patchLine struct { + path string + // Every kind of content line collapses into DiffLineContext, since an addition + // and the context line it turns into when whitespace stops counting are the same + // line of the same file. The header rows keep their kind: a file's header and the + // first line of the file it heads are not the same place. + kind types.DiffLineType + // The old file's line number for a deletion, since two consecutive deletions + // share a new-file position and differ only here; the new file's otherwise. + line int + isDeletion bool +} + +func patchLineOf(info types.DiffLineInfo) patchLine { + switch info.Type { + case types.DiffLineFileHeader, types.DiffLineHunkHeader: + return patchLine{path: info.Path, kind: info.Type, line: info.NewLine} + case types.DiffLineDeleted: + return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.OldLine, isDeletion: true} + default: + return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.NewLine} + } +} + +// RevealSelectionAfterAction moves a diff pane's selection to the change that takes the +// place of the one just acted on, once the changed diff has re-rendered. Call it with +// the pane acted in, the pane the work carries on in, and the first line of the +// selection, before triggering the re-render. +// +// The line acted on is gone from the diff, so what is remembered is its place among the +// diff's changes: the next change moves up into it, which is where you want to be to +// carry on. A range collapses to a single line at its start, and hunk mode selects the +// whole block it lands in, so that pressing the key again acts on the next hunk. The +// target pane inherits that select mode, this being the same piece of work continuing +// in another pane — and shows no selection until the restore places one, so that what +// it was left showing the last time it was used doesn't appear for a frame. +// +// advanceBy moves on by that many changes past the place remembered, for an action that +// leaves the diff as it was: lines taken into a custom patch are still in the commit's +// diff, so the place remembered is still the line acted on, and carrying on means going +// past the lines just dealt with rather than staying on them. +// +// done, which may be nil, is called once the selection is where it belongs, or once it +// turns out that no render is coming to put it there — for a caller that must not let +// the user act again in between. +func (self *DiffLineHelper) RevealSelectionAfterAction( + source types.DiffPaneContext, target types.DiffPaneContext, firstLineIdx int, advanceBy int, done func(), +) { + ordinal, ok := self.ChangeLineOrdinal(source.GetView(), firstLineIdx) + if !ok { + if done != nil { + done() + } + return + } + + sel := source.DiffSelectState() + if sel.Mode == types.DiffSelectModeRange { + sel.Mode = types.DiffSelectModeLine + sel.RangeIsSticky = false + } + *target.DiffSelectState() = *sel + selectHunk := sel.Mode == types.DiffSelectModeHunk + + targetView := target.GetView() + if target != source { + target.SetHasSelectableContent(false) + self.c.Context().UpdateSelectionHighlights() + } + + self.RevealChangeLineAtOrdinal(targetView, ordinal+advanceBy, func(viewLine int) { + if selectHunk { + self.SelectChangeBlock(target, viewLine, true) + return + } + targetView.CancelRangeSelect() + self.ShowSelectionAtLine(targetView, viewLine, true) + }, done) +} diff --git a/pkg/gui/controllers/helpers/diff_line_selection.go b/pkg/gui/controllers/helpers/diff_line_selection.go new file mode 100644 index 000000000..816ebece7 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_selection.go @@ -0,0 +1,200 @@ +package helpers + +import ( + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" +) + +// Putting a selection in the focused main view: where it starts out, and how it is +// widened to a whole change block. Both are answered from what the view is showing, +// as recovered by the queries next door. + +// EstablishSelection turns on the focused main view's selection once the view has +// been focused. clickedViewLine is the view line a click pointed at, or -1 for +// keyboard focus, which points at no particular line and so starts at the first +// change line on screen. +// +// Focusing never moves the view: you focus the diff you are reading in order to point +// at something in it, so the selection goes where you are looking rather than the +// view going where the selection would like to be. With no change line on screen at +// all — a long stretch of context — it lands on the middle visible line, the likeliest +// one to be the one being read. +// +// With hunk mode configured as the default the selection widens to the whole change +// block: keyboard focus lands on the first block on screen, and a click on a change +// line selects that line's block, ready to act on. A click on context still selects +// just that line — the click points at it precisely, so it stays editable. +func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext, clickedViewLine int) { + mainContext.ResetDiffSelectMode() + view := mainContext.GetView() + + // The panel beneath renders a diff, but that diff may hold nothing to act on: a + // binary file, or an empty commit. Rendering it worked that out, so the pane is + // already showing no selection and there is nowhere to put one. + if !self.ViewHasChangeLines(view) { + return + } + + if clickedViewLine >= 0 { + // Remember where the click landed so that a drag that follows anchors its range + // there, even when this click selects a whole hunk. + mainContext.SetDragAnchorViewLine(clickedViewLine) + if self.hunkModeApplies(view, clickedViewLine) && self.IsChangeLine(view, clickedViewLine) { + mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk + self.SelectChangeBlock(mainContext, clickedViewLine, false) + return + } + self.ShowSelectionAtLine(view, clickedViewLine, false) + return + } + + target, ok := self.changeToSelectOnScreen(view) + if !ok { + self.ShowSelectionAtLine(view, view.MiddleVisibleLineIdx(), false) + return + } + if self.hunkModeApplies(view, target) { + mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk + self.SelectChangeBlock(mainContext, target, false) + return + } + self.ShowSelectionAtLine(view, target, false) +} + +// changeToSelectOnScreen returns the change line keyboard focus establishes the +// selection on. In hunk mode that is the first block that begins on screen, so that +// the block being offered up is one the user can see the extent of, falling back to a +// block that reaches into the view from above — a change longer than the screen, where +// there is nothing else to offer. Line by line it is simply the first change line on +// screen. ok is false when the viewport shows no change at all. +func (self *DiffLineHelper) changeToSelectOnScreen(view *gocui.View) (int, bool) { + if self.c.UserConfig().Gui.UseHunkModeInDiffView { + return self.FirstChangeBlockInView(view) + } + return self.FirstChangeLineInView(view) +} + +// hunkModeApplies reports whether an established selection should start out as the +// whole change block around the given change line. That's what the config asks for, +// except over a file shown as one solid block of changes, where it would select the +// whole file — see IsSingleHunkForWholeFile. +func (self *DiffLineHelper) hunkModeApplies(view *gocui.View, changeViewLine int) bool { + return self.c.UserConfig().Gui.UseHunkModeInDiffView && + !self.IsSingleHunkForWholeFile(view, changeViewLine) +} + +// ShowSelectionAtLine moves the focused main view's selection to the given view line, +// clamped to the content. scrollIntoView scrolls the line into view when it's +// off-screen, for navigating to it; a click leaves it false, the clicked line being on +// screen already. +func (self *DiffLineHelper) ShowSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) { + view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView) + + // A search carries on from where the selection now is, so that stepping to the + // next match goes to the one after it rather than the one after the match the + // user last stepped to. + view.SetNearestSearchPosition() +} + +// SelectChangeBlock selects the whole change block around the given change line, for +// hunk mode: the cursor goes to the block's first line and the range anchor to its +// last, so the native range highlight spans the block. With no block to be found — +// a diff with no changes in it — it falls back to a single-line selection. +// +// scrollIntoView brings the block's first line on screen, for the commands that mean +// to go there; a click leaves it false, so that the view doesn't move under the mouse +// when the block the click landed in starts above the viewport. +func (self *DiffLineHelper) SelectChangeBlock( + pane types.DiffPaneContext, changeViewLine int, scrollIntoView bool, +) { + view := pane.GetView() + start, end, ok := self.ChangeBlockBounds(view, changeViewLine) + if !ok { + pane.DiffSelectState().Mode = types.DiffSelectModeLine + view.CancelRangeSelect() + self.ShowSelectionAtLine(view, changeViewLine, scrollIntoView) + return + } + view.SetRangeSelectStart(end) + self.ShowSelectionAtLine(view, start, scrollIntoView) +} + +// SelectedHunkBounds returns the change block selected in hunk mode. The range +// anchor stays on the block's far end when a click moves the cursor before its +// handler runs, so it still identifies the selected block. +func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) { + anchor := view.RangeSelectStartY() + if anchor < 0 { + return 0, 0, false + } + return self.ChangeBlockBounds(view, anchor) +} + +// RefreshInclusionGutter updates the marks drawn over the diff in the main pane, which +// say which of its lines are in the custom patch being built from it. +// +// They are shown while the focused main view holds the focus — either of its panes, so +// that moving between the diff and the patch previewed beside it doesn't make them come +// and go — and only over a diff a patch is being built from: a patch built from some +// other commit says nothing about the lines of this one. +// +// Call it whenever either of those can have changed: as a pane's content settles, when +// the focus arrives or leaves, and when the patch itself changes. +func (self *DiffLineHelper) RefreshInclusionGutter() { + view := self.c.Contexts().Normal.GetView() + + included := self.patchInclusion() + if included == nil { + view.SetInclusionGutter(false, nil) + return + } + + resolved := self.resolveDiffLines(view.DiffLineContents()) + marks := make([]bool, len(resolved)) + showsChanges := false + for i, row := range resolved { + if !row.ok || !row.info.IsChange() { + continue + } + showsChanges = true + marks[i] = included(row.info) + } + + // Nothing to mark and nowhere to mark it: the pane is showing a message rather than + // a diff, or a diff with nothing in it. + if !showsChanges { + view.SetInclusionGutter(false, nil) + return + } + view.SetInclusionGutter(true, marks) +} + +// patchInclusion asks the panel whose diff the focused main view is showing which of +// that diff's lines are in the custom patch being built from it, and answers nil where +// there is no such patch — including when the focus is elsewhere, the marks being an +// affordance of the focused view. +func (self *DiffLineHelper) patchInclusion() func(types.DiffLineInfo) bool { + if !self.mainViewIsFocused() { + return nil + } + // The panel beneath is found from the pane that holds the focus, which is not always + // the one the diff is in: moving to the pane beside it takes the other off the stack. + sidePanel := self.c.Context().NextInStack(self.c.Context().CurrentStatic()) + if sidePanel == nil { + return nil + } + actions, ok := sidePanel.GetFocusedMainViewDiffSource().(types.FocusedMainViewActions) + if !ok { + return nil + } + return actions.PatchInclusion() +} + +// ShowsCustomPatch reports whether the given view is the one previewing the custom patch +// being built, which is the lower pane while a patch is being built from the diff in the +// upper one. +func (self *DiffLineHelper) ShowsCustomPatch(view *gocui.View) bool { + return view == self.c.Contexts().NormalSecondary.GetView() && self.patchInclusion() != nil +} diff --git a/pkg/gui/controllers/helpers/files_helper.go b/pkg/gui/controllers/helpers/files_helper.go index 81c9b272e..1f7ed5188 100644 --- a/pkg/gui/controllers/helpers/files_helper.go +++ b/pkg/gui/controllers/helpers/files_helper.go @@ -46,8 +46,16 @@ func (self *FilesHelper) EditFileAtLineAndWait(filename string, lineNumber int) // Always suspend, regardless of the value of the suspend config, // since we want to prevent interacting with the UI until the editor - // returns, even if the editor doesn't use the terminal - return self.callEditor(cmdStr, true) + // returns, even if the editor doesn't use the terminal. + // + // And nothing is refreshed here; that is why this doesn't go through + // callEditor. The editor was handed a patch we wrote for it, so the repo + // hasn't changed when it returns; it changes when the caller applies what + // came back. A refresh in between reads the state from before that, and + // then races the caller's own refresh to publish it. + _, err = self.c.RunSubprocess( + self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile)) + return err } func (self *FilesHelper) OpenDirInEditor(path string) error { diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go index 4c9c79f3d..b8c7e13bf 100644 --- a/pkg/gui/controllers/helpers/helpers.go +++ b/pkg/gui/controllers/helpers/helpers.go @@ -28,8 +28,7 @@ type Helpers struct { MergeConflicts *MergeConflictsHelper CherryPick *CherryPickHelper Host *HostHelper - PatchBuilding *PatchBuildingHelper - Staging *StagingHelper + CustomPatch *CustomPatchHelper GPG *GpgHelper Upstream *UpstreamHelper AmendHelper *AmendHelper @@ -39,6 +38,7 @@ type Helpers struct { Snake *SnakeHelper // lives in context package because our contexts need it to render to main Diff *DiffHelper + DiffLine *DiffLineHelper Repos *ReposHelper RecordDirectory *RecordDirectoryHelper Update *UpdateHelper @@ -67,8 +67,7 @@ func NewStubHelpers() *Helpers { MergeConflicts: &MergeConflictsHelper{}, CherryPick: &CherryPickHelper{}, Host: &HostHelper{}, - PatchBuilding: &PatchBuildingHelper{}, - Staging: &StagingHelper{}, + CustomPatch: &CustomPatchHelper{}, GPG: &GpgHelper{}, Upstream: &UpstreamHelper{}, AmendHelper: &AmendHelper{}, @@ -76,6 +75,7 @@ func NewStubHelpers() *Helpers { Commits: &CommitsHelper{}, Snake: &SnakeHelper{}, Diff: &DiffHelper{}, + DiffLine: &DiffLineHelper{}, Repos: &ReposHelper{}, RecordDirectory: &RecordDirectoryHelper{}, Update: &UpdateHelper{}, diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 68f7ea149..a96164891 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -14,7 +14,7 @@ type ModeHelper struct { c *HelperCommon diffHelper *DiffHelper - patchBuildingHelper *PatchBuildingHelper + customPatchHelper *CustomPatchHelper cherryPickHelper *CherryPickHelper mergeAndRebaseHelper *MergeAndRebaseHelper bisectHelper *BisectHelper @@ -24,7 +24,7 @@ type ModeHelper struct { func NewModeHelper( c *HelperCommon, diffHelper *DiffHelper, - patchBuildingHelper *PatchBuildingHelper, + customPatchHelper *CustomPatchHelper, cherryPickHelper *CherryPickHelper, mergeAndRebaseHelper *MergeAndRebaseHelper, bisectHelper *BisectHelper, @@ -32,7 +32,7 @@ func NewModeHelper( return &ModeHelper{ c: c, diffHelper: diffHelper, - patchBuildingHelper: patchBuildingHelper, + customPatchHelper: customPatchHelper, cherryPickHelper: cherryPickHelper, mergeAndRebaseHelper: mergeAndRebaseHelper, bisectHelper: bisectHelper, @@ -71,9 +71,9 @@ func (self *ModeHelper) Statuses() []ModeStatus { return self.withResetButton(self.c.Tr.BuildingPatch, style.FgYellow.SetBold()) }, CancelLabel: func() string { - return self.c.Tr.ExitCustomPatchBuilder + return self.c.Tr.ResetCustomPatch }, - Reset: self.patchBuildingHelper.Reset, + Reset: self.customPatchHelper.Reset, }, { IsActive: self.c.Modes().Filtering.Active, diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go deleted file mode 100644 index ac79ee8d7..000000000 --- a/pkg/gui/controllers/helpers/patch_building_helper.go +++ /dev/null @@ -1,115 +0,0 @@ -package helpers - -import ( - "fmt" - - "github.com/jesseduffield/lazygit/pkg/commands/patch" - "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" - "github.com/jesseduffield/lazygit/pkg/gui/types" -) - -type PatchBuildingHelper struct { - c *HelperCommon -} - -func NewPatchBuildingHelper( - c *HelperCommon, -) *PatchBuildingHelper { - return &PatchBuildingHelper{ - c: c, - } -} - -func (self *PatchBuildingHelper) ShowHunkStagingHint() { - if !self.c.AppState.DidShowHunkStagingHint && self.c.UserConfig().Gui.UseHunkModeInStagingView { - self.c.AppState.DidShowHunkStagingHint = true - self.c.SaveAppStateAndLogError() - - message := fmt.Sprintf(self.c.Tr.HunkStagingHint, self.c.UserConfig().Keybinding.Main.ToggleSelectHunk) - self.c.Confirm(types.ConfirmOpts{ - Prompt: message, - }) - } -} - -// takes us from the patch building panel back to the commit files panel -func (self *PatchBuildingHelper) Escape() { - self.c.Context().Pop() -} - -// kills the custom patch and returns us back to the commit files panel if needed -func (self *PatchBuildingHelper) Reset() error { - self.c.Git().Patch.PatchBuilder.Reset() - - if self.c.Context().CurrentStatic().GetKind() != types.SIDE_CONTEXT { - self.Escape() - } - - self.c.Refresh(types.RefreshOptions{ - Scope: []types.RefreshableView{types.COMMIT_FILES}, - }) - - // refreshing the current context so that the secondary panel is hidden if necessary. - self.c.PostRefreshUpdate(self.c.Context().Current()) - return nil -} - -func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) { - selectedLineIdx := -1 - if opts.ClickedWindowName == "main" { - selectedLineIdx = opts.ClickedViewLineIdx - } - - if !self.c.Git().Patch.PatchBuilder.Active() { - self.Escape() - return - } - - // get diff from commit file that's currently selected - file := self.c.Contexts().CommitFiles.GetSelectedFile() - if file == nil { - return - } - - from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() - from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, true) - if err != nil { - return - } - - secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{ - Filename: file.Path, - PreviousPath: file.PreviousPath, - Plain: false, - Reverse: false, - TurnAddedFilesIntoDiffAgainstEmptyFile: true, - }) - - context := self.c.Contexts().CustomPatchBuilder - - oldState := context.GetState() - - state := patch_exploring.NewState(diff, selectedLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView) - context.SetState(state) - if state == nil { - self.Escape() - return - } - - mainContent := context.GetContentToRender() - - self.c.Contexts().CustomPatchBuilder.FocusSelection() - - self.c.RenderToMainViews(types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().PatchBuilding, - Main: &types.ViewUpdateOpts{ - Task: types.NewRenderStringWithoutScrollTask(mainContent), - Title: self.c.Tr.Patch, - }, - Secondary: &types.ViewUpdateOpts{ - Task: types.NewRenderStringWithoutScrollTask(secondaryDiff), - Title: self.c.Tr.CustomPatch, - }, - }) -} diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 730ed9a24..51394dd73 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -30,8 +30,6 @@ type RefreshHelper struct { c *HelperCommon refsHelper *RefsHelper mergeAndRebaseHelper *MergeAndRebaseHelper - patchBuildingHelper *PatchBuildingHelper - stagingHelper *StagingHelper mergeConflictsHelper *MergeConflictsHelper worktreeHelper *WorktreeHelper searchHelper *SearchHelper @@ -62,8 +60,6 @@ func NewRefreshHelper( c *HelperCommon, refsHelper *RefsHelper, mergeAndRebaseHelper *MergeAndRebaseHelper, - patchBuildingHelper *PatchBuildingHelper, - stagingHelper *StagingHelper, mergeConflictsHelper *MergeConflictsHelper, worktreeHelper *WorktreeHelper, searchHelper *SearchHelper, @@ -72,8 +68,6 @@ func NewRefreshHelper( c: c, refsHelper: refsHelper, mergeAndRebaseHelper: mergeAndRebaseHelper, - patchBuildingHelper: patchBuildingHelper, - stagingHelper: stagingHelper, mergeConflictsHelper: mergeConflictsHelper, worktreeHelper: worktreeHelper, searchHelper: searchHelper, @@ -248,8 +242,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { - // not refreshing staging/patch-building unless explicitly requested because we only need - // to refresh those while focused. scopeSet = set.NewFromSlice([]types.RefreshableView{ types.COMMITS, types.BRANCHES, @@ -261,7 +253,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr types.WORKTREES, types.STATUS, types.BISECT_INFO, - types.STAGING, types.PULL_REQUESTS, }) } else { @@ -500,37 +491,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) } - if scopeSet.Includes(types.STAGING) { - refresh("staging", func() { - fileWg.Wait() - // Bounce onto the UI thread so this runs after the files - // scope's model-update bounce — RefreshStagingPanel reads - // Model.Files (via Files.GetSelected) and would otherwise - // see the pre-refresh model. Guard on the generation so a - // repo switch mid-refresh drops it, like the model bounces. - self.onUIThreadUnlessRepoChanged(env, func() { - self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) - }) - }) - } - - if scopeSet.Includes(types.PATCH_BUILDING) { - refresh("patch building", func() { - // Bounce onto the UI thread, like the staging panel above: - // RefreshPatchBuildingPanel reads the commit-files selection and - // sets the patch view's origin, neither of which may run off the UI - // thread. Guard on the generation so a repo switch mid-refresh drops - // it, like the model bounces. - self.onUIThreadUnlessRepoChanged(env, func() { - self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) - }) - }) - } - if scopeSet.Includes(types.MERGE_CONFLICTS) { refresh("merge conflicts", func() { - // Bounce onto the UI thread, like the staging and patch-building - // panels above: RefreshMergeState reads the current context and + // Bounce onto the UI thread: RefreshMergeState reads the current context and // renders (or escapes) the merge-conflicts view, none of which may // run off the UI thread. self.onUIThreadUnlessRepoChanged(env, func() { @@ -573,7 +536,8 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // this runs — and the keys buffered during the refresh replay — // the refreshed state is in place. self.c.OnUIThread(func() error { - return self.c.GocuiGui().EndBlockingEvents() + self.c.GocuiGui().EndBlockingEvents() + return nil }) } @@ -659,8 +623,6 @@ func getScopeNames(scopes []types.RefreshableView) []string { types.WORKTREES: "worktrees", types.STATUS: "status", types.BISECT_INFO: "bisect", - types.STAGING: "staging", - types.PATCH_BUILDING: "patchBuilding", types.MERGE_CONFLICTS: "mergeConflicts", types.COMMIT_FILES: "commitFiles", types.PULL_REQUESTS: "pullRequests", @@ -1069,17 +1031,31 @@ type capturedCommitFilesState struct { from string to string reverse bool + // Whether there is a commit to load the files of at all. The panel is only ever + // pointed at one by being entered, and a patch can now be built from a commit's diff + // without that — after which anything that refreshes the panel would otherwise be + // asking for the files of nothing. + hasCommit bool } // captureCommitFilesState reads the commit-files refresh's diff endpoints into // an immutable snapshot. It must run on the UI thread. func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState { - from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() + commitFilesContext := self.c.Contexts().CommitFiles + if commitFilesContext.GetRef() == nil && commitFilesContext.GetRefRange() == nil { + return capturedCommitFilesState{} + } + + from, to := commitFilesContext.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - return capturedCommitFilesState{from: from, to: to, reverse: reverse} + return capturedCommitFilesState{from: from, to: to, reverse: reverse, hasCommit: true} } func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error { + if !captured.hasCommit { + return nil + } + files, err := env.git.Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) if err != nil { return err diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 0fcbeace3..f645841f8 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -50,7 +50,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions types.REFLOG, types.WORKTREES, types.BISECT_INFO, - types.STAGING, } if options.RefreshPullRequests { scope = append(scope, types.PULL_REQUESTS) diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go deleted file mode 100644 index 55b9c133b..000000000 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ /dev/null @@ -1,127 +0,0 @@ -package helpers - -import ( - "github.com/jesseduffield/lazygit/pkg/commands/models" - "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" - "github.com/jesseduffield/lazygit/pkg/gui/types" -) - -type StagingHelper struct { - c *HelperCommon -} - -func NewStagingHelper( - c *HelperCommon, -) *StagingHelper { - return &StagingHelper{ - c: c, - } -} - -// NOTE: used from outside this file -func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) { - secondaryFocused := self.secondaryStagingFocused() - mainFocused := self.mainStagingFocused() - - // this method could be called when the staging panel is not being used, - // in which case we don't want to do anything. - if !mainFocused && !secondaryFocused { - return - } - - mainSelectedLineIdx := -1 - secondarySelectedLineIdx := -1 - if focusOpts.ClickedViewLineIdx > 0 { - if secondaryFocused { - secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx - } else { - mainSelectedLineIdx = focusOpts.ClickedViewLineIdx - } - } - - mainContext := self.c.Contexts().Staging - secondaryContext := self.c.Contexts().StagingSecondary - - var file *models.File - node := self.c.Contexts().Files.GetSelected() - if node != nil { - file = node.File - } - - if file == nil || (!file.HasUnstagedChanges && !file.HasStagedChanges) { - self.handleStagingEscape() - return - } - - mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, false) - secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, true) - - // grabbing locks here and releasing before we finish the function - // because pushing say the secondary context could mean entering this function - // again, and we don't want to have a deadlock - mainContext.GetMutex().Lock() - secondaryContext.GetMutex().Lock() - - hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView - mainContext.SetState( - patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode), - ) - - secondaryContext.SetState( - patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode), - ) - - mainState := mainContext.GetState() - secondaryState := secondaryContext.GetState() - - mainContent := mainContext.GetContentToRender() - secondaryContent := secondaryContext.GetContentToRender() - - mainContext.GetMutex().Unlock() - secondaryContext.GetMutex().Unlock() - - if mainState == nil && secondaryState == nil { - self.handleStagingEscape() - return - } - - if mainState == nil && !secondaryFocused { - self.c.Context().Push(secondaryContext, focusOpts) - return - } - - if secondaryState == nil && secondaryFocused { - self.c.Context().Push(mainContext, focusOpts) - return - } - - if secondaryFocused { - self.c.Contexts().StagingSecondary.FocusSelection() - } else { - self.c.Contexts().Staging.FocusSelection() - } - - self.c.RenderToMainViews(types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Staging, - Main: &types.ViewUpdateOpts{ - Task: types.NewRenderStringWithoutScrollTask(mainContent), - Title: self.c.Tr.UnstagedChanges, - }, - Secondary: &types.ViewUpdateOpts{ - Task: types.NewRenderStringWithoutScrollTask(secondaryContent), - Title: self.c.Tr.StagedChanges, - }, - }) -} - -func (self *StagingHelper) handleStagingEscape() { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) -} - -func (self *StagingHelper) secondaryStagingFocused() bool { - return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().StagingSecondary.GetKey() -} - -func (self *StagingHelper) mainStagingFocused() bool { - return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey() -} diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 5379e9c09..7428d17e3 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -60,7 +60,7 @@ type WindowArrangementArgs struct { ContentHeightForWindow func(window string) int // Whether the main panel is split (as is the case e.g. when a file has both // staged and unstaged changes) - SplitMainPanel bool + MainPanes types.MainPanes // The current screen mode (normal, half, full) ScreenMode types.ScreenMode // The content shown on the bottom left of the screen when showing a loader @@ -103,7 +103,7 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, ContentHeightForWindow: func(window string) int { return self.windowHelper.GetContextForWindow(window).TotalContentHeight() }, - SplitMainPanel: repoState.GetSplitMainPanel(), + MainPanes: repoState.GetMainPanes(), ScreenMode: repoState.GetScreenMode(), AppStatus: appStatus, InformationStr: informationStr, @@ -215,36 +215,27 @@ func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V { } func mainSectionChildren(args WindowArrangementArgs) []*boxlayout.Box { - // if we're not in split mode we can just show the one main panel. Likewise if - // the main panel is focused and we're in full-screen mode - if !args.SplitMainPanel || (args.ScreenMode == types.SCREEN_FULL && args.CurrentWindow == "main") { - return []*boxlayout.Box{ - { - Window: "main", - Weight: 1, - }, + mainPane := &boxlayout.Box{Window: "main", Weight: 1} + secondaryPane := &boxlayout.Box{Window: "secondary", Weight: 1} + + switch args.MainPanes { + case types.MainPaneOnly: + return []*boxlayout.Box{mainPane} + case types.SecondaryPaneOnly: + return []*boxlayout.Box{secondaryPane} + case types.BothMainPanes: + // In full-screen mode the focused one takes the whole section anyway. + if args.ScreenMode == types.SCREEN_FULL { + if args.CurrentWindow == "main" { + return []*boxlayout.Box{mainPane} + } + if args.CurrentWindow == "secondary" { + return []*boxlayout.Box{secondaryPane} + } } } - if args.CurrentWindow == "secondary" && args.ScreenMode == types.SCREEN_FULL { - return []*boxlayout.Box{ - { - Window: "secondary", - Weight: 1, - }, - } - } - - return []*boxlayout.Box{ - { - Window: "main", - Weight: 1, - }, - { - Window: "secondary", - Weight: 1, - }, - } + return []*boxlayout.Box{mainPane, secondaryPane} } func getMidSectionWeights(args WindowArrangementArgs) (int, int) { @@ -382,7 +373,7 @@ func infoSectionChildren(args WindowArrangementArgs) []*boxlayout.Box { } func splitMainPanelSideBySide(args WindowArrangementArgs) bool { - if !args.SplitMainPanel { + if args.MainPanes != types.BothMainPanes { return false } diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go index 365d7f104..2235d7fb4 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go @@ -35,7 +35,7 @@ func TestGetWindowDimensions(t *testing.T) { // Each panel shows its first tab by default; for the special-cased // panels (status, stash) the view name matches the window name. ActiveViewForWindow: func(window string) string { return window }, - SplitMainPanel: false, + MainPanes: types.MainPaneOnly, ScreenMode: types.SCREEN_NORMAL, AppStatus: "", InformationStr: "information", diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 1e1a01427..983ca789f 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -722,17 +722,30 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() { } } +// secondaryPatchPanelUpdateOpts renders the custom patch being built into the pane +// beside the diff it is being built from, as a diff of the two trees the patch is +// materialized into. This way it is shown by whatever renders the rest of the diffs, +// and its lines can be pointed at and taken back out of the patch. func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts { - if c.Git().Patch.PatchBuilder.Active() { - patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false) - - return &types.ViewUpdateOpts{ - Task: types.NewRenderStringWithoutScrollTask(patch), - Title: c.Tr.CustomPatch, - } + if !c.Git().Patch.PatchBuilder.Active() { + return nil } - return nil + // A render of the same patch reuses the trees; only a change to the patch writes them + // again. + if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil { + c.Log.Error(err) + } + + // The same mode as the diff beside it: both panes of the pair have to agree about + // whether what they show can be acted on. + mode := c.Helpers().DiffLine.MainViewDiffMode() + cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(c.Git().Patch.PatchBuilder.TempDir(), mode) + + return &types.ViewUpdateOpts{ + Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode), + Title: c.Tr.CustomPatch, + } } func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 5bde8c5ff..3cdd4d16a 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -1,9 +1,13 @@ package controllers import ( + "time" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) type MainViewController struct { @@ -12,8 +16,14 @@ type MainViewController struct { context *context.MainContext otherContext *context.MainContext + + dragAutoscroller *helpers.DragAutoscroller + draggingWithMouse bool + lineFlashGeneration uint64 } +const editedLineFlashDuration = 200 * time.Millisecond + var _ types.IController = &MainViewController{} func NewMainViewController( @@ -21,12 +31,19 @@ func NewMainViewController( context *context.MainContext, otherContext *context.MainContext, ) *MainViewController { - return &MainViewController{ + controller := &MainViewController{ baseController: baseController{}, c: c, context: context, otherContext: otherContext, } + controller.dragAutoscroller = helpers.NewDragAutoscroller( + c.HelperCommon, + context, + controller.canDragAutoscroll, + controller.handleDragAutoscroll, + ) + return controller } func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { @@ -34,14 +51,114 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty { Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), Handler: self.togglePanel, - Description: self.c.Tr.ToggleStagingView, - Tooltip: self.c.Tr.ToggleStagingViewTooltip, + Description: self.c.Tr.ToggleDiffPane, + Tooltip: self.c.Tr.ToggleDiffPaneTooltip, DisplayOnScreen: true, }, + { + Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk), + Handler: self.toggleSelectHunk, + DescriptionFunc: self.diffSelectionDescription(func() string { + if self.diffSelectState().Mode == types.DiffSelectModeHunk { + return self.c.Tr.SelectLineByLine + } + return self.c.Tr.SelectHunk + }), + Description: self.c.Tr.ToggleSelectHunk, + GetDisabledReason: self.diffSelectionDisabledReason, + Tooltip: self.c.Tr.ToggleSelectHunkTooltip, + DisplayOnScreen: true, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect), + Handler: self.toggleRangeSelect, + Description: self.c.Tr.ToggleRangeSelect, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.ToggleRangeSelect), + GetDisabledReason: self.diffSelectionDisabledReason, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.Edit), + Handler: self.editLine, + Description: self.c.Tr.EditFile, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.EditFile), + GetDisabledReason: self.diffSelectionDisabledReason, + Tooltip: self.c.Tr.EditFileTooltip, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.Select), + Handler: self.primaryAction, + // The description is of the working tree's diff, which is where the key does + // the thing users know it for; over a commit's diff it says so for itself. + Description: self.c.Tr.Stage, + DescriptionFunc: self.diffActionDescription(self.c.Tr.Stage, self.c.Tr.ToggleSelectionForPatch), + GetDisabledReason: self.diffSelectionDisabledReason, + Tooltip: self.c.Tr.StageSelectionTooltip, + // Over a commit's diff the key toggles lines in the custom patch, which the + // description says for itself; there is nothing to add to it. + TooltipFunc: self.diffActionDescription(self.c.Tr.StageSelectionTooltip, ""), + DisplayOnScreen: true, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.Remove), + Handler: self.discardSelection, + Description: self.c.Tr.DiscardSelection, + DescriptionFunc: self.diffActionDescription(self.c.Tr.DiscardSelection, self.c.Tr.RemoveSelectionFromPatch), + GetDisabledReason: self.discardSelectionDisabledReason, + Tooltip: self.c.Tr.DiscardSelectionTooltip, + // Over a commit's diff the key rewrites the commit rather than touching the + // index, which is worth the warning the other tooltip carries. + TooltipFunc: self.diffActionDescription( + self.c.Tr.DiscardSelectionTooltip, self.c.Tr.RemoveSelectionFromPatchTooltip), + DisplayOnScreen: true, + }, + { + Keys: opts.GetKeys(opts.Config.Main.EditSelectHunk), + Handler: self.editHunk, + Description: self.c.Tr.EditHunk, + DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.EditHunk), + GetDisabledReason: self.diffSelectionDisabledReason, + Tooltip: self.c.Tr.EditHunkTooltip, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), + Handler: self.copySelection, + Description: self.c.Tr.CopySelectedTextToClipboard, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.CopySelectedTextToClipboard), + GetDisabledReason: self.diffSelectionDisabledReason, + }, + { + Keys: opts.GetKeys(opts.Config.Main.PrevHunk), + Handler: self.prevChangeBlock, + Description: self.c.Tr.PrevHunk, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevHunk), + GetDisabledReason: self.diffSelectionDisabledReason, + }, + { + Keys: opts.GetKeys(opts.Config.Main.NextHunk), + Handler: self.nextChangeBlock, + Description: self.c.Tr.NextHunk, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextHunk), + GetDisabledReason: self.diffSelectionDisabledReason, + }, + { + Keys: opts.GetKeys(opts.Config.Main.PrevFile), + Handler: self.prevFile, + Description: self.c.Tr.PrevFileInDiff, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevFileInDiff), + GetDisabledReason: self.diffSelectionDisabledReason, + }, + { + Keys: opts.GetKeys(opts.Config.Main.NextFile), + Handler: self.nextFile, + Description: self.c.Tr.NextFileInDiff, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff), + GetDisabledReason: self.diffSelectionDisabledReason, + }, { Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.escape, Description: self.c.Tr.ExitFocusedMainView, + DescriptionFunc: self.escapeDescription, DisplayOnScreen: true, }, { @@ -51,6 +168,54 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty Description: self.c.Tr.StartSearch, Tag: "navigation", }, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine}, + { + Tag: "navigation", + Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp), + Handler: self.extendRangeUp, + Description: self.c.Tr.RangeSelectUp, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectUp), + GetDisabledReason: self.diffSelectionDisabledReason, + }, + { + Tag: "navigation", + Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown), + Handler: self.extendRangeDown, + Description: self.c.Tr.RangeSelectDown, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectDown), + GetDisabledReason: self.diffSelectionDisabledReason, + }, + { + Keys: opts.GetKeys(opts.Config.Files.CommitChanges), + Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitPress), + Description: self.c.Tr.Commit, + DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.Commit), + Tooltip: self.c.Tr.CommitTooltip, + }, + { + Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook), + Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleWIPCommitPress), + Description: self.c.Tr.CommitChangesWithoutHook, + DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithoutHook), + }, + { + Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor), + Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitEditorPress), + Description: self.c.Tr.CommitChangesWithEditor, + DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithEditor), + }, + { + Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup), + Handler: self.workingTreeAction(self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress), + Description: self.c.Tr.FindBaseCommitForFixup, + DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.FindBaseCommitForFixup), + Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip, + }, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom}, } } @@ -68,6 +233,33 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts) Handler: self.onClickInOtherViewOfMainViewPair, FocusedView: self.otherContext.GetViewName(), }, + { + // Dragging after a click extends a range selection from the clicked line. + ViewName: self.context.GetViewName(), + Key: gocui.MouseLeft, + Modifier: gocui.ModMotion, + Handler: self.onDragInFocusedView, + FocusedView: self.context.GetViewName(), + }, + { + ViewName: self.context.GetViewName(), + Key: gocui.MouseRelease, + Handler: self.onDragRelease, + }, + { + ViewName: self.context.GetViewName(), + Key: gocui.MouseLeft, + Modifier: gocui.ModAlt, + Handler: self.editClickedLine, + HandleWhenPopupPanelFocused: true, + }, + { + ViewName: self.context.GetViewName(), + Key: gocui.MouseLeft, + Modifier: gocui.ModShift, + Handler: self.editClickedLine, + HandleWhenPopupPanelFocused: true, + }, } } @@ -75,36 +267,719 @@ func (self *MainViewController) Context() types.Context { return self.context } +// GetOnFocus brings on the marks over the lines that are in the custom patch, which +// are an affordance of the focused view, so they arrive with the focus. +func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) { + return func(types.OnFocusOpts) { + self.c.Helpers().DiffLine.RefreshInclusionGutter() + } +} + func (self *MainViewController) togglePanel() error { - if self.otherContext.GetView().Visible { - self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) + if !self.otherContext.GetView().Visible { + return nil } + // Whether the pair holds a diff is decided by the side panel beneath, which + // NextInStack only finds while our context is still the focused main view, so + // read it before pushing the other pane. + isDiff := self.isDiffView() + self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) + if isDiff { + self.c.Helpers().DiffLine.EstablishSelection(self.otherContext, -1) + } return nil } +// escape dismisses the selection a step at a time before leaving the view: a range +// collapses to its cursor line, and hunk mode the user turned on goes back to +// line-by-line. Hunk mode that is merely the configured default is not something to +// escape from, so there escape leaves. func (self *MainViewController) escape() error { + if self.selectingRange() || self.selectingHunkEnabledByUser() { + self.context.ResetDiffSelectMode() + return nil + } + self.c.Context().Pop() return nil } -func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { - sidePanelContext := self.c.Context().NextInStack(self.context) - if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil { - return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y) +func (self *MainViewController) escapeDescription() string { + if self.selectingRange() { + return self.c.Tr.DismissRangeSelect + } + if self.selectingHunkEnabledByUser() { + return self.c.Tr.SelectLineByLine + } + return self.c.Tr.ExitFocusedMainView +} + +// selectingHunkEnabledByUser reports whether we are in hunk mode because the user +// asked for it, as opposed to it being the configured default. +func (self *MainViewController) selectingHunkEnabledByUser() bool { + return self.diffSelectState().Mode == types.DiffSelectModeHunk && self.diffSelectState().UserEnabledHunkMode +} + +// isDiffView reports whether the focused main view currently shows a diff, and so +// shows a selection. See types.DiffMainViewContext. +func (self *MainViewController) isDiffView() bool { + return self.diffMainViewType() != types.DiffMainViewTypeNone +} + +// diffMainViewType reports what the diff in the focused main view belongs to, taken +// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the +// stack or has no diff panel beneath it. The IsInStack guard is essential: +// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which +// leads here) also runs for off-stack panes — at startup and while generating the +// cheatsheets, where the stack is empty. +func (self *MainViewController) diffMainViewType() types.DiffMainViewType { + if !self.c.Context().IsInStack(self.context) { + return types.DiffMainViewTypeNone + } + if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok { + return diffContext.GetDiffMainViewType() + } + return types.DiffMainViewTypeNone +} + +// diffSource returns the panel beneath the focused main view, as the thing that can +// hand out the diff it rendered there. nil when this pane isn't on the stack, or the +// panel beneath shows no diff. +func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource { + if !self.c.Context().IsInStack(self.context) { + return nil + } + sidePanel := self.c.Context().NextInStack(self.context) + if sidePanel == nil { + return nil + } + return sidePanel.GetFocusedMainViewDiffSource() +} + +// focusedMainViewActions returns what the panel beneath the focused main view does to +// a selection in its diff, or nil where it does nothing to it — a panel whose diff can +// be read and copied but not acted on. +func (self *MainViewController) focusedMainViewActions() types.FocusedMainViewActions { + actions, _ := self.diffSource().(types.FocusedMainViewActions) + return actions +} + +// primaryAction acts on the selected diff lines, leaving what that means to the panel +// beneath — which also re-renders the diff, since it is the one that changed it. +func (self *MainViewController) primaryAction() error { + actions := self.focusedMainViewActions() + if actions == nil { + return nil + } + first, last := self.context.GetView().SelectedLineRange() + return actions.PrimaryAction(self.context, first, last) +} + +// discardSelection takes the selected diff lines back out of what they are part of, +// which — like the primary action — is the panel's business, and so is the re-render +// that follows. +func (self *MainViewController) discardSelection() error { + actions := self.focusedMainViewActions() + if actions == nil { + return nil + } + first, last := self.context.GetView().SelectedLineRange() + return actions.DiscardSelection(self.context, first, last) +} + +// editHunk hands the hunk around the selection to an editor, and is only offered over +// the working tree's diff: what comes back is applied to the index, which is not +// something a commit's diff has any use for. +func (self *MainViewController) editHunk() error { + actions, ok := self.diffSource().(*WorkingTreeDiffActions) + if !ok { + return nil + } + first, last := self.context.GetView().SelectedLineRange() + return actions.EditHunk(self.context, first, last) +} + +// workingTreeAction wraps a command that acts on the working tree — committing, finding +// the commit to fix up — so that it only runs while the focused main view is showing the +// working tree's diff. Over a commit's diff the key does nothing, so that browsing +// through history can't commit by accident. The check is per press, since what the main +// view shows changes as the user moves around while the keybindings are registered once. +func (self *MainViewController) workingTreeAction(action func() error) func() error { + return func() error { + if self.diffMainViewType() != types.DiffMainViewTypeStaging { + return nil + } + return action() + } +} + +// workingTreeActionDescription gives a command's description only where the command +// applies — over the working tree's diff — so that it is listed there and nowhere else. +func (self *MainViewController) workingTreeActionDescription(description string) func() string { + return self.diffActionDescription(description, "") +} + +// diffActionDescription describes a command in the words that suit the diff it applies +// to: acting on the working tree's diff stages, acting on a commit's builds a custom +// patch. Over content that is no diff at all the command doesn't apply, and describes +// itself as nothing, which keeps it out of the keybindings menu there. +func (self *MainViewController) diffActionDescription(staging string, patchBuilding string) func() string { + return func() string { + switch self.diffMainViewType() { + case types.DiffMainViewTypeStaging: + return staging + case types.DiffMainViewTypePatchBuilding: + return patchBuilding + default: + return "" + } + } +} + +// copySelection copies the selected diff lines to the clipboard — not as the diff +// renderer drew them, but as they read in the diff itself, which is both what you meant +// to copy and the only form a renderer can't have mangled. A selection that is all +// additions or all deletions loses its +/- column, so that it can be pasted straight +// into code. +func (self *MainViewController) copySelection() error { + source := self.diffSource() + if source == nil { + return nil + } + view := self.context.GetView() + first, last := view.SelectedLineRange() + text := self.c.Helpers().DiffLine.PlainDiffOfSelection(view, first, last, + func(paths []string) string { return source.PlainDiff(self.context, paths) }) + if text == "" { + return nil + } + + self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard) + return self.c.OS().CopyToClipboard(dropDiffPrefix(text)) +} + +// diffSelectState returns this pane's diff selection mode state. +func (self *MainViewController) diffSelectState() *types.DiffSelectState { + return self.context.DiffSelectState() +} + +// diffSelectionDescription qualifies the description of a command that acts on the +// selection, so that it is listed only where it applies: the main view also shows +// content with nothing to select in it — a branch's commit log, the status dashboard — +// and a command with no description is left out of the keybindings menu. +// +// The static Description stays as it is: the cheatsheets are generated from that, and +// they document what a key does rather than when it applies. +func (self *MainViewController) diffSelectionDescription(describe func() string) func() string { + return func() string { + if !self.isDiffView() { + return "" + } + return describe() + } +} + +func (self *MainViewController) diffSelectionDescriptionText(description string) func() string { + return self.diffSelectionDescription(func() string { return description }) +} + +// diffSelectionDisabledReason disables the commands that act on the selection while +// there is none to act on: a diff view whose diff holds nothing selectable (a binary +// file, an empty commit) or which is showing a placeholder message. +func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledReason { + if !self.context.GetView().Highlight { + return &types.DisabledReason{Text: self.c.Tr.NothingToSelectInDiff} } return nil } -func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error { - self.c.Context().Push(self.context, types.OnFocusOpts{ - ClickedWindowName: self.context.GetWindowName(), - ClickedViewLineIdx: opts.Y, +// discardSelectionDisabledReason disables discarding while there is nothing to discard, +// and where the panel beneath won't have it: taking lines out of a commit means +// rewriting it, which isn't always something we may do. +func (self *MainViewController) discardSelectionDisabledReason() *types.DisabledReason { + if reason := self.diffSelectionDisabledReason(); reason != nil { + return reason + } + if actions := self.focusedMainViewActions(); actions != nil { + return actions.DiscardSelectionDisabledReason(self.context) + } + return nil +} + +func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { + self.selectClickedDiffLine(opts.Y) + return nil +} + +func (self *MainViewController) editClickedLine(opts gocui.ViewMouseBindingOpts) error { + var flashGeneration uint64 + err := self.editDiffLine(opts.Y, func() { + self.lineFlashGeneration++ + flashGeneration = self.lineFlashGeneration + self.context.GetView().SetLineFlash(opts.Y) + self.c.GocuiGui().ForceFlushViewsContentOnly(self.c.GocuiGui().Views()) }) + if flashGeneration != 0 { + time.AfterFunc(editedLineFlashDuration, func() { + self.c.OnUIThreadContentOnlyBackground(func() error { + if self.lineFlashGeneration == flashGeneration { + self.context.GetView().ClearLineFlash() + } + return nil + }) + }) + } + return err +} + +func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error { + // Carry the select mode over from the pane we're leaving, so that clicking into + // the other pane keeps hunk mode even the first time we enter it — its own mode + // would otherwise still be the default single line until it had been focused at + // least once. selectClickedDiffLine then keeps or collapses that mode depending on + // where the click landed. + *self.context.DiffSelectState() = *self.otherContext.DiffSelectState() + self.c.Context().Push(self.context, types.OnFocusOpts{}) + self.selectClickedDiffLine(opts.Y) + return nil +} + +// onDragInFocusedView extends a range selection as the mouse is dragged after a +// click, anchored at the line the click landed on rather than wherever the click left +// the selection — a click can select a whole hunk, whose far end would otherwise +// become the anchor. Dragging turns hunk mode off: you get a plain range from the +// clicked line to the line under the cursor, which gocui has already moved here. +func (self *MainViewController) onDragInFocusedView(opts gocui.ViewMouseBindingOpts) error { + view := self.context.GetView() + if !self.isDiffView() || !view.Highlight { + return nil + } + sel := self.diffSelectState() + sel.Mode = types.DiffSelectModeRange + sel.RangeIsSticky = false + sel.UserEnabledHunkMode = false + view.SetRangeSelectStart(self.context.DragAnchorViewLine()) + + // A drag that reaches the edge of the view keeps going: mouse capture means the + // pointer can be dragged past the edge, and there is more diff down there than + // fits on screen. opts.Y is where the pointer is in the content, which the + // autoscroller wants relative to the viewport. + self.draggingWithMouse = true + originY, _ := self.context.GetViewTrait().ViewPortYBounds() + self.dragAutoscroller.Update(opts.Y - originY) + return nil +} + +func (self *MainViewController) onDragRelease(gocui.ViewMouseBindingOpts) error { + self.draggingWithMouse = false + self.dragAutoscroller.Cancel() + + // The drag moved the selection without going through showSelectionAtLine: gocui + // moves the cursor for it. Let the search catch up with where it ended. + self.context.GetView().SetNearestSearchPosition() + return nil +} + +// GetOnFocusLost stops an autoscroll that is still running when the view loses focus +// mid-drag, e.g. because a popup appeared, and gives up the mouse capture with it — +// otherwise the pointer would keep driving a view that no longer has focus. +func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) { + return func(types.OnFocusLostOpts) { + self.dragAutoscroller.Cancel() + if self.draggingWithMouse { + self.draggingWithMouse = false + self.c.GocuiGui().CancelMouseCapture() + } + // Where the focus has gone is already known here, so asking again keeps the + // patch marks over a move to the pane beside this one, and takes them away + // when the focus leaves the pair. + self.c.Helpers().DiffLine.RefreshInclusionGutter() + } +} + +// canDragAutoscroll reports whether the autoscroller should run: only while a drag is +// actually extending a range in a diff. Scrolling down also has to keep the lazily +// loaded content ahead of the scroll, or it would stop at the loaded edge. +func (self *MainViewController) canDragAutoscroll(direction int) bool { + if !self.draggingWithMouse || !self.isDiffView() { + return false + } + view := self.context.GetView() + if !view.Highlight || self.diffSelectState().Mode != types.DiffSelectModeRange { + return false + } + if direction > 0 { + self.c.ReadLinesToFillView(view) + } + return true +} + +// handleDragAutoscroll extends the selection to the line the pointer ends up over +// after the autoscroller has scrolled, leaving the range anchored where the drag +// started. It reports whether the autoscroll should carry on. +// +// The pointer is usually outside the view by now — that is what mouse capture is for — +// so the line it is over is clamped to the visible ones, leaving the selection's far +// end at the edge the scroll is moving towards. +func (self *MainViewController) handleDragAutoscroll(viewLine int) bool { + if !self.canDragAutoscroll(0) { + return false + } + view := self.context.GetView() + originY, viewportHeight := self.context.GetViewTrait().ViewPortYBounds() + target := lo.Clamp(viewLine, 0, max(0, view.ViewLinesHeight()-1)) + view.SetCursorY(lo.Clamp(target-originY, 0, max(0, viewportHeight-1))) + return true +} + +// selectClickedDiffLine sets the focused main view's selection from a click at the +// given view line. In hunk mode, clicking inside the selected block collapses it to +// that line; clicking a change line outside it keeps hunk mode and selects that block. +// A click on context, or any click outside hunk mode, selects just that line too. +func (self *MainViewController) selectClickedDiffLine(viewLine int) { + if !self.isDiffView() { + return + } + view := self.context.GetView() + // Remember where the click landed so that a drag that follows anchors its range + // there, even when this click selects a whole hunk. + self.context.SetDragAnchorViewLine(viewLine) + if self.diffSelectState().Mode == types.DiffSelectModeHunk { + if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok && + viewLine >= start && viewLine <= end { + self.context.ResetDiffSelectMode() + self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false) + return + } + if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) { + self.selectHunkAround(viewLine, false) + return + } + } + self.context.ResetDiffSelectMode() + self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false) +} + +func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) { + self.c.Helpers().DiffLine.SelectChangeBlock(self.context, changeViewLine, scrollIntoView) +} + +// navigate moves the focused main view to the row find locates from the current +// anchor — the selected line when a selection is showing, otherwise the top visible +// line. With a selection we move it there and scroll it into view, re-selecting the +// whole block in hunk mode; with none we stay in scroll mode, bringing the target +// to the top without selecting anything. +func (self *MainViewController) navigate(find findDiffRowFn, forward bool) { + v := self.context.GetView() + anchor := v.OriginY() + if v.Highlight { + anchor = v.SelectedLineIdx() + } + + if target, ok := find(v, anchor, forward); ok { + self.placeNavigationTarget(target) + return + } + if !forward { + // Everything above the anchor has loaded, so a backward target that wasn't + // found doesn't exist. + return + } + + // The diff loads lazily, so a target below the loaded portion isn't there to be + // found yet. Read the rest of it in and look again before concluding there is none. + manager := self.c.GetViewBufferManagerForView(v) + if manager == nil { + return + } + manager.ReadToEnd(func() { + self.c.OnUIThread(func() error { + if target, ok := find(v, anchor, forward); ok { + self.placeNavigationTarget(target) + } + return nil + }) + }) +} + +// findDiffRowFn locates a row of the rendered diff to navigate to, given the view, +// the anchor view line to start from, and the direction. +type findDiffRowFn func(view *gocui.View, anchorViewLine int, forward bool) (int, bool) + +func (self *MainViewController) nextChangeBlock() error { + self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, true) + return nil +} + +func (self *MainViewController) prevChangeBlock() error { + self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, false) + return nil +} + +func (self *MainViewController) nextFile() error { + self.navigate(self.c.Helpers().DiffLine.AdjacentFile, true) + return nil +} + +func (self *MainViewController) prevFile() error { + self.navigate(self.c.Helpers().DiffLine.AdjacentFile, false) + return nil +} + +func (self *MainViewController) placeNavigationTarget(target int) { + v := self.context.GetView() + if !v.Highlight { + v.SetOrigin(0, target) + return + } + // Jumping to another block or file moves the cursor without shift held, so a + // range that grows only while shift is held collapses rather than stretching all + // the way to the target. A sticky range stretches instead; this is the point of + // being sticky. + self.collapseNonStickyRange() + if self.diffSelectState().Mode == types.DiffSelectModeHunk { + self.selectHunkAround(target, true) + return + } + // Line mode leaves a single-line selection at the target; an active range extends + // to it, the anchor being untouched. + self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true) +} + +// moveCursor moves the selection cursor by delta view lines (negative = up), with the +// configured scroll-off margin, reading more content in first when moving down. The +// range anchor is left untouched, so this extends or contracts a range and just moves +// the selected line otherwise. +func (self *MainViewController) moveCursor(delta int) { + v := self.context.GetView() + if delta > 0 { + self.c.ReadLinesToFillView(v) + } + before := v.SelectedLineIdx() + after := lo.Clamp(before+delta, 0, v.ViewLinesHeight()-1) + if delta == -1 { + checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after) + } else if delta == 1 { + checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after) + } + self.c.Helpers().DiffLine.ShowSelectionAtLine(v, after, true) +} + +// collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line +// selection — what a plain (non-shift, non-hunk-step) move does before moving. A +// sticky range is kept, so the move extends it. +func (self *MainViewController) collapseForLineMove() { + sel := self.diffSelectState() + if sel.Mode == types.DiffSelectModeHunk { + sel.Mode = types.DiffSelectModeLine + self.context.GetView().CancelRangeSelect() + return + } + self.collapseNonStickyRange() +} + +// collapseNonStickyRange drops a range that only grows while shift is held back to a +// single line at the cursor. +func (self *MainViewController) collapseNonStickyRange() { + sel := self.diffSelectState() + if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky { + sel.Mode = types.DiffSelectModeLine + self.context.GetView().CancelRangeSelect() + } +} + +// adjustSelection moves the selection by delta view lines, for the plain up/down and +// page keys. In hunk mode a single-line step jumps to the adjacent block, while a +// larger page step drops out of hunk mode first. A non-sticky range collapses back to +// a single line on a plain move. With no selection — non-diff content — it scrolls. +func (self *MainViewController) adjustSelection(delta int) { + if !self.context.GetView().Highlight { + self.handleLineChange(delta) + return + } + if self.diffSelectState().Mode == types.DiffSelectModeHunk && (delta == 1 || delta == -1) { + self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0) + return + } + self.collapseForLineMove() + self.moveCursor(delta) +} + +// selectAbsoluteLine moves the selection to a specific view line — the top or bottom +// of the diff — dropping hunk mode and a non-sticky range like a plain move does. +func (self *MainViewController) selectAbsoluteLine(target int) { + self.collapseForLineMove() + self.c.Helpers().DiffLine.ShowSelectionAtLine(self.context.GetView(), target, true) +} + +// selectingRange reports whether a range selection is currently active: we're in +// range mode and either it's sticky or the anchor and cursor differ, i.e. a +// non-sticky range that has actually been extended. +func (self *MainViewController) selectingRange() bool { + if self.diffSelectState().Mode != types.DiffSelectModeRange { + return false + } + start, end := self.context.GetView().SelectedLineRange() + return self.diffSelectState().RangeIsSticky || start != end +} + +// toggleSelectHunk switches between selecting the change block around the cursor and +// a single line. +func (self *MainViewController) toggleSelectHunk() error { + v := self.context.GetView() + if !v.Highlight { + return nil + } + sel := self.diffSelectState() + if sel.Mode == types.DiffSelectModeHunk { + sel.Mode = types.DiffSelectModeLine + v.CancelRangeSelect() + } else { + sel.Mode = types.DiffSelectModeHunk + sel.UserEnabledHunkMode = true + self.selectHunkAround(v.SelectedLineIdx(), true) + } + return nil +} + +// toggleRangeSelect starts or cancels a sticky range selection, which the plain +// up/down keys extend. +func (self *MainViewController) toggleRangeSelect() error { + v := self.context.GetView() + if !v.Highlight { + return nil + } + sel := self.diffSelectState() + if self.selectingRange() { + sel.Mode = types.DiffSelectModeLine + sel.RangeIsSticky = false + v.CancelRangeSelect() + } else { + sel.Mode = types.DiffSelectModeRange + sel.RangeIsSticky = true + v.SetRangeSelectStart(v.SelectedLineIdx()) + } + return nil +} + +// extendRange grows a non-sticky range selection by one line in response to +// shift+up/down, starting one at the cursor if there isn't one yet. +func (self *MainViewController) extendRange(forward bool) error { + v := self.context.GetView() + if !v.Highlight { + return nil + } + sel := self.diffSelectState() + if !self.selectingRange() { + sel.Mode = types.DiffSelectModeRange + v.SetRangeSelectStart(v.SelectedLineIdx()) + } + sel.RangeIsSticky = false + if forward { + self.moveCursor(1) + } else { + self.moveCursor(-1) + } + return nil +} + +func (self *MainViewController) extendRangeUp() error { + return self.extendRange(false) +} + +func (self *MainViewController) extendRangeDown() error { + return self.extendRange(true) +} + +func (self *MainViewController) handleLineChange(delta int) { + v := self.context.GetView() + if delta < 0 { + v.ScrollUp(-delta) + } else { + v.ScrollDown(delta) + self.c.ReadLinesToFillView(v) + } +} + +func (self *MainViewController) handlePrevLine() error { + self.adjustSelection(-1) + return nil +} + +func (self *MainViewController) handleNextLine() error { + self.adjustSelection(1) + return nil +} + +func (self *MainViewController) handlePrevPage() error { + self.adjustSelection(-self.context.GetViewTrait().PageDelta()) + return nil +} + +func (self *MainViewController) handleNextPage() error { + self.adjustSelection(self.context.GetViewTrait().PageDelta()) + return nil +} + +func (self *MainViewController) handleGotoTop() error { + v := self.context.GetView() + if !v.Highlight { + self.handleLineChange(-v.ViewLinesHeight()) + return nil + } + self.selectAbsoluteLine(0) + return nil +} + +func (self *MainViewController) handleGotoBottom() error { + if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { + manager.ReadToEnd(func() { + self.c.OnUIThread(func() error { + v := self.context.GetView() + if !v.Highlight { + self.handleLineChange(v.ViewLinesHeight()) + return nil + } + self.selectAbsoluteLine(v.ViewLinesHeight() - 1) + return nil + }) + }) + } return nil } +func (self *MainViewController) editLine() error { + view := self.context.GetView() + if !view.Highlight { + return nil + } + return self.editDiffLine(view.SelectedLineIdx(), nil) +} + +func (self *MainViewController) editDiffLine(viewLine int, beforeEdit func()) error { + info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(self.context.GetView(), viewLine) + if !ok { + return nil + } + if beforeEdit != nil { + beforeEdit() + } + + // A file-header row points at the file as a whole rather than at a line in it, so + // it opens the file without jumping anywhere — as pressing edit on a file in a side + // panel does. + if info.Type == types.DiffLineFileHeader { + return self.c.Helpers().Files.EditFiles([]string{info.Path}) + } + + // The diff may be of an older commit, whose line numbers aren't the file's current + // ones, so they have to be carried forward before we can point an editor at them. + lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName()) + return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber) +} + func (self *MainViewController) openSearch() error { if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { manager.ReadToEnd(func() { diff --git a/pkg/gui/controllers/options_menu_action.go b/pkg/gui/controllers/options_menu_action.go index be2899632..2a2b786a3 100644 --- a/pkg/gui/controllers/options_menu_action.go +++ b/pkg/gui/controllers/options_menu_action.go @@ -27,7 +27,7 @@ func (self *OptionsMenuAction) Call() error { if binding.GetDisabledReason != nil { disabledReason = binding.GetDisabledReason() } - tooltip := binding.Tooltip + tooltip := binding.GetTooltip() if len(binding.Keys) > 1 { if tooltip != "" { tooltip += "\n\n" diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go deleted file mode 100644 index e1405463a..000000000 --- a/pkg/gui/controllers/patch_building_controller.go +++ /dev/null @@ -1,277 +0,0 @@ -package controllers - -import ( - "fmt" - - "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/samber/lo" -) - -type PatchBuildingController struct { - baseController - c *ControllerCommon -} - -var _ types.IController = &PatchBuildingController{} - -func NewPatchBuildingController( - c *ControllerCommon, -) *PatchBuildingController { - return &PatchBuildingController{ - baseController: baseController{}, - c: c, - } -} - -func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { - return []*types.Binding{ - { - Keys: opts.GetKeys(opts.Config.Universal.OpenFile), - Handler: self.OpenFile, - Description: self.c.Tr.OpenFile, - Tooltip: self.c.Tr.OpenFileTooltip, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.Edit), - Handler: self.EditFile, - Description: self.c.Tr.EditFile, - Tooltip: self.c.Tr.EditFileTooltip, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.Select), - Handler: self.ToggleSelectionAndRefresh, - Description: self.c.Tr.ToggleSelectionForPatch, - DisplayOnScreen: true, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.Remove), - Handler: self.discardSelection, - GetDisabledReason: self.getDisabledReasonForDiscard, - Description: self.c.Tr.RemoveSelectionFromPatch, - Tooltip: self.c.Tr.RemoveSelectionFromPatchTooltip, - DisplayOnScreen: true, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.Return), - Handler: self.Escape, - Description: self.c.Tr.ExitCustomPatchBuilder, - DescriptionFunc: self.EscapeDescription, - DisplayOnScreen: true, - }, - } -} - -func (self *PatchBuildingController) Context() types.Context { - return self.c.Contexts().CustomPatchBuilder -} - -func (self *PatchBuildingController) context() types.IPatchExplorerContext { - return self.c.Contexts().CustomPatchBuilder -} - -func (self *PatchBuildingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { - return []*gocui.ViewMouseBinding{} -} - -func (self *PatchBuildingController) GetOnFocus() func(types.OnFocusOpts) { - return func(opts types.OnFocusOpts) { - // no need to change wrap on the secondary view because it can't be interacted with - self.c.Views().PatchBuilding.Wrap = self.c.UserConfig().Gui.WrapLinesInStagingView - - self.c.Helpers().PatchBuilding.RefreshPatchBuildingPanel(opts) - } -} - -func (self *PatchBuildingController) GetOnFocusLost() func(types.OnFocusLostOpts) { - return func(opts types.OnFocusLostOpts) { - self.context().SetState(nil) - - self.c.Views().PatchBuilding.Wrap = true - - if self.c.Git().Patch.PatchBuilder.IsEmpty() { - self.c.Git().Patch.PatchBuilder.Reset() - } - } -} - -func (self *PatchBuildingController) OpenFile() error { - self.context().GetMutex().Lock() - defer self.context().GetMutex().Unlock() - - path := self.c.Contexts().CommitFiles.GetSelectedPath() - - if path == "" { - return nil - } - - return self.c.Helpers().Files.OpenFile(path) -} - -func (self *PatchBuildingController) EditFile() error { - self.context().GetMutex().Lock() - defer self.context().GetMutex().Unlock() - - path := self.c.Contexts().CommitFiles.GetSelectedPath() - - if path == "" { - return nil - } - - lineNumber := self.context().GetState().CurrentLineNumber() - lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context().GetViewName()) - return self.c.Helpers().Files.EditFileAtLine(path, lineNumber) -} - -func (self *PatchBuildingController) ToggleSelectionAndRefresh() error { - if err := self.toggleSelection(); err != nil { - return err - } - - self.c.Refresh(types.RefreshOptions{ - Scope: []types.RefreshableView{types.PATCH_BUILDING, types.COMMIT_FILES}, - }) - return nil -} - -func (self *PatchBuildingController) toggleSelection() error { - self.context().GetMutex().Lock() - defer self.context().GetMutex().Unlock() - - file := self.c.Contexts().CommitFiles.GetSelectedFile() - if file == nil { - return nil - } - - state := self.context().GetState() - - // Get added/deleted lines in the selected patch range - lineIndicesToToggle := state.LineIndicesOfAddedOrDeletedLinesInSelectedPatchRange() - if len(lineIndicesToToggle) == 0 { - // Only context lines or header lines selected, so nothing to do - return nil - } - - includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath) - if err != nil { - return err - } - - toggleFunc := self.c.Git().Patch.PatchBuilder.AddFileLineRange - firstSelectedChangeLineIsStaged := lo.Contains(includedLineIndices, lineIndicesToToggle[0]) - if firstSelectedChangeLineIsStaged { - toggleFunc = self.c.Git().Patch.PatchBuilder.RemoveFileLineRange - } - - // add range of lines to those set for the file - if err := toggleFunc(file.Path, file.PreviousPath, lineIndicesToToggle); err != nil { - // might actually want to return an error here - self.c.Log.Error(err) - } - - if state.SelectingRange() { - state.SetLineSelectMode() - } - - state.SelectNextStageableLineOfSameIncludedState(self.context().GetIncludedLineIndices(), firstSelectedChangeLineIsStaged) - - return nil -} - -func (self *PatchBuildingController) getDisabledReasonForDiscard() *types.DisabledReason { - if !self.c.Git().Patch.PatchBuilder.CanRebase { - return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true} - } - if self.c.Git().Status.WorkingTreeState().Any() { - return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true} - } - if self.c.UserConfig().Git.DiffContextSize == 0 { - text := fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines, - self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) - return &types.DisabledReason{Text: text, ShowErrorInPanel: true} - } - return nil -} - -func (self *PatchBuildingController) discardSelection() error { - prompt := lo.Ternary(self.c.Git().Patch.PatchBuilder.IsEmpty(), - self.c.Tr.DiscardLinesFromCommitPrompt, - self.c.Tr.DiscardLinesFromCommitPromptWithReset) - - self.c.Confirm(types.ConfirmOpts{ - Title: self.c.Tr.DiscardLinesFromCommitTitle, - Prompt: prompt, - HandleConfirm: func() error { - return self.discardSelectionFromCommit() - }, - }) - - return nil -} - -func (self *PatchBuildingController) discardSelectionFromCommit() error { - // Reset the current patch if there is one. - if !self.c.Git().Patch.PatchBuilder.IsEmpty() { - self.c.Git().Patch.PatchBuilder.Reset() - } - - if err := self.toggleSelection(); err != nil { - return err - } - - if self.c.Git().Patch.PatchBuilder.IsEmpty() { - return nil - } - - commits := self.c.Model().Commits - commitIndex := self.getPatchCommitIndex() - return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) - err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex) - // Escape pops the patch-building context, so run it on the UI thread - // before the refresh below. - _ = self.c.GocuiGui().OnUIThreadAndWait(func() { - self.c.Helpers().PatchBuilding.Escape() - }) - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, types.RefreshOptions{}) - }) -} - -func (self *PatchBuildingController) getPatchCommitIndex() int { - for index, commit := range self.c.Model().Commits { - if commit.Hash() == self.c.Git().Patch.PatchBuilder.To { - return index - } - } - return -1 -} - -func (self *PatchBuildingController) Escape() error { - context := self.c.Contexts().CustomPatchBuilder - state := context.GetState() - - if state.SelectingRange() || state.SelectingHunkEnabledByUser() { - state.SetLineSelectMode() - self.c.PostRefreshUpdate(context) - return nil - } - - self.c.Helpers().PatchBuilding.Escape() - return nil -} - -func (self *PatchBuildingController) EscapeDescription() string { - context := self.c.Contexts().CustomPatchBuilder - if state := context.GetState(); state != nil { - if state.SelectingRange() { - return self.c.Tr.DismissRangeSelect - } - - if state.SelectingHunkEnabledByUser() { - return self.c.Tr.SelectLineByLine - } - } - - return self.c.Tr.ExitCustomPatchBuilder -} diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go deleted file mode 100644 index 70dfb8f4e..000000000 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ /dev/null @@ -1,416 +0,0 @@ -package controllers - -import ( - "strings" - - "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" - "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/samber/lo" -) - -type PatchExplorerControllerFactory struct { - c *ControllerCommon -} - -func NewPatchExplorerControllerFactory(c *ControllerCommon) *PatchExplorerControllerFactory { - return &PatchExplorerControllerFactory{ - c: c, - } -} - -func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController { - controller := &PatchExplorerController{ - baseController: baseController{}, - c: self.c, - context: context, - } - controller.dragAutoscroller = helpers.NewDragAutoscroller( - self.c.HelperCommon, - context, - controller.canDragAutoscroll, - controller.handleDragAutoscroll, - ) - return controller -} - -type PatchExplorerController struct { - baseController - c *ControllerCommon - - context types.IPatchExplorerContext - dragAutoscroller *helpers.DragAutoscroller - draggingWithMouse bool -} - -func (self *PatchExplorerController) Context() types.Context { - return self.context -} - -func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { - return []*types.Binding{ - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.PrevItem), - Handler: self.withRenderAndFocus(self.HandlePrevLine), - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.NextItem), - Handler: self.withRenderAndFocus(self.HandleNextLine), - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp), - Handler: self.withRenderAndFocus(self.HandlePrevLineRange), - Description: self.c.Tr.RangeSelectUp, - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown), - Handler: self.withRenderAndFocus(self.HandleNextLineRange), - Description: self.c.Tr.RangeSelectDown, - }, - { - Keys: opts.GetKeys(opts.Config.Main.PrevHunk), - Handler: self.withRenderAndFocus(self.HandlePrevHunk), - Description: self.c.Tr.PrevHunk, - }, - { - Keys: opts.GetKeys(opts.Config.Main.NextHunk), - Handler: self.withRenderAndFocus(self.HandleNextHunk), - Description: self.c.Tr.NextHunk, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect), - Handler: self.withRenderAndFocus(self.HandleToggleSelectRange), - Description: self.c.Tr.ToggleRangeSelect, - }, - { - Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk), - Handler: self.withRenderAndFocus(self.HandleToggleSelectHunk), - Description: self.c.Tr.ToggleSelectHunk, - DescriptionFunc: func() string { - if state := self.context.GetState(); state != nil && state.SelectingHunk() { - return self.c.Tr.SelectLineByLine - } - return self.c.Tr.SelectHunk - }, - Tooltip: self.c.Tr.ToggleSelectHunkTooltip, - DisplayOnScreen: true, - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.PrevPage), - Handler: self.withRenderAndFocus(self.HandlePrevPage), - Description: self.c.Tr.PrevPage, - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.NextPage), - Handler: self.withRenderAndFocus(self.HandleNextPage), - Description: self.c.Tr.NextPage, - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.GotoTop), - Handler: self.withRenderAndFocus(self.HandleGotoTop), - Description: self.c.Tr.GotoTop, - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), - Description: self.c.Tr.GotoBottom, - Handler: self.withRenderAndFocus(self.HandleGotoBottom), - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), - Handler: self.withRenderAndFocus(self.HandleScrollLeft), - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.ScrollRight), - Handler: self.withRenderAndFocus(self.HandleScrollRight), - }, - { - Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), - Handler: self.withLock(self.CopySelectedToClipboard), - Description: self.c.Tr.CopySelectedTextToClipboard, - }, - } -} - -func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { - return []*gocui.ViewMouseBinding{ - { - ViewName: self.context.GetViewName(), - Key: gocui.MouseLeft, - Handler: func(opts gocui.ViewMouseBindingOpts) error { - if self.isFocused() { - return self.withRenderAndFocus(self.HandleMouseDown)() - } - - self.c.Context().Push(self.context, types.OnFocusOpts{ - ClickedWindowName: self.context.GetWindowName(), - ClickedViewLineIdx: opts.Y, - }) - - return nil - }, - }, - { - ViewName: self.context.GetViewName(), - Key: gocui.MouseLeft, - Modifier: gocui.ModMotion, - Handler: self.handleMouseDrag, - }, - { - ViewName: self.context.GetViewName(), - Key: gocui.MouseRelease, - Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() }, - }, - } -} - -func (self *PatchExplorerController) handleMouseDrag(opts gocui.ViewMouseBindingOpts) error { - if err := self.withLock(func() error { - self.context.GetState().DragSelectLine(opts.Y) - self.renderDragSelection() - return nil - })(); err != nil { - return err - } - - self.draggingWithMouse = true - originY, _ := self.context.GetViewTrait().ViewPortYBounds() - self.dragAutoscroller.Update(opts.Y - originY) - return nil -} - -func (self *PatchExplorerController) canDragAutoscroll(int) bool { - state := self.context.GetState() - return state != nil && state.SelectingRange() -} - -func (self *PatchExplorerController) handleDragAutoscroll(viewIndex int) bool { - if !self.canDragAutoscroll(0) { - return false - } - - if err := self.withLock(func() error { - self.context.GetState().DragSelectLine(viewIndex) - self.renderDragSelection() - return nil - })(); err != nil { - return false - } - return true -} - -func (self *PatchExplorerController) renderDragSelection() { - view := self.context.GetView() - state := self.context.GetState() - originY := view.OriginY() - startIndex, _ := state.SelectedViewRange() - view.SetRangeSelectStart(startIndex) - view.SetCursorY(state.GetSelectedViewLineIdx() - originY) - self.context.Render() -} - -func (self *PatchExplorerController) handleDragRelease() error { - self.draggingWithMouse = false - self.dragAutoscroller.Cancel() - return nil -} - -func (self *PatchExplorerController) GetOnFocusLost() func(types.OnFocusLostOpts) { - return func(types.OnFocusLostOpts) { - self.dragAutoscroller.Cancel() - if self.draggingWithMouse { - self.draggingWithMouse = false - self.c.GocuiGui().CancelMouseCapture() - } - } -} - -func (self *PatchExplorerController) HandlePrevLine() error { - before := self.context.GetState().GetSelectedViewLineIdx() - self.context.GetState().CycleSelection(false) - after := self.context.GetState().GetSelectedViewLineIdx() - - if self.context.GetState().SelectingLine() { - checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after) - } - - return nil -} - -func (self *PatchExplorerController) HandleNextLine() error { - before := self.context.GetState().GetSelectedViewLineIdx() - self.context.GetState().CycleSelection(true) - after := self.context.GetState().GetSelectedViewLineIdx() - - if self.context.GetState().SelectingLine() { - checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after) - } - - return nil -} - -func (self *PatchExplorerController) HandlePrevLineRange() error { - s := self.context.GetState() - - s.CycleRange(false) - - return nil -} - -func (self *PatchExplorerController) HandleNextLineRange() error { - s := self.context.GetState() - - s.CycleRange(true) - - return nil -} - -func (self *PatchExplorerController) HandlePrevHunk() error { - self.context.GetState().SelectPreviousHunk() - - return nil -} - -func (self *PatchExplorerController) HandleNextHunk() error { - self.context.GetState().SelectNextHunk() - - return nil -} - -func (self *PatchExplorerController) HandleToggleSelectRange() error { - self.context.GetState().ToggleStickySelectRange() - - return nil -} - -func (self *PatchExplorerController) HandleToggleSelectHunk() error { - self.context.GetState().ToggleSelectHunk() - - return nil -} - -func (self *PatchExplorerController) HandleScrollLeft() error { - self.context.GetViewTrait().ScrollLeft() - - return nil -} - -func (self *PatchExplorerController) HandleScrollRight() error { - self.context.GetViewTrait().ScrollRight() - - return nil -} - -func (self *PatchExplorerController) HandlePrevPage() error { - self.context.GetState().AdjustSelectedLineIdx(-self.context.GetViewTrait().PageDelta()) - - return nil -} - -func (self *PatchExplorerController) HandleNextPage() error { - self.context.GetState().AdjustSelectedLineIdx(self.context.GetViewTrait().PageDelta()) - - return nil -} - -func (self *PatchExplorerController) HandleGotoTop() error { - self.context.GetState().SelectTop() - - return nil -} - -func (self *PatchExplorerController) HandleGotoBottom() error { - self.context.GetState().SelectBottom() - - return nil -} - -func (self *PatchExplorerController) HandleMouseDown() error { - self.context.GetState().SelectNewLineForRange(self.context.GetViewTrait().SelectedLineIdx()) - - return nil -} - -func (self *PatchExplorerController) CopySelectedToClipboard() error { - selected := self.context.GetState().PlainRenderSelected() - - self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard) - if err := self.c.OS().CopyToClipboard(dropDiffPrefix(selected)); err != nil { - return err - } - - return nil -} - -// Removes '+' or '-' from the beginning of each line in the diff string, except -// when both '+' and '-' lines are present, or diff header lines, in which case -// the diff is returned unchanged. This is useful for copying parts of diffs to -// the clipboard in order to paste them into code. -func dropDiffPrefix(diff string) string { - lines := strings.Split(strings.TrimRight(diff, "\n"), "\n") - - const ( - PLUS int = iota - MINUS - CONTEXT - OTHER - ) - - linesByType := lo.GroupBy(lines, func(line string) int { - switch { - case strings.HasPrefix(line, "+"): - return PLUS - case strings.HasPrefix(line, "-"): - return MINUS - case strings.HasPrefix(line, " "): - return CONTEXT - } - return OTHER - }) - - hasLinesOfType := func(lineType int) bool { return len(linesByType[lineType]) > 0 } - - keepPrefix := hasLinesOfType(OTHER) || (hasLinesOfType(PLUS) && hasLinesOfType(MINUS)) - if keepPrefix { - return diff - } - - return strings.Join(lo.Map(lines, func(line string, _ int) string { return line[1:] + "\n" }), "") -} - -func (self *PatchExplorerController) isFocused() bool { - return self.c.Context().Current().GetKey() == self.context.GetKey() -} - -func (self *PatchExplorerController) withRenderAndFocus(f func() error) func() error { - return self.withLock(func() error { - if err := f(); err != nil { - return err - } - - self.context.RenderAndFocus() - return nil - }) -} - -func (self *PatchExplorerController) withLock(f func() error) func() error { - return func() error { - self.context.GetMutex().Lock() - defer self.context.GetMutex().Unlock() - - if self.context.GetState() == nil { - return nil - } - - return f() - } -} diff --git a/pkg/gui/controllers/reflog_commits_controller.go b/pkg/gui/controllers/reflog_commits_controller.go index 2d0751a0b..b5dac1cdd 100644 --- a/pkg/gui/controllers/reflog_commits_controller.go +++ b/pkg/gui/controllers/reflog_commits_controller.go @@ -10,6 +10,9 @@ type ReflogCommitsController struct { baseController *ListControllerTrait[*models.Commit] c *ControllerCommon + + // what this panel offers on the diff it shows in the focused main view + diffActions *CommitDiffActions } var _ types.IController = &ReflogCommitsController{} @@ -17,7 +20,7 @@ var _ types.IController = &ReflogCommitsController{} func NewReflogCommitsController( c *ControllerCommon, ) *ReflogCommitsController { - return &ReflogCommitsController{ + controller := &ReflogCommitsController{ baseController: baseController{}, ListControllerTrait: NewListControllerTrait( c, @@ -27,6 +30,19 @@ func NewReflogCommitsController( ), c: c, } + controller.diffActions = NewCommitDiffActions(c, c.Contexts().ReflogCommits, controller.diffTarget) + return controller +} + +// diffTarget is the reflog entry the panel has selected, whose diff its main view +// shows. A reflog entry is never a commit of the checked-out branch as far as we are +// concerned, so nothing here may be rewritten. +func (self *ReflogCommitsController) diffTarget() *commitDiffTarget { + commit := self.context().GetSelected() + if commit == nil { + return nil + } + return &commitDiffTarget{from: commit.ParentRefName(), to: commit.RefName()} } func (self *ReflogCommitsController) Context() types.Context { @@ -37,6 +53,10 @@ func (self *ReflogCommitsController) context() *context.ReflogCommitsContext { return self.c.Contexts().ReflogCommits } +func (self *ReflogCommitsController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self.diffActions +} + func (self *ReflogCommitsController) GetOnRenderToMain() func() { return func() { self.c.Helpers().Diff.WithDiffModeCheck(func() { @@ -45,9 +65,10 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() { if commit == nil { task = types.NewRenderStringTask("No reflog history") } else { - cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit)) + mode := self.c.Helpers().DiffLine.MainViewDiffMode() + cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit), mode) - task = types.NewRunPtyTask(cmdObj.GetCmd()) + task = types.NewMainViewDiffTask(cmdObj.GetCmd(), mode) } self.c.RenderToMainViews(types.RefreshMainOpts{ @@ -56,6 +77,7 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() { Title: "Reflog Entry", Task: task, }, + Secondary: secondaryPatchPanelUpdateOpts(self.c), }) }) } diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go deleted file mode 100644 index 505a07fc4..000000000 --- a/pkg/gui/controllers/staging_controller.go +++ /dev/null @@ -1,358 +0,0 @@ -package controllers - -import ( - "fmt" - "strings" - - "github.com/jesseduffield/lazygit/pkg/commands/git_commands" - "github.com/jesseduffield/lazygit/pkg/commands/patch" - "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/types" -) - -type StagingController struct { - baseController - c *ControllerCommon - - context types.IPatchExplorerContext - otherContext types.IPatchExplorerContext - - // if true, we're dealing with the secondary context i.e. dealing with staged file changes - staged bool -} - -var _ types.IController = &StagingController{} - -func NewStagingController( - c *ControllerCommon, - context types.IPatchExplorerContext, - otherContext types.IPatchExplorerContext, - staged bool, -) *StagingController { - return &StagingController{ - baseController: baseController{}, - c: c, - context: context, - otherContext: otherContext, - staged: staged, - } -} - -func (self *StagingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { - return []*types.Binding{ - { - Keys: opts.GetKeys(opts.Config.Universal.Select), - Handler: self.ToggleStaged, - Description: self.c.Tr.Stage, - Tooltip: self.c.Tr.StageSelectionTooltip, - DisplayOnScreen: true, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.Remove), - Handler: self.DiscardSelection, - Description: self.c.Tr.DiscardSelection, - Tooltip: self.c.Tr.DiscardSelectionTooltip, - DisplayOnScreen: true, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.OpenFile), - Handler: self.OpenFile, - Description: self.c.Tr.OpenFile, - Tooltip: self.c.Tr.OpenFileTooltip, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.Edit), - Handler: self.EditFile, - Description: self.c.Tr.EditFile, - Tooltip: self.c.Tr.EditFileTooltip, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.Return), - Handler: self.Escape, - Description: self.c.Tr.ReturnToFilesPanel, - DescriptionFunc: self.EscapeDescription, - DisplayOnScreen: true, - }, - { - Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), - Handler: self.TogglePanel, - Description: self.c.Tr.ToggleStagingView, - Tooltip: self.c.Tr.ToggleStagingViewTooltip, - DisplayOnScreen: true, - }, - { - Keys: opts.GetKeys(opts.Config.Main.EditSelectHunk), - Handler: self.EditHunkAndRefresh, - Description: self.c.Tr.EditHunk, - Tooltip: self.c.Tr.EditHunkTooltip, - }, - { - Keys: opts.GetKeys(opts.Config.Files.CommitChanges), - Handler: self.c.Helpers().WorkingTree.HandleCommitPress, - Description: self.c.Tr.Commit, - Tooltip: self.c.Tr.CommitTooltip, - }, - { - Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook), - Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress, - Description: self.c.Tr.CommitChangesWithoutHook, - }, - { - Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor), - Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress, - Description: self.c.Tr.CommitChangesWithEditor, - }, - { - Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup), - Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress, - Description: self.c.Tr.FindBaseCommitForFixup, - Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip, - }, - } -} - -func (self *StagingController) Context() types.Context { - return self.context -} - -func (self *StagingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { - return []*gocui.ViewMouseBinding{} -} - -func (self *StagingController) GetOnFocus() func(types.OnFocusOpts) { - return func(opts types.OnFocusOpts) { - wrap := self.c.UserConfig().Gui.WrapLinesInStagingView - self.c.Views().Staging.Wrap = wrap - self.c.Views().StagingSecondary.Wrap = wrap - - self.c.Helpers().Staging.RefreshStagingPanel(opts) - } -} - -func (self *StagingController) GetOnFocusLost() func(types.OnFocusLostOpts) { - return func(opts types.OnFocusLostOpts) { - self.context.SetState(nil) - - if opts.NewContextKey != self.otherContext.GetKey() { - self.c.Views().Staging.Wrap = true - self.c.Views().StagingSecondary.Wrap = true - } - } -} - -func (self *StagingController) OpenFile() error { - self.context.GetMutex().Lock() - defer self.context.GetMutex().Unlock() - - path := self.FilePath() - - if path == "" { - return nil - } - - return self.c.Helpers().Files.OpenFile(path) -} - -func (self *StagingController) EditFile() error { - self.context.GetMutex().Lock() - defer self.context.GetMutex().Unlock() - - path := self.FilePath() - - if path == "" { - return nil - } - - lineNumber := self.context.GetState().CurrentLineNumber() - lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context.GetViewName()) - return self.c.Helpers().Files.EditFileAtLine(path, lineNumber) -} - -func (self *StagingController) Escape() error { - if self.context.GetState().SelectingRange() || self.context.GetState().SelectingHunkEnabledByUser() { - self.context.GetState().SetLineSelectMode() - self.c.PostRefreshUpdate(self.context) - return nil - } - - self.c.Context().Pop() - return nil -} - -func (self *StagingController) EscapeDescription() string { - if state := self.context.GetState(); state != nil { - if state.SelectingRange() { - return self.c.Tr.DismissRangeSelect - } - - if state.SelectingHunkEnabledByUser() { - return self.c.Tr.SelectLineByLine - } - } - - return self.c.Tr.ReturnToFilesPanel -} - -func (self *StagingController) TogglePanel() error { - if self.otherContext.GetState() != nil { - self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) - } - - return nil -} - -func (self *StagingController) ToggleStaged() error { - if self.c.UserConfig().Git.DiffContextSize == 0 { - return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage, - self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) - } - - return self.applySelectionAndRefresh(self.staged) -} - -func (self *StagingController) DiscardSelection() error { - if self.c.UserConfig().Git.DiffContextSize == 0 { - return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard, - self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) - } - - return self.c.ConfirmIf(!self.staged && !self.c.UserConfig().Gui.SkipDiscardChangeWarning, - types.ConfirmOpts{ - Title: self.c.Tr.DiscardChangeTitle, - Prompt: self.c.Tr.DiscardChangePrompt, - HandleConfirm: func() error { return self.applySelectionAndRefresh(true) }, - }) -} - -func (self *StagingController) applySelectionAndRefresh(reverse bool) error { - if err := self.applySelection(reverse); err != nil { - return err - } - - // Block input until the refresh has landed: it rebuilds the staging panel - // and moves the selection to the next stageable change, and a quick second - // keypress must act on that, not on the stale pre-refresh diff. - self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) - return nil -} - -func (self *StagingController) applySelection(reverse bool) error { - self.context.GetMutex().Lock() - defer self.context.GetMutex().Unlock() - - state := self.context.GetState() - path := self.FilePath() - if path == "" { - return nil - } - - firstLineIdx, lastLineIdx := state.SelectedPatchRange() - patchToApply := patch. - Parse(state.GetDiff()). - Transform(patch.TransformOpts{ - Reverse: reverse, - IncludedLineIndices: patch.ExpandRange(firstLineIdx, lastLineIdx), - FileNameOverride: path, - }). - FormatPlain() - - if patchToApply == "" { - return nil - } - - // apply the patch then refresh this panel - // create a new temp file with the patch, then call git apply with that patch - self.c.LogAction(self.c.Tr.Actions.ApplyPatch) - err := self.c.Git().Patch.ApplyPatch( - patchToApply, - git_commands.ApplyPatchOpts{ - Reverse: reverse, - Cached: !reverse || self.staged, - }, - ) - if err != nil { - return err - } - - if state.SelectingRange() { - firstLine, _ := state.SelectedViewRange() - state.SelectLine(firstLine) - } - - return nil -} - -func (self *StagingController) EditHunkAndRefresh() error { - if err := self.editHunk(); err != nil { - return err - } - - // Block input like applySelectionAndRefresh does; the refresh rebuilds the - // staging panel from the post-edit diff. - self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) - return nil -} - -func (self *StagingController) editHunk() error { - self.context.GetMutex().Lock() - defer self.context.GetMutex().Unlock() - - state := self.context.GetState() - path := self.FilePath() - if path == "" { - return nil - } - - hunkStartIdx, hunkEndIdx := state.CurrentHunkBounds() - patchText := patch. - Parse(state.GetDiff()). - Transform(patch.TransformOpts{ - Reverse: self.staged, - IncludedLineIndices: patch.ExpandRange(hunkStartIdx, hunkEndIdx), - FileNameOverride: path, - }). - FormatPlain() - - patchFilepath, err := self.c.Git().Patch.SaveTemporaryPatch(patchText) - if err != nil { - return err - } - - lineOffset := 3 - lineIdxInHunk := state.GetSelectedPatchLineIdx() - hunkStartIdx - if err := self.c.Helpers().Files.EditFileAtLineAndWait(patchFilepath, lineIdxInHunk+lineOffset); err != nil { - return err - } - - editedPatchText, err := self.c.Git().File.Cat(patchFilepath) - if err != nil { - return err - } - - self.c.LogAction(self.c.Tr.Actions.ApplyPatch) - - lineCount := strings.Count(editedPatchText, "\n") + 1 - newPatchText := patch. - Parse(editedPatchText). - Transform(patch.TransformOpts{ - IncludedLineIndices: patch.ExpandRange(0, lineCount), - FileNameOverride: path, - }). - FormatPlain() - - if err := self.c.Git().Patch.ApplyPatch( - newPatchText, - git_commands.ApplyPatchOpts{ - Reverse: self.staged, - Cached: true, - }, - ); err != nil { - return err - } - - return nil -} - -func (self *StagingController) FilePath() string { - return self.c.Contexts().Files.GetSelectedPath() -} diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 7730587f4..4303d8851 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -92,10 +92,12 @@ func (self *StashController) GetOnRenderToMain() func() { if stashEntry == nil { task = types.NewRenderStringTask(self.c.Tr.NoStashEntries) } else { + mode := self.c.Helpers().DiffLine.MainViewDiffMode() prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description()) - task = types.NewRunPtyTaskWithPrefix( - self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(), + task = types.NewMainViewDiffTaskWithPrefix( + self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, mode).GetCmd(), prefix, + mode, ) } @@ -106,6 +108,7 @@ func (self *StashController) GetOnRenderToMain() func() { SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: task, }, + Secondary: secondaryPatchPanelUpdateOpts(self.c), }) }) } diff --git a/pkg/gui/controllers/sub_commits_controller.go b/pkg/gui/controllers/sub_commits_controller.go index d3d0c0b98..7f7a163c9 100644 --- a/pkg/gui/controllers/sub_commits_controller.go +++ b/pkg/gui/controllers/sub_commits_controller.go @@ -56,6 +56,7 @@ func (self *SubCommitsController) GetOnRenderToMain() func() { SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: task, }, + Secondary: secondaryPatchPanelUpdateOpts(self.c), }) }) } diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index 82ca509ca..c807e6352 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" @@ -123,7 +125,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() { if file == nil { task = types.NewRenderStringTask(prefix) } else { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names()) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, git_commands.DiffModeRendered, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names()) task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix) } } diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index afdf92c80..30db57355 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -4,6 +4,7 @@ import ( "path/filepath" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -24,17 +25,48 @@ type SwitchToDiffFilesController struct { baseController c *ControllerCommon context CanSwitchToDiffFiles + + // what this panel offers on the diff it shows in the focused main view + diffActions *CommitDiffActions } func NewSwitchToDiffFilesController( c *ControllerCommon, context CanSwitchToDiffFiles, ) *SwitchToDiffFilesController { - return &SwitchToDiffFilesController{ + controller := &SwitchToDiffFilesController{ baseController: baseController{}, c: c, context: context, } + controller.diffActions = NewCommitDiffActions(c, context, controller.diffTarget) + return controller +} + +// diffTarget is the commit — or stash entry, or range of commits — the panel has +// selected, whose whole diff its main view shows. +func (self *SwitchToDiffFilesController) diffTarget() *commitDiffTarget { + ref := self.context.GetSelectedRef() + if ref == nil { + return nil + } + refRange := self.context.GetSelectedRefRangeForDiffFiles() + from, to := context.FromAndToForDiff(ref, refRange) + return &commitDiffTarget{from: from, to: to, canRebase: self.canRebase(ref, refRange)} +} + +// canRebase reports whether the given selection is one lazygit may rewrite: the panel +// has to allow it in the first place, a range of commits can't be rewritten as one, +// and in diffing mode what the main view shows is a diff against another ref rather +// than the commit itself, unless that other ref is the selected commit. +func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refRange *types.RefRange) bool { + if !self.context.CanRebase() { + return false + } + if self.c.Modes().Diffing.Active() { + return self.c.Modes().Diffing.Ref == ref.RefName() + } + return refRange == nil } func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { @@ -64,21 +96,16 @@ func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error { } } +func (self *SwitchToDiffFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource { + return self.diffActions +} + func (self *SwitchToDiffFilesController) enter() error { ref := self.context.GetSelectedRef() refsRange := self.context.GetSelectedRefRangeForDiffFiles() commitFilesContext := self.c.Contexts().CommitFiles - canRebase := self.context.CanRebase() - if canRebase { - if self.c.Modes().Diffing.Active() { - if self.c.Modes().Diffing.Ref != ref.RefName() { - canRebase = false - } - } else if refsRange != nil { - canRebase = false - } - } + canRebase := self.canRebase(ref, refsRange) commitFilesContext.ClearFilter() commitFilesContext.ReInit(ref, refsRange) diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index 5606a0bab..2bb6870a9 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -1,7 +1,9 @@ package controllers import ( + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -61,21 +63,51 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context { } func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().Normal) + return self.focusMainView(self.c.Contexts().Normal, opts.Y) } func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().NormalSecondary) + return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y) } func (self *SwitchToFocusedMainViewController) handleFocusMainView() error { - return self.focusMainView(self.c.Contexts().Normal) + return focusMainView(self.c, self.context, -1) } -func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error { - if context, ok := mainViewContext.(types.ISearchableContext); ok { - context.ClearSearchString() +func focusMainView(c *ControllerCommon, source types.Context, clickedLineIdx int) error { + // Usually the main pane, but the content can be in the secondary one alone: a file + // with nothing but staged changes shows them there. + mainViewContext := c.Contexts().Normal + if c.State().GetRepoState().GetMainPanes() == types.SecondaryPaneOnly { + mainViewContext = c.Contexts().NormalSecondary } - self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) + return focusMainViewPane(c, source, mainViewContext, clickedLineIdx) +} + +func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error { + return focusMainViewPane(self.c, self.context, mainViewContext, clickedLineIdx) +} + +func focusMainViewPane(c *ControllerCommon, source types.Context, mainViewContext *context.MainContext, clickedLineIdx int) error { + mainViewContext.ClearSearchString() + c.Context().Push(mainViewContext, types.OnFocusOpts{}) + + if _, ok := source.(types.DiffMainViewContext); !ok { + return nil + } + + // The diff on screen was produced for reading, and the renderer that produced it may + // have laid it out in a way that says nothing about which line of which file each row + // is. Now that the user wants to act on it, it is re-rendered as git's own diff — the + // panel below decides that for itself, from the same question — and the selection + // goes on that instead of on rows we can't place. + if c.Helpers().DiffLine.MainViewDiffMode() == git_commands.DiffModeRaw { + c.Helpers().DiffLine.RenderFocusedMainViewAgain(mainViewContext.GetView(), source, func() { + c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx) + }) + return nil + } + + c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx) return nil } diff --git a/pkg/gui/controllers/toggle_whitespace_action.go b/pkg/gui/controllers/toggle_whitespace_action.go index 67bb59d86..8d25a99cb 100644 --- a/pkg/gui/controllers/toggle_whitespace_action.go +++ b/pkg/gui/controllers/toggle_whitespace_action.go @@ -1,32 +1,18 @@ package controllers -import ( - "errors" - - "github.com/jesseduffield/lazygit/pkg/gui/context" - "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/samber/lo" -) - type ToggleWhitespaceAction struct { c *ControllerCommon } func (self *ToggleWhitespaceAction) Call() error { - contextsThatDontSupportIgnoringWhitespace := []types.ContextKey{ - context.STAGING_MAIN_CONTEXT_KEY, - context.STAGING_SECONDARY_CONTEXT_KEY, - context.PATCH_BUILDING_MAIN_CONTEXT_KEY, - } - - if lo.Contains(contextsThatDontSupportIgnoringWhitespace, self.c.Context().Current().GetKey()) { - // Ignoring whitespace is not supported in these views. Let the user - // know that it's not going to work in case they try to turn it on. - return errors.New(self.c.Tr.IgnoreWhitespaceNotSupportedHere) - } - self.c.UserConfig().Git.IgnoreWhitespaceInDiffView = !self.c.UserConfig().Git.IgnoreWhitespaceInDiffView + // You toggle this to see whether what you are looking at is more than + // reindentation, so that is the thing to keep in front of you — even though + // ignoring whitespace, unlike the other ways of re-rendering a diff, can take + // the line away entirely along with the hunk or file it was in. + self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView()) + self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView()) self.c.Context().CurrentSide().HandleRenderToMain() return nil } diff --git a/pkg/gui/controllers/view_selection_controller.go b/pkg/gui/controllers/view_selection_controller.go deleted file mode 100644 index 1a97a9a30..000000000 --- a/pkg/gui/controllers/view_selection_controller.go +++ /dev/null @@ -1,100 +0,0 @@ -package controllers - -import ( - "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/types" -) - -type ViewSelectionControllerFactory struct { - c *ControllerCommon -} - -func NewViewSelectionControllerFactory(c *ControllerCommon) *ViewSelectionControllerFactory { - return &ViewSelectionControllerFactory{ - c: c, - } -} - -func (self *ViewSelectionControllerFactory) Create(context types.Context) types.IController { - return &ViewSelectionController{ - baseController: baseController{}, - c: self.c, - context: context, - } -} - -type ViewSelectionController struct { - baseController - c *ControllerCommon - - context types.Context -} - -func (self *ViewSelectionController) Context() types.Context { - return self.context -} - -func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { - return []*types.Binding{ - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom}, - } -} - -func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { - return []*gocui.ViewMouseBinding{} -} - -func (self *ViewSelectionController) handleLineChange(delta int) { - v := self.Context().GetView() - if delta < 0 { - v.ScrollUp(-delta) - } else { - v.ScrollDown(delta) - self.c.ReadLinesToFillView(v) - } -} - -func (self *ViewSelectionController) handlePrevLine() error { - self.handleLineChange(-1) - return nil -} - -func (self *ViewSelectionController) handleNextLine() error { - self.handleLineChange(1) - return nil -} - -func (self *ViewSelectionController) handlePrevPage() error { - self.handleLineChange(-self.context.GetViewTrait().PageDelta()) - return nil -} - -func (self *ViewSelectionController) handleNextPage() error { - self.handleLineChange(self.context.GetViewTrait().PageDelta()) - return nil -} - -func (self *ViewSelectionController) handleGotoTop() error { - v := self.Context().GetView() - self.handleLineChange(-v.ViewLinesHeight()) - return nil -} - -func (self *ViewSelectionController) handleGotoBottom() error { - if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { - manager.ReadToEnd(func() { - self.c.OnUIThread(func() error { - v := self.Context().GetView() - self.handleLineChange(v.ViewLinesHeight()) - return nil - }) - }) - } - - return nil -} diff --git a/pkg/gui/controllers/working_tree_diff_actions.go b/pkg/gui/controllers/working_tree_diff_actions.go new file mode 100644 index 000000000..c4ec68b3f --- /dev/null +++ b/pkg/gui/controllers/working_tree_diff_actions.go @@ -0,0 +1,394 @@ +package controllers + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/patch" + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" +) + +// WorkingTreeDiffActions implements what the files panel offers on the diff it renders +// into the focused main view: the diff itself, for the commands that need to read lines +// out of it rather than off the screen. +type WorkingTreeDiffActions struct { + c *ControllerCommon +} + +var _ types.FocusedMainViewActions = &WorkingTreeDiffActions{} + +func NewWorkingTreeDiffActions(c *ControllerCommon) *WorkingTreeDiffActions { + return &WorkingTreeDiffActions{c: c} +} + +func (self *WorkingTreeDiffActions) context() *context.WorkingTreeContext { + return self.c.Contexts().Files +} + +// PlainDiff hands out the working tree's diff for the given files, taken from the +// side of the index that the asking pane shows. +func (self *WorkingTreeDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string { + node := self.context().GetSelected() + if node == nil { + return "" + } + // An error means there is no diff to be had, which for our purposes is the same as + // an empty one. + diff, _ := self.c.Git().WorkingTree. + WorktreeFileDiffCmdObj(node, git_commands.DiffModePlain, self.showsStagedSide(pane), paths). + RunWithOutput() + return diff +} + +// showsStagedSide reports whether the given main pane is the one showing the staged +// side of a file's diff, which is always the lower one. +func (self *WorkingTreeDiffActions) showsStagedSide(pane types.DiffPaneContext) bool { + return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey() +} + +// PrimaryAction stages the selected diff lines, or takes them back out of the index +// when what is selected is the staged side of the diff. +func (self *WorkingTreeDiffActions) PrimaryAction(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error { + if self.c.UserConfig().Git.DiffContextSize == 0 { + return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage, + self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) + } + + infos, onStagedSide, ok := self.diffLineSelection(pane, firstLineIdx, lastLineIdx) + if !ok { + return nil + } + + // Either way the patch goes to the index: forwards from the unstaged side to stage + // it, backwards from the staged side to take it back out. + return self.applyDiffLineSelection(pane, firstLineIdx, infos, onStagedSide, + git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true}) +} + +// DiscardSelection takes the selected diff lines out of the working tree — or, on the +// staged side, out of the index, which is where "discard this" means "I don't want it +// staged". +func (self *WorkingTreeDiffActions) DiscardSelection(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error { + if self.c.UserConfig().Git.DiffContextSize == 0 { + return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard, + self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) + } + + infos, onStagedSide, ok := self.diffLineSelection(pane, firstLineIdx, lastLineIdx) + if !ok { + return nil + } + + // Either way the change is applied backwards; which side it is applied to decides + // how destructive that is. On the staged side it goes to the index; this is the + // same as unstaging, so nothing is lost. On the unstaged side it goes to the + // working tree, where the change is gone for good, so we ask first. + return self.c.ConfirmIf(!onStagedSide && !self.c.UserConfig().Gui.SkipDiscardChangeWarning, + types.ConfirmOpts{ + Title: self.c.Tr.DiscardChangeTitle, + Prompt: self.c.Tr.DiscardChangePrompt, + HandleConfirm: func() error { + return self.applyDiffLineSelection(pane, firstLineIdx, infos, onStagedSide, + git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide}) + }, + }) +} + +// DiscardSelectionDisabledReason is nil: a change of the working tree can always be +// thrown away, and one in the index always taken back out of it. +func (self *WorkingTreeDiffActions) DiscardSelectionDisabledReason(types.DiffPaneContext) *types.DisabledReason { + return nil +} + +// EditHunk opens the git hunk holding the selection in an editor, as a patch against +// the index, and applies whatever comes back. It is how you stage something the diff +// can't express — half of a changed line, or a change written differently from either +// side — since what the editor hands back is applied rather than matched against the +// file's own diff. +// +// The hunk is the git one, context and all, rather than lazygit's block of adjacent +// changes: an editable patch is one that still applies, and the context lines are what +// let git place it. +func (self *WorkingTreeDiffActions) EditHunk( + pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int, +) error { + infos, onStagedSide, ok := self.diffLineSelection(pane, firstLineIdx, lastLineIdx) + if !ok { + return nil + } + file := self.fileForDiffLinePath(infos[0].Path) + if file == nil { + return nil + } + + parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, onStagedSide)) + lineIndices := patch.ChangeLineIndicesForLines(parsedPatch, + []patch.LineIdentity{infos[0].PatchLineIdentity()}) + if len(lineIndices) == 0 { + return nil + } + + hunkIdx := parsedPatch.HunkContainingLine(lineIndices[0]) + hunkStartIdx := parsedPatch.HunkStartIdx(hunkIdx) + patchText := parsedPatch. + Transform(patch.TransformOpts{ + Reverse: onStagedSide, + IncludedLineIndices: patch.ExpandRange(hunkStartIdx, parsedPatch.HunkEndIdx(hunkIdx)), + FileNameOverride: file.GetPath(), + }). + FormatPlain() + + patchFilepath, err := self.c.Git().Patch.SaveTemporaryPatch(patchText) + if err != nil { + return err + } + + // The patch is written with a two-line header before its hunk, so the line the + // user was on sits that much further down the file they are about to edit. + const headerLineCount = 2 + if err := self.c.Helpers().Files.EditFileAtLineAndWait(patchFilepath, + lineIndices[0]-hunkStartIdx+headerLineCount+1); err != nil { + return err + } + + editedPatchText, err := self.c.Git().File.Cat(patchFilepath) + if err != nil { + return err + } + + self.c.LogAction(self.c.Tr.Actions.ApplyPatch) + + // Everything the editor left behind is taken, this being a patch the user wrote + // rather than a selection out of one of ours. + lineCount := strings.Count(editedPatchText, "\n") + 1 + newPatchText := patch. + Parse(editedPatchText). + Transform(patch.TransformOpts{ + IncludedLineIndices: patch.ExpandRange(0, lineCount), + FileNameOverride: file.GetPath(), + }). + FormatPlain() + + if err := self.c.Git().Patch.ApplyPatch(newPatchText, git_commands.ApplyPatchOpts{ + Reverse: onStagedSide, + Cached: true, + }); err != nil { + return err + } + + // Block input until the refresh has landed, as the staging commands do: the diff is + // about to be rebuilt from a file that no longer looks the way it did. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) + return nil +} + +// PatchInclusion is nil: a custom patch is built from a commit's diff, never from the +// working tree's, so no line of this diff is ever in one. +func (self *WorkingTreeDiffActions) PatchInclusion() func(types.DiffLineInfo) bool { + return nil +} + +// diffLineSelection resolves what the user has selected in a pane of the focused main +// view to the change lines to act on, and reports whether they are the staged side of +// the diff — which is a question about the pane, so it is the same for every file of a +// directory's diff. ok is false when the selection holds no change line, in which case +// there is nothing to act on. +func (self *WorkingTreeDiffActions) diffLineSelection( + pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int, +) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) { + infos = self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx) + if len(infos) == 0 { + return nil, false, false + } + return infos, self.showsStagedSide(pane), true +} + +// applyDiffLineSelection applies the selected change lines, a patch per file, and +// re-renders what that changed. onStagedSide says which of the file's two diffs the +// lines were selected in and so are to be found in; opts says how to apply them. +// firstLineIdx is where the selection started, which is where the work carries on from +// once the diff has changed under it. +func (self *WorkingTreeDiffActions) applyDiffLineSelection( + pane types.DiffPaneContext, firstLineIdx int, + infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts, +) error { + self.c.LogAction(self.c.Tr.Actions.ApplyPatch) + + // A directory's diff spans several files, and a patch is of one file, so the + // selected lines are grouped by the file they belong to and applied file by file. + infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path }) + acted := set.New[string]() + actedSideRemains := false + for path, fileInfos := range infosByFile { + file := self.fileForDiffLinePath(path) + if file == nil { + continue + } + changesLeft, err := self.applyDiffLines(file, fileInfos, onStagedSide, opts) + if err != nil { + return err + } + acted.Add(file.GetPath()) + actedSideRemains = actedSideRemains || changesLeft + } + if !actedSideRemains { + actedSideRemains = self.anyFileHasChangesOnSide(acted, onStagedSide) + } + + // Whether the other side has anything decides which pane the work carries on in. + // If the lines were staged, they are in the index now, so that side has them. If + // they were discarded, the other side was not touched, so the model still + // describes it correctly. + otherSideHasChanges := opts.Cached || self.anyFileHasChangesOnSide(set.New[string](), !onStagedSide) + + // The refresh below queues the re-render of the diff we just changed; this rides it, + // so that the selection ends up on the change that took the place of the one acted + // on rather than at a position that means nothing any more — and in the pane the + // work carries on in, which is not always the one it was in. + self.revealSelectionInPaneItLandsIn(pane, firstLineIdx, actedSideRemains, otherSideHasChanges) + + // Block input until the refresh has landed, so that a quick second keypress acts on + // the diff as it now is rather than on the one we just changed. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) + return nil +} + +// fileForDiffLinePath maps the absolute path a diff line carries to the working tree +// file it belongs to, or nil for a path that is no file of this repo's working tree. +func (self *WorkingTreeDiffActions) fileForDiffLinePath(path string) *models.File { + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path) + if err != nil { + return nil + } + return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath)) +} + +// applyDiffLines applies the given change lines of one file — a line, a hunk, a range — +// as a patch built from that file's own diff: +// +// - stage: read the unstaged diff, apply it to the index +// - unstage: read the staged diff, apply it to the index backwards +// +// sourceCached names the diff the lines were selected in, which is where they are found +// again; opts says how to apply what is built from them. The two are independent — a +// discard reads one side and reverses it — so they are passed separately. +// +// Each selected line is looked for by where it sits in the file. This tells the two +// halves of a modified line apart: the deletion and the addition replacing it share a +// position in the new file and differ only in being a deletion. Context lines are not +// selected: a patch of the lines you picked keeps whatever context it needs around +// them by itself. +// +// It reports whether the diff it read holds changes the selection didn't cover. The +// caller uses this to tell whether the side acted on still has anything of this file +// in it once we are done. +func (self *WorkingTreeDiffActions) applyDiffLines( + file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts, +) (bool, error) { + parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, sourceCached)) + + patchLineIndices := patch.ChangeLineIndicesForLines(parsedPatch, + lo.Map(infos, func(info types.DiffLineInfo, _ int) patch.LineIdentity { + return info.PatchLineIdentity() + })) + + changesLeft := len(patchLineIndices) < changeLineCount(parsedPatch) + + // Acting on every change of a file is acting on the file itself, and saying so is + // not the same as applying its diff. The diff of a deleted file is its content + // going away, and putting that into the index line by line leaves an empty file + // there rather than the deletion; the diff of an added one is its whole content, + // and taking that back out leaves an empty file in the index rather than an + // untracked one. + if !changesLeft && opts.Cached { + if opts.Reverse { + return false, self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked) + } + return false, self.c.Git().WorkingTree.StageFile(file.GetPath()) + } + + patchToApply := parsedPatch. + Transform(patch.TransformOpts{ + Reverse: opts.Reverse, + IncludedLineIndices: patchLineIndices, + FileNameOverride: file.GetPath(), + }). + FormatPlain() + if patchToApply == "" { + return changesLeft, nil + } + + return changesLeft, self.c.Git().Patch.ApplyPatch(patchToApply, opts) +} + +// changeLineCount returns how many of a patch's lines are changes rather than context +// or header. A selection of the whole diff covers exactly that many. +func changeLineCount(p *patch.Patch) int { + return lo.CountBy(p.Lines(), func(line *patch.PatchLine) bool { + return line.IsAddition() || line.IsDeletion() + }) +} + +// revealSelectionInPaneItLandsIn arranges for the selection to carry on where the work +// does, which is not always the pane it was in. +// +// Each side of the diff has a pane of its own, so acting on one usually leaves +// everything where it is. But a pane is only shown while its side has something in it: +// staging the last unstaged change takes the upper pane away, and unstaging the last +// staged one takes the lower one away. The refresh moves the focus into whichever pane +// is left, and this puts the selection there to meet it — on the lines just acted on, +// which are in that pane now, unless they were discarded rather than moved, in which +// case on what is left of the file. +func (self *WorkingTreeDiffActions) revealSelectionInPaneItLandsIn( + pane types.DiffPaneContext, firstLineIdx int, actedSideRemains bool, otherSideHasChanges bool, +) { + target := pane + if !actedSideRemains && otherSideHasChanges { + target = self.otherPane(pane) + } + + // Hold input back until the selection is on the change the work carries on from. The + // refresh holds it until the model is up to date, but the diff is re-rendered after + // that, and until it has been the selection is still on lines that aren't there any + // more — so a key pressed meanwhile would act on nothing. + self.c.GocuiGui().BeginBlockingEvents() + self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, target, firstLineIdx, 0, + self.c.GocuiGui().EndBlockingEvents) +} + +// otherPane returns the main pane that isn't the given one. +func (self *WorkingTreeDiffActions) otherPane(pane types.DiffPaneContext) types.DiffPaneContext { + if pane.GetKey() == self.c.Contexts().Normal.GetKey() { + return self.c.Contexts().NormalSecondary + } + return self.c.Contexts().Normal +} + +// anyFileHasChangesOnSide reports whether any file under the selected node, other than +// the ones named by except, has changes on the given side of the index, as the model +// has them. The model is right about any file the action didn't touch; the ones it did +// touch report for themselves, their entry not being right until the refresh lands. +func (self *WorkingTreeDiffActions) anyFileHasChangesOnSide(except *set.Set[string], staged bool) bool { + node := self.context().GetSelected() + if node == nil { + return false + } + + found := false + _ = node.ForEachFile(func(file *models.File) error { + if except.Includes(file.GetPath()) { + return nil + } + if (staged && file.HasStagedChanges) || (!staged && file.HasUnstagedChanges) { + found = true + } + return nil + }) + return found +} diff --git a/pkg/gui/global_handlers.go b/pkg/gui/global_handlers.go index a5e59a84e..b85330f30 100644 --- a/pkg/gui/global_handlers.go +++ b/pkg/gui/global_handlers.go @@ -22,12 +22,7 @@ func (gui *Gui) scrollDownView(view *gocui.View) { } func (gui *Gui) scrollUpMain() error { - var view *gocui.View - if gui.c.Context().Current().GetWindowName() == "secondary" { - view = gui.secondaryView() - } else { - view = gui.mainView() - } + view := gui.mainSectionView() if view.Name() == "mergeConflicts" { // although we have this same logic in the controller, this method can be invoked @@ -43,12 +38,7 @@ func (gui *Gui) scrollUpMain() error { } func (gui *Gui) scrollDownMain() error { - var view *gocui.View - if gui.c.Context().Current().GetWindowName() == "secondary" { - view = gui.secondaryView() - } else { - view = gui.mainView() - } + view := gui.mainSectionView() if view.Name() == "mergeConflicts" { gui.State.Contexts.MergeConflicts.SetUserScrolling(true) @@ -59,6 +49,17 @@ func (gui *Gui) scrollDownMain() error { return nil } +// mainSectionView returns the view that the keys for scrolling the main section act +// on: the pane the focus is in when it is in one of them, and otherwise the pane the +// section is showing — which is the lower one whenever it has the section to itself. +func (gui *Gui) mainSectionView() *gocui.View { + if gui.c.Context().Current().GetWindowName() == "secondary" || + gui.State.MainPanes == types.SecondaryPaneOnly { + return gui.secondaryView() + } + return gui.mainView() +} + func (gui *Gui) mainView() *gocui.View { viewName := gui.helpers.Window.GetViewNameForWindow("main") view, _ := gui.g.View(viewName) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 801fe14d3..8b67284b1 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -101,6 +101,10 @@ type Gui struct { // this tells us whether our views have been initially set up ViewsSetup bool + // the label for the key that focuses the main view, worn by whichever of the two + // main panes that key focuses (see showFocusMainViewJumpLabelOn) + focusMainViewJumpLabel string + Views types.Views // Log of the commands/actions logged in the Command Log panel. @@ -231,7 +235,7 @@ type GuiRepoState struct { Model *types.Model Modes *types.Modes - SplitMainPanel bool + MainPanes types.MainPanes SearchState *types.SearchState // Lets us not load everything at once. Written and read from refresh @@ -321,12 +325,12 @@ func (self *GuiRepoState) GetSearchState() *types.SearchState { return self.SearchState } -func (self *GuiRepoState) SetSplitMainPanel(value bool) { - self.SplitMainPanel = value +func (self *GuiRepoState) SetMainPanes(value types.MainPanes) { + self.MainPanes = value } -func (self *GuiRepoState) GetSplitMainPanel() bool { - return self.SplitMainPanel +func (self *GuiRepoState) GetMainPanes() types.MainPanes { + return self.MainPanes } func (gui *Gui) onSwitchToNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error { diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index d693fd77f..d467a0834 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -175,8 +175,6 @@ func (self *guiCommon) RenderToMainViews(opts types.RefreshMainOpts) { func (self *guiCommon) MainViewPairs() types.MainViewPairs { return types.MainViewPairs{ Normal: self.gui.normalMainContextPair(), - Staging: self.gui.stagingMainContextPair(), - PatchBuilding: self.gui.patchBuildingMainContextPair(), MergeConflicts: self.gui.mergingMainContextPair(), } } @@ -185,6 +183,10 @@ func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.View return self.gui.getViewBufferManagerForView(view) } +func (self *guiCommon) GetOrCreateViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager { + return self.gui.getManager(view) +} + func (self *guiCommon) ReadLinesToFillView(view *gocui.View) { self.gui.readLinesToFillView(view) } diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go index bc4a4219e..1ee5a8e34 100644 --- a/pkg/gui/main_panels.go +++ b/pkg/gui/main_panels.go @@ -1,9 +1,12 @@ package gui import ( + "strings" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/tasks" ) func (gui *Gui) runTaskForView(view *gocui.View, task types.UpdateTask) error { @@ -77,20 +80,6 @@ func (gui *Gui) normalMainContextPair() types.MainContextPair { ) } -func (gui *Gui) stagingMainContextPair() types.MainContextPair { - return types.NewMainContextPair( - gui.State.Contexts.Staging, - gui.State.Contexts.StagingSecondary, - ) -} - -func (gui *Gui) patchBuildingMainContextPair() types.MainContextPair { - return types.NewMainContextPair( - gui.State.Contexts.CustomPatchBuilder, - gui.State.Contexts.CustomPatchBuilderSecondary, - ) -} - func (gui *Gui) mergingMainContextPair() types.MainContextPair { return types.NewMainContextPair( gui.State.Contexts.MergeConflicts, @@ -101,23 +90,32 @@ func (gui *Gui) mergingMainContextPair() types.MainContextPair { func (gui *Gui) allMainContextPairs() []types.MainContextPair { return []types.MainContextPair{ gui.normalMainContextPair(), - gui.stagingMainContextPair(), - gui.patchBuildingMainContextPair(), gui.mergingMainContextPair(), } } func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { + panes := mainPanesFor(opts) + + // Before the render is triggered, so that the pane the focus moves into can be + // told where to put its selection as it renders. + gui.followFocusIntoWorkablePane(opts) + gui.keepDiffSelectionAcrossACommitRewrite(opts) + gui.moveMainContextPairToTop(opts.Pair) + gui.handOverMainSection(opts.Pair, panes) + if opts.Main != nil { gui.RefreshMainView(opts.Main, opts.Pair.Main) + } else { + gui.clearMainView(opts.Pair.Main) } if opts.Secondary != nil { gui.RefreshMainView(opts.Secondary, opts.Pair.Secondary) } else if opts.Pair.Secondary != nil { - opts.Pair.Secondary.GetView().Clear() + gui.clearMainView(opts.Pair.Secondary) } // Reset the scroll positions of all the other main views. We do this after @@ -134,11 +132,375 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { } } - gui.splitMainPanel(opts.Secondary != nil) + gui.setMainPanes(panes) } -func (gui *Gui) splitMainPanel(splitMainPanel bool) { - gui.State.SplitMainPanel = splitMainPanel +// handOverMainSection carries the content of the main section from the pane that has +// been showing it on its own to the pane about to, when a render moves the section's +// content from one to the other — a file's changes going from unstaged to staged, say. +// +// The section is one region of the screen to the user, so a change of which pane holds +// it has to look like that region re-rendering rather than blanking and filling in +// again: the incoming pane shows what the outgoing one was showing, where it was +// showing it, until its own render has read enough to be swapped in. It renders from +// the top when it does, the content it took over not being its own (see +// clearMainView). +func (gui *Gui) handOverMainSection(pair types.MainContextPair, panes types.MainPanes) { + // The lower pane is always the same view, being the only one a render can leave + // holding the section on its own; the upper one is whichever view of the main + // window this render is for, which moveMainContextPairToTop has just given a copy + // of what that window was showing. + upper, lower := pair.Main.GetView(), gui.Views.Secondary + + var from, to *gocui.View + switch { + case gui.State.MainPanes == types.MainPaneOnly && panes == types.SecondaryPaneOnly: + from, to = upper, lower + case gui.State.MainPanes == types.SecondaryPaneOnly && panes == types.MainPaneOnly: + from, to = lower, upper + default: + return + } + + gui.g.CopyContent(from, to) +} + +// mainPanesFor says which panes the given render occupies: the one it has content for, +// or both when it has content for both. +func mainPanesFor(opts types.RefreshMainOpts) types.MainPanes { + switch { + case opts.Secondary == nil: + return types.MainPaneOnly + case opts.Main == nil: + return types.SecondaryPaneOnly + default: + return types.BothMainPanes + } +} + +// followFocusIntoWorkablePane moves the focus out of a main pane that the render about +// to happen leaves nothing to work on, and into the one it does. +// +// Each side of a file's diff has a pane of its own, and a pane holds something only +// while its side of the file does. So anything that empties the side the focus is on +// leaves that pane with nothing: staging the last unstaged change, committing what was +// staged, or either of those happening outside lazygit and arriving with a refresh. +// Usually the pane goes away with its content; configured to always split the diff it +// stays, empty. Either way the focus has nothing left to act on where it is. +// +// The pane moved into gets its selection once the render has finished and there is +// something to put one on, and shows none until then, so that the selection it was +// left with the last time it was used doesn't appear for a frame. A pane that has +// already been told where to put its selection — by the action that caused all this — +// keeps what it was told. +func (gui *Gui) followFocusIntoWorkablePane(opts types.RefreshMainOpts) { + // The focused main view's two panes only: the staging and patch-building views + // arrange theirs for themselves, and the merge-conflicts view has just the one. + if opts.Pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY { + return + } + + current := gui.State.ContextMgr.CurrentStatic().GetKey() + if current != opts.Pair.Main.GetKey() && current != opts.Pair.Secondary.GetKey() { + return + } + pane := onlyWorkablePane(opts) + if pane == nil || pane.GetKey() == current { + return + } + + target := gui.mainContextForView(pane.GetView()) + target.SetHasSelectableContent(false) + gui.State.ContextMgr.UpdateSelectionHighlights() + if manager := gui.getManager(target.GetView()); !manager.HasRestoreForNextTask() { + manager.SetRestoreForNextTask(&tasks.RenderRestore{ + // The whole render is read before it is shown: where the selection goes + // is decided from what is there, and a change line further down would + // otherwise be missed. + FirstPaintReady: func() bool { return false }, + Apply: func(swapIn func()) { + swapIn() + gui.helpers.DiffLine.EstablishSelection(target, -1) + }, + }) + } + gui.State.ContextMgr.Push(target, types.OnFocusOpts{}) +} + +// keepDiffSelectionAcrossACommitRewrite arranges for a selection in the focused main +// view to come back on the same change of the diff when the render about to happen is +// of a different diff from the one on screen. This happens when a commit is rewritten +// under the user, by moving a patch out of it, discarding lines from it, or undoing +// either. The selection is then left at a position in a rendering that no longer exists. +// +// What is remembered is which change of the diff the selection was on rather than which +// line of which file, a rewrite being precisely a change to those lines: the change that +// takes its place is where the work carries on. +// +// It is asked of every render, and does nothing unless all three of these hold: there +// is a selection to keep; nothing more precise is already waiting to be put back (the +// position preserves and the post-action reveals know better where their selection +// belongs); and the diff really is another one. A plain refresh re-renders the same +// diff, where the selection, possibly a range the user is in the middle of making, is +// still exactly right. +func (gui *Gui) keepDiffSelectionAcrossACommitRewrite(opts types.RefreshMainOpts) { + // The focused main view's two panes only: no other pair has a diff selection. + if opts.Pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY { + return + } + + current := gui.State.ContextMgr.CurrentStatic().GetKey() + for _, pane := range []struct { + context types.Context + update *types.ViewUpdateOpts + }{ + {opts.Pair.Main, opts.Main}, + {opts.Pair.Secondary, opts.Secondary}, + } { + if pane.update == nil || pane.context.GetKey() != current { + continue + } + mainContext := gui.mainContextForView(pane.context.GetView()) + if mainContext == nil || !mainContext.GetView().Highlight { + continue + } + manager := gui.getViewBufferManagerForView(mainContext.GetView()) + if manager == nil || manager.HasRestoreForNextTask() { + continue + } + key, ok := diffTaskCommandKey(pane.update.Task) + if !ok || key == manager.GetTaskKey() { + continue + } + + first, _ := mainContext.GetView().SelectedLineRange() + gui.helpers.DiffLine.RevealSelectionAfterAction(mainContext, mainContext, first, 0, nil) + } +} + +// diffTaskCommandKey returns the key the given render will be remembered under. Two +// renders of the same diff have the same key, so comparing keys says whether a render +// is of the diff already on screen. ok is false for a render that is a message rather +// than a diff. +func diffTaskCommandKey(task types.UpdateTask) (string, bool) { + switch task := task.(type) { + case *types.RunCommandTask: + return strings.Join(task.Cmd.Args, " "), true + case *types.RunPtyTask: + return strings.Join(task.Cmd.Args, " "), true + } + return "", false +} + +// onlyWorkablePane returns the main pane a render leaves as the only one worth having +// the focus in, or nil when that is true of both of them or of neither. Being shown is +// not the same as being worth working in: a pane the layout keeps around for the sake +// of always splitting the diff shows an empty side of the file. +func onlyWorkablePane(opts types.RefreshMainOpts) types.Context { + main := opts.Main != nil && !opts.Main.NothingToActOn + secondary := opts.Secondary != nil && !opts.Secondary.NothingToActOn + if main == secondary { + return nil + } + if main { + return opts.Pair.Main + } + return opts.Pair.Secondary +} + +// clampDiffSelectionToContent brings the focused main view's selection back onto the +// content when the render that just finished left the diff with fewer lines than the +// selection was on — a diff renderer that renders the same diff more compactly, a +// smaller context size. That selection lives in the view rather than in a model, so +// nothing else re-derives it, and past the end of the content it isn't drawn at all, +// which reads as having no selection until an arrow key brings it back. +// +// Called at end of input, when the content is final: doing it while the render is +// still loading would drag the selection to a line that only looks like the last one. +// Only these two views need it; every other view's selection is derived from a model +// as it renders, and so is clamped along with it. +func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) { + if gui.mainContextForView(view) == nil { + return + } + if !view.Highlight { + return + } + + if lastLine := view.ViewLinesHeight() - 1; view.SelectedLineIdx() > lastLine { + view.FocusPoint(0, max(0, lastLine), true) + } +} + +// clearMainView empties a pane that is being given nothing to show, selection and all. +// +// An emptied pane is showing nothing, so it also goes back to the top and stops +// claiming the render it was showing: whatever it is given next is content the user +// hasn't seen there, and is shown from the top like any other. +// +// A position waiting to be put back goes too: this pane is getting no render for it +// to ride, and whoever is waiting for the view to be back where it belongs has to +// hear that it never will be. +func (gui *Gui) clearMainView(mainContext types.Context) { + view := mainContext.GetView() + view.Clear() + view.SetOrigin(0, 0) + mainContext.SetHasSelectableContent(false) + gui.State.ContextMgr.UpdateSelectionHighlights() + if manager := gui.getViewBufferManagerForView(view); manager != nil { + manager.ForgetRenderedContent() + manager.DropRestoreForNextTask() + } +} + +// updateDiffPaneDecorations re-derives what is drawn over a main pane's content, rather +// than being part of it: whether a selection is shown, and which lines are marked as +// being in the custom patch. +// +// A pane holds something for a selection to sit on only beneath a panel whose main +// view is a diff, and only while that diff holds something to select — never over a +// message like "No changed files", and never over a diff with nothing in it, such as a +// binary file's or an empty commit's. Whether the selection is then drawn, and drawn as +// the active one, follows from the context stack. +// +// It is asked wherever the pane's content changes: as a string is rendered, at the +// paint that reveals a command's output, with every further batch of that output, and +// once it has been read to the end. contentIsComplete tells those apart, since a render +// still being read can leave the question open (see diffPaneHasSomethingToSelect). The +// pane never answers from the render before it, and a render that leaves the question +// open is read on until it doesn't, so the answer is always about what is there. +func (gui *Gui) updateDiffPaneDecorations(view *gocui.View, contentIsComplete bool) { + mainContext := gui.mainContextForView(view) + if mainContext == nil { + return + } + + gui.dropAnAnswerAboutAnotherRender(mainContext, view) + + if hasSomethingToSelect, known := gui.diffPaneHasSomethingToSelect( + mainContext, view, contentIsComplete, + ); known { + mainContext.SetHasSelectableContent(hasSomethingToSelect) + gui.State.ContextMgr.UpdateSelectionHighlights() + } else { + gui.readOnUntilTheDiffPaneCanTell(view) + } + + // The marks are over the diff in the upper pane; the lower one shows the patch + // they are marks of. + if view == gui.Views.Main { + gui.helpers.DiffLine.RefreshInclusionGutter() + } +} + +// dropAnAnswerAboutAnotherRender takes away what the pane worked out about the content +// of an earlier render, so that this one starts from no answer rather than inheriting +// one. An answer about other content says nothing about this content: carried over, it +// shows a selection over a diff that may have nothing to select, or hides one over a +// diff that has. +// +// A re-render of the same content keeps its answer, and with it the selection drawn +// over it, since that answer is still about what the pane is showing. +func (gui *Gui) dropAnAnswerAboutAnotherRender(mainContext *context.MainContext, view *gocui.View) { + manager := gui.getViewBufferManagerForView(view) + if manager == nil { + return + } + + if key := manager.GetTaskKey(); key != mainContext.SelectableContentRenderKey() { + mainContext.SetSelectableContentRenderKey(key) + mainContext.SetHasSelectableContent(false) + gui.State.ContextMgr.UpdateSelectionHighlights() + } +} + +// readOnUntilTheDiffPaneCanTell keeps a render going past the lines that were asked of +// it, while the pane still can't say whether there is anything in it to select. +// +// A render is asked for as many lines as the scrollbar needs (see +// linesToReadFromCmdTask), and what a commit's diff opens with can run past that: the +// diffstat of a commit touching thousands of files, or a commit message thousands of +// lines long. Without this the pane would be left with no answer until the user +// scrolled far enough to ask for the rest themselves, which is no way to find out +// whether a diff can be acted on. Another render's worth is asked for each time, so the +// reading stops soon after the first change line, and only runs to the end of a diff +// that has none. +func (gui *Gui) readOnUntilTheDiffPaneCanTell(view *gocui.View) { + manager := gui.getViewBufferManagerForView(view) + if manager == nil { + return + } + + step := gui.linesToReadFromCmdTask(view).Total + if step < 0 { + // A view that is being searched is already being read to the end. + return + } + manager.ReadLinesAndWait(view.LinesHeight() + step) +} + +// diffPaneHasSomethingToSelect answers whether the given main pane holds anything for a +// selection to sit on, from what it is showing so far. known is false while a render +// still being read leaves the question open. +func (gui *Gui) diffPaneHasSomethingToSelect( + mainContext *context.MainContext, view *gocui.View, contentIsComplete bool, +) (bool, bool) { + if _, showsDiff := gui.State.ContextMgr.CurrentSide().(types.DiffMainViewContext); !showsDiff { + // Under a panel that shows no diff there is nothing to select whatever the pane + // ends up holding, so this needs no content to answer. Answering it now matters, + // because a render may never reach an end. The rest of a long commit log is read + // only as far as the user scrolls, and until then the pane would go on showing + // the selection it was left with under the panel before. + return false, true + } + + if !contentIsComplete && mainContext.HasSelectableContent() { + // This render has already found something to select, and its content only grows + // from here, so there is nothing to ask again — nor to read the diff for. An + // answer the render before it gave has been dropped by now (see + // dropAnAnswerAboutAnotherRender), so this really is about the content in hand. + return true, true + } + + hasChangeLines := gui.helpers.DiffLine.ViewHasChangeLines(view) + + // One change line among those read settles it. Finding none in a render that is + // still going may only mean the changes are in the part still to come. A commit's + // diff opens with a diffstat, and for a commit touching hundreds of files that runs + // well past the screenful the first paint reveals, so that answer waits. + return hasChangeLines, contentIsComplete || hasChangeLines +} + +// mainContextForView returns the context of the main pane the given view is, or nil for +// any other view. +func (gui *Gui) mainContextForView(view *gocui.View) *context.MainContext { + switch view { + case gui.Views.Main: + return gui.State.Contexts.Normal + case gui.Views.Secondary: + return gui.State.Contexts.NormalSecondary + } + return nil +} + +func (gui *Gui) setMainPanes(panes types.MainPanes) { + gui.State.MainPanes = panes + + // The label for the key that focuses the main view belongs on the pane that key + // focuses, which is the secondary one while it is the only one shown. + if panes == types.SecondaryPaneOnly { + gui.showFocusMainViewJumpLabelOn(gui.Views.Secondary) + } else { + gui.showFocusMainViewJumpLabelOn(gui.Views.Main) + } +} + +// showFocusMainViewJumpLabelOn puts the main view's jump label on the given pane and +// takes it off the other one, so that only the pane the key focuses wears it. +func (gui *Gui) showFocusMainViewJumpLabelOn(view *gocui.View) { + gui.Views.Main.TitlePrefix = "" + gui.Views.Secondary.TitlePrefix = "" + view.TitlePrefix = gui.focusMainViewJumpLabel } // reApplySearch runs a search the view holds again over the content a render has just diff --git a/pkg/gui/options_map.go b/pkg/gui/options_map.go index 962187bff..880a766b8 100644 --- a/pkg/gui/options_map.go +++ b/pkg/gui/options_map.go @@ -50,7 +50,11 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { })...) bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool { - return len(binding.Keys) > 0 && binding.DisplayOnScreen && !binding.IsDisabled() + // A binding that describes itself as nothing has nothing to do where we are — + // that is how a command which only applies to some of a view's contents says so + // — and an empty entry in the options bar would say nothing about it. + return len(binding.Keys) > 0 && binding.DisplayOnScreen && !binding.IsDisabled() && + binding.GetShortDescription() != "" }) optionsMap := lo.Map(bindingsToDisplay, func(binding *types.Binding, _ int) bindingInfo { diff --git a/pkg/gui/patch_exploring/focus.go b/pkg/gui/patch_exploring/focus.go deleted file mode 100644 index cf917cd4d..000000000 --- a/pkg/gui/patch_exploring/focus.go +++ /dev/null @@ -1,47 +0,0 @@ -package patch_exploring - -func calculateOrigin(currentOrigin int, bufferHeight int, numLines int, firstLineIdx int, lastLineIdx int, selectedLineIdx int, mode selectMode) int { - needToSeeIdx, wantToSeeIdx := getNeedAndWantLineIdx(firstLineIdx, lastLineIdx, selectedLineIdx, mode) - - return calculateNewOriginWithNeededAndWantedIdx(currentOrigin, bufferHeight, numLines, needToSeeIdx, wantToSeeIdx) -} - -// we want to scroll our origin so that the index we need to see is in view -// and the other index we want to see (e.g. the other side of a line range) -// is as close to being in view as possible. -func calculateNewOriginWithNeededAndWantedIdx(currentOrigin int, bufferHeight int, numLines int, needToSeeIdx int, wantToSeeIdx int) int { - origin := currentOrigin - if needToSeeIdx < currentOrigin || needToSeeIdx >= currentOrigin+bufferHeight { - origin = max(min(needToSeeIdx-bufferHeight/2, numLines-bufferHeight), 0) - } - - bottom := origin + bufferHeight - - if wantToSeeIdx < origin { - requiredChange := origin - wantToSeeIdx - allowedChange := bottom - needToSeeIdx - return origin - min(requiredChange, allowedChange) - } else if wantToSeeIdx >= bottom { - requiredChange := wantToSeeIdx + 1 - bottom - allowedChange := needToSeeIdx - origin - return origin + min(requiredChange, allowedChange) - } - return origin -} - -func getNeedAndWantLineIdx(firstLineIdx int, lastLineIdx int, selectedLineIdx int, mode selectMode) (int, int) { - switch mode { - case LINE: - return selectedLineIdx, selectedLineIdx - case RANGE: - if selectedLineIdx == firstLineIdx { - return firstLineIdx, lastLineIdx - } - return lastLineIdx, firstLineIdx - case HUNK: - return firstLineIdx, lastLineIdx - default: - // we should never land here - panic("unknown mode") - } -} diff --git a/pkg/gui/patch_exploring/focus_test.go b/pkg/gui/patch_exploring/focus_test.go deleted file mode 100644 index 290f1356c..000000000 --- a/pkg/gui/patch_exploring/focus_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package patch_exploring - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewOrigin(t *testing.T) { - type scenario struct { - name string - origin int - bufferHeight int - numLines int - firstLineIdx int - lastLineIdx int - selectedLineIdx int - selectMode selectMode - expected int - } - - scenarios := []scenario{ - { - name: "selection above scroll window, enough room to put it in the middle", - origin: 250, - bufferHeight: 100, - numLines: 500, - firstLineIdx: 210, - lastLineIdx: 210, - selectedLineIdx: 210, - selectMode: LINE, - expected: 160, - }, - { - name: "selection above scroll window, not enough room to put it in the middle", - origin: 50, - bufferHeight: 100, - numLines: 500, - firstLineIdx: 10, - lastLineIdx: 10, - selectedLineIdx: 10, - selectMode: LINE, - expected: 0, - }, - { - name: "selection below scroll window, enough room to put it in the middle", - origin: 0, - bufferHeight: 100, - numLines: 500, - firstLineIdx: 150, - lastLineIdx: 150, - selectedLineIdx: 150, - selectMode: LINE, - expected: 100, - }, - { - name: "selection below scroll window, not enough room to put it in the middle", - origin: 0, - bufferHeight: 100, - numLines: 200, - firstLineIdx: 199, - lastLineIdx: 199, - selectedLineIdx: 199, - selectMode: LINE, - expected: 100, - }, - { - name: "selection within scroll window", - origin: 0, - bufferHeight: 100, - numLines: 500, - firstLineIdx: 50, - lastLineIdx: 50, - selectedLineIdx: 50, - selectMode: LINE, - expected: 0, - }, - { - name: "range ending below scroll window with selection at end of range", - origin: 0, - bufferHeight: 100, - numLines: 500, - firstLineIdx: 40, - lastLineIdx: 150, - selectedLineIdx: 150, - selectMode: RANGE, - expected: 50, - }, - { - name: "range ending below scroll window with selection at beginning of range", - origin: 0, - bufferHeight: 100, - numLines: 500, - firstLineIdx: 40, - lastLineIdx: 150, - selectedLineIdx: 40, - selectMode: RANGE, - expected: 40, - }, - { - name: "range starting above scroll window with selection at beginning of range", - origin: 50, - bufferHeight: 100, - numLines: 500, - firstLineIdx: 40, - lastLineIdx: 150, - selectedLineIdx: 40, - selectMode: RANGE, - expected: 40, - }, - { - name: "hunk extending beyond both bounds of scroll window", - origin: 50, - bufferHeight: 100, - numLines: 500, - firstLineIdx: 40, - lastLineIdx: 200, - selectedLineIdx: 70, - selectMode: HUNK, - expected: 40, - }, - } - - for _, s := range scenarios { - t.Run(s.name, func(t *testing.T) { - assert.EqualValues(t, s.expected, calculateOrigin(s.origin, s.bufferHeight, s.numLines, s.firstLineIdx, s.lastLineIdx, s.selectedLineIdx, s.selectMode)) - }) - } -} diff --git a/pkg/gui/patch_exploring/state.go b/pkg/gui/patch_exploring/state.go deleted file mode 100644 index 7222fef3b..000000000 --- a/pkg/gui/patch_exploring/state.go +++ /dev/null @@ -1,438 +0,0 @@ -package patch_exploring - -import ( - "strings" - - "github.com/jesseduffield/generics/set" - "github.com/jesseduffield/lazygit/pkg/commands/patch" - "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/samber/lo" -) - -// State represents the current state of the patch explorer context i.e. when -// you're staging a file or you're building a patch from an existing commit -// this struct holds the info about the diff you're interacting with and what's currently selected. -type State struct { - // These are in terms of view lines (wrapped), not patch lines - selectedLineIdx int - rangeStartLineIdx int - // If a range is sticky, it means we expand the range when we move up or down. - // Otherwise, we cancel the range when we move up or down. - rangeIsSticky bool - diff string - patch *patch.Patch - selectMode selectMode - - // Array of indices of the wrapped lines indexed by a patch line index - viewLineIndices []int - // Array of indices of the original patch lines indexed by a wrapped view line index - patchLineIndices []int - - // whether the user has switched to hunk mode manually; if hunk mode is on - // but this is false, then hunk mode was enabled because the config makes it - // on by default. - // this makes a difference for whether we want to escape out of hunk mode - userEnabledHunkMode bool -} - -// these represent what select mode we're in -type selectMode int - -const ( - LINE selectMode = iota - RANGE - HUNK -) - -func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool) *State { - if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 { - // if we're here then we can return the old state. If selectedLineIdx was not -1 - // then that would mean we were trying to click and potentially drag a range, which - // is why in that case we continue below - return oldState - } - - patch := patch.Parse(diff) - - if !patch.ContainsChanges() { - return nil - } - - viewLineIndices, patchLineIndices := wrapPatchLines(diff, view) - - rangeStartLineIdx := 0 - if oldState != nil { - rangeStartLineIdx = oldState.rangeStartLineIdx - } - - selectMode := LINE - if useHunkModeByDefault && !patch.IsSingleHunkForWholeFile() { - selectMode = HUNK - } - - userEnabledHunkMode := false - if oldState != nil { - userEnabledHunkMode = oldState.userEnabledHunkMode - } - - // if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line - if selectedLineIdx >= 0 { - // Clamp to the number of wrapped view lines; index might be out of - // bounds if a custom diff renderer is being used which produces more lines - selectedLineIdx = min(selectedLineIdx, len(viewLineIndices)-1) - - selectMode = RANGE - rangeStartLineIdx = selectedLineIdx - } else if oldState != nil { - // if we previously had a selectMode of RANGE, we want that to now be line again (or hunk, if that's the default) - if oldState.selectMode != RANGE { - selectMode = oldState.selectMode - } - oldPatchLineIdx := oldState.patchLineIndices[oldState.selectedLineIdx] - newPatchLineIdx := patch.GetNextChangeIdx(oldPatchLineIdx) - // When staging an addition from a consecutive changes block, the unselected deletions get - // reordered to appear before the remaining additions in the new diff. This can cause the - // cursor to land on a deletion at the same patch line index where the staged addition used - // to be. In that case, skip forward past any deletions, then call GetNextChangeIdx from the - // first non-deletion position, which correctly lands on the next meaningful change. - newLines := patch.Lines() - if newPatchLineIdx == oldPatchLineIdx && - oldState.patch.Lines()[oldPatchLineIdx].IsAddition() && - newLines[newPatchLineIdx].IsDeletion() && - patch.HunkOldStartForLine(newPatchLineIdx) == oldState.patch.HunkOldStartForLine(oldPatchLineIdx) { - for newPatchLineIdx < len(newLines) && newLines[newPatchLineIdx].IsDeletion() { - newPatchLineIdx++ - } - newPatchLineIdx = patch.GetNextChangeIdx(newPatchLineIdx) - } - selectedLineIdx = viewLineIndices[newPatchLineIdx] - } else { - selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(0)] - } - - return &State{ - patch: patch, - selectedLineIdx: selectedLineIdx, - selectMode: selectMode, - rangeStartLineIdx: rangeStartLineIdx, - rangeIsSticky: false, - diff: diff, - viewLineIndices: viewLineIndices, - patchLineIndices: patchLineIndices, - userEnabledHunkMode: userEnabledHunkMode, - } -} - -func (s *State) OnViewWidthChanged(view *gocui.View) { - if !view.Wrap { - return - } - - selectedPatchLineIdx := s.patchLineIndices[s.selectedLineIdx] - var rangeStartPatchLineIdx int - if s.selectMode == RANGE { - rangeStartPatchLineIdx = s.patchLineIndices[s.rangeStartLineIdx] - } - s.viewLineIndices, s.patchLineIndices = wrapPatchLines(s.diff, view) - s.selectedLineIdx = s.viewLineIndices[selectedPatchLineIdx] - if s.selectMode == RANGE { - s.rangeStartLineIdx = s.viewLineIndices[rangeStartPatchLineIdx] - } -} - -func (s *State) GetSelectedPatchLineIdx() int { - return s.patchLineIndices[s.selectedLineIdx] -} - -func (s *State) GetSelectedViewLineIdx() int { - return s.selectedLineIdx -} - -func (s *State) GetDiff() string { - return s.diff -} - -func (s *State) ToggleSelectHunk() { - if s.selectMode == HUNK { - s.selectMode = LINE - } else { - s.selectMode = HUNK - s.userEnabledHunkMode = true - - // If we are not currently on a change line, select the next one (or the - // previous one if there is no next one): - s.selectedLineIdx = s.viewLineIndices[s.patch.GetNextChangeIdx( - s.patchLineIndices[s.selectedLineIdx])] - } -} - -func (s *State) ToggleStickySelectRange() { - s.ToggleSelectRange(true) -} - -func (s *State) ToggleSelectRange(sticky bool) { - if s.SelectingRange() { - s.selectMode = LINE - } else { - s.selectMode = RANGE - s.rangeStartLineIdx = s.selectedLineIdx - s.rangeIsSticky = sticky - } -} - -func (s *State) SetRangeIsSticky(value bool) { - s.rangeIsSticky = value -} - -func (s *State) SelectingHunk() bool { - return s.selectMode == HUNK -} - -func (s *State) SelectingHunkEnabledByUser() bool { - return s.selectMode == HUNK && s.userEnabledHunkMode -} - -func (s *State) SelectingRange() bool { - return s.selectMode == RANGE && (s.rangeIsSticky || s.rangeStartLineIdx != s.selectedLineIdx) -} - -func (s *State) SelectingLine() bool { - return s.selectMode == LINE -} - -func (s *State) SetLineSelectMode() { - s.selectMode = LINE -} - -func (s *State) DismissHunkSelectMode() { - if s.SelectingHunk() { - s.selectMode = LINE - } -} - -// For when you move the cursor without holding shift (meaning if we're in -// a non-sticky range select, we'll cancel it) -func (s *State) SelectLine(newSelectedLineIdx int) { - if s.selectMode == RANGE && !s.rangeIsSticky { - s.selectMode = LINE - } - - s.selectLineWithoutRangeCheck(newSelectedLineIdx) -} - -func (s *State) clampLineIdx(lineIdx int) int { - return lo.Clamp(lineIdx, 0, len(s.patchLineIndices)-1) -} - -// This just moves the cursor without caring about range select -func (s *State) selectLineWithoutRangeCheck(newSelectedLineIdx int) { - s.selectedLineIdx = s.clampLineIdx(newSelectedLineIdx) -} - -func (s *State) SelectNewLineForRange(newSelectedLineIdx int) { - s.rangeStartLineIdx = s.clampLineIdx(newSelectedLineIdx) - - s.selectMode = RANGE - - s.selectLineWithoutRangeCheck(newSelectedLineIdx) -} - -func (s *State) DragSelectLine(newSelectedLineIdx int) { - s.selectMode = RANGE - - s.selectLineWithoutRangeCheck(newSelectedLineIdx) -} - -func (s *State) CycleSelection(forward bool) { - if s.SelectingHunk() { - if forward { - s.SelectNextHunk() - } else { - s.SelectPreviousHunk() - } - } else { - s.CycleLine(forward) - } -} - -func (s *State) SelectPreviousHunk() { - patchLines := s.patch.Lines() - patchLineIdx := s.patchLineIndices[s.selectedLineIdx] - nextNonChangeLine := patchLineIdx - for nextNonChangeLine >= 0 && patchLines[nextNonChangeLine].IsChange() { - nextNonChangeLine-- - } - nextChangeLine := nextNonChangeLine - for nextChangeLine >= 0 && !patchLines[nextChangeLine].IsChange() { - nextChangeLine-- - } - if nextChangeLine >= 0 { - // Now we found a previous hunk, but we're on its last line. Skip to the beginning. - for nextChangeLine > 0 && patchLines[nextChangeLine-1].IsChange() { - nextChangeLine-- - } - s.selectedLineIdx = s.viewLineIndices[nextChangeLine] - } -} - -func (s *State) SelectNextHunk() { - patchLines := s.patch.Lines() - patchLineIdx := s.patchLineIndices[s.selectedLineIdx] - nextNonChangeLine := patchLineIdx - for nextNonChangeLine < len(patchLines) && patchLines[nextNonChangeLine].IsChange() { - nextNonChangeLine++ - } - nextChangeLine := nextNonChangeLine - for nextChangeLine < len(patchLines) && !patchLines[nextChangeLine].IsChange() { - nextChangeLine++ - } - if nextChangeLine < len(patchLines) { - s.selectedLineIdx = s.viewLineIndices[nextChangeLine] - } -} - -func (s *State) CycleLine(forward bool) { - change := 1 - if !forward { - change = -1 - } - - s.SelectLine(s.selectedLineIdx + change) -} - -// This is called when we use shift+arrow to expand the range (i.e. a non-sticky -// range) -func (s *State) CycleRange(forward bool) { - if !s.SelectingRange() { - s.ToggleSelectRange(false) - } - - s.SetRangeIsSticky(false) - - change := 1 - if !forward { - change = -1 - } - - s.selectLineWithoutRangeCheck(s.selectedLineIdx + change) -} - -// returns first and last patch line index of current hunk -func (s *State) CurrentHunkBounds() (int, int) { - hunkIdx := s.patch.HunkContainingLine(s.patchLineIndices[s.selectedLineIdx]) - start := s.patch.HunkStartIdx(hunkIdx) - end := s.patch.HunkEndIdx(hunkIdx) - return start, end -} - -func (s *State) selectionRangeForCurrentBlockOfChanges() (int, int) { - patchLines := s.patch.Lines() - patchLineIdx := s.patchLineIndices[s.selectedLineIdx] - - patchStart := patchLineIdx - for patchStart > 0 && patchLines[patchStart-1].IsChange() { - patchStart-- - } - - patchEnd := patchLineIdx - for patchEnd < len(patchLines)-1 && patchLines[patchEnd+1].IsChange() { - patchEnd++ - } - - viewStart, viewEnd := s.viewLineIndices[patchStart], s.viewLineIndices[patchEnd] - - // Increase viewEnd in case the last patch line is wrapped to more than one view line. - for viewEnd < len(s.patchLineIndices)-1 && s.patchLineIndices[viewEnd] == s.patchLineIndices[viewEnd+1] { - viewEnd++ - } - - return viewStart, viewEnd -} - -func (s *State) SelectedViewRange() (int, int) { - switch s.selectMode { - case HUNK: - return s.selectionRangeForCurrentBlockOfChanges() - case RANGE: - if s.rangeStartLineIdx > s.selectedLineIdx { - return s.selectedLineIdx, s.rangeStartLineIdx - } - return s.rangeStartLineIdx, s.selectedLineIdx - case LINE: - return s.selectedLineIdx, s.selectedLineIdx - default: - // should never happen - return 0, 0 - } -} - -func (s *State) SelectedPatchRange() (int, int) { - start, end := s.SelectedViewRange() - return s.patchLineIndices[start], s.patchLineIndices[end] -} - -// Returns the line indices of the selected patch range that are changes (i.e. additions or deletions) -func (s *State) LineIndicesOfAddedOrDeletedLinesInSelectedPatchRange() []int { - viewStart, viewEnd := s.SelectedViewRange() - patchStart, patchEnd := s.patchLineIndices[viewStart], s.patchLineIndices[viewEnd] - lines := s.patch.Lines() - indices := []int{} - for i := patchStart; i <= patchEnd; i++ { - if lines[i].IsChange() { - indices = append(indices, i) - } - } - return indices -} - -func (s *State) CurrentLineNumber() int { - return s.patch.LineNumberOfLine(s.patchLineIndices[s.selectedLineIdx]) -} - -func (s *State) AdjustSelectedLineIdx(change int) { - s.DismissHunkSelectMode() - s.SelectLine(s.selectedLineIdx + change) -} - -func (s *State) RenderForLineIndices(includedLineIndices []int) string { - includedLineIndicesSet := set.NewFromSlice(includedLineIndices) - return s.patch.FormatView(patch.FormatViewOpts{ - IncLineIndices: includedLineIndicesSet, - }) -} - -func (s *State) PlainRenderSelected() string { - firstLineIdx, lastLineIdx := s.SelectedPatchRange() - return s.patch.FormatRangePlain(firstLineIdx, lastLineIdx) -} - -func (s *State) SelectBottom() { - s.DismissHunkSelectMode() - s.SelectLine(len(s.patchLineIndices) - 1) -} - -func (s *State) SelectTop() { - s.DismissHunkSelectMode() - s.SelectLine(0) -} - -func (s *State) CalculateOrigin(currentOrigin int, bufferHeight int, numLines int) int { - firstLineIdx, lastLineIdx := s.SelectedViewRange() - - return calculateOrigin(currentOrigin, bufferHeight, numLines, firstLineIdx, lastLineIdx, s.GetSelectedViewLineIdx(), s.selectMode) -} - -func wrapPatchLines(diff string, view *gocui.View) ([]int, []int) { - _, viewLineIndices, patchLineIndices := utils.WrapViewLinesToWidth( - view.Wrap, view.Editable, strings.TrimSuffix(diff, "\n"), view.InnerWidth(), view.TabWidth) - return viewLineIndices, patchLineIndices -} - -func (s *State) SelectNextStageableLineOfSameIncludedState(includedLines []int, included bool) { - _, lastLineIdx := s.SelectedPatchRange() - patchLineIdx, found := s.patch.GetNextChangeIdxOfSameIncludedState(lastLineIdx+1, includedLines, included) - if found { - s.SelectLine(s.viewLineIndices[patchLineIdx]) - } -} diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go index c7e333682..9a92731a0 100644 --- a/pkg/gui/presentation/files_test.go +++ b/pkg/gui/presentation/files_test.go @@ -227,9 +227,10 @@ M file1 } patchBuilder := patch.NewPatchBuilder( utils.NewDummyLog(), - func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) { + func(from string, to string, reverse bool, filename string, previousPath string) (string, error) { return "", nil }, + nil, ) patchBuilder.Start("from", "to", false, false) result := RenderCommitFileTree(viewModel, patchBuilder, false, &config.CustomIconsConfig{}) diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index fb7ba352e..eda2aa295 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -65,11 +65,26 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error // Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly. cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width)) + // Ask whatever renders the diff to state, in an OSC 1717 record per line, + // which line of which file it is rendering. This lets us act on the line the + // user is pointing at even when the rendering no longer looks like a diff. + // The variable names the protocol versions we understand, and a renderer + // that doesn't understand it ignores it, so we can set it always. Like + // LAZYGIT_COLUMNS it has to be set before the no-pty path below, since on + // that path git renders the diff itself, and git speaks the protocol too, + // for its word-diff formats, whose markup we could not otherwise resolve. + cmd.Env = append(cmd.Env, "OSC1717=V1") + if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit { // If we're not using a custom diff renderer, then we don't need to use a pty return gui.newCmdTask(view, cmd, prefix) } + // The key the render is remembered under says which diff it is of, so that a + // re-render of the same diff can be told from a render of another one. Take it + // before the git config the pty path passes along below, which is no part of that. + cmdStr := strings.Join(cmd.Args, " ") + cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS) // Mark the view as loading synchronously now, before the layout pass: the @@ -89,8 +104,6 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error width = view.InnerWidth() pager := gui.stateAccessor.GetDiffRendererConfigManager().GetStdinFilterCommand(width) - cmdStr := strings.Join(cmd.Args, " ") - // This communicates to diff renderers that we're in a very simple // terminal that they should not expect to have much capabilities. // Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities. diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3ed141d68..86cdf9218 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -87,10 +87,14 @@ func (gui *Gui) newStringTask(view *gocui.View, str string) error { func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error { manager := gui.getManager(view) + // Whatever the view was going to be put back to belonged to a re-render of its + // content; this is a message instead, so there is nothing to put back. + manager.DropRestoreForNextTask() f := func(tasks.TaskOpts) error { return gui.g.OnUIThreadAndWaitBackground(func() { gui.c.SetViewContent(view, str) + gui.updateDiffPaneDecorations(view, true) gui.reApplySearch(view) }) } @@ -104,11 +108,15 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error { func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX int, originY int) error { manager := gui.getManager(view) + // Whatever the view was going to be put back to belonged to a re-render of its + // content; this is a message instead, so there is nothing to put back. + manager.DropRestoreForNextTask() f := func(tasks.TaskOpts) error { return gui.g.OnUIThreadAndWaitBackground(func() { gui.c.SetViewContent(view, str) view.SetOrigin(originX, originY) + gui.updateDiffPaneDecorations(view, true) gui.reApplySearch(view) }) } @@ -122,11 +130,15 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) error { manager := gui.getManager(view) + // Whatever the view was going to be put back to belonged to a re-render of its + // content; this is a message instead, so there is nothing to put back. + manager.DropRestoreForNextTask() f := func(tasks.TaskOpts) error { return gui.g.OnUIThreadAndWaitBackground(func() { gui.c.ResetViewOrigin(view) gui.c.SetViewContent(view, str) + gui.updateDiffPaneDecorations(view, true) gui.reApplySearch(view) }) } @@ -154,10 +166,20 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { func() { // As the task reads more lines, the only thing that changes is the // view's content (and its scrollbar); the window layout doesn't. So a - // content-only render is enough, and it's much cheaper than a full - // layout-and-redraw on every read - which matters a lot when reading - // a long diff, where reads happen repeatedly as the user scrolls. - gui.renderContentOnly() + // content-only render is enough — it skips the layout pass and redraws + // only the cells that differ — and it's much cheaper than a full + // layout-and-redraw on every read, which matters a lot when reading a + // long diff, where reads happen repeatedly as the user scrolls. + // + // What this draws is more of the content than the pane held a moment + // ago, so it is also where what is drawn over that content is worked + // out again. The screenful the first paint reveals may not be enough + // to say whether there is anything to select, and for a diff that + // opens with a long diffstat it isn't. + gui.c.OnUIThreadContentOnly(func() error { + gui.updateDiffPaneDecorations(view, false) + return nil + }) }, func() { // The content is fully loaded now, so let the scrollbar track it @@ -174,13 +196,22 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { view.SetOrigin(0, newOriginY) } + gui.updateDiffPaneDecorations(view, true) + gui.clampDiffSelectionToContent(view) gui.reApplySearch(view) }, func() { view.SetOrigin(0, 0) }, view.BeginOffscreenRender, - view.SwapInOffscreenRender, + func() { + view.SwapInOffscreenRender() + + // The content the pane is being given is on display from here on, so + // what is drawn over it is settled against that content rather than + // against the render before it. + gui.updateDiffPaneDecorations(view, false) + }, func() gocui.Task { // A background task: rendering content into a view is display // work, not lazygit driving a git operation, so it must not diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 58ccf6d60..42350751a 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -72,6 +72,10 @@ type IGuiCommon interface { // return the view buffer manager for the given view, or nil if it doesn't have one GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager + // return the view buffer manager for the given view, making one if the view has + // never rendered anything, for saying something about a render still to come + GetOrCreateViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager + // read enough lines into the given view's buffer to fill it at its current // scroll position, plus some read-ahead for smooth scrolling ReadLinesToFillView(view *gocui.View) @@ -455,8 +459,8 @@ type IRepoStateAccessor interface { SetScreenMode(ScreenMode) InSearchPrompt() bool GetSearchState() *SearchState - SetSplitMainPanel(bool) - GetSplitMainPanel() bool + SetMainPanes(MainPanes) + GetMainPanes() MainPanes GetMergeOrRebaseStartedInLazygit() bool SetMergeOrRebaseStartedInLazygit(bool) } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 93b92c70e..ef013b8e9 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -3,9 +3,7 @@ package types import ( "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/sasha-s/go-deadlock" ) type ContextKind int @@ -78,6 +76,7 @@ type IBaseContext interface { // true if the context holds something for a selection to sit on. Contexts that // don't show a selection at all say false, and so do lists with nothing in them. HasSelectableContent() bool + SetHasSelectableContent(bool) // the total height of the content that the view is currently showing TotalContentHeight() int @@ -102,12 +101,13 @@ type IBaseContext interface { // that the generic ListController can be specialized by view-specific controllers. // We'll need to think of a better way to do this. AddOnDoubleClickFn(func() error) - // Likewise for the focused main view: we need this to communicate between a - // side panel controller and the focused main view controller. - AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error) // Adding on to the above, this is so that a list-specific handler can register // a hook for doing additional click handling AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error) + // Likewise for the focused main view, which acts on the diff of whichever panel + // is beneath it and so has to reach that panel's controller. nil for a panel + // that shows no diff. + AddFocusedMainViewDiffSource(FocusedMainViewDiffSource) AddOnRenderToMainFn(func()) AddOnFocusFn(func(OnFocusOpts)) @@ -180,6 +180,96 @@ type DiffableContext interface { RefForAdjustingLineNumberInDiff() string } +// DiffMainViewContext is implemented by the side panel contexts whose focused +// main view shows a unified diff — files, local commits, sub-commits, reflog, +// stash, and commit files — as opposed to a commit log or other non-diff content +// (branches, tags, status, …). It is distinct from DiffableContext, which is +// about producing a diff between two refs for the diff menu. The focused main +// view shows a selection only for a context that implements this: a selection is +// only meaningful where there are diff lines to act on (edit one, copy some, jump +// by hunk or file). The returned type additionally classifies what acting on that +// selection means. +type DiffMainViewContext interface { + Context + + GetDiffMainViewType() DiffMainViewType +} + +// DiffMainViewType classifies what the focused main view's diff belongs to, which +// decides what acting on a selection in it means. +type DiffMainViewType int + +const ( + // DiffMainViewTypeNone: the main view holds no diff, so there is nothing to + // select. A side panel that doesn't implement DiffMainViewContext counts as + // this; no panel returns it itself. + DiffMainViewTypeNone DiffMainViewType = iota + // DiffMainViewTypeStaging: the diff is the working tree's, so the selection can + // be staged or unstaged (the files panel). + DiffMainViewTypeStaging + // DiffMainViewTypePatchBuilding: the diff belongs to a commit, so the selection + // can be taken into a custom patch (the commit files / commits / sub-commits / + // reflog / stash panels). + DiffMainViewTypePatchBuilding +) + +// DiffPaneContext is one of the two panes the main section can show, as the thing +// that holds a diff with a selection in it. The panels that act on such a selection +// are handed the pane it was made in, and speak to it through this. +type DiffPaneContext interface { + Context + + DiffSelectState() *DiffSelectState +} + +// FocusedMainViewDiffSource is how a side panel hands out the diff behind what it +// renders into the focused main view: the diff of the given files as git writes it, +// with no colour and no diff renderer in the way. What the main view shows is that +// same diff after a renderer has had it, which may have restructured, reordered or +// dropped parts of it — so anything that needs the diff itself, rather than a picture +// of it, asks the panel that produced it. +// +// paths are repo-relative, and are asked for rather than assumed so that a few lines +// of a commit's diff can be had without fetching the whole thing. pane says which of +// the two main panes is asking, since a panel can show a different diff in each — the +// files panel shows the unstaged changes in one and the staged ones in the other. +type FocusedMainViewDiffSource interface { + PlainDiff(pane DiffPaneContext, paths []string) string +} + +// FocusedMainViewActions describes what a side panel does when the user acts on a +// selection of diff lines in the focused main view. The main view owns the selection +// and the keys; what acting on it means is the panel's business, e.g. the working +// tree panel stages and unstages. +// +// It extends the diff source rather than standing beside it, because acting on a +// selection needs the diff behind the rendering just as reading it does; a panel that +// implements only the source offers a diff to read and copy but nothing to do to it. +type FocusedMainViewActions interface { + FocusedMainViewDiffSource + + // PrimaryAction acts on the diff lines in the inclusive view-line range, which is + // the current selection in the given pane: a single line, a range, or a hunk. The + // panel re-renders the diff itself, being the one that knows what it did to it. + PrimaryAction(pane DiffPaneContext, firstLineIdx int, lastLineIdx int) error + + // DiscardSelection takes the selected diff lines back out of whatever they are part + // of: the working tree for the files panel, the commit itself for the panels showing + // a commit's diff. + DiscardSelection(pane DiffPaneContext, firstLineIdx int, lastLineIdx int) error + + // DiscardSelectionDisabledReason says why the selection can't be discarded where it + // is, and nil when it can. Taking lines out of a commit means rewriting it, which + // isn't always something we may do; the working tree has no such condition. + DiscardSelectionDisabledReason(pane DiffPaneContext) *DisabledReason + + // PatchInclusion says which lines of the diff this panel shows are in the custom + // patch being built from it. The marks over those lines are drawn from this. nil + // where nothing about this diff is being built into a patch, which is always so + // for a diff that can't be. + PatchInclusion() func(info DiffLineInfo) bool +} + type IListContext interface { Context @@ -199,20 +289,6 @@ type IListContext interface { IndexForGotoBottom() int } -type IPatchExplorerContext interface { - Context - - GetState() *patch_exploring.State - SetState(*patch_exploring.State) - GetIncludedLineIndices() []int - RenderAndFocus() - Render() - GetContentToRender() string - NavigateTo(selectedLineIdx int) - GetMutex() *deadlock.Mutex - IsPatchExplorerContext() // used for type switch -} - type IViewTrait interface { FocusPoint(yIdx int, scrollIntoView bool) SetRangeSelectStart(yIdx int) @@ -276,9 +352,10 @@ type HasKeybindings interface { // decides not to do anything with the click. GetOnClick() func(opts gocui.ViewMouseBindingOpts) error - // Implement this in a side-panel controller to get called when there's a click in the main view - // that belongs to your panel while the main view is already focused. - GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error + // Implement this in a side-panel controller to hand out the diff behind what your + // panel renders into the focused main view, for the commands that act on a + // selection in it. nil for a controller whose panel shows no diff. + GetFocusedMainViewDiffSource() FocusedMainViewDiffSource } type IController interface { @@ -339,11 +416,12 @@ type IContextMgr interface { CurrentSide() Context CurrentPopup() []Context NextInStack(context Context) Context + IsInStack(context Context) bool + UpdateSelectionHighlights() IsCurrent(c Context) bool IsCurrentOrParent(c Context) bool ForEach(func(Context)) AllList() []IListContext AllFilterable() []IFilterableContext AllSearchable() []ISearchableContext - AllPatchExplorer() []IPatchExplorerContext } diff --git a/pkg/gui/types/diff_line_info.go b/pkg/gui/types/diff_line_info.go new file mode 100644 index 000000000..12fed2053 --- /dev/null +++ b/pkg/gui/types/diff_line_info.go @@ -0,0 +1,62 @@ +package types + +import "github.com/jesseduffield/lazygit/pkg/commands/patch" + +// DiffLineType classifies a row of a rendered diff. +type DiffLineType int + +const ( + DiffLineFileHeader DiffLineType = iota + DiffLineHunkHeader + DiffLineContext + DiffLineAdded + DiffLineDeleted + // DiffLineOther is anything that isn't one of the above, e.g. the + // "\ No newline at end of file" marker. + DiffLineOther +) + +// DiffLineInfo is the identity of a row of a rendered diff in terms of the patch +// it was rendered from: which file the row belongs to, what kind of row it is, +// and where the line sits in the old and new versions of that file. This lets us +// act on the line the user is pointing at in a diff view: stage it, open it in an +// editor, keep the cursor on it across a re-render. The rendered text alone tells +// us none of that. +type DiffLineInfo struct { + // Path is the absolute path of the file the line belongs to. + Path string + Type DiffLineType + // NewLine is the line's position in the new version of the file. Set for all + // content lines (for a deletion it is the position the deletion sits at) and + // for hunk headers (the first line of the hunk they head). + NewLine int + // OldLine is the line's position in the old version of the file. Set only + // for deletions, which are the only rows that need it: two consecutive + // deletions share a new-file position and differ only here. + OldLine int +} + +// IsChange reports whether the row is an added or deleted line, as opposed to a +// context line or a header. It mirrors patch.PatchLine.IsChange: those are the +// rows a patch is built from, and the rows navigation moves between. +func (self DiffLineInfo) IsChange() bool { + return self.Type == DiffLineAdded || self.Type == DiffLineDeleted +} + +// PatchLineIdentity says which change line of the file the row is, in the terms a patch +// of that file is built and read in: an addition by where it sits in the new version of +// the file, a deletion by where it sat in the old one. Only meaningful for a change row. +func (self DiffLineInfo) PatchLineIdentity() patch.LineIdentity { + if self.Type == DiffLineDeleted { + return patch.LineIdentity{LineNumber: self.OldLine, IsDeletion: true} + } + return patch.LineIdentity{LineNumber: self.NewLine} +} + +// IsContent reports whether the row is a line of the file itself — a change or a +// context line — as opposed to a header or a marker. Those are the rows that have a +// position in the file, and so can be looked for in another rendering of the same +// diff, or in the diff itself. +func (self DiffLineInfo) IsContent() bool { + return self.IsChange() || self.Type == DiffLineContext +} diff --git a/pkg/gui/types/diff_select.go b/pkg/gui/types/diff_select.go new file mode 100644 index 000000000..041ac5969 --- /dev/null +++ b/pkg/gui/types/diff_select.go @@ -0,0 +1,28 @@ +package types + +// DiffSelectMode is how the focused main view's diff selection extends from the +// cursor: a single line, a range from a fixed anchor, or the change block (hunk) +// around the cursor. +type DiffSelectMode int + +const ( + DiffSelectModeLine DiffSelectMode = iota + DiffSelectModeRange + DiffSelectModeHunk +) + +// DiffSelectState holds the *mode* of the focused main view's diff selection. The +// selected line and the range anchor themselves live in the gocui view (its cursor +// and range-select start), so only the mode lives here. It's on the context rather +// than on the controller because the controller that drives the selection, the +// controller that establishes it on focus, and the pane-toggle that seeds it on the +// other pane all reach the pane through its context. +type DiffSelectState struct { + Mode DiffSelectMode + // When a range is sticky, moving the cursor without holding shift extends the + // range; otherwise it collapses the range back to a single line. + RangeIsSticky bool + // Whether hunk mode was turned on by the user rather than being the configured + // default. This decides whether escape leaves hunk mode or leaves the view. + UserEnabledHunkMode bool +} diff --git a/pkg/gui/types/keybindings.go b/pkg/gui/types/keybindings.go index 337162d76..32d0e7fa4 100644 --- a/pkg/gui/types/keybindings.go +++ b/pkg/gui/types/keybindings.go @@ -39,6 +39,11 @@ type Binding struct { // to be displayed if the keybinding is highlighted from within a menu Tooltip string + // TooltipFunc is used instead of Tooltip if non-nil, for a command whose tooltip + // depends on context, as DescriptionFunc is for its description — and with the + // same two conditions: it must not be an expensive call, and a generic Tooltip + // must still be given, since that is the one the cheatsheet prints. + TooltipFunc func() string // Function to decide whether the command is enabled, and why. If this // returns an empty string, it is; if it returns a non-empty string, it is @@ -59,6 +64,13 @@ func (b *Binding) GetDescription() string { return b.Description } +func (b *Binding) GetTooltip() string { + if b.TooltipFunc != nil { + return b.TooltipFunc() + } + return b.Tooltip +} + func (b *Binding) GetShortDescription() string { if b.ShortDescriptionFunc != nil { return b.ShortDescriptionFunc() diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index d40a1bec5..76aa35d9b 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -16,8 +16,6 @@ const ( WORKTREES STATUS SUBMODULES - STAGING - PATCH_BUILDING MERGE_CONFLICTS COMMIT_FILES // not actually views. Will refactor this later diff --git a/pkg/gui/types/rendering.go b/pkg/gui/types/rendering.go index 70e47e033..63585f202 100644 --- a/pkg/gui/types/rendering.go +++ b/pkg/gui/types/rendering.go @@ -2,6 +2,8 @@ package types import ( "os/exec" + + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" ) type MainContextPair struct { @@ -13,11 +15,22 @@ func NewMainContextPair(main Context, secondary Context) MainContextPair { return MainContextPair{Main: main, Secondary: secondary} } +// MainPanes says which of the two panes of the main section are shown. Most content +// takes the main pane alone; content with two sides to it — the working tree's +// unstaged and staged changes, a commit's diff and the patch built from it — takes +// both; and content whose only side is the second one takes the secondary pane alone, +// so that it has the whole section rather than sitting under an empty pane. +type MainPanes int + +const ( + MainPaneOnly MainPanes = iota + BothMainPanes + SecondaryPaneOnly +) + type MainViewPairs struct { Normal MainContextPair MergeConflicts MainContextPair - Staging MainContextPair - PatchBuilding MainContextPair } type ViewUpdateOpts struct { @@ -25,6 +38,11 @@ type ViewUpdateOpts struct { SubTitle string Task UpdateTask + + // NothingToActOn marks a pane that is being shown only because the layout is + // configured to always split the diff: its side of the file holds nothing, so it + // is not a pane to leave the focus in. + NothingToActOn bool } type RefreshMainOpts struct { @@ -98,3 +116,19 @@ func NewRunPtyTask(cmd *exec.Cmd) *RunPtyTask { func NewRunPtyTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunPtyTask { return &RunPtyTask{Cmd: cmd, Prefix: prefix} } + +// NewMainViewDiffTask returns the task for rendering a diff into a main view. Diffs +// normally run under a pty, since git only hands its output to a diff renderer when it +// thinks it is talking to a terminal — but a diff we are producing with git itself, +// because the renderer's version of it couldn't be acted on, has to keep the renderer +// out, so it runs as a plain command instead. +func NewMainViewDiffTask(cmd *exec.Cmd, mode git_commands.DiffMode) UpdateTask { + return NewMainViewDiffTaskWithPrefix(cmd, "", mode) +} + +func NewMainViewDiffTaskWithPrefix(cmd *exec.Cmd, prefix string, mode git_commands.DiffMode) UpdateTask { + if mode == git_commands.DiffModeRaw { + return NewRunCommandTaskWithPrefix(cmd, prefix) + } + return NewRunPtyTaskWithPrefix(cmd, prefix) +} diff --git a/pkg/gui/types/views.go b/pkg/gui/types/views.go index 1a48d170a..2dff6c2cb 100644 --- a/pkg/gui/types/views.go +++ b/pkg/gui/types/views.go @@ -15,13 +15,9 @@ type Views struct { Commits *gocui.View Stash *gocui.View - Main *gocui.View - Secondary *gocui.View - Staging *gocui.View - StagingSecondary *gocui.View - PatchBuilding *gocui.View - PatchBuildingSecondary *gocui.View - MergeConflicts *gocui.View + Main *gocui.View + Secondary *gocui.View + MergeConflicts *gocui.View Options *gocui.View Confirmation *gocui.View diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 3d924a566..9d70b6e4b 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -133,14 +133,6 @@ func (gui *Gui) render() { gui.c.OnUIThread(func() error { return nil }) } -// renderContentOnly triggers a re-render that skips the layout pass and only -// redraws the views whose content changed (relying on tcell's cell-level dirty -// tracking to emit just the cells that actually differ). Use it when only a -// view's content changed, not the window layout. -func (gui *Gui) renderContentOnly() { - gui.c.OnUIThreadContentOnly(func() error { return nil }) -} - // postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed // if the context's view is set to another context we do nothing. // if the context's view is the current view we trigger a focus; re-selecting the current item. @@ -154,7 +146,7 @@ func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) { // The render may have given the context its first item, or taken its last one // away, which decides whether its view draws a selection at all. - gui.State.ContextMgr.updateSelectionHighlights() + gui.State.ContextMgr.UpdateSelectionHighlights() if gui.currentViewName() == c.GetInputViewName() { c.HandleFocus(opts) diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 7b6fa93eb..9b7fd75de 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -41,10 +41,6 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping { {viewPtr: &gui.Views.SubCommits, name: "subCommits"}, {viewPtr: &gui.Views.CommitFiles, name: "commitFiles"}, - {viewPtr: &gui.Views.Staging, name: "staging"}, - {viewPtr: &gui.Views.StagingSecondary, name: "stagingSecondary"}, - {viewPtr: &gui.Views.PatchBuilding, name: "patchBuilding"}, - {viewPtr: &gui.Views.PatchBuildingSecondary, name: "patchBuildingSecondary"}, {viewPtr: &gui.Views.MergeConflicts, name: "mergeConflicts"}, {viewPtr: &gui.Views.Secondary, name: "secondary"}, {viewPtr: &gui.Views.Main, name: "main"}, @@ -106,16 +102,12 @@ func (gui *Gui) createAllViews() error { gui.Views.Search.Frame = false gui.Views.Search.Editor = gocui.EditorFunc(gui.searchEditor) - for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.Staging, gui.Views.StagingSecondary, gui.Views.PatchBuilding, gui.Views.PatchBuildingSecondary, gui.Views.MergeConflicts} { + for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.MergeConflicts} { view.Wrap = true view.UnderlineHyperLinksOnlyOnHover = true view.AutoRenderHyperLinks = true } - gui.Views.Staging.Wrap = true - gui.Views.StagingSecondary.Wrap = true - gui.Views.PatchBuilding.Wrap = true - gui.Views.PatchBuildingSecondary.Wrap = true gui.Views.MergeConflicts.Wrap = false gui.Views.Limit.Wrap = true @@ -168,6 +160,22 @@ func (gui *Gui) createAllViews() error { gui.Views.Snake.FgColor = gocui.ColorGreen + // The main views show diffs, whose own colors say what each line is: which side of + // the diff it's on, and often its syntax highlighting too. A selection painted + // across the whole line takes those colors over, which for a whole selected hunk + // leaves one unreadable block; so mark the selection with a narrow bar at the left + // edge instead, and leave the rest of the line to the diff. Two columns, enough to + // read as a marker rather than as an artefact. + gui.Views.Main.SelectedLineColorWidth = 2 + gui.Views.Secondary.SelectedLineColorWidth = 2 + + // A tick, for the lines of a commit's diff that are in the custom patch being + // built. A plus would collide with a diff's own plus column. + gui.Views.Main.InclusionGutterMarker = "✓" + gui.Views.Main.InclusionGutterMarkerColor = gocui.ColorGreen + gui.Views.Secondary.InclusionGutterMarker = "✓" + gui.Views.Secondary.InclusionGutterMarkerColor = gocui.ColorGreen + return nil } @@ -179,6 +187,9 @@ func frameRunesWithTopCorners(frameRunes []rune, topLeft rune, topRight rune) [] } func (gui *Gui) configureViewProperties() { + gui.Views.Main.Wrap = gui.c.UserConfig().Gui.WrapLinesInDiffView + gui.Views.Secondary.Wrap = gui.c.UserConfig().Gui.WrapLinesInDiffView + frameRunes := []rune{'─', '│', '┌', '┐', '└', '┘'} // The corners for a view that hangs off the bottom of another one, so that the // border they share reads as a divider rather than as two frames touching. @@ -220,19 +231,15 @@ func (gui *Gui) configureViewProperties() { gui.Views.Submodules.Title = gui.c.Tr.SubmodulesTitle gui.Views.Tags.Title = gui.c.Tr.TagsTitle gui.Views.Files.Title = gui.c.Tr.FilesTitle - gui.Views.PatchBuilding.Title = gui.c.Tr.Patch - gui.Views.PatchBuildingSecondary.Title = gui.c.Tr.CustomPatch gui.Views.MergeConflicts.Title = gui.c.Tr.MergeConflictsTitle gui.Views.Limit.Title = gui.c.Tr.NotEnoughSpace gui.Views.Status.Title = gui.c.Tr.StatusTitle - gui.Views.Staging.Title = gui.c.Tr.UnstagedChanges - gui.Views.StagingSecondary.Title = gui.c.Tr.StagedChanges gui.Views.CommitMessage.Title = gui.c.Tr.CommitSummary gui.Views.CommitDescription.Title = gui.c.Tr.CommitDescriptionTitle gui.Views.Extras.Title = gui.c.Tr.CommandLog gui.Views.Snake.Title = gui.c.Tr.SnakeTitle - for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.Staging, gui.Views.StagingSecondary, gui.Views.PatchBuilding, gui.Views.PatchBuildingSecondary, gui.Views.MergeConflicts} { + for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary, gui.Views.MergeConflicts} { view.Title = gui.c.Tr.DiffTitle view.CanScrollPastBottom = gui.c.UserConfig().Gui.ScrollPastBottom view.TabWidth = gui.c.UserConfig().Gui.TabWidth @@ -273,11 +280,11 @@ func (gui *Gui) configureViewProperties() { } } + gui.focusMainViewJumpLabel = "" if gui.c.UserConfig().Gui.ShowPanelJumps { - gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView) - } else { - gui.Views.Main.TitlePrefix = "" + gui.focusMainViewJumpLabel = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView) } + gui.showFocusMainViewJumpLabelOn(gui.Views.Main) // Index the tab strips by view so we can both set them on views that are // part of a multi-tab panel and clear them on views that no longer are diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 9c72b53df..1115f5f56 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -21,7 +21,6 @@ type TranslationSet struct { EasterEgg string UnstagedChanges string StagedChanges string - StagingTitle string MergingTitle string NormalTitle string LogTitle string @@ -302,16 +301,16 @@ type TranslationSet struct { DiscardSelectionTooltip string ToggleSelectHunk string SelectHunk string + NothingToSelectInDiff string SelectLineByLine string ToggleSelectHunkTooltip string - HunkStagingHint string ToggleSelectionForPatch string RemoveSelectionFromPatch string RemoveSelectionFromPatchTooltip string EditHunk string EditHunkTooltip string - ToggleStagingView string - ToggleStagingViewTooltip string + ToggleDiffPane string + ToggleDiffPaneTooltip string ReturnToFilesPanel string FastForward string FastForwardTooltip string @@ -348,7 +347,6 @@ type TranslationSet struct { CommitMenuTitle string RemotesTitle string RemoteBranchesTitle string - PatchBuildingTitle string InformationTitle string SecondaryTitle string ReflogCommitsTitle string @@ -396,6 +394,8 @@ type TranslationSet struct { AskQuestion string PrevHunk string NextHunk string + PrevFileInDiff string + NextFileInDiff string PrevConflict string NextConflict string SelectPrevHunk string @@ -455,6 +455,7 @@ type TranslationSet struct { CheckoutCommitFileTooltip string CannotCheckoutWithModifiedFilesErr string CanOnlyDiscardFromLocalCommits string + CannotDiscardFromCustomPatchView string CannotDiscardFromMultipleCommits string Remove string DiscardOldFileChangeTooltip string @@ -546,9 +547,9 @@ type TranslationSet struct { PatchOptionsTitle string NoPatchError string EmptyPatchError string - EnterCommitFile string - EnterCommitFileTooltip string - ExitCustomPatchBuilder string + FocusCommitFileDiff string + FocusCommitFileDiffTooltip string + ResetCustomPatch string ExitFocusedMainView string EnterUpstream string InvalidUpstream string @@ -792,7 +793,6 @@ type TranslationSet struct { ToggleWhitespaceInDiffView string ToggleWhitespaceInDiffViewTooltip string IgnoreWhitespaceDiffViewSubTitle string - IgnoreWhitespaceNotSupportedHere string IncreaseContextInDiffView string IncreaseContextInDiffViewTooltip string DecreaseContextInDiffView string @@ -834,7 +834,6 @@ type TranslationSet struct { SortOrderPrompt string SortCommits string SortCommitsTooltip string - CantChangeContextSizeError string CantChangeRenameThresholdError string OpenCommitInBrowser string ViewBisectOptions string @@ -1156,15 +1155,6 @@ const englishNonReloadableConfigWarning = `The following config settings were ch {{configs}}` -const englishHunkStagingHint = `Hunk selection mode is now the default for staging. If you want to stage individual lines, press '%s' to switch to line-by-line mode. - -If you prefer to use line-by-line mode by default (like in earlier lazygit versions), add - -gui: - useHunkModeInStagingView: false - -to your lazygit config.` - // exporting this so we can use it in tests func EnglishTranslationSet() *TranslationSet { return &TranslationSet{ @@ -1178,7 +1168,6 @@ func EnglishTranslationSet() *TranslationSet { EasterEgg: "Easter egg", UnstagedChanges: "Unstaged changes", StagedChanges: "Staged changes", - StagingTitle: "Main panel (staging)", MergingTitle: "Main panel (merging)", NormalTitle: "Main panel (normal)", LogTitle: "Log", @@ -1453,25 +1442,25 @@ func EnglishTranslationSet() *TranslationSet { ExpandAll: "Expand all files", ExpandAllTooltip: "Expand all directories in the file tree", DisabledInFlatView: "Not available in flat view", - FileEnter: `Stage lines / Collapse directory`, - FileEnterTooltip: "If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it.", + FileEnter: `Focus file diff / Collapse directory`, + FileEnterTooltip: "If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it.", StageSelectionTooltip: `Toggle selection staged / unstaged.`, DiscardSelection: `Discard`, DiscardSelectionTooltip: "When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change.", ToggleRangeSelect: "Toggle range select", DismissRangeSelect: "Dismiss range select", ToggleSelectHunk: "Toggle hunk selection", + NothingToSelectInDiff: "There is nothing to select here", SelectHunk: "Select hunks", SelectLineByLine: "Select line-by-line", ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.", - HunkStagingHint: englishHunkStagingHint, ToggleSelectionForPatch: `Toggle lines in patch`, RemoveSelectionFromPatch: `Remove lines from commit`, RemoveSelectionFromPatchTooltip: "Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines.", EditHunk: `Edit hunk`, EditHunkTooltip: "Edit selected hunk in external editor.", - ToggleStagingView: "Switch view", - ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).", + ToggleDiffPane: "Switch diff pane", + ToggleDiffPaneTooltip: "Switch to the other focused diff pane.", ReturnToFilesPanel: `Return to files panel`, FastForward: `Fast-forward`, FastForwardTooltip: "Fast-forward selected branch from its upstream.", @@ -1506,7 +1495,6 @@ func EnglishTranslationSet() *TranslationSet { CommitMenuTitle: "Commit Menu", RemotesTitle: "Remotes", RemoteBranchesTitle: "Remote branches", - PatchBuildingTitle: "Main panel (patch building)", InformationTitle: "Information", SecondaryTitle: "Secondary", ReflogCommitsTitle: "Reflog", @@ -1560,6 +1548,8 @@ func EnglishTranslationSet() *TranslationSet { AskQuestion: "Ask Question", PrevHunk: "Go to previous hunk", NextHunk: "Go to next hunk", + PrevFileInDiff: "Go to previous file", + NextFileInDiff: "Go to next file", PrevConflict: "Previous conflict", NextConflict: "Next conflict", SelectPrevHunk: "Previous hunk", @@ -1619,6 +1609,7 @@ func EnglishTranslationSet() *TranslationSet { CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.", CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.", CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits", + CannotDiscardFromCustomPatchView: "Lines shown here are the custom patch's; press space to take them back out of it", CannotDiscardFromMultipleCommits: "Changes cannot be discarded from a multiselection of commits", Remove: "Remove", DiscardOldFileChangeTooltip: "Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file.", @@ -1711,9 +1702,9 @@ func EnglishTranslationSet() *TranslationSet { PatchOptionsTitle: "Patch options", NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines", EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.", - EnterCommitFile: "Enter file / Toggle directory collapsed", - EnterCommitFileTooltip: "If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory.", - ExitCustomPatchBuilder: `Exit custom patch builder`, + FocusCommitFileDiff: "Focus file diff / Toggle directory", + FocusCommitFileDiffTooltip: "If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it.", + ResetCustomPatch: `Reset custom patch`, ExitFocusedMainView: "Exit back to side panel", EnterUpstream: `Enter upstream as ' '`, InvalidUpstream: "Invalid upstream. Must be in the format ' '", @@ -1954,7 +1945,6 @@ func EnglishTranslationSet() *TranslationSet { ToggleWhitespaceInDiffView: "Toggle whitespace", ToggleWhitespaceInDiffViewTooltip: "Toggle whether or not whitespace changes are shown in the diff view.\n\nThe default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'.", IgnoreWhitespaceDiffViewSubTitle: "(ignoring whitespace)", - IgnoreWhitespaceNotSupportedHere: "Ignoring whitespace is not supported in this view", IncreaseContextInDiffView: "Increase diff context size", IncreaseContextInDiffViewTooltip: "Increase the amount of the context shown around changes in the diff view.\n\nThe default can be changed in the config file with the key 'git.diffContextSize'.", DecreaseContextInDiffView: "Decrease diff context size", @@ -1994,7 +1984,6 @@ func EnglishTranslationSet() *TranslationSet { SortBasedOnReflog: "(based on reflog)", SortCommits: "Commit sort order", SortCommitsTooltip: "Change the sort order of the commits in the commit log.\n\nThe default can be changed in the config file with the key 'git.log.sortOrder'.", - CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!", CantChangeRenameThresholdError: "Cannot change the rename similarity threshold while in patch building mode, because the custom patch can't cope with a rename turning into a delete and add underneath it.", OpenCommitInBrowser: "Open commit in browser", ViewBisectOptions: "View bisect options", diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index dfae58c4b..a52e78e47 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -88,6 +88,17 @@ func (self *ViewDriver) IsImmediatelyBelow(upper *ViewDriver) *ViewDriver { return self } +// TitlePrefix asserts on the label a view wears in front of its title, which is the +// key that jumps to it. +func (self *ViewDriver) TitlePrefix(expected *TextMatcher) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + actual := self.getView().TitlePrefix + return expected.context(fmt.Sprintf("%s title prefix", self.context)).test(actual) + }) + + return self +} + func (self *ViewDriver) Clear() *ViewDriver { // clearing multiple times in case there's multiple lines // (the clear button only clears a single line at a time) @@ -177,7 +188,7 @@ func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver { expectedContent := expectedContentFromMatchers(matchers) return false, fmt.Sprintf( - "Expected the following to be contained in the staging panel:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----\nSelected range: %d-%d", + "Expected the following lines to be contained in the selected range:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----\nSelected range: %d-%d", expectedContent, content, startIdx, @@ -249,6 +260,21 @@ func (self *ViewDriver) SelectedLines(matchers ...*TextMatcher) *ViewDriver { return self } +// SelectedViewLineRange asserts which view lines the selection covers. View lines +// count the wrapped segments a line is drawn as, so this can say whether a selection +// covers a wrapped line to its end; SelectedLines, which reports the lines of the +// content, cannot. +func (self *ViewDriver) SelectedViewLineRange(first int, last int) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + actualFirst, actualLast := self.getSelectedRange() + return actualFirst == first && actualLast == last, + fmt.Sprintf("%s: Expected view lines %d-%d to be selected, but %d-%d were.", + self.context, first, last, actualFirst, actualLast) + }) + + return self +} + func (self *ViewDriver) validateMatchersPassed(matchers []*TextMatcher) { if len(matchers) < 1 { self.t.fail("'Lines' methods require at least one matcher to be passed as an argument. If you are trying to assert that there are no lines, use .IsEmpty()") @@ -360,6 +386,48 @@ func (self *ViewDriver) Content(matcher *TextMatcher) *ViewDriver { return self } +// MarkedLines asserts which lines of the view are marked as being in the custom patch +// being built. The marks are drawn over the content rather than being part of it, so +// they are read from the view rather than matched against what Content returns. +func (self *ViewDriver) MarkedLines(matchers ...*TextMatcher) *ViewDriver { + self.validateMatchersPassed(matchers) + + self.t.assertWithRetries(func() (bool, string) { + markedLines := self.getView().MarkedLines() + + markedContent := strings.Join(markedLines, "\n") + expectedContent := expectedContentFromMatchers(matchers) + + if len(markedLines) != len(matchers) { + return false, fmt.Sprintf("%s: Expected the following lines to be marked as being in the custom patch:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----", self.context, expectedContent, markedContent) + } + + for i, line := range markedLines { + ok, message := matchers[i].test(line) + if !ok { + return false, fmt.Sprintf("%s: Error: %s. Expected the following lines to be marked as being in the custom patch:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----", self.context, message, expectedContent, markedContent) + } + } + + return true, "" + }) + + return self +} + +// NoMarkedLines asserts that no line of the view is marked as being in the custom +// patch, which is also what a view showing no marks at all reports. +func (self *ViewDriver) NoMarkedLines() *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + markedLines := self.getView().MarkedLines() + return len(markedLines) == 0, fmt.Sprintf( + "%s: Expected no line to be marked as being in the custom patch, but these were:\n-----\n%s\n-----", + self.context, strings.Join(markedLines, "\n")) + }) + + return self +} + // SelectionIsActive asserts that the view draws its selection as the one the user // is working in. These three assertions read the highlight flags rather than the // selected lines, which say nothing about whether the selection is drawn at all. diff --git a/pkg/integration/components/views.go b/pkg/integration/components/views.go index 5c91b6937..e8cb1dc61 100644 --- a/pkg/integration/components/views.go +++ b/pkg/integration/components/views.go @@ -34,10 +34,6 @@ func (self *Views) regularView(viewName string) *ViewDriver { } } -func (self *Views) patchExplorerViewByName(viewName string) *ViewDriver { - return self.regularView(viewName) -} - func (self *Views) MergeConflicts() *ViewDriver { return self.regularView("mergeConflicts") } @@ -102,24 +98,6 @@ func (self *Views) Stash() *ViewDriver { return self.regularView("stash") } -func (self *Views) Staging() *ViewDriver { - return self.patchExplorerViewByName("staging") -} - -func (self *Views) StagingSecondary() *ViewDriver { - return self.patchExplorerViewByName("stagingSecondary") -} - -func (self *Views) PatchBuilding() *ViewDriver { - return self.patchExplorerViewByName("patchBuilding") -} - -func (self *Views) PatchBuildingSecondary() *ViewDriver { - // this is not a patch explorer view because you can't actually focus it: it - // just renders content - return self.regularView("patchBuildingSecondary") -} - func (self *Views) Menu() *ViewDriver { return self.regularView("menu") } diff --git a/pkg/integration/tests/commit/discard_old_file_changes.go b/pkg/integration/tests/commit/discard_old_file_changes.go index 2dc8006bb..0d0dc7818 100644 --- a/pkg/integration/tests/commit/discard_old_file_changes.go +++ b/pkg/integration/tests/commit/discard_old_file_changes.go @@ -133,7 +133,7 @@ var DiscardOldFileChanges = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("multiLineFile")). PressEnter() - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). SelectedLine( Contains("+this file has"), diff --git a/pkg/integration/tests/commit/stage_range_of_lines.go b/pkg/integration/tests/commit/stage_range_of_lines.go index 5cac85d49..a3d9209bc 100644 --- a/pkg/integration/tests/commit/stage_range_of_lines.go +++ b/pkg/integration/tests/commit/stage_range_of_lines.go @@ -10,7 +10,7 @@ var StageRangeOfLines = NewIntegrationTest(NewIntegrationTestArgs{ ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false + config.GetUserConfig().Gui.UseHunkModeInDiffView = false }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("myfile", "1st\n2nd\n3rd\n4th\n5th\n6th\n") @@ -20,9 +20,10 @@ var StageRangeOfLines = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().Staging(). + t.Views().Main(). + IsFocused(). Content( Contains("-1st\n-2nd\n+1st changed\n+2nd changed\n 3rd\n 4th\n-5th\n+5th changed\n 6th"), ). diff --git a/pkg/integration/tests/commit/staged.go b/pkg/integration/tests/commit/staged.go index aac40313b..29a694f49 100644 --- a/pkg/integration/tests/commit/staged.go +++ b/pkg/integration/tests/commit/staged.go @@ -28,25 +28,25 @@ var Staged = NewIntegrationTest(NewIntegrationTestArgs{ ). SelectNextItem(). PressPrimaryAction(). // stage the file - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().StagingSecondary(). + t.Views().Secondary(). IsFocused(). Tap(func() { // we start with both lines having been staged - t.Views().StagingSecondary().Content(Contains("+myfile content")) - t.Views().StagingSecondary().Content(Contains("+with a second line")) - t.Views().Staging().Content(DoesNotContain("+myfile content")) - t.Views().Staging().Content(DoesNotContain("+with a second line")) + t.Views().Secondary().Content(Contains("+myfile content")) + t.Views().Secondary().Content(Contains("+with a second line")) + t.Views().Main().Content(DoesNotContain("+myfile content")) + t.Views().Main().Content(DoesNotContain("+with a second line")) }). // unstage the selected line PressPrimaryAction(). Tap(func() { // the line should have been moved to the main view - t.Views().StagingSecondary().Content(DoesNotContain("+myfile content")) - t.Views().StagingSecondary().Content(Contains("+with a second line")) - t.Views().Staging().Content(Contains("+myfile content")) - t.Views().Staging().Content(DoesNotContain("+with a second line")) + t.Views().Secondary().Content(DoesNotContain("+myfile content")) + t.Views().Secondary().Content(Contains("+with a second line")) + t.Views().Main().Content(Contains("+myfile content")) + t.Views().Main().Content(DoesNotContain("+with a second line")) }). Press(keys.Files.CommitChanges) @@ -58,10 +58,9 @@ var Staged = NewIntegrationTest(NewIntegrationTestArgs{ Contains(commitMessage), ) - t.Views().StagingSecondary(). - IsEmpty() + t.Views().Secondary().IsInvisible() - t.Views().Staging(). + t.Views().Main(). IsFocused(). Content(Contains("+myfile content")). Content(DoesNotContain("+with a second line")) diff --git a/pkg/integration/tests/commit/staged_without_hooks.go b/pkg/integration/tests/commit/staged_without_hooks.go index b4e16c8ea..fe3c9fa4e 100644 --- a/pkg/integration/tests/commit/staged_without_hooks.go +++ b/pkg/integration/tests/commit/staged_without_hooks.go @@ -34,23 +34,23 @@ var StagedWithoutHooks = NewIntegrationTest(NewIntegrationTestArgs{ ). SelectNextItem(). PressPrimaryAction(). - PressEnter() + Press(keys.Universal.FocusMainView) // we start with both lines having been staged - t.Views().StagingSecondary().Content( + t.Views().Secondary().Content( Contains("+myfile content").Contains("+with a second line"), ) - t.Views().Staging().Content( + t.Views().Main().Content( DoesNotContain("+myfile content").DoesNotContain("+with a second line"), ) // unstage the selected line - t.Views().StagingSecondary(). + t.Views().Secondary(). IsFocused(). PressPrimaryAction(). Tap(func() { // the line should have been moved to the main view - t.Views().Staging().Content(Contains("+myfile content").DoesNotContain("+with a second line")) + t.Views().Main().Content(Contains("+myfile content").DoesNotContain("+with a second line")) }). Content(DoesNotContain("+myfile content").Contains("+with a second line")). Press(keys.Files.CommitChangesWithoutHook) @@ -63,10 +63,9 @@ var StagedWithoutHooks = NewIntegrationTest(NewIntegrationTestArgs{ Contains(commitMessage), ) - t.Views().StagingSecondary(). - IsEmpty() + t.Views().Secondary().IsInvisible() - t.Views().Staging(). + t.Views().Main(). IsFocused(). Content(Contains("+myfile content")). Content(DoesNotContain("+with a second line")) diff --git a/pkg/integration/tests/commit/unstaged.go b/pkg/integration/tests/commit/unstaged.go index 043e6ea9c..b12c3f259 100644 --- a/pkg/integration/tests/commit/unstaged.go +++ b/pkg/integration/tests/commit/unstaged.go @@ -27,20 +27,20 @@ var Unstaged = NewIntegrationTest(NewIntegrationTestArgs{ Contains("myfile2"), ). SelectNextItem(). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().Staging(). + t.Views().Main(). IsFocused(). Tap(func() { - t.Views().StagingSecondary().Content(DoesNotContain("+myfile content")) - t.Views().Staging().SelectedLine(Equals("+myfile content")) + t.Views().Secondary().Content(DoesNotContain("+myfile content")) + t.Views().Main().SelectedLine(Equals("+myfile content")) }). // stage the first line PressPrimaryAction(). Tap(func() { - t.Views().Staging().Content(DoesNotContain("+myfile content")). + t.Views().Main().Content(DoesNotContain("+myfile content")). SelectedLine(Equals("+with a second line")) - t.Views().StagingSecondary().Content(Contains("+myfile content")) + t.Views().Secondary().Content(Contains("+myfile content")) }). Press(keys.Files.CommitChanges) @@ -52,8 +52,6 @@ var Unstaged = NewIntegrationTest(NewIntegrationTestArgs{ Contains(commitMessage), ) - t.Views().Staging().IsFocused() - - // TODO: assert that the staging panel has been refreshed (it currently does not get correctly refreshed) + t.Views().Main().IsFocused() }, }) diff --git a/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go b/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go index 30ae73e54..1355ef520 100644 --- a/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go +++ b/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go @@ -54,6 +54,6 @@ var ResolveWithoutTrailingLf = NewIntegrationTest(NewIntegrationTestArgs{ Contains("M file").IsSelected(), ) - t.Views().Main().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol")) + t.Views().Secondary().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol")) }, }) diff --git a/pkg/integration/tests/demo/custom_patch.go b/pkg/integration/tests/demo/custom_patch.go index 24e05c633..923596397 100644 --- a/pkg/integration/tests/demo/custom_patch.go +++ b/pkg/integration/tests/demo/custom_patch.go @@ -22,7 +22,7 @@ var CustomPatch = NewIntegrationTest(NewIntegrationTestArgs{ IsDemo: true, SetupConfig: func(cfg *config.AppConfig) { setDefaultDemoConfig(cfg) - cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(30) @@ -54,7 +54,7 @@ var CustomPatch = NewIntegrationTest(NewIntegrationTestArgs{ Wait(1000). PressEnter(). Tap(func() { - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). NavigateToLine(Contains("TODO")). Wait(500). diff --git a/pkg/integration/tests/demo/stage_lines.go b/pkg/integration/tests/demo/stage_lines.go index 1304d39e5..8d05749f6 100644 --- a/pkg/integration/tests/demo/stage_lines.go +++ b/pkg/integration/tests/demo/stage_lines.go @@ -58,7 +58,7 @@ var StageLines = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). PressEnter() - t.Views().Staging(). + t.Views().Main(). IsFocused(). Press(keys.Universal.ToggleRangeSelect). PressFast(keys.Universal.NextItem). diff --git a/pkg/integration/tests/diff/diff_and_apply_patch.go b/pkg/integration/tests/diff/diff_and_apply_patch.go index 6801e7bea..79b54080c 100644 --- a/pkg/integration/tests/diff/diff_and_apply_patch.go +++ b/pkg/integration/tests/diff/diff_and_apply_patch.go @@ -73,6 +73,7 @@ var DiffAndApplyPatch = NewIntegrationTest(NewIntegrationTestArgs{ Focus(). SelectedLine(Contains("file1")) - t.Views().Main().Content(Contains("+second line")) + // The patch was applied to the index, so the file's changes are all staged. + t.Views().Secondary().Content(Contains("+second line")) }, }) diff --git a/pkg/integration/tests/diff/diff_renderer_metadata.go b/pkg/integration/tests/diff/diff_renderer_metadata.go new file mode 100644 index 000000000..ff211717d --- /dev/null +++ b/pkg/integration/tests/diff/diff_renderer_metadata.go @@ -0,0 +1,50 @@ +package diff + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiffRendererMetadata = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A diff renderer is told that we understand the OSC 1717 metadata protocol, and the records it emits don't show up in the rendered diff", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // A fake conforming renderer: it announces the protocol with a + // version-only record, reports the protocol versions it was offered, and + // then passes the diff through with a per-line record in front of every + // line. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007'; ` + + `printf 'OFFERED:%s\n' "$OSC1717"; ` + + `while IFS= read -r line; do printf '\033]1717;1;c;1;;file1\007%s\n' "$line"; done`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\n") + shell.Commit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("one").IsSelected(), + ) + + t.Views().Main(). + // The renderer was offered the protocol version we understand. + Content(Contains("OFFERED:V1")). + // Its records are escape sequences, so none of them reaches the + // screen; the diff reads exactly as the renderer wrote it. + ContainsLines( + Equals("diff --git a/file1 b/file1"), + Contains("new file mode"), + Contains("index "), + Equals("--- /dev/null"), + Equals("+++ b/file1"), + Equals("@@ -0,0 +1,2 @@"), + Equals("+one"), + Equals("+two"), + ) + }, +}) diff --git a/pkg/integration/tests/file/directory_diff_with_renamed_files.go b/pkg/integration/tests/file/directory_diff_with_renamed_files.go index 18906bf03..10890130e 100644 --- a/pkg/integration/tests/file/directory_diff_with_renamed_files.go +++ b/pkg/integration/tests/file/directory_diff_with_renamed_files.go @@ -32,7 +32,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" R dir/nested/file3 → file3"), ) - t.Views().Main().ContainsLines( + t.Views().Secondary().ContainsLines( Equals("diff --git a/file1 b/dir/file1"), Equals("similarity index 100%"), Equals("rename from file1"), @@ -51,7 +51,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ SelectNextItem(). SelectedLine(Equals(" ▼ dir")) - t.Views().Main(). + t.Views().Secondary(). ContainsLines( Equals("diff --git a/file1 b/dir/file1"), Equals("similarity index 100%"), @@ -75,7 +75,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" R file1 → file1"), ) - t.Views().Main(). + t.Views().Secondary(). ContainsLines( Equals("diff --git a/file1 b/dir/file1"), Equals("similarity index 100%"), diff --git a/pkg/integration/tests/file/pane_shown_again_starts_at_the_top.go b/pkg/integration/tests/file/pane_shown_again_starts_at_the_top.go new file mode 100644 index 000000000..3fc38c584 --- /dev/null +++ b/pkg/integration/tests/file/pane_shown_again_starts_at_the_top.go @@ -0,0 +1,61 @@ +package file + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PaneShownAgainStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A pane that was emptied while it wasn't shown starts at the top when it comes back, rather than where it was left", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.CreateFileAndAdd("file2", "one\n") + shell.Commit("one") + + // More staged changes in file1 than fit in the pane they are shown in, so that + // there is a position in it to lose, plus an unstaged change to give the file a + // second pane. + for i := range lines { + lines[i] = strings.ToUpper(lines[i]) + } + shell.UpdateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.UpdateFile("file1", strings.Join(lines, "\n")+"unstaged\n") + + shell.UpdateFile("file2", "two\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + NavigateToLine(Contains("file1")) + + t.Views().Secondary(). + IsVisible(). + Title(Equals("Staged changes")). + ScrollWheelDown(). + ScrollWheelDown(). + OriginYAtLeast(1) + + // A file with nothing staged leaves that pane with nothing to show, so it goes + // away and is emptied. + t.Views().Files().NavigateToLine(Contains("file2")) + t.Views().Secondary().IsInvisible() + + t.Views().Files().NavigateToLine(Contains("file1")) + t.Views().Secondary(). + IsVisible(). + Content(Contains("+LINE40")). + OriginY(0) + }, +}) diff --git a/pkg/integration/tests/file/pane_taking_over_starts_at_the_top.go b/pkg/integration/tests/file/pane_taking_over_starts_at_the_top.go new file mode 100644 index 000000000..fe8d673b9 --- /dev/null +++ b/pkg/integration/tests/file/pane_taking_over_starts_at_the_top.go @@ -0,0 +1,85 @@ +package file + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// changedLines is a file's worth of numbered lines, prefixed so that each file's diff +// can be told from the other's on screen. +func changedLines(prefix string) string { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("%s%02d", prefix, i+1) + } + return strings.Join(lines, "\n") + "\n" +} + +var PaneTakingOverStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A pane taking the main section over shows its diff from the top, rather than at the offset it was left at", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", changedLines("one")) + shell.CreateFileAndAdd("file2", changedLines("two")) + shell.Commit("one") + + // One file's changes are unstaged and the other's are staged, so each is shown + // in a pane of its own — and selecting one after the other hands the section + // from one pane to the other. Both diffs are longer than the section, so either + // pane can be scrolled. + shell.UpdateFile("file1", changedLines("ONE")) + shell.UpdateFileAndAdd("file2", changedLines("TWO")) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + NavigateToLine(Contains("file1")) + + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsVisible(). + Title(Equals("Unstaged changes")). + ScrollWheelDown(). + ScrollWheelDown(). + OriginYAtLeast(1) + + t.Views().Files().NavigateToLine(Contains("file2")) + + t.Views().Main().IsInvisible() + t.Views().Secondary(). + IsVisible(). + Title(Equals("Staged changes")). + Content(Contains("+TWO40")). + OriginY(0). + ScrollWheelDown(). + ScrollWheelDown(). + OriginYAtLeast(1) + + // Back to the pane that was left scrolled: what it is given is a diff the user + // hasn't seen there, so it starts at the top like any other. + t.Views().Files().NavigateToLine(Contains("file1")) + + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsVisible(). + Title(Equals("Unstaged changes")). + Content(Contains("+ONE40")). + OriginY(0) + + t.Views().Files().NavigateToLine(Contains("file2")) + + t.Views().Main().IsInvisible() + t.Views().Secondary(). + IsVisible(). + Title(Equals("Staged changes")). + Content(Contains("+TWO40")). + OriginY(0) + }, +}) diff --git a/pkg/integration/tests/file/rename_similarity_threshold_change.go b/pkg/integration/tests/file/rename_similarity_threshold_change.go index ac3ae37d3..6151846c2 100644 --- a/pkg/integration/tests/file/rename_similarity_threshold_change.go +++ b/pkg/integration/tests/file/rename_similarity_threshold_change.go @@ -34,7 +34,7 @@ var RenameSimilarityThresholdChange = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.FocusMainView). Tap(func() { - t.Views().Main(). + t.Views().Secondary(). Press(keys.Universal.IncreaseRenameSimilarityThreshold) t.ExpectToast(Equals("Changed rename similarity threshold to 50%")) }). diff --git a/pkg/integration/tests/file/staged_changes_in_lower_pane.go b/pkg/integration/tests/file/staged_changes_in_lower_pane.go new file mode 100644 index 000000000..e23fb9f06 --- /dev/null +++ b/pkg/integration/tests/file/staged_changes_in_lower_pane.go @@ -0,0 +1,81 @@ +package file + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StagedChangesInLowerPane = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A file's staged changes are shown in the lower pane whether or not it also has unstaged ones", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("both", "one\n") + shell.CreateFileAndAdd("indexOnly", "one\n") + shell.CreateFileAndAdd("worktreeOnly", "one\n") + shell.Commit("one") + + shell.UpdateFileAndAdd("both", "one\nstaged\n") + shell.UpdateFile("both", "one\nstaged\nunstaged\n") + // More staged lines than fit in the pane, so that it can be scrolled. + staged := make([]string, 40) + for i := range staged { + staged[i] = fmt.Sprintf("staged%02d", i+1) + } + shell.UpdateFileAndAdd("indexOnly", "one\n"+strings.Join(staged, "\n")+"\n") + shell.UpdateFile("worktreeOnly", "one\nunstaged\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + NavigateToLine(Contains("both")) + + // With changes on both sides, each side has its own pane. + t.Views().Main(). + Title(Equals("Unstaged changes")). + Content(Contains("+unstaged")) + t.Views().Secondary(). + Title(Equals("Staged changes")). + Content(Contains("+staged")) + + // With nothing unstaged, the staged side keeps its pane, which then has the + // whole space to itself. + t.Views().Files().NavigateToLine(Contains("indexOnly")) + + t.Views().Main().IsInvisible() + t.Views().Secondary(). + IsVisible(). + Title(Equals("Staged changes")). + Content(Contains("+staged01")). + // The key that focuses the diff wears its label, wherever the diff is. + TitlePrefix(Equals("[0]")). + OriginY(0) + + // And the keys for scrolling the diff scroll the pane it is in. + t.GlobalPress(keys.Universal.ScrollDownMain) + t.Views().Secondary().OriginYAtLeast(1) + t.GlobalPress(keys.Universal.ScrollUpMain) + t.Views().Secondary().OriginY(0) + + // And focusing the diff focuses the pane it is in. + t.Views().Files().Press(keys.Universal.FocusMainView) + t.Views().Secondary().IsFocused() + t.Views().Secondary().PressEscape() + + // With nothing staged, only the upper pane is shown. + t.Views().Files(). + IsFocused(). + NavigateToLine(Contains("worktreeOnly")) + + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsVisible(). + Title(Equals("Unstaged changes")). + Content(Contains("+unstaged")). + TitlePrefix(Equals("[0]")) + }, +}) diff --git a/pkg/integration/tests/filter_and_search/nested_filter.go b/pkg/integration/tests/filter_and_search/nested_filter.go index 6ccb606f2..7400b505e 100644 --- a/pkg/integration/tests/filter_and_search/nested_filter.go +++ b/pkg/integration/tests/filter_and_search/nested_filter.go @@ -71,19 +71,7 @@ var NestedFilter = NewIntegrationTest(NewIntegrationTestArgs{ FilterOrSearch("grape"). Lines( Equals("A grape").IsSelected(), - ). - PressEnter() - - t.Views().PatchBuilding(). - IsFocused(). - FilterOrSearch("newline"). - SelectedLine(Contains("No newline at end of file")). - PressEscape(). // cancel search - Tap(func() { - t.Views().Search().IsInvisible() - }). - // escape to commit-files view - PressEscape() + ) t.Views().CommitFiles(). IsFocused(). diff --git a/pkg/integration/tests/main_view/advance_after_staging_shifts_line_numbers.go b/pkg/integration/tests/main_view/advance_after_staging_shifts_line_numbers.go new file mode 100644 index 000000000..da2399901 --- /dev/null +++ b/pkg/integration/tests/main_view/advance_after_staging_shifts_line_numbers.go @@ -0,0 +1,45 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AdvanceAfterStagingShiftsLineNumbers = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Staging a hunk that adds a line still leaves the selection on the next hunk, whose line numbers it moved", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = true + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "1\n2\n3\n4\n5\n6\n7\n8\n") + shell.Commit("one") + + // Three change blocks: a modification, an added line, and another modification + // below it. Staging the middle one changes how many lines the file has, and so + // where the last one sits. + shell.UpdateFile("file1", "1\nX\n3\n4\nNEW\n5\n6\nY\n8\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-2"), + Contains("+X"), + ). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("+NEW"), + ). + PressPrimaryAction(). + SelectedLines( + Contains("-7"), + Contains("+Y"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/apply.go b/pkg/integration/tests/main_view/apply_custom_patch.go similarity index 74% rename from pkg/integration/tests/patch_building/apply.go rename to pkg/integration/tests/main_view/apply_custom_patch.go index 533ad1e23..b3953d2a4 100644 --- a/pkg/integration/tests/patch_building/apply.go +++ b/pkg/integration/tests/main_view/apply_custom_patch.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var Apply = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Apply a custom patch", +var ApplyCustomPatch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Apply a custom patch built from a branch commit's focused diff", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-a") shell.CreateFileAndAdd("file1", "first line\n") @@ -18,7 +20,6 @@ var Apply = NewIntegrationTest(NewIntegrationTestArgs{ shell.NewBranch("branch-b") shell.UpdateFileAndAdd("file1", "first line\nsecond line\n") shell.Commit("update") - shell.Checkout("branch-a") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { @@ -44,12 +45,15 @@ var Apply = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("M file1").IsSelected(), ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+second line")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Views().Secondary().Content(Contains("second line")) - t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`)) t.Views().Files(). @@ -57,8 +61,6 @@ var Apply = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("file1").IsSelected(), ) - - t.Views().Main(). - Content(Contains("second line")) + t.Views().Secondary().Content(Contains("second line")) }, }) diff --git a/pkg/integration/tests/patch_building/apply_in_reverse.go b/pkg/integration/tests/main_view/apply_custom_patch_in_reverse.go similarity index 69% rename from pkg/integration/tests/patch_building/apply_in_reverse.go rename to pkg/integration/tests/main_view/apply_custom_patch_in_reverse.go index f2aa6b3a8..a4b92c840 100644 --- a/pkg/integration/tests/patch_building/apply_in_reverse.go +++ b/pkg/integration/tests/main_view/apply_custom_patch_in_reverse.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var ApplyInReverse = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Apply a custom patch in reverse", +var ApplyCustomPatchInReverse = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Apply a custom patch built from a focused commit diff in reverse", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content\n") shell.CreateFileAndAdd("file2", "file2 content\n") @@ -30,13 +32,15 @@ var ApplyInReverse = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" A file1"), Equals(" A file2"), ). - SelectNextItem(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+file1 content")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Views().Secondary().Content(Contains("+file1 content")) - t.Common().SelectPatchOption(Contains("Apply patch in reverse")) t.Views().Files(). @@ -44,8 +48,6 @@ var ApplyInReverse = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("D").Contains("file1").IsSelected(), ) - - t.Views().Main(). - Content(Contains("-file1 content")) + t.Views().Secondary().Content(Contains("-file1 content")) }, }) diff --git a/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go b/pkg/integration/tests/main_view/apply_custom_patch_in_reverse_with_conflict.go similarity index 70% rename from pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go rename to pkg/integration/tests/main_view/apply_custom_patch_in_reverse_with_conflict.go index d9f99a703..803d933a9 100644 --- a/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go +++ b/pkg/integration/tests/main_view/apply_custom_patch_in_reverse_with_conflict.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var ApplyInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Apply a custom patch in reverse, resulting in a conflict", +var ApplyCustomPatchInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Apply a multi-file custom patch in reverse when one file conflicts", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content\n") shell.CreateFileAndAdd("file2", "file2 content\n") @@ -38,21 +40,17 @@ var ApplyInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" M file1"), Equals(" M file2"), ). - SelectNextItem(). - // Add both files to the patch; the first will conflict, the second won't - PressPrimaryAction(). - Tap(func() { - t.Views().Information().Content(Contains("Building patch")) + Press(keys.Universal.FocusMainView) - t.Views().Secondary().Content( - Contains("+more file1 content")) - }). - SelectNextItem(). + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+more file1 content")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+more file2 content")). PressPrimaryAction() - t.Views().Secondary().Content( - Contains("+more file1 content").Contains("+more file2 content")) - + t.Views().Information().Content(Contains("Building patch")) + t.Views().Secondary().Content(Contains("+more file1 content").Contains("+more file2 content")) t.Common().SelectPatchOption(Contains("Apply patch in reverse")) t.ExpectPopup().Alert(). @@ -87,12 +85,10 @@ var ApplyInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" M file1").IsSelected(), Equals(" M file2"), ) - - t.Views().Main(). - ContainsLines( - Contains(" file1 content"), - Contains("-more file1 content"), - Contains("-even more file1"), - ) + t.Views().Secondary().ContainsLines( + Contains(" file1 content"), + Contains("-more file1 content"), + Contains("-even more file1"), + ) }, }) diff --git a/pkg/integration/tests/patch_building/apply_with_modified_file_no_conflict.go b/pkg/integration/tests/main_view/apply_custom_patch_with_modified_file.go similarity index 76% rename from pkg/integration/tests/patch_building/apply_with_modified_file_no_conflict.go rename to pkg/integration/tests/main_view/apply_custom_patch_with_modified_file.go index 66d32a654..8360a8bf5 100644 --- a/pkg/integration/tests/patch_building/apply_with_modified_file_no_conflict.go +++ b/pkg/integration/tests/main_view/apply_custom_patch_with_modified_file.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var ApplyWithModifiedFileNoConflict = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Apply a custom patch, with a modified file in the working tree that does not conflict with the patch", +var ApplyCustomPatchWithModifiedFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Apply a custom patch alongside a non-conflicting working-tree change", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-a") shell.CreateFileAndAdd("file1", "1\n2\n3\n") @@ -45,12 +47,15 @@ var ApplyWithModifiedFileNoConflict = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Equals("M file1").IsSelected(), ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+4")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Views().Secondary().Content(Contains("3\n+4")) - t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`)) t.ExpectPopup().Confirmation().Title(Equals("Must stage files")). @@ -62,8 +67,6 @@ var ApplyWithModifiedFileNoConflict = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Equals("M file1").IsSelected(), ) - - t.Views().Main(). - Content(Contains("-1\n+11\n 2\n 3\n+4")) + t.Views().Secondary().Content(Contains("-1\n+11\n 2\n 3\n+4")) }, }) diff --git a/pkg/integration/tests/patch_building/apply_with_modified_file_conflict.go b/pkg/integration/tests/main_view/apply_custom_patch_with_modified_file_conflict.go similarity index 78% rename from pkg/integration/tests/patch_building/apply_with_modified_file_conflict.go rename to pkg/integration/tests/main_view/apply_custom_patch_with_modified_file_conflict.go index 5b529ad70..708e7337f 100644 --- a/pkg/integration/tests/patch_building/apply_with_modified_file_conflict.go +++ b/pkg/integration/tests/main_view/apply_custom_patch_with_modified_file_conflict.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var ApplyWithModifiedFileConflict = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Apply a custom patch, with a modified file in the working tree that conflicts with the patch", +var ApplyCustomPatchWithModifiedFileConflict = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Apply a custom patch that conflicts with a working-tree change", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.NewBranch("branch-a") shell.CreateFileAndAdd("file1", "1\n2\n3\n") @@ -45,12 +47,17 @@ var ApplyWithModifiedFileConflict = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Equals("M file1").IsSelected(), ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-1")). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Views().Secondary().Content(Contains("-1\n+11\n")) - t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`)) t.ExpectPopup().Confirmation().Title(Equals("Must stage files")). diff --git a/pkg/integration/tests/main_view/build_patch_from_a_commits_diff.go b/pkg/integration/tests/main_view/build_patch_from_a_commits_diff.go new file mode 100644 index 000000000..895a61e6f --- /dev/null +++ b/pkg/integration/tests/main_view/build_patch_from_a_commits_diff.go @@ -0,0 +1,104 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var BuildPatchFromACommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Take lines of a commit's diff into a custom patch, and back out of it, from the focused main view", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "ONE\ntwo\nTHREE\nfour\nfive\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("second commit").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-one"), + ). + // The line goes into the patch, which the pane beside the diff previews, and + // the selection moves on to the next change rather than staying where a + // second press would take it straight back out. + PressPrimaryAction(). + SelectedLines( + Contains("+ONE"), + ) + + t.Views().Information().Content(Contains("Building patch")) + t.Views().Secondary().ContainsLines( + Contains("-one"), + Contains(" two"), + ) + // The line that is in the patch is marked as such over the diff itself. + t.Views().Main().MarkedLines( + Contains("-one"), + ) + + // The addition of the same modification goes in too, and the patch holds both. + t.Views().Main(). + IsFocused(). + PressPrimaryAction(). + SelectedLines( + Contains("-three"), + ) + + t.Views().Secondary().ContainsLines( + Contains("-one"), + Contains("+ONE"), + Contains(" two"), + ) + t.Views().Main().MarkedLines( + Contains("-one"), + Contains("+ONE"), + ) + + // Pointing at a line that is in the patch takes it back out. + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("+ONE")). + PressPrimaryAction() + + t.Views().Secondary(). + ContainsLines( + Contains("-one"), + Contains(" two"), + ). + Content(DoesNotContain("+ONE")) + t.Views().Main().MarkedLines( + Contains("-one"), + ) + + // Taking the last line out ends the patch, so the pane previewing it goes away. + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("-one")). + PressPrimaryAction() + + t.Views().Information().Content(DoesNotContain("Building patch")) + t.Views().Main().NoMarkedLines() + }, +}) diff --git a/pkg/integration/tests/main_view/build_patch_from_a_reflog_entry.go b/pkg/integration/tests/main_view/build_patch_from_a_reflog_entry.go new file mode 100644 index 000000000..f97d502c2 --- /dev/null +++ b/pkg/integration/tests/main_view/build_patch_from_a_reflog_entry.go @@ -0,0 +1,54 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var BuildPatchFromAReflogEntry = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Take lines of the diff of a reflog entry into a custom patch, which can then be applied", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().ReflogCommits(). + Focus(). + Lines( + Contains("second commit").IsSelected(), + Contains("first commit"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + t.Views().Secondary().ContainsLines( + Contains("-two"), + Contains(" three"), + ) + + // A reflog entry is never a commit we may rewrite, so the patch can be applied + // but not moved out of the commit it came from. + t.Common().SelectPatchOption(Contains("Apply patch in reverse")) + + t.Views().Files(). + Focus(). + Lines( + Contains("M").Contains("file1"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/build_patch_from_a_whole_commits_diff.go b/pkg/integration/tests/main_view/build_patch_from_a_whole_commits_diff.go new file mode 100644 index 000000000..462e062eb --- /dev/null +++ b/pkg/integration/tests/main_view/build_patch_from_a_whole_commits_diff.go @@ -0,0 +1,75 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var BuildPatchFromAWholeCommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Take lines of two files into a custom patch from the whole diff of a commit, without entering its files first", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = true + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("second commit").IsSelected(), + Contains("first commit"), + ). + Press(keys.Universal.FocusMainView) + + // The commit's whole diff spans both files, and hunk mode offers the first + // changed block of the first of them. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + Contains("+TWO"), + ). + PressPrimaryAction(). + // The selection moves past the block just taken in, which is file2's block. + SelectedLines( + Contains("-beta"), + Contains("+BETA"), + ). + PressPrimaryAction() + + // The patch spans both files. + t.Views().Secondary(). + Content(Contains("file1")). + Content(Contains("file2")). + ContainsLines( + Contains("-two"), + Contains("+TWO"), + Contains(" three"), + ). + ContainsLines( + Contains("-beta"), + Contains("+BETA"), + Contains(" gamma"), + ) + + // Applying it to the working tree puts both files' changes there, which is the + // proof that the patch really holds what the preview says it does. + t.Common().SelectPatchOption(Contains("Apply patch in reverse")) + + t.Views().Files(). + Focus(). + ContainsLines( + Contains("M").Contains("file1"), + Contains("M").Contains("file2"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/build_patch_with_mixed_selections.go b/pkg/integration/tests/main_view/build_patch_with_mixed_selections.go new file mode 100644 index 000000000..63d4bc29e --- /dev/null +++ b/pkg/integration/tests/main_view/build_patch_with_mixed_selections.go @@ -0,0 +1,114 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var BuildPatchWithMixedSelections = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Build a custom patch from a whole file, a hunk, individual lines, and a range", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("hunk-file", "1a\n1b\n1c\n1d\n1e\n1f\n1g\n1h\n1i\n1j\n1k\n1l\n1m\n1n\n1o\n1p\n1q\n1r\n1s\n1t\n1u\n1v\n1w\n1x\n1y\n1z\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("hunk-file", "aa\n1b\ncc\n1d\n1e\n1f\n1g\n1h\n1i\n1j\n1k\n1l\n1m\n1n\n1o\n1p\n1q\n1r\n1s\ntt\nuu\nvv\n1w\n1x\n1y\n1z\n") + shell.CreateFileAndAdd("line-file", "2a\n2b\n2c\n2d\n2e\n2f\n2g\n2h\n2i\n2j\n2k\n2l\n2m\n2n\n2o\n2p\n2q\n2r\n2s\n2t\n2u\n2v\n2w\n2x\n2y\n2z\n") + shell.CreateFileAndAdd("direct-file", "direct file content") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("second commit").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + // The side panel remains the way to put an entire file operation into a patch. + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Contains("direct-file"), + Contains("hunk-file"), + Contains("line-file"), + ). + SelectNextItem(). + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + t.Views().Secondary().Content(Contains("direct file content")) + + // Add the first modification of hunk-file as one change block. + t.Views().CommitFiles(). + NavigateToLine(Contains("hunk-file")). + Press(keys.Universal.FocusMainView) + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-1a")). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-1a"), + Contains("+aa"), + ). + PressPrimaryAction(). + PressEscape(). + PressEscape() + + // Build line-file's part from one line, a range, and another line. + t.Views().CommitFiles(). + IsFocused(). + NavigateToLine(Contains("line-file")). + Press(keys.Universal.FocusMainView) + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+2a")). + PressPrimaryAction(). + NavigateToLine(Contains("+2c")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+2e")). + PressPrimaryAction(). + NavigateToLine(Contains("+2g")). + PressPrimaryAction(). + PressEscape() + + t.Views().Secondary().ContainsLines( + // direct-file patch + Contains(`diff --git a/direct-file b/direct-file`), + Contains(`index`), + Contains(`--- a/direct-file`), + Contains(`+++ b/direct-file`), + Contains(`@@ -0,0 +1 @@`), + Contains(`+direct file content`), + Contains(`\ No newline at end of file`), + // hunk-file patch + Contains(`diff --git a/hunk-file b/hunk-file`), + Contains(`index`), + Contains(`--- a/hunk-file`), + Contains(`+++ b/hunk-file`), + Contains(`@@ -1,4 +1,4 @@`), + Contains(`-1a`), + Contains(`+aa`), + Contains(` 1b`), + Contains(` 1c`), + Contains(` 1d`), + // line-file patch + Contains(`diff --git a/line-file b/line-file`), + Contains(`index`), + Contains(`--- a/line-file`), + Contains(`+++ b/line-file`), + Contains(`@@ -0,0 +1,5 @@`), + Contains(`+2a`), + Contains(`+2c`), + Contains(`+2d`), + Contains(`+2e`), + Contains(`+2g`), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/change_context_size_while_building_patch.go b/pkg/integration/tests/main_view/change_context_size_while_building_patch.go new file mode 100644 index 000000000..fd4ec24ce --- /dev/null +++ b/pkg/integration/tests/main_view/change_context_size_while_building_patch.go @@ -0,0 +1,54 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ChangeContextSizeWhileBuildingPatch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Change diff context size while building a custom patch, then add another line", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + config.GetUserConfig().Git.DiffContextSize = 1 + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("first commit") + shell.UpdateFileAndAdd("file1", "ONE\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nTEN\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("second commit").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-one")). + PressPrimaryAction(). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + NavigateToLine(Contains("-ten")). + PressPrimaryAction() + + t.Views().Secondary().Content( + Contains("-one").Contains("-ten"), + ) + t.Views().Main().MarkedLines( + Contains("-one"), + Contains("-ten"), + ) + }, +}) diff --git a/pkg/integration/tests/staging/diff_change_screen_mode.go b/pkg/integration/tests/main_view/change_screen_mode_in_focused_diff.go similarity index 58% rename from pkg/integration/tests/staging/diff_change_screen_mode.go rename to pkg/integration/tests/main_view/change_screen_mode_in_focused_diff.go index b42439cc8..c53c9eb1c 100644 --- a/pkg/integration/tests/staging/diff_change_screen_mode.go +++ b/pkg/integration/tests/main_view/change_screen_mode_in_focused_diff.go @@ -1,12 +1,12 @@ -package staging +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var DiffChangeScreenMode = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Change the staged changes screen mode", +var ChangeScreenModeInFocusedDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Enlarge the focused diff, which leaves the pane showing the other side of the file behind", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -16,32 +16,30 @@ var DiffChangeScreenMode = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Focus(). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().Staging(). + t.Views().Main(). IsFocused(). PressPrimaryAction(). Title(Equals("Unstaged changes")). Content(Contains("+second line").DoesNotContain("+first line")). PressTab() - t.Views().StagingSecondary(). + t.Views().Secondary(). IsFocused(). Title(Equals("Staged changes")). Content(Contains("+first line").DoesNotContain("+second line")). Press(keys.Universal.NextScreenMode). Tap(func() { - t.Views().AppStatus(). - IsInvisible() - t.Views().Staging(). - IsVisible() + // Half screen: the side panels are gone, the two diff panes are not. + t.Views().AppStatus().IsInvisible() + t.Views().Main().IsVisible() }). Press(keys.Universal.NextScreenMode). Tap(func() { - t.Views().AppStatus(). - IsInvisible() - t.Views().Staging(). - IsInvisible() + // Full screen: the focused pane has the window to itself. + t.Views().AppStatus().IsInvisible() + t.Views().Main().IsInvisible() }) }, }) diff --git a/pkg/integration/tests/main_view/click_selects_diff_line.go b/pkg/integration/tests/main_view/click_selects_diff_line.go new file mode 100644 index 000000000..cba60d930 --- /dev/null +++ b/pkg/integration/tests/main_view/click_selects_diff_line.go @@ -0,0 +1,40 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ClickSelectsDiffLine = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Clicking a line of the main view's diff focuses the view and selects that line", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused() + + // The click both focuses the view and points at a line, so that line is selected + // rather than the first change. + t.Views().Main(). + Click(0, 4). + IsFocused(). + SelectionIsActive(). + SelectedLines( + Contains("@@ -1,5 +1,5 @@"), + ). + // A click in the already-focused view moves the selection to the clicked line. + Click(0, 7). + SelectedLines( + Contains("-three"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/commit_from_main_view.go b/pkg/integration/tests/main_view/commit_from_main_view.go new file mode 100644 index 000000000..3e69207d1 --- /dev/null +++ b/pkg/integration/tests/main_view/commit_from_main_view.go @@ -0,0 +1,67 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CommitFromMainView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Commit what you staged without leaving the focused main view, but not while looking at a commit's diff", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nADDED\ntwo\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Committing acts on the working tree, so it is offered over the working tree's + // diff and does nothing over a commit's. + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Files.CommitChanges). + // Nothing happened: the commit message panel would have taken the focus. + IsFocused() + + t.Views().Files(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+ADDED"), + ). + PressPrimaryAction() + + // Staging it left nothing unstaged, so the diff — and the focus with it — is in + // the pane the staged side has. + t.Views().Secondary(). + IsFocused(). + Press(keys.Files.CommitChanges) + + t.ExpectPopup().CommitMessagePanel(). + Type("staged from the diff"). + Confirm() + + // The commit left the staged side with nothing in it, so the pane it was made + // from is gone and the focus is in the one that is still there. + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsFocused(). + Content(Contains("No changed files")) + + t.Views().Commits().Lines( + Contains("staged from the diff"), + Contains("one"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/copy_selected_diff_lines.go b/pkg/integration/tests/main_view/copy_selected_diff_lines.go new file mode 100644 index 000000000..350e6ae60 --- /dev/null +++ b/pkg/integration/tests/main_view/copy_selected_diff_lines.go @@ -0,0 +1,93 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The clipboard is emulated by a file, so that this works on CI too. +func expectClipboard(t *TestDriver, matcher *TextMatcher) { + defer t.Shell().DeleteFile("clipboard") + + t.FileSystem().FileContent("clipboard", matcher) +} + +var CopySelectedDiffLines = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Copy the selected diff lines from the focused main view, as the diff reads rather than as the renderer drew it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = true + // Emulate the clipboard by writing to a file. + cfg.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" + // A renderer that decorates every line of a diff's body, so that what is on + // screen is not what the diff says. It announces the metadata protocol, so that + // its output is taken at its word rather than replaced by git's own; and it + // reads the +/- column, so it wants its input uncoloured. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + { + Command: `printf '\033]1717;1\007'; ` + + `awk '/^@@/ { body = 1 } body && /^[-+ ]/ { print $0 " <<<"; next } { print }'`, + ColorArg: "never", + }, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("two") + + shell.UpdateFile("file1", "one\nTWO\nADD1\nADD2\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+ADD1 <<<"), + Contains("+ADD2 <<<"), + ). + Press(keys.Universal.CopyToClipboard) + + // The renderer's decoration is nowhere in what was copied, and a selection that + // is all additions loses its '+' column, ready to be pasted into code. + expectClipboard(t, Equals("ADD1\nADD2\n")) + + // A commit's diff is copied the same way, through the panel that produced it. + t.Views().Commits(). + Focus(). + NavigateToLine(Contains("two")). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two <<<"), + Contains("+TWO <<<"), + ). + Press(keys.Universal.CopyToClipboard) + + // Both kinds of line are in the selection, so the columns stay: what comes out + // is the diff itself. + expectClipboard(t, Equals("-two\n+TWO\n")) + + // The pane showing the custom patch is a diff too, of the patch's own lines. + t.Views().Main(). + PressPrimaryAction(). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("-two <<<"), + Contains("+TWO <<<"), + ). + Press(keys.Universal.CopyToClipboard) + + expectClipboard(t, Equals("-two\n+TWO\n")) + }, +}) diff --git a/pkg/integration/tests/main_view/custom_patch_goes_through_the_diff_renderer.go b/pkg/integration/tests/main_view/custom_patch_goes_through_the_diff_renderer.go new file mode 100644 index 000000000..ae9d0a33e --- /dev/null +++ b/pkg/integration/tests/main_view/custom_patch_goes_through_the_diff_renderer.go @@ -0,0 +1,50 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CustomPatchGoesThroughTheDiffRenderer = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The custom patch is shown by the configured diff renderer, being rendered as a diff of real files rather than assembled by us", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // A renderer that announces the metadata protocol — so that focusing the main + // view keeps its output — and says who it is above the diff it passes through. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007RENDERED BY ME\n'; cat`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Content(Contains("RENDERED BY ME")). + SelectedLines( + Contains("-two"), + ). + PressPrimaryAction() + + // The patch is a diff like any other, so the renderer has had it too. git worked + // out its context, and that is where the unchanged line either side comes from. + t.Views().Secondary(). + Content(Contains("RENDERED BY ME")). + ContainsLines( + Contains(" one"), + Contains("-two"), + Contains(" three"), + ) + }, +}) diff --git a/pkg/integration/tests/staging/discard_all_changes.go b/pkg/integration/tests/main_view/discard_all_changes.go similarity index 64% rename from pkg/integration/tests/staging/discard_all_changes.go rename to pkg/integration/tests/main_view/discard_all_changes.go index 89725da78..e0a1fefd6 100644 --- a/pkg/integration/tests/staging/discard_all_changes.go +++ b/pkg/integration/tests/main_view/discard_all_changes.go @@ -1,4 +1,4 @@ -package staging +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" @@ -6,10 +6,12 @@ import ( ) var DiscardAllChanges = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Discard all changes of a file in the staging panel, then assert we land in the staging panel of the next file", + Description: "Discard all changes of a file from the focused main view, then land on the next file's diff", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "one\ntwo\n") shell.CreateFileAndAdd("file2", "1\n2\n") @@ -27,30 +29,25 @@ var DiscardAllChanges = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" M file2"), ). SelectNextItem(). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().Staging(). + t.Views().Main(). IsFocused(). - Press(keys.Main.ToggleSelectHunk). SelectedLines(Contains("+three")). - // discard the line Press(keys.Universal.Remove). Tap(func() { t.Common().ConfirmDiscardLines() }). SelectedLines(Contains("+four")). - // discard the other line Press(keys.Universal.Remove). Tap(func() { t.Common().ConfirmDiscardLines() + }) - // because there are no more changes in file1 we switch to file2 - t.Views().Files(). - Lines( - Equals(" M file2"), - ) - }). - // assert we are still in the staging panel, but now looking at the changes of the other file + t.Views().Files().Lines( + Equals(" M file2"), + ) + t.Views().Main(). IsFocused(). SelectedLines(Contains("+3")) }, diff --git a/pkg/integration/tests/main_view/discard_diff_lines.go b/pkg/integration/tests/main_view/discard_diff_lines.go new file mode 100644 index 000000000..02726643a --- /dev/null +++ b/pkg/integration/tests/main_view/discard_diff_lines.go @@ -0,0 +1,70 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardDiffLines = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discard a hunk of the working tree's diff from the focused main view, and unstage one from the staged half", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\nsix\nseven\nUNSTAGED\neight\nnine\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("MM file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + // Discarding from the unstaged side throws the change away, so it asks first. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+UNSTAGED"), + ). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Confirmation(). + Title(Equals("Discard change")). + Content(Contains("Are you sure you want to discard this change")). + Confirm() + }) + + // Nothing is unstaged any more, so that pane is gone and the focus has followed + // the file's remaining changes into the staged one. + t.Views().Files().Lines( + Contains("M file1"), + ) + t.Views().Main().IsInvisible() + + // There the same key means "I don't want this staged", which is unstaging, so it + // doesn't ask. + t.Views().Secondary(). + IsFocused(). + Title(Equals("Staged changes")). + SelectedLines( + Contains("+STAGED"), + ). + Press(keys.Universal.Remove) + + t.Views().Files().Lines( + Contains(" M file1"), + ) + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsFocused(). + Title(Equals("Unstaged changes")). + Content(Contains("+STAGED")) + }, +}) diff --git a/pkg/integration/tests/main_view/discard_from_a_commit_only_where_it_can_be_rewritten.go b/pkg/integration/tests/main_view/discard_from_a_commit_only_where_it_can_be_rewritten.go new file mode 100644 index 000000000..0a6ae023e --- /dev/null +++ b/pkg/integration/tests/main_view/discard_from_a_commit_only_where_it_can_be_rewritten.go @@ -0,0 +1,63 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardFromACommitOnlyWhereItCanBeRewritten = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding lines is refused over a diff that belongs to no commit we may rewrite, and over the custom patch itself", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFile("file1", "one\nTWO\nthree\n") + shell.Stash("a stashed change") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // A stash entry is no commit of ours to rewrite, so its lines can go into a + // patch but can't be taken out of what they are part of. + t.Views().Stash(). + Focus(). + Lines( + Contains("a stashed change").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + Press(keys.Universal.Remove) + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("Changes can only be discarded from local commits")). + Confirm() + + // The pane previewing the patch shows the patch's own lines, which are not the + // commit's to discard; space takes them back out of the patch instead. + t.Views().Main(). + IsFocused(). + PressPrimaryAction(). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + Press(keys.Universal.Remove) + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("Lines shown here are the custom patch's")). + Confirm() + }, +}) diff --git a/pkg/integration/tests/patch_building/discard_lines_from_commit.go b/pkg/integration/tests/main_view/discard_line_from_added_file_in_commit.go similarity index 55% rename from pkg/integration/tests/patch_building/discard_lines_from_commit.go rename to pkg/integration/tests/main_view/discard_line_from_added_file_in_commit.go index ab1414053..ba2293f47 100644 --- a/pkg/integration/tests/patch_building/discard_lines_from_commit.go +++ b/pkg/integration/tests/main_view/discard_line_from_added_file_in_commit.go @@ -1,18 +1,19 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var DiscardLinesFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Discard specific lines from a commit using the 'd' shortcut in the patch building view", +var DiscardLineFromAddedFileInCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discard one line from an added file in a commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") - shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("commit to remove from") }, @@ -30,15 +31,11 @@ var DiscardLinesFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("A file1").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - // Select the second line (+2nd line) and press 'd' to remove it - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). - SelectNextItem(). - SelectedLines( - Contains("+2nd line"), - ). + NavigateToLine(Contains("+2nd line")). Press(keys.Universal.Remove) t.ExpectPopup().Confirmation(). @@ -46,18 +43,11 @@ var DiscardLinesFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ Content(Equals("Are you sure you want to discard the selected lines from this commit?")). Confirm() - // After the rebase, we should be back at the commit files view - // and the commit should now only contain the 1st and 3rd lines - t.Views().CommitFiles(). + t.Views().Main(). IsFocused(). - Lines( - Contains("A file1").IsSelected(), - ). - PressEscape() - - t.Views().Main().ContainsLines( - Equals("+1st line"), - Equals("+3rd line"), - ) + ContainsLines( + Equals("+1st line"), + Equals("+3rd line"), + ) }, }) diff --git a/pkg/integration/tests/main_view/discard_lines_from_a_commit.go b/pkg/integration/tests/main_view/discard_lines_from_a_commit.go new file mode 100644 index 000000000..43f9ce675 --- /dev/null +++ b/pkg/integration/tests/main_view/discard_lines_from_a_commit.go @@ -0,0 +1,62 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardLinesFromACommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discard the selected lines of a commit's diff from the commit itself, in the focused main view", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\nFOUR\nfive\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("second commit").IsSelected(), + Contains("first commit"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+TWO")). + SelectedLines( + Contains("-two"), + Contains("+TWO"), + ). + Press(keys.Universal.Remove) + + t.ExpectPopup().Confirmation(). + Title(Equals("Discard lines from commit")). + Content(Contains("Are you sure you want to discard the selected lines from this commit?")). + Confirm() + + // The commit keeps its other change and has given up the one discarded, and the + // selection carries on from the change that has taken its place. + t.Views().Main(). + IsFocused(). + Content(DoesNotContain("+TWO")). + SelectedLines( + Contains("-four"), + ) + + // The rewrite is the commit's own business: nothing is left lying in the working + // tree. + t.Views().Files().IsEmpty() + }, +}) diff --git a/pkg/integration/tests/main_view/drag_range_with_autoscroll.go b/pkg/integration/tests/main_view/drag_range_with_autoscroll.go new file mode 100644 index 000000000..5b9006d7e --- /dev/null +++ b/pkg/integration/tests/main_view/drag_range_with_autoscroll.go @@ -0,0 +1,43 @@ +package main_view + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragRangeWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep scrolling while dragging a range selection at the edge of the focused main view", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + fileContent := "base\n" + shell.CreateFileAndAdd("file1", fileContent) + shell.Commit("one") + for i := 1; i <= 40; i++ { + fileContent += fmt.Sprintf("line %d\n", i) + } + shell.UpdateFile("file1", fileContent) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // The diff is longer than the view, so holding the pointer at the bottom edge has + // to keep scrolling and extending the selection rather than stopping there. + t.Views().Main(). + IsFocused(). + ClickAndHold(1, 6). + MouseMoveToBottom(1). + OriginYAtLeast(3). + SelectedLineIdxAtLeast(9). + MouseRelease() + }, +}) diff --git a/pkg/integration/tests/main_view/drag_selects_diff_line_range.go b/pkg/integration/tests/main_view/drag_selects_diff_line_range.go new file mode 100644 index 000000000..c3c64503a --- /dev/null +++ b/pkg/integration/tests/main_view/drag_selects_diff_line_range.go @@ -0,0 +1,55 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragSelectsDiffLineRange = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Dragging in the main view's diff selects the range from the line the drag started on", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = true + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // Hunk mode is on, so the mouse-down alone selects the whole block; the drag + // anchors the range where the mouse went down instead, one line at a time. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-three"), + Contains("-four"), + Contains("+THREE"), + Contains("+FOUR"), + ). + ClickAndHold(0, 8). + MouseMove(0, 9). + SelectedLines( + Contains("-four"), + Contains("+THREE"), + ). + MouseMove(0, 10). + SelectedLines( + Contains("-four"), + Contains("+THREE"), + Contains("+FOUR"), + ). + MouseRelease(). + SelectedLines( + Contains("-four"), + Contains("+THREE"), + Contains("+FOUR"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/edit_line_in_patch_building_panel.go b/pkg/integration/tests/main_view/edit_historical_diff_line.go similarity index 78% rename from pkg/integration/tests/patch_building/edit_line_in_patch_building_panel.go rename to pkg/integration/tests/main_view/edit_historical_diff_line.go index 2be88b7b8..5a2a97040 100644 --- a/pkg/integration/tests/patch_building/edit_line_in_patch_building_panel.go +++ b/pkg/integration/tests/main_view/edit_historical_diff_line.go @@ -1,15 +1,16 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var EditLineInPatchBuildingPanel = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Edit a line in the patch building panel; make sure we end up on the right line", +var EditHistoricalDiffLine = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Edit a historical diff line at its current working-tree line number", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command" }, SetupRepo: func(shell *Shell) { @@ -34,9 +35,9 @@ var EditLineInPatchBuildingPanel = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("A file.txt").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). Content(Contains("+4\n+5\n+6")). NavigateToLine(Contains("+5")). diff --git a/pkg/integration/tests/main_view/edit_hunk_in_focused_diff.go b/pkg/integration/tests/main_view/edit_hunk_in_focused_diff.go new file mode 100644 index 000000000..8fa244780 --- /dev/null +++ b/pkg/integration/tests/main_view/edit_hunk_in_focused_diff.go @@ -0,0 +1,59 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var EditHunkInFocusedDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Edit the hunk around the selection in an editor, and stage what comes back", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // Stand in for the editor: record the line it was pointed at, outside the + // repo so that the files panel keeps saying what the test is about, then + // write a patch that stages something neither side of the diff says. That + // is the point of editing a hunk. + cfg.GetUserConfig().OS.EditAtLineAndWait = "echo {{line}} > ../edit-line && " + + "printf '%s\\n' '--- a/file1' '+++ b/file1' '@@ -1,3 +1,3 @@' " + + "' one' '-two' '+TWO_EDITED' ' three' > {{filename}}" + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nTWO\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-two")). + Press(keys.Main.EditSelectHunk) + + // The patch is written with a two-line header, so the deletion the cursor was + // on is its fifth line. + t.FileSystem().FileContent("../edit-line", Equals("5\n")) + + // What the editor wrote went into the index, leaving the working tree as it + // was: the file is changed on both sides now, differently. + t.Views().Files().Lines( + Contains("MM").Contains("file1"), + ) + t.Views().Secondary().ContainsLines( + Contains("-two"), + Contains("+TWO_EDITED"), + ) + t.Views().Main().ContainsLines( + Contains("-TWO_EDITED"), + Contains("+TWO"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/edit_selected_diff_line.go b/pkg/integration/tests/main_view/edit_selected_diff_line.go new file mode 100644 index 000000000..6f7b97971 --- /dev/null +++ b/pkg/integration/tests/main_view/edit_selected_diff_line.go @@ -0,0 +1,45 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var EditSelectedDiffLine = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Open the selected line of the main view's diff in the editor, at that line of the file", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command" + config.GetUserConfig().OS.Edit = "echo {{filename}} > edit-command" + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // The addition is the third line of the file as it now stands. + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("+THREE")). + Press(keys.Universal.Edit) + + // The editor is pointed at the file by absolute path. + t.FileSystem().FileContent("edit-command", Contains("/repo/file1:3\n")) + + // A file header points at the file rather than at a line in it, so it opens the + // file with no line to jump to. + t.Views().Main(). + NavigateToLine(Contains("diff --git a/file1 b/file1")). + Press(keys.Universal.Edit) + + t.FileSystem().FileContent("edit-command", Contains("/repo/file1\n")) + }, +}) diff --git a/pkg/integration/tests/main_view/enter_and_double_click_focus_file_diff.go b/pkg/integration/tests/main_view/enter_and_double_click_focus_file_diff.go new file mode 100644 index 000000000..a9be802b9 --- /dev/null +++ b/pkg/integration/tests/main_view/enter_and_double_click_focus_file_diff.go @@ -0,0 +1,69 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var EnterAndDoubleClickFocusFileDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Enter and double-click focus a file's diff in the main view", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "base\n") + shell.Commit("add file") + shell.UpdateFile("file1", "changed\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + PressEnter() + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-base")). + PressEscape() + + t.Views().Files(). + IsFocused(). + Click(3, 0). + Click(3, 0) + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-base")). + PressEscape() + + t.Views().Commits(). + Focus(). + Lines( + Contains("add file").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + PressEnter() + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+base")). + PressEscape() + + t.Views().CommitFiles(). + IsFocused(). + Click(3, 0). + Click(3, 0) + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+base")) + }, +}) diff --git a/pkg/integration/tests/main_view/escape_dismisses_selection.go b/pkg/integration/tests/main_view/escape_dismisses_selection.go new file mode 100644 index 000000000..a9c80c72a --- /dev/null +++ b/pkg/integration/tests/main_view/escape_dismisses_selection.go @@ -0,0 +1,59 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var EscapeDismissesSelection = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Escape gives up a range selection, then hunk mode, before leaving the focused main view", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nTWO\nTHREE\nfour\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // A sticky range: escape collapses it to the cursor line rather than leaving. + t.Views().Main(). + IsFocused(). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("-two"), + Contains("-three"), + ). + Press(keys.Universal.Return). + IsFocused(). + SelectedLines( + Contains("-three"), + ). + // Hunk mode the user asked for: escape goes back to line-by-line. + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-two"), + Contains("-three"), + Contains("+TWO"), + Contains("+THREE"), + ). + Press(keys.Universal.Return). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + // With nothing left to give up, escape leaves. + Press(keys.Universal.Return) + + t.Views().Files(). + IsFocused() + }, +}) diff --git a/pkg/integration/tests/main_view/focus_follows_a_pane_emptied_from_outside.go b/pkg/integration/tests/main_view/focus_follows_a_pane_emptied_from_outside.go new file mode 100644 index 000000000..b087fed4a --- /dev/null +++ b/pkg/integration/tests/main_view/focus_follows_a_pane_emptied_from_outside.go @@ -0,0 +1,52 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FocusFollowsAPaneEmptiedFromOutside = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Committing the staged changes outside lazygit takes the staged pane away, so the focus follows into the one that is left", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + // One change on each side, so the diff is split. + shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\n") + shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nUNSTAGED\nfour\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + Title(Equals("Staged changes")). + SelectedLines( + Contains("+STAGED"), + ) + + // Nothing lazygit did empties the staged side here; the refresh simply finds + // it empty, and the pane the focus was in is gone by the time it lands. + t.Shell().Commit("two") + t.GlobalPress(keys.Universal.Refresh) + + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsFocused(). + Title(Equals("Unstaged changes")). + SelectedLines( + Contains("+UNSTAGED"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/focus_follows_into_a_pane_taking_over.go b/pkg/integration/tests/main_view/focus_follows_into_a_pane_taking_over.go new file mode 100644 index 000000000..a2f91eead --- /dev/null +++ b/pkg/integration/tests/main_view/focus_follows_into_a_pane_taking_over.go @@ -0,0 +1,74 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FocusFollowsIntoAPaneTakingOver = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The pane the focus follows into as it takes the section over gets its selection from the top of the diff it is given", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + // Everything staged and nothing unstaged, so the staged side has the section to + // itself, with more changes in it than fit. + for i := range lines { + lines[i] = strings.ToUpper(lines[i]) + } + shell.UpdateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("M file1").IsSelected(), + ) + + // Read a way into the diff before focusing it, so that the selection starts out + // somewhere other than the first change. + t.Views().Secondary(). + IsVisible(). + ScrollWheelDown(). + ScrollWheelDown(). + ScrollWheelDown(). + ScrollWheelDown(). + OriginY(8) + + t.Views().Files().Press(keys.Universal.FocusMainView) + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("-line04"), + ) + + // The index is reset outside lazygit, so the staged side empties and the + // unstaged side has everything: the pane the focus is in goes away and the other + // one takes the section over with a diff that is new to it. + t.Shell().RunCommand([]string{"git", "reset"}) + t.GlobalPress(keys.Universal.Refresh) + + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsFocused(). + Title(Equals("Unstaged changes")). + OriginY(0). + SelectedLines( + Contains("-line01"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/focus_follows_staged_side.go b/pkg/integration/tests/main_view/focus_follows_staged_side.go new file mode 100644 index 000000000..2230fd28c --- /dev/null +++ b/pkg/integration/tests/main_view/focus_follows_staged_side.go @@ -0,0 +1,54 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FocusFollowsStagedSide = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Unstaging from a fully staged file leaves the focus on the staged side, which keeps its pane", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + // Two staged additions and nothing unstaged, so only the pane the staged side + // lives in is shown. + shell.UpdateFileAndAdd("file1", "one\nSTAGED1\ntwo\nthree\nfour\nfive\nsix\nseven\nSTAGED2\neight\nnine\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("M file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main().IsInvisible() + t.Views().Secondary(). + IsFocused(). + Title(Equals("Staged changes")). + SelectedLines( + Contains("+STAGED1"), + ). + PressPrimaryAction() + + // The line taken out of the index turns up in the pane that has just appeared + // above, and the work carries on where it was, on the next staged change. + t.Views().Files().Lines( + Contains("MM file1"), + ) + t.Views().Main(). + IsVisible(). + Content(Contains("+STAGED1")) + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("+STAGED2"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/focus_follows_when_pane_goes.go b/pkg/integration/tests/main_view/focus_follows_when_pane_goes.go new file mode 100644 index 000000000..0d9089e06 --- /dev/null +++ b/pkg/integration/tests/main_view/focus_follows_when_pane_goes.go @@ -0,0 +1,47 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FocusFollowsWhenPaneGoes = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Staging the last unstaged change takes the upper pane away, so the focus follows the lines into the lower one", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nADDED\ntwo\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Title(Equals("Unstaged changes")). + SelectedLines( + Contains("+ADDED"), + ). + PressPrimaryAction() + + // Nothing is unstaged any more, so that pane is gone and the line is in the one + // below, where the focus and the selection now are. + t.Views().Files().Lines( + Contains("M file1"), + ) + t.Views().Main().IsInvisible() + t.Views().Secondary(). + IsFocused(). + Title(Equals("Staged changes")). + SelectedLines( + Contains("+ADDED"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/focus_leaves_an_always_split_empty_pane.go b/pkg/integration/tests/main_view/focus_leaves_an_always_split_empty_pane.go new file mode 100644 index 000000000..8987ece6b --- /dev/null +++ b/pkg/integration/tests/main_view/focus_leaves_an_always_split_empty_pane.go @@ -0,0 +1,53 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FocusLeavesAnAlwaysSplitEmptyPane = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Configured to always split the diff, the emptied pane stays but the focus still leaves it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + cfg.GetUserConfig().Gui.SplitDiff = "always" + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\n") + shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nUNSTAGED\nfour\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("+STAGED"), + ) + + t.Shell().Commit("two") + t.GlobalPress(keys.Universal.Refresh) + + // The staged side is empty now, but its pane is still shown because the split + // is configured as permanent. There is nothing left to act on in it, so the + // focus goes where there is. + t.Views().Secondary(). + IsVisible(). + Content(DoesNotContain("STAGED")) + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+UNSTAGED"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/focus_returns_when_split_collapses.go b/pkg/integration/tests/main_view/focus_returns_when_split_collapses.go new file mode 100644 index 000000000..58959070d --- /dev/null +++ b/pkg/integration/tests/main_view/focus_returns_when_split_collapses.go @@ -0,0 +1,56 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FocusReturnsWhenSplitCollapses = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Unstaging the last staged change from the secondary pane brings the focus back to the main one", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + // One change on each side, so the diff is split. + shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\nsix\nseven\nUNSTAGED\neight\nnine\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("MM file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("+STAGED"), + ). + PressPrimaryAction() + + // With nothing staged left the diff isn't split any more, so the pane that was + // showing the staged side is gone — and the focus is back on the main one, where + // the change just taken out of the index now is. + t.Views().Files().Lines( + Contains(" M file1"), + ) + t.Views().Main(). + IsFocused(). + Content(Contains("+STAGED")). + Content(Contains("+UNSTAGED")). + SelectedLines( + Contains("+STAGED"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/hide_selection_when_changes_vanish.go b/pkg/integration/tests/main_view/hide_selection_when_changes_vanish.go new file mode 100644 index 000000000..c9b15c982 --- /dev/null +++ b/pkg/integration/tests/main_view/hide_selection_when_changes_vanish.go @@ -0,0 +1,47 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var HideSelectionWhenChangesVanish = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The main view's selection disappears along with the changes it was on", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsActive(). + Press(keys.Universal.Return) + + // Discarding the change leaves the main view with a placeholder to show, so the + // selection that was on the change goes with it rather than lingering over the + // message. + t.Views().Files(). + IsFocused(). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + }). + IsEmpty() + + t.Views().Main(). + Content(Contains("No changed files")). + SelectionIsHidden() + }, +}) diff --git a/pkg/integration/tests/main_view/keep_a_wrapped_line_covered_across_a_rerender.go b/pkg/integration/tests/main_view/keep_a_wrapped_line_covered_across_a_rerender.go new file mode 100644 index 000000000..88093d357 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_a_wrapped_line_covered_across_a_rerender.go @@ -0,0 +1,60 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepAWrappedLineCoveredAcrossARerender = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A selection over a line too long for the view still covers all of it after a re-render", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 80, + Height: 20, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = true + }, + SetupRepo: func(shell *Shell) { + long := strings.Repeat("word ", 40) + lines := make([]string, 20) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + before := strings.Join(lines[:10], "\n") + "\n" + after := strings.Join(lines[10:], "\n") + "\n" + + shell.CreateFileAndAdd("file1", before+long+"\n"+after) + shell.Commit("one") + + shell.UpdateFile("file1", before+"CHANGED "+long+"\n"+after) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // The changed line is far too long for the view, so each half of the change + // is drawn as several view lines, and hunk mode selects all of them. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-word word"), + Contains("+CHANGED word"), + ). + SelectedViewLineRange(8, 16). + // The same two lines of the diff, wrapped the same way, are still covered + // to their ends once the diff has been rendered again. + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + SelectedLines( + Contains("-word word"), + Contains("+CHANGED word"), + ). + SelectedViewLineRange(9, 17) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_both_halves_of_a_change_selected.go b/pkg/integration/tests/main_view/keep_both_halves_of_a_change_selected.go new file mode 100644 index 000000000..03c792f17 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_both_halves_of_a_change_selected.go @@ -0,0 +1,68 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepBothHalvesOfAChangeSelected = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A change selected on the one row a renderer draws it as is selected on both rows of a renderer that splits it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = true + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + // Git's own diff, which has a row for each half of a change. It announces + // the metadata protocol, so lazygit acts on its output rather than + // replacing it; it states no records of its own, so the rows are located + // by parsing the text, which still looks like a diff. + {Name: "unified", Command: `printf '\033]1717;1\007'; cat`}, + // A renderer that puts the two halves of a change beside each other on one + // row. Only the records it states can say where those halves are; parsing + // the text could not. It ignores its input and prints this one. + {Name: "columns", Command: `printf '\033]1717;1\007'; ` + + `printf '\033]1717;1;f;;;file1\007file1\n'; ` + + `printf '\033]1717;1;h;1;;file1\007@@\n'; ` + + `printf '\033]1717;1;c;1;;file1\007one one\n'; ` + + `printf '\033]1717;1;d;2;2;file1\007two \033]1717;1;a;2;;file1\007TWO\n'; ` + + `printf '\033]1717;1;c;3;;file1\007three three\n'; ` + + `cat >/dev/null`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nTWO\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + Contains("+TWO"), + ). + // The change is one row here, and selecting it selects that row: both + // halves are on it. + Press(keys.Universal.CycleDiffRenderers). + Tap(func() { + t.ExpectToast(Equals("Diff renderer: columns (2 of 2)")) + }). + SelectedLines( + Contains("two TWO"), + ). + // Split apart again, the same change is the same two lines it was. + Press(keys.Universal.CycleDiffRenderers). + Tap(func() { + t.ExpectToast(Equals("Diff renderer: unified (1 of 2)")) + }). + SelectedLines( + Contains("-two"), + Contains("+TWO"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_by_the_visible_end_of_a_selection.go b/pkg/integration/tests/main_view/keep_position_by_the_visible_end_of_a_selection.go new file mode 100644 index 000000000..7926d46c5 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_by_the_visible_end_of_a_selection.go @@ -0,0 +1,102 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionByTheVisibleEndOfASelection = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A re-render keeps the place by the end of a selected hunk that is on screen when its other end isn't", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = true + // One line per scroll, so that the test can put the top of the view exactly + // where it wants it. + cfg.GetUserConfig().Gui.ScrollHeight = 1 + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 60) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + // A first change tall enough to be scrolled halfway out of the view, and more + // of them below it, so that a context-size change moves the lines further down + // the diff by more than it moves the first change. + for _, i := range []int{10, 11, 12, 13, 14, 15, 30, 45} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-line10"), + Contains("-line11"), + Contains("-line12"), + Contains("-line13"), + Contains("-line14"), + Contains("-line15"), + Contains("+LINE10"), + Contains("+LINE11"), + Contains("+LINE12"), + Contains("+LINE13"), + Contains("+LINE14"), + Contains("+LINE15"), + ). + SelectedLineIdx(8). + // Scroll past the start of the selected block, leaving its last lines on + // screen and the cursor above the top of the view. + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + OriginY(14). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + // The block's last line was the fifth row of the screen, and one context + // line more above the block puts it a line further down the diff: the view + // follows it, rather than the middle visible line, which the hunks below + // have pushed further still. + OriginY(15). + SelectedLines( + Contains("-line10"), + Contains("-line11"), + Contains("-line12"), + Contains("-line13"), + Contains("-line14"), + Contains("-line15"), + Contains("+LINE10"), + Contains("+LINE11"), + Contains("+LINE12"), + Contains("+LINE13"), + Contains("+LINE14"), + Contains("+LINE15"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_in_both_panes_when_changing_context_size.go b/pkg/integration/tests/main_view/keep_position_in_both_panes_when_changing_context_size.go new file mode 100644 index 000000000..bc029f9ea --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_in_both_panes_when_changing_context_size.go @@ -0,0 +1,71 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionInBothPanesWhenChangingContextSize = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Changing the diff's context size keeps the place in the lower pane too, not only in the upper one", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + // Four staged changes, far enough apart that they stay four hunks as the + // context size grows, and one unstaged one to split the file's diff across + // both panes. + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + shell.GitAddAll() + + lines[39] = strings.ToUpper(lines[39]) + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // The lower pane holds the staged changes; getting to the last of them scrolls + // it, so there is a position to lose. + t.Views().Main(). + IsFocused(). + PressTab() + + t.Views().Secondary(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(35). + OriginY(14). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(42). + OriginY(21) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_in_both_panes_when_ignoring_whitespace.go b/pkg/integration/tests/main_view/keep_position_in_both_panes_when_ignoring_whitespace.go new file mode 100644 index 000000000..122f654ee --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_in_both_panes_when_ignoring_whitespace.go @@ -0,0 +1,70 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionInBothPanesWhenIgnoringWhitespace = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Ignoring whitespace keeps the place in the lower pane too, not only in the upper one", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 60) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + // Staged: real changes at lines 5, 25 and 45, and a whitespace-only one at 15, + // whose hunk goes when whitespace stops counting. + lines[4] = strings.ToUpper(lines[4]) + lines[14] = " " + lines[14] + lines[24] = strings.ToUpper(lines[24]) + lines[44] = strings.ToUpper(lines[44]) + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + shell.GitAddAll() + + // And one unstaged change, to split the file's diff across both panes. + lines[59] = strings.ToUpper(lines[59]) + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + PressTab() + + t.Views().Secondary(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-line45"), + ). + SelectedLineIdx(35). + OriginY(14). + Press(keys.Universal.ToggleWhitespaceInDiffView). + // The hunk above this one held nothing but a whitespace change, so it is + // gone and has taken nine lines of the lower pane's diff with it — leaving + // the line we were on where it was on the screen. + SelectedLines( + Contains("-line45"), + ). + SelectedLineIdx(26). + OriginY(5) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_in_both_panes_when_switching_diff_renderers.go b/pkg/integration/tests/main_view/keep_position_in_both_panes_when_switching_diff_renderers.go new file mode 100644 index 000000000..30b7ddc87 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_in_both_panes_when_switching_diff_renderers.go @@ -0,0 +1,78 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionInBothPanesWhenSwitchingDiffRenderers = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Switching to another diff renderer keeps the place in the lower pane too, not only in the upper one", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // Renderers that speak the metadata protocol, so that focusing the main view + // keeps their rendering rather than falling back to git's own diff. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Name: "plain", Command: `printf '\033]1717;1\007'; cat`}, + // The same diff, three lines further down the view. (Lines before the + // diff's own header aren't part of it, so it still reads the same.) + {Name: "banner", Command: `printf '\033]1717;1\007'; printf 'rendered for you\n\n\n'; cat`}, + } + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + // Four staged changes to have a diff worth scrolling in the lower pane, and one + // unstaged one to split the file's diff across both panes. + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + shell.GitAddAll() + + lines[39] = strings.ToUpper(lines[39]) + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + PressTab() + + t.Views().Secondary(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(35). + OriginY(14). + Press(keys.Universal.CycleDiffRenderers). + Tap(func() { + t.ExpectToast(Equals("Diff renderer: banner (2 of 2)")) + }). + // The banner pushed the whole diff three lines down, and the lower pane came + // along with it, just as the upper one would have. + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(38). + OriginY(17) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_when_changing_context_size.go b/pkg/integration/tests/main_view/keep_position_when_changing_context_size.go new file mode 100644 index 000000000..1b7299bfa --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_when_changing_context_size.go @@ -0,0 +1,97 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionWhenChangingContextSize = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Changing the diff's context size keeps the line you were looking at where it was", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + // Four changes, far enough apart that they stay four hunks as the context + // size grows. + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-line35"), + ). + // The diff is longer than the view, so getting to the last hunk scrolled + // it: the selected line sits 21 rows down the screen. + SelectedLineIdx(35). + OriginY(14). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + // A context line more on either side of each of the four hunks pushes the + // selected line seven lines further into the diff. The view follows it, so + // it is still the same line on the same screen row (42 - 21 = 21). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(42). + OriginY(21). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 3")) + }). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + // And the same the other way (28 - 21 = 7). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(28). + OriginY(7). + // Leaving the view gives up the selection but not the scroll position, and + // with no selection to keep, it is the middle visible line that stays put. + PressEscape() + + t.Views().Files(). + IsFocused(). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 3")) + }) + + // The middle visible line here is a hunk's header, and a context-size change + // rewrites those — they name the lines the hunk covers. So the restore falls + // back to the nearest line that does survive, the context line just below it, + // and puts that back on the row it was on. + t.Views().Main(). + SelectionIsHidden(). + OriginY(12) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_when_ignoring_whitespace.go b/pkg/integration/tests/main_view/keep_position_when_ignoring_whitespace.go new file mode 100644 index 000000000..74b22d2ff --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_when_ignoring_whitespace.go @@ -0,0 +1,85 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionWhenIgnoringWhitespace = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Ignoring whitespace keeps the line you were looking at where it was, even when it turns into a context line", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + // Real changes at lines 5 and 25, whitespace-only ones at 15, 27 and 35. The + // one at 27 shares a hunk with the change at 25, so ignoring whitespace turns + // it into a context line rather than taking its hunk away. + lines[4] = strings.ToUpper(lines[4]) + lines[14] = " " + lines[14] + lines[24] = strings.ToUpper(lines[24]) + lines[26] = lines[26] + " " + lines[34] = " " + lines[34] + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-line25"), + ). + SelectedLineIdx(26). + OriginY(14). + Press(keys.Universal.ToggleWhitespaceInDiffView). + // The hunk above this one held nothing but a whitespace change, so it is + // gone and has taken nine lines of diff with it. This is still the line we + // were on, on the row we were on (26 - 14 = 17 - 5). + SelectedLines( + Contains("-line25"), + ). + SelectedLineIdx(17). + OriginY(5). + // And back again, whitespace and all. + Press(keys.Universal.ToggleWhitespaceInDiffView). + SelectedLines( + Contains("-line25"), + ). + SelectedLineIdx(26). + OriginY(14). + // The whitespace-only change further down this hunk is a line of the file + // like any other: ignoring whitespace shows it as context instead of as a + // change, and that is still where we are. + Press(keys.Main.NextHunk). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("+line27"), + ). + SelectedLineIdx(30). + OriginY(14). + Press(keys.Universal.ToggleWhitespaceInDiffView). + SelectedLines( + Contains(" line27"), + ). + SelectedLineIdx(20). + OriginY(4) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_when_ignoring_whitespace_removes_it.go b/pkg/integration/tests/main_view/keep_position_when_ignoring_whitespace_removes_it.go new file mode 100644 index 000000000..60119bec0 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_when_ignoring_whitespace_removes_it.go @@ -0,0 +1,81 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionWhenIgnoringWhitespaceRemovesIt = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Ignoring whitespace where that takes the line you were on out of the diff lands on the nearest line it kept", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n") + shell.Commit("one") + + // Real changes at lines 5, 15 and 25, and a whitespace-only one at 35, far + // enough apart to be hunks of their own. + for _, i := range []int{5, 15, 25} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + lines[34] = " " + lines[34] + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + + // Nothing but reindentation, so ignoring whitespace leaves no diff at all. + shell.UpdateFile("file2", " one\n two\n three\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + SelectNextItem(). + SelectedLine(Contains("file1")). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(35). + OriginY(14). + Press(keys.Universal.ToggleWhitespaceInDiffView). + // That hunk was a whitespace change and nothing else, so ignoring + // whitespace takes it — and the context around it — out of the diff + // entirely. The nearest line the diff kept is the last line of the hunk + // above, so that is where the selection lands; it goes back on the row it + // was on itself, which leaves everything above it exactly where it was. + SelectedLines( + Contains(" line28"), + ). + SelectedLineIdx(30). + OriginY(14). + // The whole diff can go this way, and then there is nothing to land on. + Press(keys.Universal.ToggleWhitespaceInDiffView). + PressEscape() + + t.Views().Files(). + IsFocused(). + SelectNextItem(). + SelectedLine(Contains("file2")). + Press(keys.Universal.ToggleWhitespaceInDiffView) + + t.Views().Main(). + Content(Equals("")) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_when_switching_diff_renderers.go b/pkg/integration/tests/main_view/keep_position_when_switching_diff_renderers.go new file mode 100644 index 000000000..5f3499dbd --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_when_switching_diff_renderers.go @@ -0,0 +1,78 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionWhenSwitchingDiffRenderers = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Switching to another diff renderer keeps the line you were looking at where it was", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // Both announce the metadata protocol, so that their output is taken at its + // word and shown as it is; a renderer that says nothing about what it renders + // is replaced by git's own diff as soon as the main view is focused. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Name: "plain", Command: `printf '\033]1717;1\007'; cat`}, + // The same diff, three lines further down the view. (Lines before the + // diff's own header aren't part of it, so it still reads the same.) + {Name: "banner", Command: `printf '\033]1717;1\007rendered for you\n\n\n'; cat`}, + } + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(35). + OriginY(14). + Press(keys.Universal.CycleDiffRenderers). + Tap(func() { + t.ExpectToast(Equals("Diff renderer: banner (2 of 2)")) + }). + // The banner pushed the whole diff three lines down, and the view came + // along with it: the same line on the same screen row (38 - 17 = 21). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(38). + OriginY(17). + Press(keys.Universal.CycleDiffRenderers). + Tap(func() { + t.ExpectToast(Equals("Diff renderer: plain (1 of 2)")) + }). + SelectedLines( + Contains("-line35"), + ). + SelectedLineIdx(35). + OriginY(14) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_when_the_selection_is_off_screen.go b/pkg/integration/tests/main_view/keep_position_when_the_selection_is_off_screen.go new file mode 100644 index 000000000..fa5337413 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_when_the_selection_is_off_screen.go @@ -0,0 +1,65 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionWhenTheSelectionIsOffScreen = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A re-render keeps the lines that are on screen where they are, not a selection scrolled away from", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // Half a diff per scroll, to leave the selection well behind in two presses. + cfg.GetUserConfig().Gui.ScrollHeight = 15 + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 60) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + for _, i := range []int{5, 15, 25, 35, 45, 55} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-line05"), + ). + // Reading on past the selection leaves it far behind, off the top of the + // view. + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + OriginY(30). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + // A context line less on either side of the four hunks above what is on + // screen pulls it nine lines up the diff, and the view follows it there: the + // lines the user was reading are still on the rows they were on. + OriginY(21). + // The selection is where it always was, on its own line of the diff, rather + // than having been dragged back into view. + SelectedLines( + Contains("-line05"), + ). + SelectedLineIdx(7) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_scroll_when_the_diff_cant_be_read.go b/pkg/integration/tests/main_view/keep_scroll_when_the_diff_cant_be_read.go new file mode 100644 index 000000000..7839bc8e6 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_scroll_when_the_diff_cant_be_read.go @@ -0,0 +1,58 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepScrollWhenTheDiffCantBeRead = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Changing the context size under a diff renderer whose rows can't be placed keeps the scroll position rather than jumping to the top", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // A renderer that says nothing about which line of which file each row shows, + // and mangles the diff enough that it can't be read back as one either: no line + // of it can be looked for in the re-render. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Name: "opaque", Command: `sed -e 's/^/| /'`}, + } + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain) + + t.Views().Main(). + Content(Contains("| +LINE05")). + OriginY(6). + Tap(func() { + t.Views().Files().Press(keys.Universal.IncreaseContextInDiffView) + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + // The re-render is a different command, and nothing in its output can be + // matched up with what was on screen, so the offset is all there is to keep — + // and it is a good deal closer than the top. + OriginY(6) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_selected_range_when_changing_context_size.go b/pkg/integration/tests/main_view/keep_selected_range_when_changing_context_size.go new file mode 100644 index 000000000..8f2e6998b --- /dev/null +++ b/pkg/integration/tests/main_view/keep_selected_range_when_changing_context_size.go @@ -0,0 +1,123 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepSelectedRangeWhenChangingContextSize = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A range selection still covers the same lines of the diff after the context size changes", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // A range from a change down into the context below it, so that the cursor is + // on the last line of the selection and the other end is three lines above. + t.Views().Main(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). + Press(keys.Universal.NextItem). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("-line25"), + Contains("+LINE25"), + Contains(" line26"), + Contains(" line27"), + ). + // Both ends are still lines of the diff with more context around the + // change, so the selection still covers the same four. + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + SelectedLines( + Contains("-line25"), + Contains("+LINE25"), + Contains(" line26"), + Contains(" line27"), + ). + // With a single line of context, the line the cursor was on is no longer in + // the diff. The end that survived stays put and the cursor lands on the + // nearest line that is left, so the selection shrinks with the diff. + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 3")) + }). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 1")) + }). + SelectedLines( + Contains("-line25"), + Contains("+LINE25"), + Contains(" line26"), + ). + // The other way round: a range extended upwards, so that it is the far end + // that the shrinking context takes away. There is no guessing which line + // inherits it, so what is left is the line the cursor is on. + PressEscape(). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 3")) + }). + SelectedLines( + Contains(" line26"), + ). + Press(keys.Universal.NextItem). + Press(keys.Universal.RangeSelectUp). + Press(keys.Universal.RangeSelectUp). + Press(keys.Universal.RangeSelectUp). + SelectedLines( + Contains("-line25"), + Contains("+LINE25"), + Contains(" line26"), + Contains(" line27"), + ). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 1")) + }). + SelectedLines( + Contains("-line25"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_selection_after_moving_patch_out.go b/pkg/integration/tests/main_view/keep_selection_after_moving_patch_out.go new file mode 100644 index 000000000..1d3749184 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_selection_after_moving_patch_out.go @@ -0,0 +1,75 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepSelectionAfterMovingPatchOut = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Moving a custom patch out of a commit leaves the focused main view's selection on a change that is still there", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "ONE\ntwo\nTHREE\nfour\nFIVE\n") + shell.Commit("commit to move a patch out of") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("commit to move a patch out of").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // Take the first modification into a custom patch. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-one"), + ). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("-one"), + Contains("+ONE"), + ). + PressPrimaryAction() + + // Keep a range selected across lines that the pending patch will remove from + // the commit and a later change that will remain. + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("-one")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+THREE")). + SelectedLines( + Contains("-one"), + Contains("+ONE"), + Contains(" two"), + Contains("-three"), + Contains("+THREE"), + ) + + t.Common().SelectPatchOption(Contains("Move patch out into index")) + + // The moved lines are gone from the commit, so the range collapses onto the + // change that has taken their place. + t.Views().Main(). + IsFocused(). + Content(DoesNotContain("+ONE")). + SelectedLines( + Contains("-three"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go b/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go new file mode 100644 index 000000000..521670cc9 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go @@ -0,0 +1,59 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepSelectionVisibleWhenDiffShrinks = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The selection stays on the content when a re-render leaves the diff with fewer lines than the selection was on", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Name: "plain", Command: `printf '\033]1717;1\007'; cat`}, + // The same diff in fewer lines, as a renderer that collapses or elides + // parts of it would give us: the addition at the end goes, and the hunk + // header says so, since a diff that contradicts its own header can't be + // read as one. (It has to read all of its input: one that exits early + // leaves the render looking like it is still loading, which holds off the + // clamping this test is about.) + {Name: "shrinking", Command: `printf '\033]1717;1\007'; ` + + `sed -e 's/@@ -1,5 +1,5 @@/@@ -1,5 +1,4 @@/' -e '$d'`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nFIVE\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Universal.GotoBottom). + SelectedLines( + Contains("+FIVE"), + ). + Press(keys.Universal.CycleDiffRenderers). + Tap(func() { + t.ExpectToast(Equals("Diff renderer: shrinking (2 of 2)")) + }) + + // The selection has nowhere to be but the last line there is. Asserting on the + // index first waits for that to happen: the re-render and the clamp that + // follows it are a frame apart, and reading the selected line's text in + // between would be reading past the content. + t.Views().Main(). + SelectionIsActive(). + SelectedLineIdx(10). + SelectedLines( + Contains("-five"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/move_range_to_index.go b/pkg/integration/tests/main_view/move_multi_file_range_to_index.go similarity index 73% rename from pkg/integration/tests/patch_building/move_range_to_index.go rename to pkg/integration/tests/main_view/move_multi_file_range_to_index.go index c8d379c97..e6793f639 100644 --- a/pkg/integration/tests/patch_building/move_range_to_index.go +++ b/pkg/integration/tests/main_view/move_multi_file_range_to_index.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveRangeToIndex = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Apply a custom patch", +var MoveMultiFileRangeToIndex = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move a multi-file range from a commit to the index", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "first line\n") shell.Commit("first commit") @@ -36,24 +38,22 @@ var MoveRangeToIndex = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" A file2"), Equals(" A file3"), ). - SelectNextItem(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+second line")). Press(keys.Universal.ToggleRangeSelect). - NavigateToLine(Contains("file2")). + NavigateToLine(Contains("+file two content")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - - t.Views().Secondary().Content(Contains("second line")) - t.Views().Secondary().Content(Contains("file two content")) + t.Views().Secondary(). + Content(Contains("second line")). + Content(Contains("file two content")) t.Common().SelectPatchOption(MatchesRegexp(`Move patch out into index$`)) - t.Views().CommitFiles(). - IsFocused(). - Lines( - Contains("file3").IsSelected(), - ).PressEscape() - t.Views().Files(). Focus(). Lines( @@ -61,13 +61,8 @@ var MoveRangeToIndex = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" M file1"), Equals(" A file2"), ) - - t.Views().Main(). - Content(Contains("second line")) - - t.Views().Files().Focus().NavigateToLine(Contains("file2")) - - t.Views().Main(). - Content(Contains("file two content")) + t.Views().Secondary().Content(Contains("second line")) + t.Views().Files().NavigateToLine(Contains("file2")) + t.Views().Secondary().Content(Contains("file two content")) }, }) diff --git a/pkg/integration/tests/patch_building/move_to_index_part_of_adjacent_added_lines.go b/pkg/integration/tests/main_view/move_part_of_adjacent_added_lines_to_index.go similarity index 67% rename from pkg/integration/tests/patch_building/move_to_index_part_of_adjacent_added_lines.go rename to pkg/integration/tests/main_view/move_part_of_adjacent_added_lines_to_index.go index bf06270b7..997eb07dc 100644 --- a/pkg/integration/tests/patch_building/move_to_index_part_of_adjacent_added_lines.go +++ b/pkg/integration/tests/main_view/move_part_of_adjacent_added_lines_to_index.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToIndexPartOfAdjacentAddedLines = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a commit to the index, with only some lines of a range of adjacent added lines in the patch", +var MovePartOfAdjacentAddedLinesToIndex = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move only one of two adjacent added lines from a commit to the index", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "") shell.Commit("first commit") @@ -36,34 +38,24 @@ var MoveToIndexPartOfAdjacentAddedLines = NewIntegrationTest(NewIntegrationTestA Lines( Contains("file1").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). + SelectedLines(Contains("+1st line")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch out into index")) - t.Views().CommitFiles(). + t.Views().Main(). IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - Tap(func() { - t.Views().Main(). - Content(Contains("+2nd line"). - DoesNotContain("1st line")) - }) - + Content(Contains("+2nd line").DoesNotContain("1st line")) t.Views().Files(). Focus(). ContainsLines( Contains("M").Contains("file1"), ) - - t.Views().Main(). - Content(Contains("+1st line\n 2nd line")) + t.Views().Secondary().Content(Contains("+1st line\n 2nd line")) }, }) diff --git a/pkg/integration/tests/main_view/move_partial_patch_to_index.go b/pkg/integration/tests/main_view/move_partial_patch_to_index.go new file mode 100644 index 000000000..6b2391a02 --- /dev/null +++ b/pkg/integration/tests/main_view/move_partial_patch_to_index.go @@ -0,0 +1,84 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MovePartialPatchToIndex = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move part of a file's changes from a commit to the index", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "first line\nsecond line\nthird line\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "first line2\nsecond line\nthird line2\n") + shell.Commit("second commit") + + shell.CreateFileAndAdd("file2", "file1 content") + shell.Commit("third commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("third commit").IsSelected(), + Contains("second commit"), + Contains("first commit"), + ). + NavigateToLine(Contains("second commit")). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains(`-first line`)). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). + SelectedLines( + Contains(`-first line`), + Contains(`+first line2`), + ). + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + t.Views().Secondary().ContainsLines( + Contains(`-first line`), + Contains(`+first line2`), + Contains(` second line`), + Contains(` third line`), + ) + + t.Common().SelectPatchOption(Contains("Move patch out into index")) + + t.Views().Files().Lines( + Contains("M").Contains("file1"), + ) + t.Views().Main(). + IsFocused(). + ContainsLines( + Contains(` first line`), + Contains(` second line`), + Contains(`-third line`), + Contains(`+third line2`), + ) + + t.Views().Files().Focus() + t.Views().Secondary().ContainsLines( + Contains(`-first line`), + Contains(`+first line2`), + Contains(` second line`), + Contains(` third line2`), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/move_to_later_commit_partial_hunk.go b/pkg/integration/tests/main_view/move_partial_patch_to_later_commit.go similarity index 71% rename from pkg/integration/tests/patch_building/move_to_later_commit_partial_hunk.go rename to pkg/integration/tests/main_view/move_partial_patch_to_later_commit.go index 974dd4ec6..0b18fe8a2 100644 --- a/pkg/integration/tests/patch_building/move_to_later_commit_partial_hunk.go +++ b/pkg/integration/tests/main_view/move_partial_patch_to_later_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToLaterCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a commit to a later commit, with only parts of a hunk in the patch", +var MovePartialPatchToLaterCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move one of two adjacent additions from a commit to a later commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "") shell.Commit("first commit") @@ -36,23 +38,15 @@ var MoveToLaterCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("file1").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). - PressPrimaryAction(). - PressEscape() + SelectedLines(Contains("+1st line")). + PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - - t.Views().CommitFiles(). - IsFocused(). - PressEscape() - - t.Views().Commits(). - IsFocused(). - SelectPreviousItem() - + t.Views().Commits().Focus().SelectPreviousItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) t.Views().Commits(). @@ -73,8 +67,7 @@ var MoveToLaterCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ ). SelectNextItem(). Tap(func() { - t.Views().Main(). - Content(Contains("+1st line\n 2nd line")) + t.Views().Main().Content(Contains("+1st line\n 2nd line")) }). PressEscape() @@ -89,9 +82,7 @@ var MoveToLaterCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ Contains("file1").IsSelected(), ). Tap(func() { - t.Views().Main(). - Content(Contains("+2nd line"). - DoesNotContain("1st line")) + t.Views().Main().Content(Contains("+2nd line").DoesNotContain("1st line")) }) }, }) diff --git a/pkg/integration/tests/patch_building/move_to_new_commit_partial_hunk.go b/pkg/integration/tests/main_view/move_partial_patch_to_new_commit.go similarity index 73% rename from pkg/integration/tests/patch_building/move_to_new_commit_partial_hunk.go rename to pkg/integration/tests/main_view/move_partial_patch_to_new_commit.go index 4d12e0f90..950d97feb 100644 --- a/pkg/integration/tests/patch_building/move_to_new_commit_partial_hunk.go +++ b/pkg/integration/tests/main_view/move_partial_patch_to_new_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToNewCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a commit to a new commit, with only parts of a hunk in the patch", +var MovePartialPatchToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move one of two adjacent additions from a commit to a new commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "") shell.Commit("first commit") @@ -36,14 +38,14 @@ var MoveToNewCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("file1").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). + SelectedLines(Contains("+1st line")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit")) t.ExpectPopup().CommitMessagePanel(). @@ -66,19 +68,12 @@ var MoveToNewCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ Contains("file1").IsSelected(), ). Tap(func() { - t.Views().Main(). - Content(Contains("+1st line\n 2nd line")) + t.Views().Main().Content(Contains("+1st line\n 2nd line")) }). PressEscape() t.Views().Commits(). IsFocused(). - Lines( - Contains("third commit"), - Contains("new commit").IsSelected(), - Contains("commit to move from"), - Contains("first commit"), - ). SelectNextItem(). PressEnter() @@ -88,9 +83,7 @@ var MoveToNewCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{ Contains("file1").IsSelected(), ). Tap(func() { - t.Views().Main(). - Content(Contains("+2nd line"). - DoesNotContain("1st line")) + t.Views().Main().Content(Contains("+2nd line").DoesNotContain("1st line")) }) }, }) diff --git a/pkg/integration/tests/patch_building/move_to_earlier_commit_from_added_file.go b/pkg/integration/tests/main_view/move_patch_from_added_file_to_earlier_commit.go similarity index 79% rename from pkg/integration/tests/patch_building/move_to_earlier_commit_from_added_file.go rename to pkg/integration/tests/main_view/move_patch_from_added_file_to_earlier_commit.go index 7f0d3584f..1cad9e646 100644 --- a/pkg/integration/tests/patch_building/move_to_earlier_commit_from_added_file.go +++ b/pkg/integration/tests/main_view/move_patch_from_added_file_to_earlier_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToEarlierCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a file that was added in a commit to an earlier commit", +var MovePatchFromAddedFileToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move part of an added file from a commit to an earlier commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") shell.EmptyCommit("destination commit") @@ -31,23 +33,18 @@ var MoveToEarlierCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs Lines( Contains("A file").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). - SelectNextItem(). + NavigateToLine(Contains("+2nd line")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - - t.Views().Commits(). - Focus(). - SelectNextItem() - + t.Views().Commits().Focus().SelectNextItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) - // This results in a conflict at the commit we're moving from, because - // it tries to add a file that already exists + // The source commit tries to add a file that the moved line now causes to exist. t.Common().AcknowledgeConflicts() t.Views().Files(). @@ -69,7 +66,7 @@ var MoveToEarlierCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs Contains(">>>>>>>"), ). SelectNextItem(). - PressPrimaryAction() // choose the version with all three lines + PressPrimaryAction() t.Common().ContinueOnConflictsResolved("rebase") diff --git a/pkg/integration/tests/patch_building/move_to_index_from_added_file_with_conflict.go b/pkg/integration/tests/main_view/move_patch_from_added_file_to_index_with_conflict.go similarity index 82% rename from pkg/integration/tests/patch_building/move_to_index_from_added_file_with_conflict.go rename to pkg/integration/tests/main_view/move_patch_from_added_file_to_index_with_conflict.go index 177e76e04..5037784f1 100644 --- a/pkg/integration/tests/patch_building/move_to_index_from_added_file_with_conflict.go +++ b/pkg/integration/tests/main_view/move_patch_from_added_file_to_index_with_conflict.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToIndexFromAddedFileWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a file that was added in a commit to the index, causing a conflict", +var MovePatchFromAddedFileToIndexWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move part of an added file from a commit to the index, causing a conflict", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") @@ -34,17 +36,15 @@ var MoveToIndexFromAddedFileWithConflict = NewIntegrationTest(NewIntegrationTest Lines( Contains("file1").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). - SelectNextItem(). + NavigateToLine(Contains("+2nd line")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch out into index")) - t.Common().AcknowledgeConflicts() t.Views().Files(). diff --git a/pkg/integration/tests/patch_building/move_to_new_commit_from_added_file.go b/pkg/integration/tests/main_view/move_patch_from_added_file_to_new_commit.go similarity index 81% rename from pkg/integration/tests/patch_building/move_to_new_commit_from_added_file.go rename to pkg/integration/tests/main_view/move_patch_from_added_file_to_new_commit.go index 11abe23f4..d1718c9e9 100644 --- a/pkg/integration/tests/patch_building/move_to_new_commit_from_added_file.go +++ b/pkg/integration/tests/main_view/move_patch_from_added_file_to_new_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToNewCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a file that was added in a commit to a new commit", +var MovePatchFromAddedFileToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move part of an added file from a commit to a new commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") @@ -30,15 +32,14 @@ var MoveToNewCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("file1").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). - SelectNextItem(). + NavigateToLine(Contains("+2nd line")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit")) t.ExpectPopup().CommitMessagePanel(). diff --git a/pkg/integration/tests/patch_building/move_to_new_commit_from_deleted_file.go b/pkg/integration/tests/main_view/move_patch_from_deleted_file_to_new_commit.go similarity index 81% rename from pkg/integration/tests/patch_building/move_to_new_commit_from_deleted_file.go rename to pkg/integration/tests/main_view/move_patch_from_deleted_file_to_new_commit.go index 9edc06fb2..a60a35161 100644 --- a/pkg/integration/tests/patch_building/move_to_new_commit_from_deleted_file.go +++ b/pkg/integration/tests/main_view/move_patch_from_deleted_file_to_new_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToNewCommitFromDeletedFile = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a file that was deleted in a commit to a new commit", +var MovePatchFromDeletedFileToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move part of a deleted file from a commit to a new commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("first commit") @@ -30,15 +32,14 @@ var MoveToNewCommitFromDeletedFile = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("D file1").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). - SelectNextItem(). + NavigateToLine(Contains("-2nd line")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit")) t.ExpectPopup().CommitMessagePanel(). @@ -74,7 +75,6 @@ var MoveToNewCommitFromDeletedFile = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().CommitFiles(). IsFocused(). Lines( - // In the original commit the file is no longer deleted, but modified Contains("M file1").IsSelected(), ). Tap(func() { diff --git a/pkg/integration/tests/patch_building/move_to_earlier_commit.go b/pkg/integration/tests/main_view/move_patch_to_earlier_commit.go similarity index 80% rename from pkg/integration/tests/patch_building/move_to_earlier_commit.go rename to pkg/integration/tests/main_view/move_patch_to_earlier_commit.go index 0c5e60f35..e3d938040 100644 --- a/pkg/integration/tests/patch_building/move_to_earlier_commit.go +++ b/pkg/integration/tests/main_view/move_patch_to_earlier_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{ +var MovePatchToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to an earlier commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "file1 content") @@ -42,15 +44,17 @@ var MoveToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{ Contains(" D file2"), Contains(" A file3"), ). - PressPrimaryAction(). - PressEscape() + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-file1 content")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+file3 content")). + PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - - t.Views().Commits(). - IsFocused(). - SelectNextItem() - + t.Views().Commits().Focus().SelectNextItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) t.Views().Commits(). @@ -79,7 +83,6 @@ var MoveToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{ SelectPreviousItem(). PressEnter() - // the original commit has no more files in it t.Views().CommitFiles(). IsFocused(). Lines( diff --git a/pkg/integration/tests/patch_building/move_to_index.go b/pkg/integration/tests/main_view/move_patch_to_index.go similarity index 71% rename from pkg/integration/tests/patch_building/move_to_index.go rename to pkg/integration/tests/main_view/move_patch_to_index.go index 6eca8865f..ed4886a96 100644 --- a/pkg/integration/tests/patch_building/move_to_index.go +++ b/pkg/integration/tests/main_view/move_patch_to_index.go @@ -1,11 +1,11 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToIndex = NewIntegrationTest(NewIntegrationTestArgs{ +var MovePatchToIndex = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to the index", ExtraCmdArgs: []string{}, Skip: false, @@ -30,39 +30,29 @@ var MoveToIndex = NewIntegrationTest(NewIntegrationTestArgs{ Contains("file1"), Contains("file2"), ). - SelectNextItem(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+file1 content")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Views().Secondary().Content(Contains("+file1 content")) t.Common().SelectPatchOption(Contains("Move patch out into index")) - t.Views().Files(). - Lines( - Contains("A").Contains("file1"), - ) - - t.Views().CommitFiles(). + t.Views().Files().Lines( + Contains("A").Contains("file1"), + ) + t.Views().Main(). IsFocused(). - Lines( - Contains("file2").IsSelected(), - ). - PressEscape() - - t.Views().Main(). Content(Contains("+file2 content")) + t.Views().Commits().Lines( + Contains("first commit").IsSelected(), + ) - t.Views().Commits(). - Lines( - Contains("first commit").IsSelected(), - ) - - t.Views().Files(). - Focus() - - t.Views().Main(). - Content(Contains("file1 content")) + t.Views().Files().Focus() + t.Views().Secondary().Content(Contains("file1 content")) }, }) diff --git a/pkg/integration/tests/patch_building/move_to_index_with_conflict.go b/pkg/integration/tests/main_view/move_patch_to_index_with_conflict.go similarity index 83% rename from pkg/integration/tests/patch_building/move_to_index_with_conflict.go rename to pkg/integration/tests/main_view/move_patch_to_index_with_conflict.go index bdf0765d9..204d5121c 100644 --- a/pkg/integration/tests/patch_building/move_to_index_with_conflict.go +++ b/pkg/integration/tests/main_view/move_patch_to_index_with_conflict.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToIndexWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ +var MovePatchToIndexWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to the index, causing a conflict", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content") shell.Commit("first commit") @@ -36,12 +38,17 @@ var MoveToIndexWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("file1").IsSelected(), ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-file1 content")). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch out into index")) - t.Common().AcknowledgeConflicts() t.Views().Files(). diff --git a/pkg/integration/tests/patch_building/move_to_index_works_even_if_noprefix_is_set.go b/pkg/integration/tests/main_view/move_patch_to_index_with_custom_diff_config.go similarity index 73% rename from pkg/integration/tests/patch_building/move_to_index_works_even_if_noprefix_is_set.go rename to pkg/integration/tests/main_view/move_patch_to_index_with_custom_diff_config.go index 8c2ba84b0..0ccedc2e3 100644 --- a/pkg/integration/tests/patch_building/move_to_index_works_even_if_noprefix_is_set.go +++ b/pkg/integration/tests/main_view/move_patch_to_index_with_custom_diff_config.go @@ -1,11 +1,11 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToIndexWorksEvenIfNoprefixIsSet = NewIntegrationTest(NewIntegrationTestArgs{ +var MovePatchToIndexWithCustomDiffConfig = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Moving a patch to the index works even if diff.noprefix or diff.external are set", ExtraCmdArgs: []string{}, Skip: false, @@ -14,7 +14,6 @@ var MoveToIndexWorksEvenIfNoprefixIsSet = NewIntegrationTest(NewIntegrationTestA shell.CreateFileAndAdd("file1", "file1 content\n") shell.Commit("first commit") - // Test that this works even if custom diff options are set shell.SetConfig("diff.noprefix", "true") shell.SetConfig("diff.external", "echo") }, @@ -31,20 +30,19 @@ var MoveToIndexWorksEvenIfNoprefixIsSet = NewIntegrationTest(NewIntegrationTestA Lines( Contains("file1").IsSelected(), ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+file1 content")). PressPrimaryAction() t.Views().Secondary().Content(Contains("+file1 content")) - t.Common().SelectPatchOption(Contains("Move patch out into index")) - t.Views().CommitFiles().IsFocused(). - Lines( - Equals("(none)"), - ) - - t.Views().Files(). - Lines( - Contains("A").Contains("file1"), - ) + t.Views().CommitFiles().Lines(Equals("(none)")) + t.Views().Files().Lines( + Contains("A").Contains("file1"), + ) }, }) diff --git a/pkg/integration/tests/patch_building/move_to_index_with_modified_file.go b/pkg/integration/tests/main_view/move_patch_to_index_with_modified_file.go similarity index 67% rename from pkg/integration/tests/patch_building/move_to_index_with_modified_file.go rename to pkg/integration/tests/main_view/move_patch_to_index_with_modified_file.go index 93aba6d41..a58373349 100644 --- a/pkg/integration/tests/patch_building/move_to_index_with_modified_file.go +++ b/pkg/integration/tests/main_view/move_patch_to_index_with_modified_file.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToIndexWithModifiedFile = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a commit to the index, with a modified file in the working tree that conflicts with the patch", +var MovePatchToIndexWithModifiedFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move a patch from a commit to the index with a conflicting working-tree change", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "1\n2\n3\n4\n") shell.Commit("first commit") @@ -31,10 +33,16 @@ var MoveToIndexWithModifiedFile = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Equals("M file1"), ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-1")). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Views().Secondary().Content(Contains("-1\n+11")) t.Common().SelectPatchOption(Contains("Move patch out into index")) @@ -49,11 +57,8 @@ var MoveToIndexWithModifiedFile = NewIntegrationTest(NewIntegrationTestArgs{ Equals("MM file1"), ) - t.Views().Main(). - Content(Contains("-11\n+111\n")) - t.Views().Secondary(). - Content(Contains("-1\n+11\n")) - + t.Views().Main().Content(Contains("-11\n+111\n")) + t.Views().Secondary().Content(Contains("-1\n+11\n")) t.Views().Stash().IsEmpty() }, }) diff --git a/pkg/integration/tests/patch_building/move_to_later_commit.go b/pkg/integration/tests/main_view/move_patch_to_later_commit.go similarity index 80% rename from pkg/integration/tests/patch_building/move_to_later_commit.go rename to pkg/integration/tests/main_view/move_patch_to_later_commit.go index aa97a9504..f640f499f 100644 --- a/pkg/integration/tests/patch_building/move_to_later_commit.go +++ b/pkg/integration/tests/main_view/move_patch_to_later_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToLaterCommit = NewIntegrationTest(NewIntegrationTestArgs{ +var MovePatchToLaterCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to a later commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "file1 content") @@ -43,15 +45,17 @@ var MoveToLaterCommit = NewIntegrationTest(NewIntegrationTestArgs{ Contains(" D file2"), Contains(" A file3"), ). - PressPrimaryAction(). - PressEscape() + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-file1 content")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+file3 content")). + PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - - t.Views().Commits(). - IsFocused(). - SelectPreviousItem() - + t.Views().Commits().Focus().SelectPreviousItem() t.Common().SelectPatchOption(Contains("Move patch to selected commit")) t.Views().Commits(). @@ -80,7 +84,6 @@ var MoveToLaterCommit = NewIntegrationTest(NewIntegrationTestArgs{ SelectNextItem(). PressEnter() - // the original commit has no more files in it t.Views().CommitFiles(). IsFocused(). Lines( diff --git a/pkg/integration/tests/patch_building/move_to_new_commit.go b/pkg/integration/tests/main_view/move_patch_to_new_commit.go similarity index 82% rename from pkg/integration/tests/patch_building/move_to_new_commit.go rename to pkg/integration/tests/main_view/move_patch_to_new_commit.go index 8f1c77376..b672cbbd0 100644 --- a/pkg/integration/tests/patch_building/move_to_new_commit.go +++ b/pkg/integration/tests/main_view/move_patch_to_new_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{ +var MovePatchToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Move a patch from a commit to a new commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "file1 content") @@ -43,11 +45,16 @@ var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{ Contains(" D file2"), Contains(" A file3"), ). - PressPrimaryAction(). - PressEscape() + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-file1 content")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+file3 content")). + PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit")) t.ExpectPopup().CommitMessagePanel(). @@ -76,16 +83,9 @@ var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). - Lines( - Contains("third commit"), - Contains("new commit").IsSelected(), - Contains("commit to move from"), - Contains("first commit"), - ). SelectNextItem(). PressEnter() - // the original commit has no more files in it t.Views().CommitFiles(). IsFocused(). Lines( diff --git a/pkg/integration/tests/patch_building/move_to_new_commit_before.go b/pkg/integration/tests/main_view/move_patch_to_new_commit_before.go similarity index 81% rename from pkg/integration/tests/patch_building/move_to_new_commit_before.go rename to pkg/integration/tests/main_view/move_patch_to_new_commit_before.go index 41e59d5b0..93e353dc6 100644 --- a/pkg/integration/tests/patch_building/move_to_new_commit_before.go +++ b/pkg/integration/tests/main_view/move_patch_to_new_commit_before.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToNewCommitBefore = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a commit to a new commit before the original one", +var MovePatchToNewCommitBefore = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move a patch from a commit to a new commit before the source", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateDir("dir") shell.CreateFileAndAdd("dir/file1", "file1 content") @@ -43,11 +45,16 @@ var MoveToNewCommitBefore = NewIntegrationTest(NewIntegrationTestArgs{ Contains(" D file2"), Contains(" A file3"), ). - PressPrimaryAction(). - PressEscape() + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("-file1 content")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+file3 content")). + PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch into new commit before the original commit")) t.ExpectPopup().CommitMessagePanel(). @@ -80,7 +87,6 @@ var MoveToNewCommitBefore = NewIntegrationTest(NewIntegrationTestArgs{ SelectPreviousItem(). PressEnter() - // the original commit has no more files in it t.Views().CommitFiles(). IsFocused(). Lines( diff --git a/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go b/pkg/integration/tests/main_view/move_patch_to_new_commit_in_stacked_branch.go similarity index 80% rename from pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go rename to pkg/integration/tests/main_view/move_patch_to_new_commit_in_stacked_branch.go index 67170b35a..ba296c342 100644 --- a/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go +++ b/pkg/integration/tests/main_view/move_patch_to_new_commit_in_stacked_branch.go @@ -1,17 +1,18 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a commit to a new commit, in the last commit of a branch in the middle of a stack", +var MovePatchToNewCommitInStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move a patch to a new commit after the last commit of a branch in the middle of a stack", ExtraCmdArgs: []string{}, Skip: false, GitVersion: AtLeast("2.38.0"), SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Git.Log.ShowGraph = "never" + config.GetUserConfig().Gui.UseHunkModeInDiffView = false }, SetupRepo: func(shell *Shell) { shell. @@ -46,12 +47,14 @@ var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrati Equals(" A file1"), Equals(" A file2"), ). - SelectNextItem(). - PressPrimaryAction(). - PressEscape() + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+file1 content")). + PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit")) t.ExpectPopup().CommitMessagePanel(). diff --git a/pkg/integration/tests/main_view/navigate_by_hunk_and_file.go b/pkg/integration/tests/main_view/navigate_by_hunk_and_file.go new file mode 100644 index 000000000..3a6eb134d --- /dev/null +++ b/pkg/integration/tests/main_view/navigate_by_hunk_and_file.go @@ -0,0 +1,88 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var NavigateByHunkAndFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Jump from hunk to hunk and from file to file in the focused main view of a commit's diff", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + shell.UpdateFileAndAdd("file2", "one\ntwo\nTHREE\n") + shell.Commit("two") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("two").IsSelected(), + Contains("one"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-three"), + ). + // Hunk navigation moves between change blocks. A hunk, in lazygit's sense, + // is a run of changes bounded by context; one @@ hunk may hold several. + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-nine"), + ). + Press(keys.Main.PrevHunk). + SelectedLines( + Contains("-three"), + ). + // File navigation lands on the top of the next file's diff, which for a + // parseable diff is its header. + Press(keys.Main.NextFile). + SelectedLines( + Contains("diff --git a/file2 b/file2"), + ). + Press(keys.Main.NextFile). + SelectedLines( + Contains("diff --git a/file2 b/file2"), + ). + Press(keys.Main.PrevFile). + SelectedLines( + Contains("diff --git a/file1 b/file1"), + ). + // A range that only grows while shift is held is a plain selection again once + // we jump elsewhere, rather than stretching to wherever we land. + Press(keys.Main.NextHunk). + Press(keys.Universal.RangeSelectDown). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-nine"), + ). + // A sticky range does stretch to it. + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Main.PrevHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + Contains(" four"), + Contains(" five"), + Contains(" six"), + Contains(" seven"), + Contains(" eight"), + Contains("-nine"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/no_selection_over_a_binary_diff.go b/pkg/integration/tests/main_view/no_selection_over_a_binary_diff.go new file mode 100644 index 000000000..17169414f --- /dev/null +++ b/pkg/integration/tests/main_view/no_selection_over_a_binary_diff.go @@ -0,0 +1,56 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var NoSelectionOverABinaryDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A diff with nothing selectable in it shows no selection, however it came to be showing", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("text", "one\ntwo\nthree\n") + shell.CreateFileAndAdd("binary", "\x00one\x00two\x00") + shell.Commit("one") + + shell.UpdateFile("text", "one\nTWO\nthree\n") + shell.UpdateFile("binary", "\x00one\x00TWO\x00") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // git says only that the file differs, so there is nothing to select — and a + // refresh, which renders the same diff again, doesn't make one appear. + t.Views().Files(). + IsFocused(). + NavigateToLine(Contains("binary")). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsHidden(). + Tap(func() { + t.GlobalPress(keys.Universal.Refresh) + }). + SelectionIsHidden() + + // The same when acting on a diff of several files leaves nothing selectable in + // it: staging the text file's only change leaves the binary one behind. + t.Views().Files(). + Focus(). + NavigateToLine(Contains("▼ /")). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsActive(). + SelectedLines( + Contains("-two"), + ). + Press(keys.Main.ToggleSelectHunk). + PressPrimaryAction(). + SelectionIsHidden() + }, +}) diff --git a/pkg/integration/tests/main_view/no_selection_over_a_commit_log.go b/pkg/integration/tests/main_view/no_selection_over_a_commit_log.go new file mode 100644 index 000000000..54f392822 --- /dev/null +++ b/pkg/integration/tests/main_view/no_selection_over_a_commit_log.go @@ -0,0 +1,48 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var NoSelectionOverACommitLog = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view over a branch's commit log shows no selection, even for a log too long to be read in one go", + ExtraCmdArgs: []string{}, + Skip: false, + // A short terminal, so that the log below is longer than the initial read of it. + Width: 100, + Height: 20, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(60) + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("add file1") + shell.UpdateFile("file1", "one\ntwo modified\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Leave a selection behind in the main view, on the file's diff. + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsActive(). + PressEscape() + + t.Views().Branches(). + Focus() + + t.Views().Main(). + Content(Contains("commit-60")) + + // A commit log holds nothing to point at, so focusing it shows no selection — + // not even the one the pane was left with under the files panel. + t.Views().Branches(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsHidden() + }, +}) diff --git a/pkg/integration/tests/main_view/no_selection_when_no_changes.go b/pkg/integration/tests/main_view/no_selection_when_no_changes.go new file mode 100644 index 000000000..39d0871f9 --- /dev/null +++ b/pkg/integration/tests/main_view/no_selection_when_no_changes.go @@ -0,0 +1,31 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var NoSelectionWhenNoChanges = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view when there are no changes shows no selection, and navigating doesn't conjure one", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + IsEmpty(). + Press(keys.Universal.FocusMainView) + + // There's nothing to act on, so the placeholder is shown with no selection — and + // a navigation key just scrolls rather than conjuring one. + t.Views().Main(). + IsFocused(). + Content(Contains("No changed files")). + SelectionIsHidden(). + Press(keys.Universal.GotoTop). + SelectionIsHidden() + }, +}) diff --git a/pkg/integration/tests/main_view/patch_marks_follow_a_renderer_switch.go b/pkg/integration/tests/main_view/patch_marks_follow_a_renderer_switch.go new file mode 100644 index 000000000..5d0e35272 --- /dev/null +++ b/pkg/integration/tests/main_view/patch_marks_follow_a_renderer_switch.go @@ -0,0 +1,58 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PatchMarksFollowARendererSwitch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Switching diff renderers mid-build leaves the marks on the lines that are in the custom patch, wherever the new rendering puts them", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // Two renderers that announce the metadata protocol — so that focusing the main + // view keeps their output rather than falling back to git's own — and pass the + // diff through under a banner of their own. The second one's banner is a line + // longer, so every line of the diff it renders is a line further down than the + // first one's. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Name: "one", Command: `printf '\033]1717;1\007RENDERED BY ONE\n'; cat`}, + {Name: "two", Command: `printf '\033]1717;1\007RENDERED BY TWO\nAND ONE MORE LINE\n'; cat`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Content(Contains("RENDERED BY ONE")). + SelectedLines( + Contains("-two"), + ). + PressPrimaryAction(). + MarkedLines( + Contains("-two"), + ). + Press(keys.Universal.CycleDiffRenderers) + + t.ExpectToast(Equals("Diff renderer: two (2 of 2)")) + + // The marks are of lines of the diff, not of rows of the rendering, so the new + // rendering has them on the same line of the file. + t.Views().Main(). + Content(Contains("AND ONE MORE LINE")). + MarkedLines( + Contains("-two"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/patch_marks_show_while_the_diff_is_focused.go b/pkg/integration/tests/main_view/patch_marks_show_while_the_diff_is_focused.go new file mode 100644 index 000000000..3f2c6efeb --- /dev/null +++ b/pkg/integration/tests/main_view/patch_marks_show_while_the_diff_is_focused.go @@ -0,0 +1,61 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PatchMarksShowWhileTheDiffIsFocused = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The marks over the lines in the custom patch are shown while either pane of the focused main view holds the focus, and not once it leaves", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + PressPrimaryAction(). + MarkedLines( + Contains("-two"), + ). + // Moving to the pane previewing the patch is still working on the same patch, + // so the marks stay. + Press(keys.Universal.TogglePanel) + + t.Views().Secondary().IsFocused() + t.Views().Main().MarkedLines( + Contains("-two"), + ) + + // Leaving the diff behind takes them away: they say what pressing space here + // would act on. + t.Views().Secondary().Press(keys.Universal.Return) + + t.Views().Commits().IsFocused() + t.Views().Main().NoMarkedLines() + + // And they are back with the focus. + t.Views().Commits().Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + MarkedLines( + Contains("-two"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/range_select_diff_lines.go b/pkg/integration/tests/main_view/range_select_diff_lines.go new file mode 100644 index 000000000..0dfe98dd2 --- /dev/null +++ b/pkg/integration/tests/main_view/range_select_diff_lines.go @@ -0,0 +1,59 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RangeSelectDiffLines = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Select a range of diff lines in the focused main view, both sticky and with shift", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // A sticky range is extended by the plain arrow keys, and pressing the key again + // collapses it back to the cursor line. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-three"), + ). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("-three"), + Contains("-four"), + ). + Press(keys.Universal.ToggleRangeSelect). + SelectedLines( + Contains("-four"), + ). + // A non-sticky range only grows while shift is held, and a plain move + // collapses it again. + Press(keys.Universal.RangeSelectDown). + SelectedLines( + Contains("-four"), + Contains("+THREE"), + ). + Press(keys.Universal.RangeSelectUp). + SelectedLines( + Contains("-four"), + ). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("+THREE"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/raw_fallback_under_an_external_diff.go b/pkg/integration/tests/main_view/raw_fallback_under_an_external_diff.go new file mode 100644 index 000000000..92fabfe33 --- /dev/null +++ b/pkg/integration/tests/main_view/raw_fallback_under_an_external_diff.go @@ -0,0 +1,63 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RawFallbackUnderAnExternalDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view under an external diff that says nothing about its rows brings git's own diff, keeping the scroll position", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // An external diff whose output has nothing to do with the diff it was given, + // let alone anything to say about which line of which file each row shows. It + // is long enough to be scrolled about in. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Name: "opaque", Type: "extDiff", Command: `sh -c 'seq -f "EXT-%g" 40'`}, + } + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Browsing shows what the renderer produced, whatever that is. + t.Views().Main(). + Content(Contains("EXT-1")). + Tap(func() { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain) + }). + OriginY(6). + Tap(func() { + t.Views().Files().Press(keys.Universal.FocusMainView) + }). + IsFocused(). + // Focusing the view to act on it brings git's own diff instead — the + // renderer's rows can't be placed in the file — and leaves the view at the + // offset it was at, rather than at the top. + Content(Contains("+LINE05")). + Content(DoesNotContain("EXT-1")). + OriginY(6). + SelectionIsActive() + }, +}) diff --git a/pkg/integration/tests/patch_building/renamed_file_partial.go b/pkg/integration/tests/main_view/remove_content_change_from_renamed_file.go similarity index 52% rename from pkg/integration/tests/patch_building/renamed_file_partial.go rename to pkg/integration/tests/main_view/remove_content_change_from_renamed_file.go index 3c37c13a2..c3d110dc3 100644 --- a/pkg/integration/tests/patch_building/renamed_file_partial.go +++ b/pkg/integration/tests/main_view/remove_content_change_from_renamed_file.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var RenamedFilePartial = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Select part of a renamed file's changes into a custom patch and remove it from the commit, keeping the rename in place", +var RemoveContentChangeFromRenamedFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Remove a renamed file's content change from a commit while keeping its rename", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n") shell.Commit("first commit") @@ -32,42 +34,50 @@ var RenamedFilePartial = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("original → renamed").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - // The main view shows the rename together with its content change. - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). Content(Contains("rename from original").Contains("rename to renamed")). + SelectedLines(Contains("-line2")). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("-line2"), + Contains("+line2 changed"), + ). + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + + // Only part of the file is in the patch, so the patch leaves the rename behind in + // the commit and carries the content change alone. The pane beside the diff shows + // it that way, over the name the file was renamed to. + t.Views().Secondary(). + ContainsLines( + Contains("diff --git a/renamed b/renamed"), + Contains("index"), + Contains("--- a/renamed"), + Contains("+++ b/renamed"), + ). ContainsLines( Contains(" line1"), Contains("-line2"), Contains("+line2 changed"), Contains(" line3"), - ). - // Add the hunk (a line selection, as opposed to adding the whole - // file), so this is a partial patch. - PressPrimaryAction() - - t.Views().Information().Content(Contains("Building patch")) + ) t.Common().SelectPatchOption(Contains("Remove patch from original commit")) - // The rename is preserved; only the content change is gone, so the file - // is still shown as a rename but now has no content change. - t.Views().CommitFiles(). - IsFocused(). - Lines( - Contains("original → renamed").IsSelected(), - ) - + t.Views().CommitFiles().Lines( + Contains("original → renamed").IsSelected(), + ) t.Views().Main(). + IsFocused(). Content(DoesNotContain("line2 changed")) - - t.Views().Commits(). - Focus(). - Lines( - Contains("rename with modification").IsSelected(), - Contains("first commit"), - ) + t.Views().Commits().Lines( + Contains("rename with modification").IsSelected(), + Contains("first commit"), + ) }, }) diff --git a/pkg/integration/tests/main_view/remove_lines_from_the_custom_patch.go b/pkg/integration/tests/main_view/remove_lines_from_the_custom_patch.go new file mode 100644 index 000000000..5e3a53d52 --- /dev/null +++ b/pkg/integration/tests/main_view/remove_lines_from_the_custom_patch.go @@ -0,0 +1,90 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RemoveLinesFromTheCustomPatch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Take a line back out of the custom patch from the pane previewing it, where the patch's own numbering is not the commit's", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\n") + shell.Commit("first commit") + + // Additions with a line of the file between them, so that leaving the first of + // them out of the patch puts the others at line numbers the commit's diff has + // unchanged lines at: a line of the patch can only be found by counting the + // patch's own changes. + shell.UpdateFileAndAdd("file1", "one\nadded a\ntwo\nadded b\nthree\nadded c\nfour\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + // Take the second and third additions into the patch, leaving the first out. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+added a"), + ). + NavigateToLine(Contains("+added b")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+added c")). + PressPrimaryAction(). + MarkedLines( + Contains("+added b"), + Contains("+added c"), + ) + + t.Views().Secondary().ContainsLines( + Contains("+added b"), + Contains(" three"), + Contains("+added c"), + ) + + // Point at the first of the patch's lines and take it back out. + t.Views().Main().Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("+added b"), + ). + PressPrimaryAction(). + // What is left is the line that wasn't pointed at, and the selection has + // stayed with it. + SelectedLines( + Contains("+added c"), + ). + Content(DoesNotContain("+added b")) + + t.Views().Main().MarkedLines( + Contains("+added c"), + ) + + // And the patch really is only that line: applying it to the working tree brings + // back nothing else. + t.Common().SelectPatchOption(Contains("Apply patch in reverse")) + + t.Views().Files(). + Focus(). + Lines( + Contains("M").Contains("file1"), + ) + + // The patch went to the index as well as the working tree, so the file's changes + // are on the staged side of its diff. + t.Views().Secondary(). + ContainsLines( + Contains("-added c"), + ). + Content(DoesNotContain("+added b")) + }, +}) diff --git a/pkg/integration/tests/patch_building/remove_parts_of_added_file.go b/pkg/integration/tests/main_view/remove_part_of_added_file_from_commit.go similarity index 63% rename from pkg/integration/tests/patch_building/remove_parts_of_added_file.go rename to pkg/integration/tests/main_view/remove_part_of_added_file_from_commit.go index 9a0b9a951..18d3b8e1a 100644 --- a/pkg/integration/tests/patch_building/remove_parts_of_added_file.go +++ b/pkg/integration/tests/main_view/remove_part_of_added_file_from_commit.go @@ -1,18 +1,19 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var RemovePartsOfAddedFile = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Remove a custom patch from a file that was added in a commit", +var RemovePartOfAddedFileFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Remove a custom patch containing one line of an added file from its commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.EmptyCommit("first commit") - shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n") shell.Commit("commit to remove from") }, @@ -30,27 +31,21 @@ var RemovePartsOfAddedFile = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("A file1").IsSelected(), ). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().PatchBuilding(). + t.Views().Main(). IsFocused(). - SelectNextItem(). + NavigateToLine(Contains("+2nd line")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Common().SelectPatchOption(Contains("Remove patch from original commit")) - t.Views().CommitFiles(). + t.Views().Main(). IsFocused(). - Lines( - Contains("A file1").IsSelected(), - ). - PressEscape() - - t.Views().Main().ContainsLines( - Equals("+1st line"), - Equals("+3rd line"), - ) + ContainsLines( + Equals("+1st line"), + Equals("+3rd line"), + ) }, }) diff --git a/pkg/integration/tests/patch_building/remove_from_commit.go b/pkg/integration/tests/main_view/remove_patch_from_commit.go similarity index 67% rename from pkg/integration/tests/patch_building/remove_from_commit.go rename to pkg/integration/tests/main_view/remove_patch_from_commit.go index fbd78fcf7..b514fb105 100644 --- a/pkg/integration/tests/patch_building/remove_from_commit.go +++ b/pkg/integration/tests/main_view/remove_patch_from_commit.go @@ -1,15 +1,17 @@ -package patch_building +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -var RemoveFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Remove a custom patch from a commit", +var RemovePatchFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Remove a whole-file custom patch from its original commit", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) {}, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "file1 content\n") shell.CreateFileAndAdd("file2", "file2 content\n") @@ -30,30 +32,23 @@ var RemoveFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ Contains("file1"), Contains("file2"), ). - SelectNextItem(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+file1 content")). PressPrimaryAction() t.Views().Information().Content(Contains("Building patch")) - t.Views().Secondary().Content(Contains("+file1 content")) - t.Common().SelectPatchOption(Contains("Remove patch from original commit")) t.Views().Files().IsEmpty() - - t.Views().CommitFiles(). - IsFocused(). - Lines( - Contains("file2").IsSelected(), - ). - PressEscape() - t.Views().Main(). + IsFocused(). Content(Contains("+file2 content")) - - t.Views().Commits(). - Lines( - Contains("first commit").IsSelected(), - ) + t.Views().Commits().Lines( + Contains("first commit").IsSelected(), + ) }, }) diff --git a/pkg/integration/tests/main_view/reset_a_patch_built_from_a_commits_diff.go b/pkg/integration/tests/main_view/reset_a_patch_built_from_a_commits_diff.go new file mode 100644 index 000000000..353336485 --- /dev/null +++ b/pkg/integration/tests/main_view/reset_a_patch_built_from_a_commits_diff.go @@ -0,0 +1,49 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ResetAPatchBuiltFromACommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Reset a custom patch built from a commit's diff without ever entering the commit's files, and stay in the diff", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Build the patch straight from the commit's diff, so that nothing has ever told + // the commit files panel which commit it would be showing. + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + ). + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + t.Views().Secondary().IsVisible().Content(Contains("-two")) + + t.Common().SelectPatchOption(Contains("Reset patch")) + + // Giving up the patch leaves the diff it was being built from, and the focus in it, + // while the pane that was previewing the patch goes with it. + t.Views().Information().Content(DoesNotContain("Building patch")) + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsFocused(). + Content(Contains("-two")) + }, +}) diff --git a/pkg/integration/tests/main_view/reset_patch_with_escape.go b/pkg/integration/tests/main_view/reset_patch_with_escape.go new file mode 100644 index 000000000..cc41ccacb --- /dev/null +++ b/pkg/integration/tests/main_view/reset_patch_with_escape.go @@ -0,0 +1,49 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ResetPatchWithEscape = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Reset a custom patch after escaping from the focused commit diff", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "file1 content") + shell.Commit("first commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("first commit").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+file1 content")). + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + + // Leave the focused diff and then the commit files panel. Escape at the top + // level gives up the patch. + t.Views().Main().PressEscape() + t.Views().CommitFiles().IsFocused().PressEscape() + t.Views().Commits().IsFocused().PressEscape() + + t.Views().Information().Content(DoesNotContain("Building patch")) + }, +}) diff --git a/pkg/integration/tests/main_view/reset_the_patch_from_the_pane_showing_it.go b/pkg/integration/tests/main_view/reset_the_patch_from_the_pane_showing_it.go new file mode 100644 index 000000000..0cebe5d8c --- /dev/null +++ b/pkg/integration/tests/main_view/reset_the_patch_from_the_pane_showing_it.go @@ -0,0 +1,49 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ResetThePatchFromThePaneShowingIt = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Reset a custom patch while the focus is in the pane previewing it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + Contains("+TWO"), + ). + PressPrimaryAction(). + Press(keys.Universal.TogglePanel) + + t.Views().Information().Content(Contains("Building patch")) + t.Views().Secondary(). + IsFocused(). + Content(Contains("-two")) + + t.Common().SelectPatchOption(Contains("Reset patch")) + + // The pane goes with the patch it was previewing, and the focus follows into the + // one showing the diff the patch was built from. + t.Views().Information().Content(DoesNotContain("Building patch")) + t.Views().Secondary().IsInvisible() + t.Views().Main(). + IsFocused(). + Content(Contains("-two")) + }, +}) diff --git a/pkg/integration/tests/main_view/search_collapses_the_selection.go b/pkg/integration/tests/main_view/search_collapses_the_selection.go new file mode 100644 index 000000000..ec345fff6 --- /dev/null +++ b/pkg/integration/tests/main_view/search_collapses_the_selection.go @@ -0,0 +1,51 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SearchCollapsesTheSelection = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Searching the focused main view leaves a single line selected at the match, whatever was selected before", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + // The match is in another block entirely, so the hunk selection the cursor + // has just left goes with it. + FilterOrSearch("NINE"). + SelectedLines( + Contains("+NINE"), + ). + // And the same for a hunk selected while a search is on: the next match is + // not part of it either. + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-nine"), + Contains("+NINE"), + ). + Press(keys.Universal.NextMatch). + SelectedLines( + Contains("+NINE"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/search_follows_the_selection.go b/pkg/integration/tests/main_view/search_follows_the_selection.go new file mode 100644 index 000000000..55048cf0f --- /dev/null +++ b/pkg/integration/tests/main_view/search_follows_the_selection.go @@ -0,0 +1,44 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SearchFollowsTheSelection = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stepping to the next match in the focused main view carries on from the selection", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "") + shell.Commit("one") + + shell.UpdateFile("file1", "NEEDLE a\ntwo\nNEEDLE b\nfour\nNEEDLE c\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + FilterOrSearch("NEEDLE"). + SelectedLines(Contains("+NEEDLE a")). + Tap(func() { + t.Views().Search().Content(Contains("matches for 'NEEDLE' (1 of 3)")) + }). + // Move the selection past the second match by hand. + SelectNextItem(). + SelectNextItem(). + SelectNextItem(). + SelectedLines(Contains("+four")). + Tap(func() { + t.Views().Search().Content(Contains("matches for 'NEEDLE' (2 of 3)")) + }). + // So the next match is the one after where the selection is, not the one + // after the match it was last on. + Press(keys.Universal.NextMatch). + SelectedLines(Contains("+NEEDLE c")) + }, +}) diff --git a/pkg/integration/tests/main_view/select_below_a_long_commit_message.go b/pkg/integration/tests/main_view/select_below_a_long_commit_message.go new file mode 100644 index 000000000..606fb226d --- /dev/null +++ b/pkg/integration/tests/main_view/select_below_a_long_commit_message.go @@ -0,0 +1,64 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectBelowALongCommitMessage = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view over a commit whose diff begins below a very long commit message still shows a selection", + ExtraCmdArgs: []string{}, + Skip: false, + // A short terminal, so that the message below is longer than a render is asked to + // read, and the diff under it is only reached by reading on. + Width: 100, + Height: 20, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("first commit") + + body := make([]string, 1000) + for i := range body { + body[i] = fmt.Sprintf("message line %d", i+1) + } + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("a commit with a great deal to say\n\n" + strings.Join(body, "\n")) + + shell.EmptyCommit("nothing to see here") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // A commit with nothing to select leaves the pane showing no selection. + t.Views().Commits(). + Focus(). + Lines( + Contains("nothing to see here").IsSelected(), + Contains("a commit with a great deal to say"), + Contains("first commit"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsHidden(). + PressEscape() + + // The commit below it has a change, a thousand lines further down than a render + // reads by itself. The pane reads on until it knows, rather than taking the + // answer from the commit before it or waiting for the user to scroll. + t.Views().Commits(). + IsFocused(). + SelectNextItem(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + // The change is in the pane, a thousand lines below where the render stopped + // reading of its own accord. + Content(Contains("+TWO")). + SelectionIsActive() + }, +}) diff --git a/pkg/integration/tests/main_view/select_below_a_long_diffstat.go b/pkg/integration/tests/main_view/select_below_a_long_diffstat.go new file mode 100644 index 000000000..865035976 --- /dev/null +++ b/pkg/integration/tests/main_view/select_below_a_long_diffstat.go @@ -0,0 +1,61 @@ +package main_view + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectBelowALongDiffstat = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view over a commit whose diff begins below a long diffstat still shows a selection", + ExtraCmdArgs: []string{}, + Skip: false, + // A short terminal, so that the diffstat below fills more than the screenful the + // first paint reveals, and the diff itself is longer than the initial read of it. + Width: 100, + Height: 20, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + for i := range 40 { + shell.CreateFileAndAdd(fmt.Sprintf("file%02d", i+1), "one\ntwo\nthree\n") + } + shell.Commit("first commit") + for i := range 40 { + shell.UpdateFileAndAdd(fmt.Sprintf("file%02d", i+1), "one\nTWO\nthree\n") + } + shell.Commit("touch every file") + shell.EmptyCommit("nothing to see here") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // A commit with nothing to select leaves the pane showing no selection. + t.Views().Commits(). + Focus(). + Lines( + Contains("nothing to see here").IsSelected(), + Contains("touch every file"), + Contains("first commit"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsHidden(). + PressEscape() + + // The commit below it has plenty to select, even though none of it is among + // the diffstat the first paint shows. + t.Views().Commits(). + IsFocused(). + SelectNextItem() + + t.Views().Main().Content(Contains("40 files changed")) + + t.Views().Commits(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsActive() + }, +}) diff --git a/pkg/integration/tests/main_view/select_diff_lines.go b/pkg/integration/tests/main_view/select_diff_lines.go new file mode 100644 index 000000000..412c850f3 --- /dev/null +++ b/pkg/integration/tests/main_view/select_diff_lines.go @@ -0,0 +1,54 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectDiffLines = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view selects the first visible change line, and the arrow keys move the selection", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + // The selection starts on the first change line rather than at the top of the + // diff, so that it lands on something to act on without the view jumping. + t.Views().Main(). + IsFocused(). + SelectionIsActive(). + SelectedLines( + Contains("-three"), + ). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("+THREE"), + ). + Press(keys.Universal.PrevItem). + SelectedLines( + Contains("-three"), + ). + Press(keys.Universal.GotoTop). + SelectedLines( + Contains("diff --git a/file1 b/file1"), + ). + Press(keys.Universal.Return) + + t.Views().Files(). + IsFocused() + }, +}) diff --git a/pkg/integration/tests/main_view/select_hunk_below_last_change.go b/pkg/integration/tests/main_view/select_hunk_below_last_change.go new file mode 100644 index 000000000..ea0290dea --- /dev/null +++ b/pkg/integration/tests/main_view/select_hunk_below_last_change.go @@ -0,0 +1,37 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectHunkBelowLastChange = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Toggling hunk selection while below the last change selects the last change block", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nTWO\nthree\nfour\nfive\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // Below the last change there is no block ahead to select, so hunk mode takes + // the one behind rather than doing nothing. + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains(" five")). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-two"), + Contains("+TWO"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_hunk_in_diff.go b/pkg/integration/tests/main_view/select_hunk_in_diff.go new file mode 100644 index 000000000..c4684cfda --- /dev/null +++ b/pkg/integration/tests/main_view/select_hunk_in_diff.go @@ -0,0 +1,59 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectHunkInDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Toggle hunk selection in the focused main view, and step from hunk to hunk", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // Hunk mode widens the selection to the whole change block around the cursor — + // which is lazygit's notion of a hunk, so the two changed lines and their + // replacements are one block, and the isolated change further down is another. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-three"), + ). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("-four"), + Contains("+THREE"), + Contains("+FOUR"), + ). + // In hunk mode the arrow keys step from block to block rather than by line. + Press(keys.Universal.NextItem). + SelectedLines( + Contains("-nine"), + Contains("+NINE"), + ). + Press(keys.Universal.PrevItem). + SelectedLines( + Contains("-three"), + Contains("-four"), + Contains("+THREE"), + Contains("+FOUR"), + ). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_hunk_on_focusing_main_view.go b/pkg/integration/tests/main_view/select_hunk_on_focusing_main_view.go new file mode 100644 index 000000000..00e413530 --- /dev/null +++ b/pkg/integration/tests/main_view/select_hunk_on_focusing_main_view.go @@ -0,0 +1,67 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectHunkOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "When hunk mode is the default, focusing the main view selects the first whole change block", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = true + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // No key press needed: the whole block is selected just by focusing. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + // Hunk mode being the configured default, it isn't something escape gives up: + // escape leaves the view. + Press(keys.Universal.Return) + + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + // A click on a change line keeps hunk mode and selects that line's block. + Click(0, 14). + SelectedLines( + Contains("-nine"), + Contains("+NINE"), + ). + // A click inside the selected block collapses hunk mode to that line. + Click(0, 15). + SelectedLines( + Contains("+NINE"), + ). + // Switch back to hunk mode so the context click below proves that it gives + // hunk mode up, rather than merely keeping line mode. + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-nine"), + Contains("+NINE"), + ). + // A click on a context line points at it precisely, so it selects that line. + Click(0, 12). + SelectedLines( + Contains(" seven"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_in_a_diff_read_in_part.go b/pkg/integration/tests/main_view/select_in_a_diff_read_in_part.go new file mode 100644 index 000000000..9299c984d --- /dev/null +++ b/pkg/integration/tests/main_view/select_in_a_diff_read_in_part.go @@ -0,0 +1,36 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectInADiffReadInPart = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view over a diff longer than the part of it that has been read still shows a selection", + ExtraCmdArgs: []string{}, + Skip: false, + // A short terminal, so that the file below is longer than the initial read of its diff. + Width: 100, + Height: 20, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + lines := make([]string, 600) + for i := range lines { + lines[i] = fmt.Sprintf("line%03d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one big commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsActive() + }, +}) diff --git a/pkg/integration/tests/main_view/select_line_when_whole_file_is_one_hunk.go b/pkg/integration/tests/main_view/select_line_when_whole_file_is_one_hunk.go new file mode 100644 index 000000000..054539550 --- /dev/null +++ b/pkg/integration/tests/main_view/select_line_when_whole_file_is_one_hunk.go @@ -0,0 +1,43 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectLineWhenWholeFileIsOneHunk = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Hunk mode falls back to a single line for a file that is one solid block of changes, rather than selecting all of it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = true + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + shell.CreateFileAndAdd("added", "one\ntwo\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("added").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + // Every line of the file is an addition, so widening to the change block would + // select the file entire; hunk mode gives way to a single line. + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("+one"), + ). + // Toggling hunk mode on explicitly still selects the whole block: the fallback + // is about what the default does, not about forbidding the selection. + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("+one"), + Contains("+two"), + Contains("+three"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_next_change_after_staging.go b/pkg/integration/tests/main_view/select_next_change_after_staging.go new file mode 100644 index 000000000..2b393d602 --- /dev/null +++ b/pkg/integration/tests/main_view/select_next_change_after_staging.go @@ -0,0 +1,54 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectNextChangeAfterStaging = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "After staging from the focused main view the selection lands on the change that took the place of the one staged", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nADD1\nADD2\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // Line by line, each press leaves the selection on the next change to stage. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+ADD1"), + ). + PressPrimaryAction(). + SelectedLines( + Contains("+ADD2"), + ). + PressPrimaryAction(). + SelectedLines( + Contains("-nine"), + ). + // A hunk goes the same way: the block after the one staged takes its place, + // and here there is none, so the last change stays selected. + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-nine"), + Contains("+NINE"), + ). + PressPrimaryAction(). + Tap(func() { + t.Views().Files().Lines( + Contains("M file1"), + ) + }) + }, +}) diff --git a/pkg/integration/tests/main_view/select_next_change_after_unstaging.go b/pkg/integration/tests/main_view/select_next_change_after_unstaging.go new file mode 100644 index 000000000..5eb2fb1d1 --- /dev/null +++ b/pkg/integration/tests/main_view/select_next_change_after_unstaging.go @@ -0,0 +1,45 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectNextChangeAfterUnstaging = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Taking one line of a staged modification back out of the index moves on to the line that replaced it, in the pane the staged side ended up in", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "c0\na1\na2\na3\nc1\nc2\noldB\nc3\n") + shell.Commit("one") + + // Staged: a block of deletions, and a modification below it. + shell.UpdateFileAndAdd("file1", "c0\nc1\nc2\nnewB\nc3\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // The file has nothing but staged changes, so the pane holding them is the only + // one shown. + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("-a1"), + ). + NavigateToLine(Contains("-oldB")). + PressPrimaryAction() + + // The unstaged pane has appeared above, and the work carries on where it was: + // on the line that takes the place of the one taken out. + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("+newB"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_next_deletion_after_staging_one.go b/pkg/integration/tests/main_view/select_next_deletion_after_staging_one.go new file mode 100644 index 000000000..19fdb74ed --- /dev/null +++ b/pkg/integration/tests/main_view/select_next_deletion_after_staging_one.go @@ -0,0 +1,43 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectNextDeletionAfterStagingOne = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Staging one deletion in the middle of a block of them moves on to the next, not back to the block's first line", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "keep1\nd1\nd2\nd3\nd4\nkeep2\n") + shell.Commit("one") + + // Four deletions in a row. They all sit at the same place in the new file, so + // that position alone cannot tell them apart. + shell.UpdateFile("file1", "keep1\nkeep2\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-d1"), + ). + Press(keys.Universal.NextItem). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("-d3"), + ). + PressPrimaryAction(). + SelectedLines( + Contains("-d4"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_visible_change_on_focusing_main_view.go b/pkg/integration/tests/main_view/select_visible_change_on_focusing_main_view.go new file mode 100644 index 000000000..3be6bc766 --- /dev/null +++ b/pkg/integration/tests/main_view/select_visible_change_on_focusing_main_view.go @@ -0,0 +1,77 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectVisibleChangeOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view selects a change that is already on screen, leaving the diff where it is", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // Enough context around each change to scroll into a stretch of the diff that + // holds none. + cfg.GetUserConfig().Git.DiffContextSize = 20 + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 60) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + lines[4] = strings.ToUpper(lines[4]) + lines[54] = strings.ToUpper(lines[54]) + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain) + + t.Views().Main(). + OriginY(4). + Tap(func() { + t.Views().Files().Press(keys.Universal.FocusMainView) + }). + IsFocused(). + // The first change is on screen, so it is the one to point at — and the view + // hasn't moved to point at it. + SelectedLines( + Contains("-line05"), + ). + OriginY(4). + PressEscape() + + t.Views().Files(). + IsFocused(). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain) + + t.Views().Main(). + OriginY(12). + Tap(func() { + t.Views().Files().Press(keys.Universal.FocusMainView) + }). + IsFocused(). + // Now the whole screen is context: the changes are above and below it. The + // selection goes to the middle of what is on screen rather than to a change + // the user would have to be scrolled to. + SelectedLines( + Contains(" line19"), + ). + SelectedLineIdx(24). + OriginY(12) + }, +}) diff --git a/pkg/integration/tests/main_view/select_visible_hunk_on_focusing_main_view.go b/pkg/integration/tests/main_view/select_visible_hunk_on_focusing_main_view.go new file mode 100644 index 000000000..b5d0d8c15 --- /dev/null +++ b/pkg/integration/tests/main_view/select_visible_hunk_on_focusing_main_view.go @@ -0,0 +1,93 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectVisibleHunkOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view in hunk mode picks a block that begins on screen, leaving the diff where it is", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = true + // One line per scroll, so that the test can put the top of the view exactly + // where it wants it, and enough context to scroll about within one hunk. + cfg.GetUserConfig().Gui.ScrollHeight = 1 + cfg.GetUserConfig().Git.DiffContextSize = 20 + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 80) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + for _, i := range []int{10, 20, 70} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + scrollDown := func(lines int) { + for range lines { + t.Views().Files().Press(keys.Universal.ScrollDownMain) + } + } + + t.Views().Files().IsFocused() + + // The top of the view is the second line of the first change block, so that + // block is only half on screen; the second one begins below it, in full. + scrollDown(15) + + t.Views().Main(). + OriginY(15). + Tap(func() { + t.Views().Files().Press(keys.Universal.FocusMainView) + }). + IsFocused(). + SelectedLines( + Contains("-line20"), + Contains("+LINE20"), + ). + OriginY(15). + PressEscape() + + // Now nothing begins on screen: the second block starts just above the top and + // the third change is far below. Only the half-visible block is on screen, so + // it is selected, with its first line off screen, since the view stays put. + scrollDown(11) + + t.Views().Main(). + OriginY(26). + Tap(func() { + t.Views().Files().Press(keys.Universal.FocusMainView) + }). + IsFocused(). + SelectedLines( + Contains("-line20"), + Contains("+LINE20"), + ). + SelectedLineIdx(25). + OriginY(26). + PressEscape() + + // And a click inside a block that begins above the viewport selects the whole + // block without pulling the view up to its start either. + t.Views().Main(). + Click(0, 0). + IsFocused(). + SelectedLines( + Contains("-line20"), + Contains("+LINE20"), + ). + OriginY(26) + }, +}) diff --git a/pkg/integration/tests/main_view/selection_command_tooltips_follow_the_diff.go b/pkg/integration/tests/main_view/selection_command_tooltips_follow_the_diff.go new file mode 100644 index 000000000..d49b3cdec --- /dev/null +++ b/pkg/integration/tests/main_view/selection_command_tooltips_follow_the_diff.go @@ -0,0 +1,62 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectionCommandTooltipsFollowTheDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The tooltips of the selection commands describe what they do over the diff they are offered on", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nTWO\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Over the working tree's diff the two keys act on the index. + t.Views().Files(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Universal.OptionMenu) + + t.ExpectPopup().Menu(). + Title(Equals("Keybindings")). + Select(Contains("Stage")). + Tooltip(Equals("Toggle selection staged / unstaged.")). + Select(Contains("Discard")). + Tooltip(Contains("discard the change using `git reset`")). + Cancel() + + t.Views().Main().PressEscape() + + // Over a commit's diff they build a custom patch and rewrite the commit, so + // the index wording would be wrong; taking lines out of a commit is worth a + // warning of its own. + t.Views().Commits(). + Focus(). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Universal.OptionMenu) + + t.ExpectPopup().Menu(). + Title(Equals("Keybindings")). + Select(Contains("Toggle lines in patch")). + Tooltip(Equals("")). + Select(Contains("Remove lines from commit")). + Tooltip(Contains("runs an interactive rebase in the background")). + Cancel() + }, +}) diff --git a/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go b/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go new file mode 100644 index 000000000..652b8adea --- /dev/null +++ b/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go @@ -0,0 +1,58 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectionCommandsOnlyWhereTheyApply = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The keybindings menu offers the commands that act on a diff selection only in a main view that has one", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // A branch's commit log has nothing to select, so the commands that act on a + // selection have no business being listed there. + t.Views().Branches(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Universal.OptionMenu) + + t.ExpectPopup().Menu(). + Title(Equals("Keybindings")). + Tap(func() { + t.Views().Menu(). + Content(DoesNotContain("Select hunks")). + Content(DoesNotContain("Toggle range select")). + Content(DoesNotContain("Go to next hunk")). + Content(DoesNotContain("Go to next file")) + }). + Cancel() + + // A diff view does list them, and with nothing changed to select they're + // offered but disabled. + t.Views().Files(). + Focus(). + IsEmpty(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsHidden(). + Press(keys.Universal.OptionMenu) + + t.ExpectPopup().Menu(). + Title(Equals("Keybindings")). + Select(Contains("Select hunks")). + Confirm() + + t.ExpectToast(Contains("There is nothing to select here")) + }, +}) diff --git a/pkg/integration/tests/main_view/selection_over_the_custom_patch.go b/pkg/integration/tests/main_view/selection_over_the_custom_patch.go new file mode 100644 index 000000000..2d76959b6 --- /dev/null +++ b/pkg/integration/tests/main_view/selection_over_the_custom_patch.go @@ -0,0 +1,47 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectionOverTheCustomPatch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The pane showing a custom patch keeps its selection across a refresh, its content being a diff like any other", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n") + shell.Commit("two") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + NavigateToLine(Contains("two")). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + PressPrimaryAction(). + Press(keys.Universal.FocusMainView) + + // The patch built from the commit is shown in the other pane, and it is a diff, + // so it has a selection of its own — one that a refresh doesn't take away. + t.Views().Main(). + IsFocused(). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + Title(Equals("Custom patch")). + SelectionIsActive(). + Tap(func() { + t.GlobalPress(keys.Universal.Refresh) + }). + SelectionIsActive() + }, +}) diff --git a/pkg/integration/tests/main_view/stage_deleted_file.go b/pkg/integration/tests/main_view/stage_deleted_file.go new file mode 100644 index 000000000..9005fb364 --- /dev/null +++ b/pkg/integration/tests/main_view/stage_deleted_file.go @@ -0,0 +1,80 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageDeletedFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Acting on the whole of a file's block in a directory's diff acts on the file: staging a deletion, and unstaging an addition", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("fileA", "a\n") + shell.CreateFileAndAdd("fileB", "b1\nb2\n") + shell.Commit("one") + + shell.UpdateFile("fileA", "a\nfromA\n") + shell.DeleteFile("fileB") + shell.CreateFileAndAdd("fileC", "c1\nc2\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("▼ /").IsSelected(), + Contains(" M fileA"), + Contains(" D fileB"), + Contains("A fileC"), + ). + Press(keys.Universal.FocusMainView) + + // Select everything the deleted file contributes to the diff. + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("-b1")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("-b2")). + SelectedLines( + Contains("-b1"), + Contains("-b2"), + ). + PressPrimaryAction() + + // The file is staged as deleted. Applying its lines as a patch would have left + // an empty file in the index instead, which is not what deleting a file means. + t.Views().Files().Lines( + Contains("▼ /"), + Contains(" M fileA"), + Contains("D fileB"), + Contains("A fileC"), + ) + + // The same the other way round: taking the whole of an added file back out of + // the index leaves it untracked, rather than tracked and empty. + t.Views().Main(). + IsFocused(). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + NavigateToLine(Contains("+c1")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+c2")). + SelectedLines( + Contains("+c1"), + Contains("+c2"), + ). + PressPrimaryAction() + + t.Views().Files().Lines( + Contains("▼ /"), + Contains(" M fileA"), + Contains("D fileB"), + Contains("?? fileC"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/stage_diff_lines.go b/pkg/integration/tests/main_view/stage_diff_lines.go new file mode 100644 index 000000000..5e103bd85 --- /dev/null +++ b/pkg/integration/tests/main_view/stage_diff_lines.go @@ -0,0 +1,70 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageDiffLines = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stage a line and a hunk of the working tree's diff from the focused main view", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + // Two change blocks, far enough apart to stay separate hunks. + shell.UpdateFile("file1", "one\ntwo\nADD1\nADD2\nthree\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + // A single line goes into the index by itself, leaving the rest of its block + // unstaged — which is the whole point of staging from the diff. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+ADD1"), + ). + PressPrimaryAction() + + t.Views().Files().Lines( + Contains("MM file1"), + ) + t.Views().Secondary(). + ContainsLines( + Contains("+ADD1"), + ). + Content(DoesNotContain("+ADD2")) + t.Views().Main(). + Content(DoesNotContain("+ADD1")). + ContainsLines( + Contains("+ADD2"), + ) + + // A whole change block goes in one press. + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("-nine")). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-nine"), + Contains("+NINE"), + ). + PressPrimaryAction() + + t.Views().Secondary().ContainsLines( + Contains("-nine"), + Contains("+NINE"), + ) + t.Views().Main().Content(DoesNotContain("NINE")) + }, +}) diff --git a/pkg/integration/tests/main_view/stage_hunks_with_rapid_keypresses.go b/pkg/integration/tests/main_view/stage_hunks_with_rapid_keypresses.go new file mode 100644 index 000000000..9dadb4c12 --- /dev/null +++ b/pkg/integration/tests/main_view/stage_hunks_with_rapid_keypresses.go @@ -0,0 +1,54 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The second space is pressed before the diff the first one changed has re-rendered. +// The selection only moves to the next hunk once that render arrives, so until then it +// still covers lines that are no longer in the diff. The second press has to wait for +// the render rather than act on those lines. +var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stage two hunks from the focused main view with two space presses in rapid succession", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = true + }, + SetupRepo: func(shell *Shell) { + // Seven context lines between the two change blocks, so that git makes them two + // hunks. + shell.CreateFileAndAdd("file1", "1\n2\na\nb\nc\nd\ne\nf\ng\n3\n4\n") + shell.Commit("one") + + shell.UpdateFile("file1", "1b\n2b\na\nb\nc\nd\ne\nf\ng\n3b\n4b\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + PressRapidly(keys.Universal.Select, keys.Universal.Select) + + // Both presses were acted on, so the whole file is staged. + t.Views().Files().Lines( + Contains("M file1"), + ) + t.Views().Secondary(). + Title(Equals("Staged changes")). + ContainsLines( + Contains("+1b"), + Contains("+2b"), + ). + ContainsLines( + Contains("+3b"), + Contains("+4b"), + ) + }, +}) diff --git a/pkg/integration/tests/staging/stage_partial_block_of_changes_first_lines.go b/pkg/integration/tests/main_view/stage_partial_block_of_changes_first_lines.go similarity index 91% rename from pkg/integration/tests/staging/stage_partial_block_of_changes_first_lines.go rename to pkg/integration/tests/main_view/stage_partial_block_of_changes_first_lines.go index 0588184a0..5af78d5c7 100644 --- a/pkg/integration/tests/staging/stage_partial_block_of_changes_first_lines.go +++ b/pkg/integration/tests/main_view/stage_partial_block_of_changes_first_lines.go @@ -1,4 +1,4 @@ -package staging +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" @@ -10,7 +10,7 @@ var StagePartialBlockOfChangesFirstLines = NewIntegrationTest(NewIntegrationTest ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false + config.GetUserConfig().Gui.UseHunkModeInDiffView = false }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "1\n2\n3\n4\n5\n6\n7\n8\n") @@ -26,7 +26,7 @@ var StagePartialBlockOfChangesFirstLines = NewIntegrationTest(NewIntegrationTest ). PressEnter() - t.Views().Staging(). + t.Views().Main(). IsFocused(). ContainsLines( Contains(" 1"), @@ -53,7 +53,7 @@ var StagePartialBlockOfChangesFirstLines = NewIntegrationTest(NewIntegrationTest SelectedLines(Contains("+3b")). PressPrimaryAction() - t.Views().StagingSecondary(). + t.Views().Secondary(). ContainsLines( Contains(" 1"), Contains("-2"), diff --git a/pkg/integration/tests/staging/stage_partial_block_of_changes_last_lines.go b/pkg/integration/tests/main_view/stage_partial_block_of_changes_last_lines.go similarity index 91% rename from pkg/integration/tests/staging/stage_partial_block_of_changes_last_lines.go rename to pkg/integration/tests/main_view/stage_partial_block_of_changes_last_lines.go index 355b02292..6846b2fb9 100644 --- a/pkg/integration/tests/staging/stage_partial_block_of_changes_last_lines.go +++ b/pkg/integration/tests/main_view/stage_partial_block_of_changes_last_lines.go @@ -1,4 +1,4 @@ -package staging +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" @@ -10,7 +10,7 @@ var StagePartialBlockOfChangesLastLines = NewIntegrationTest(NewIntegrationTestA ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false + config.GetUserConfig().Gui.UseHunkModeInDiffView = false }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "1\n2\n3\n4\n5\n6\n7\n8\n") @@ -26,7 +26,7 @@ var StagePartialBlockOfChangesLastLines = NewIntegrationTest(NewIntegrationTestA ). PressEnter() - t.Views().Staging(). + t.Views().Main(). IsFocused(). ContainsLines( Contains(" 1"), @@ -53,7 +53,7 @@ var StagePartialBlockOfChangesLastLines = NewIntegrationTest(NewIntegrationTestA SelectedLines(Contains("+7b")). PressPrimaryAction() - t.Views().StagingSecondary(). + t.Views().Secondary(). ContainsLines( Contains(" 3"), Contains(" 4"), diff --git a/pkg/integration/tests/staging/stage_partial_block_of_changes_middle_lines.go b/pkg/integration/tests/main_view/stage_partial_block_of_changes_middle_lines.go similarity index 93% rename from pkg/integration/tests/staging/stage_partial_block_of_changes_middle_lines.go rename to pkg/integration/tests/main_view/stage_partial_block_of_changes_middle_lines.go index 085c188a9..e6004526b 100644 --- a/pkg/integration/tests/staging/stage_partial_block_of_changes_middle_lines.go +++ b/pkg/integration/tests/main_view/stage_partial_block_of_changes_middle_lines.go @@ -1,4 +1,4 @@ -package staging +package main_view import ( "github.com/jesseduffield/lazygit/pkg/config" @@ -10,7 +10,7 @@ var StagePartialBlockOfChangesMiddleLines = NewIntegrationTest(NewIntegrationTes ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false + config.GetUserConfig().Gui.UseHunkModeInDiffView = false }, SetupRepo: func(shell *Shell) { shell.CreateFileAndAdd("file1", "1\n2\n3\n4\n5\n6\n7\n8\n") @@ -26,7 +26,7 @@ var StagePartialBlockOfChangesMiddleLines = NewIntegrationTest(NewIntegrationTes ). PressEnter() - t.Views().Staging(). + t.Views().Main(). IsFocused(). ContainsLines( Contains(" 1"), @@ -53,7 +53,7 @@ var StagePartialBlockOfChangesMiddleLines = NewIntegrationTest(NewIntegrationTes SelectedLines(Contains("+5b")). PressPrimaryAction() - t.Views().StagingSecondary(). + t.Views().Secondary(). // This is not the desired result, ideally the added lines would come right after the // deleted lines. However, this is hard to do, and it's a lot less common than staging // either the first lines or last lines of a block of changes, so we live with the diff --git a/pkg/integration/tests/main_view/stage_range_spanning_files.go b/pkg/integration/tests/main_view/stage_range_spanning_files.go new file mode 100644 index 000000000..585d019da --- /dev/null +++ b/pkg/integration/tests/main_view/stage_range_spanning_files.go @@ -0,0 +1,55 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageRangeSpanningFiles = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stage a range reaching from one file's diff into another's, in a directory's focused main view", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("fileA", "a\n") + shell.CreateFileAndAdd("fileB", "b\n") + shell.CreateFileAndAdd("fileC", "c\n") + shell.Commit("one") + + shell.UpdateFile("fileA", "a\nfromA\n") + shell.UpdateFile("fileB", "b\nfromB\n") + shell.UpdateFile("fileC", "c\nfromC\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // With the root of the tree selected, the main view shows all three files' diffs. + t.Views().Files(). + IsFocused(). + Lines( + Contains("▼ /").IsSelected(), + Contains(" M fileA"), + Contains(" M fileB"), + Contains(" M fileC"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+fromA"), + ). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+fromB")). + PressPrimaryAction() + + // Both files the range reached into are staged, each by its own patch, and the + // file below it is untouched. + t.Views().Files().Lines( + Contains("▼ /"), + Contains("M fileA"), + Contains("M fileB"), + Contains(" M fileC"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/stage_under_conforming_diff_renderer.go b/pkg/integration/tests/main_view/stage_under_conforming_diff_renderer.go new file mode 100644 index 000000000..1e3d54a3d --- /dev/null +++ b/pkg/integration/tests/main_view/stage_under_conforming_diff_renderer.go @@ -0,0 +1,55 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageUnderConformingDiffRenderer = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A diff renderer that announces the metadata protocol is taken at its word, so its diff is what stays on screen when the main view is focused", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // A renderer that announces the protocol with a version-only record before + // anything else, then says who it is and passes the diff through. The diff it + // passes through keeps its structure, so the rows can be placed by reading it. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007RENDERED BY ME\n'; cat`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused() + + // The announcement itself leaves nothing on the screen; the line after it shows + // whose output this is. + t.Views().Main().Content(Contains("RENDERED BY ME")) + + t.Views().Files().Press(keys.Universal.FocusMainView) + + // Focusing left the renderer's output alone, and the selection went on it. + t.Views().Main(). + IsFocused(). + Content(Contains("RENDERED BY ME")). + SelectedLines( + Contains("-three"), + ). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + PressPrimaryAction() + + t.Views().Secondary().ContainsLines( + Contains("-three"), + Contains("+THREE"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/stage_under_unsupported_diff_renderer.go b/pkg/integration/tests/main_view/stage_under_unsupported_diff_renderer.go new file mode 100644 index 000000000..ce5c1af17 --- /dev/null +++ b/pkg/integration/tests/main_view/stage_under_unsupported_diff_renderer.go @@ -0,0 +1,71 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageUnderUnsupportedDiffRenderer = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A diff renderer that says nothing about what it renders is replaced by git's own diff when the main view is focused, so the diff can still be staged from", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // `cat -n` numbers every line, which pushes the +/- column off the start of it: + // the diff can't be read back from the text, and cat says nothing about what it + // is rendering, so there is no way to act on what it produces. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: "cat -n"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // While browsing, the view shows the renderer's output. + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ) + t.Views().Main().Content(Contains("1 diff --git a/file1 b/file1")) + + // Focusing it to act on it brings git's own diff instead, and the selection goes + // on that. + t.Views().Files().Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Content(DoesNotContain("1 diff --git")). + SelectedLines( + Contains("-three"), + ). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + PressPrimaryAction() + + t.Views().Secondary().ContainsLines( + Contains("-three"), + Contains("+THREE"), + ) + // The re-render after staging stays with git's diff, so the next hunk can be + // staged as well. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-nine"), + Contains("+NINE"), + ). + PressPrimaryAction() + + t.Views().Files().Lines( + Contains("M file1"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/start_patch_from_another_commit.go b/pkg/integration/tests/main_view/start_patch_from_another_commit.go new file mode 100644 index 000000000..2e339103d --- /dev/null +++ b/pkg/integration/tests/main_view/start_patch_from_another_commit.go @@ -0,0 +1,71 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StartPatchFromAnotherCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Confirm replacing a custom patch when selecting lines from another commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "file1 content") + shell.Commit("first commit") + shell.CreateFileAndAdd("file2", "file2 content") + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("second commit").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("file2").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+file2 content")). + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + t.Views().Secondary().Content(Contains("file2")) + + t.Views().Main().PressEscape() + t.Views().CommitFiles().IsFocused().PressEscape() + t.Views().Commits(). + IsFocused(). + NavigateToLine(Contains("first commit")). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines(Contains("+file1 content")). + PressPrimaryAction() + + t.ExpectPopup().Confirmation(). + Title(Contains("Discard patch")). + Content(Contains("You can only build a patch from one commit/stash-entry at a time. Discard current patch?")). + Confirm() + + t.Views().Secondary().Content(Contains("file1").DoesNotContain("file2")) + }, +}) diff --git a/pkg/integration/tests/main_view/unstage_diff_lines.go b/pkg/integration/tests/main_view/unstage_diff_lines.go new file mode 100644 index 000000000..5863a9ba7 --- /dev/null +++ b/pkg/integration/tests/main_view/unstage_diff_lines.go @@ -0,0 +1,57 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var UnstageDiffLines = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Take a line back out of the index from the staged half of the focused main view", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + // Two staged additions, far enough apart to be separate hunks, plus an unstaged + // one, so that the diff is split into a staged and an unstaged half. + shell.UpdateFileAndAdd("file1", "one\nSTAGED1\ntwo\nthree\nfour\nfive\nsix\nseven\nSTAGED2\neight\nnine\nten\n") + shell.UpdateFile("file1", "one\nSTAGED1\ntwo\nthree\nUNSTAGED\nfour\nfive\nsix\nseven\nSTAGED2\neight\nnine\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("MM file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + // The main half holds the unstaged changes; the staged ones are next door. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+UNSTAGED"), + ). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + SelectedLines( + Contains("+STAGED1"), + ). + PressPrimaryAction() + + // The line acted on is out of the index, and the one below it stays in. + t.Views().Secondary(). + Content(DoesNotContain("+STAGED1")). + ContainsLines( + Contains("+STAGED2"), + ) + t.Views().Main().ContainsLines( + Contains("+STAGED1"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/move_to_index_partial.go b/pkg/integration/tests/patch_building/move_to_index_partial.go deleted file mode 100644 index 2f2e3ea42..000000000 --- a/pkg/integration/tests/patch_building/move_to_index_partial.go +++ /dev/null @@ -1,96 +0,0 @@ -package patch_building - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var MoveToIndexPartial = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Move a patch from a commit to the index. This is different from the MoveToIndex test in that we're only selecting a partial patch from a file", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) {}, - SetupRepo: func(shell *Shell) { - shell.CreateFileAndAdd("file1", "first line\nsecond line\nthird line\n") - shell.Commit("first commit") - - shell.UpdateFileAndAdd("file1", "first line2\nsecond line\nthird line2\n") - shell.Commit("second commit") - - shell.CreateFileAndAdd("file2", "file1 content") - shell.Commit("third commit") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Commits(). - Focus(). - Lines( - Contains("third commit").IsSelected(), - Contains("second commit"), - Contains("first commit"), - ). - NavigateToLine(Contains("second commit")). - PressEnter() - - t.Views().CommitFiles(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().PatchBuilding(). - IsFocused(). - ContainsLines( - Contains(`-first line`).IsSelected(), - Contains(`+first line2`), - Contains(` second line`), - Contains(`-third line`), - Contains(`+third line2`), - ). - PressPrimaryAction(). - Tap(func() { - t.Views().Information().Content(Contains("Building patch")) - - t.Views().Secondary(). - ContainsLines( - Contains(`-first line`), - Contains(`+first line2`), - Contains(` second line`), - Contains(` third line`), - ) - - t.Common().SelectPatchOption(Contains("Move patch out into index")) - - t.Views().Files(). - Lines( - Contains("M").Contains("file1"), - ) - }) - - // Focus is automatically returned to the commit files panel. Arguably it shouldn't be. - t.Views().CommitFiles(). - IsFocused(). - Lines( - Contains("file1"), - ) - - t.Views().Main(). - ContainsLines( - Contains(` first line`), - Contains(` second line`), - Contains(`-third line`), - Contains(`+third line2`), - ) - - t.Views().Files(). - Focus() - - t.Views().Main(). - ContainsLines( - Contains(`-first line`), - Contains(`+first line2`), - Contains(` second line`), - Contains(` third line2`), - ) - }, -}) diff --git a/pkg/integration/tests/patch_building/renamed_file_whole.go b/pkg/integration/tests/patch_building/renamed_file_whole.go index f4151a766..71b0cbedc 100644 --- a/pkg/integration/tests/patch_building/renamed_file_whole.go +++ b/pkg/integration/tests/patch_building/renamed_file_whole.go @@ -36,11 +36,16 @@ var RenamedFileWhole = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Information().Content(Contains("Building patch")) - // The whole file is added, so the patch carries the rename itself. + // The whole file is added, so the patch carries the rename itself, and the diff + // the patch is shown as carries it too. The trees the patch is materialized into + // are what git names in the rename lines, the paths of the two sides being all it + // has to go by. t.Views().Secondary(). ContainsLines( - Contains("rename from original"), - Contains("rename to renamed"), + Contains("diff --git a/original b/renamed"), + Contains("similarity index"), + Contains("rename from a/original"), + Contains("rename to b/renamed"), ) t.Common().SelectPatchOption(Contains("Remove patch from original commit")) diff --git a/pkg/integration/tests/patch_building/reset_with_escape.go b/pkg/integration/tests/patch_building/reset_with_escape.go deleted file mode 100644 index 7046890d3..000000000 --- a/pkg/integration/tests/patch_building/reset_with_escape.go +++ /dev/null @@ -1,43 +0,0 @@ -package patch_building - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var ResetWithEscape = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Reset a custom patch with the escape keybinding", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) {}, - SetupRepo: func(shell *Shell) { - shell.CreateFileAndAdd("file1", "file1 content") - shell.Commit("first commit") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Commits(). - Focus(). - Lines( - Contains("first commit").IsSelected(), - ). - PressEnter() - - t.Views().CommitFiles(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressPrimaryAction(). - Tap(func() { - t.Views().Information().Content(Contains("Building patch")) - }). - PressEscape() - - // hitting escape at the top level will reset the patch - t.Views().Commits(). - IsFocused(). - PressEscape() - - t.Views().Information().Content(DoesNotContain("Building patch")) - }, -}) diff --git a/pkg/integration/tests/patch_building/specific_selection.go b/pkg/integration/tests/patch_building/specific_selection.go deleted file mode 100644 index 2e140e41c..000000000 --- a/pkg/integration/tests/patch_building/specific_selection.go +++ /dev/null @@ -1,159 +0,0 @@ -package patch_building - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var SpecificSelection = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Build a custom patch with a specific selection of lines, adding individual lines, as well as a range and hunk, and adding a file directly", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false - }, - SetupRepo: func(shell *Shell) { - shell.CreateFileAndAdd("hunk-file", "1a\n1b\n1c\n1d\n1e\n1f\n1g\n1h\n1i\n1j\n1k\n1l\n1m\n1n\n1o\n1p\n1q\n1r\n1s\n1t\n1u\n1v\n1w\n1x\n1y\n1z\n") - shell.Commit("first commit") - - // making changes in two separate places for the sake of having two hunks - shell.UpdateFileAndAdd("hunk-file", "aa\n1b\ncc\n1d\n1e\n1f\n1g\n1h\n1i\n1j\n1k\n1l\n1m\n1n\n1o\n1p\n1q\n1r\n1s\ntt\nuu\nvv\n1w\n1x\n1y\n1z\n") - - shell.CreateFileAndAdd("line-file", "2a\n2b\n2c\n2d\n2e\n2f\n2g\n2h\n2i\n2j\n2k\n2l\n2m\n2n\n2o\n2p\n2q\n2r\n2s\n2t\n2u\n2v\n2w\n2x\n2y\n2z\n") - shell.CreateFileAndAdd("direct-file", "direct file content") - shell.Commit("second commit") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Commits(). - Focus(). - Lines( - Contains("second commit").IsSelected(), - Contains("first commit"), - ). - PressEnter() - - t.Views().CommitFiles(). - IsFocused(). - Lines( - Equals("▼ /").IsSelected(), - Contains("direct-file"), - Contains("hunk-file"), - Contains("line-file"), - ). - SelectNextItem(). - PressPrimaryAction(). - Tap(func() { - t.Views().Information().Content(Contains("Building patch")) - - t.Views().Secondary().Content(Contains("direct file content")) - }). - NavigateToLine(Contains("hunk-file")). - PressEnter() - - t.Views().PatchBuilding(). - IsFocused(). - SelectedLines( - Contains("-1a"), - ). - Press(keys.Main.ToggleSelectHunk). - SelectedLines( - Contains(`-1a`), - Contains(`+aa`), - ). - PressPrimaryAction(). - SelectedLines( - Contains(`-1c`), - Contains(`+cc`), - ). - Tap(func() { - t.Views().Information().Content(Contains("Building patch")) - - t.Views().Secondary().Content( - // when we're inside the patch building panel, we only show the patch - // in the secondary panel that relates to the selected file - DoesNotContain("direct file content"). - Contains("@@ -1,6 +1,6 @@"). - Contains(" 1f"), - ) - }). - // Cancel hunk select - PressEscape(). - // Escape the view - PressEscape() - - t.Views().CommitFiles(). - IsFocused(). - NavigateToLine(Contains("line-file")). - PressEnter() - - t.Views().PatchBuilding(). - IsFocused(). - SelectedLines( - Contains("+2a"), - ). - PressPrimaryAction(). - SelectedLines( - Contains("+2b"), - ). - NavigateToLine(Contains("+2c")). - Press(keys.Universal.ToggleRangeSelect). - NavigateToLine(Contains("+2e")). - PressPrimaryAction(). - SelectedLines( - Contains("+2f"), - ). - NavigateToLine(Contains("+2g")). - PressPrimaryAction(). - SelectedLines( - Contains("+2h"), - ). - Tap(func() { - t.Views().Information().Content(Contains("Building patch")) - - t.Views().Secondary().ContainsLines( - Contains("+2a"), - Contains("+2c"), - Contains("+2d"), - Contains("+2e"), - Contains("+2g"), - ) - }). - PressEscape(). - Tap(func() { - t.Views().Secondary().ContainsLines( - // direct-file patch - Contains(`diff --git a/direct-file b/direct-file`), - Contains(`index`), - Contains(`--- a/direct-file`), - Contains(`+++ b/direct-file`), - Contains(`@@ -0,0 +1 @@`), - Contains(`+direct file content`), - Contains(`\ No newline at end of file`), - // hunk-file patch - Contains(`diff --git a/hunk-file b/hunk-file`), - Contains(`index`), - Contains(`--- a/hunk-file`), - Contains(`+++ b/hunk-file`), - Contains(`@@ -1,6 +1,6 @@`), - Contains(`-1a`), - Contains(`+aa`), - Contains(` 1b`), - Contains(` 1c`), - Contains(` 1d`), - Contains(` 1e`), - Contains(` 1f`), - // line-file patch - Contains(`diff --git a/line-file b/line-file`), - Contains(`index`), - Contains(`--- a/line-file`), - Contains(`+++ b/line-file`), - Contains(`@@ -0,0 +1,5 @@`), - Contains(`+2a`), - Contains(`+2c`), - Contains(`+2d`), - Contains(`+2e`), - Contains(`+2g`), - ) - }) - }, -}) diff --git a/pkg/integration/tests/patch_building/start_new_patch.go b/pkg/integration/tests/patch_building/start_new_patch.go deleted file mode 100644 index 88402a953..000000000 --- a/pkg/integration/tests/patch_building/start_new_patch.go +++ /dev/null @@ -1,62 +0,0 @@ -package patch_building - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var StartNewPatch = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Attempt to add a file from another commit to a patch, then agree to start a new patch", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) {}, - SetupRepo: func(shell *Shell) { - shell.CreateFileAndAdd("file1", "file1 content") - shell.Commit("first commit") - - shell.CreateFileAndAdd("file2", "file2 content") - shell.Commit("second commit") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Commits(). - Focus(). - Lines( - Contains("second commit").IsSelected(), - Contains("first commit"), - ). - PressEnter() - - t.Views().CommitFiles(). - IsFocused(). - Lines( - Contains("file2").IsSelected(), - ). - PressPrimaryAction(). - Tap(func() { - t.Views().Information().Content(Contains("Building patch")) - - t.Views().Secondary().Content(Contains("file2")) - }). - PressEscape() - - t.Views().Commits(). - IsFocused(). - NavigateToLine(Contains("first commit")). - PressEnter() - - t.Views().CommitFiles(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressPrimaryAction(). - Tap(func() { - t.ExpectPopup().Confirmation(). - Title(Contains("Discard patch")). - Content(Contains("You can only build a patch from one commit/stash-entry at a time. Discard current patch?")). - Confirm() - - t.Views().Secondary().Content(Contains("file1").DoesNotContain("file2")) - }) - }, -}) diff --git a/pkg/integration/tests/staging/diff_context_change.go b/pkg/integration/tests/staging/diff_context_change.go deleted file mode 100644 index dae511971..000000000 --- a/pkg/integration/tests/staging/diff_context_change.go +++ /dev/null @@ -1,123 +0,0 @@ -package staging - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var DiffContextChange = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Change the number of diff context lines while in the staging panel", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) {}, - SetupRepo: func(shell *Shell) { - // need to be working with a few lines so that git perceives it as two separate hunks - shell.CreateFileAndAdd("file1", "1a\n2a\n3a\n4a\n5a\n6a\n7a\n8a\n9a\n10a\n11a\n12a\n13a\n14a\n15a") - shell.Commit("one") - - shell.UpdateFile("file1", "1a\n2a\n3b\n4a\n5a\n6a\n7a\n8a\n9a\n10a\n11a\n12a\n13b\n14a\n15a") - - // hunk looks like: - // diff --git a/file1 b/file1 - // index 3653080..a6388b6 100644 - // --- a/file1 - // +++ b/file1 - // @@ -1,6 +1,6 @@ - // 1a - // 2a - // -3a - // +3b - // 4a - // 5a - // 6a - // @@ -10,6 +10,6 @@ - // 10a - // 11a - // 12a - // -13a - // +13b - // 14a - // 15a - // \ No newline at end of file - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().Staging(). - IsFocused(). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - Press(keys.Universal.IncreaseContextInDiffView). - Tap(func() { - t.ExpectToast(Equals("Changed diff context size to 4")) - }). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - Press(keys.Universal.DecreaseContextInDiffView). - Tap(func() { - t.ExpectToast(Equals("Changed diff context size to 3")) - }). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - Press(keys.Universal.DecreaseContextInDiffView). - Tap(func() { - t.ExpectToast(Equals("Changed diff context size to 2")) - }). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - Press(keys.Universal.DecreaseContextInDiffView). - Tap(func() { - t.ExpectToast(Equals("Changed diff context size to 1")) - }). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - PressPrimaryAction(). - Press(keys.Universal.TogglePanel) - - t.Views().StagingSecondary(). - IsFocused(). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - Press(keys.Universal.DecreaseContextInDiffView). - Tap(func() { - t.ExpectToast(Equals("Changed diff context size to 0")) - }). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - Press(keys.Universal.IncreaseContextInDiffView). - Tap(func() { - t.ExpectToast(Equals("Changed diff context size to 1")) - }). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - Press(keys.Universal.IncreaseContextInDiffView). - Tap(func() { - t.ExpectToast(Equals("Changed diff context size to 2")) - }). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ) - }, -}) diff --git a/pkg/integration/tests/staging/search.go b/pkg/integration/tests/staging/search.go deleted file mode 100644 index 1f2c95777..000000000 --- a/pkg/integration/tests/staging/search.go +++ /dev/null @@ -1,42 +0,0 @@ -package staging - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var Search = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Use the search feature in the staging panel", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) {}, - SetupRepo: func(shell *Shell) { - shell.CreateFile("file1", "one\ntwo\nthree\nfour\nfive") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().Staging(). - IsFocused(). - Press(keys.Universal.StartSearch). - Tap(func() { - t.ExpectSearch(). - Type("four"). - Confirm() - - t.Views().Search().IsVisible().Content(Contains("matches for 'four' (1 of 1)")) - }). - SelectedLine(Contains("+four")). // stage the line - PressPrimaryAction(). - Content(DoesNotContain("+four")). - Tap(func() { - t.Views().StagingSecondary(). - Content(Contains("+four")) - }) - }, -}) diff --git a/pkg/integration/tests/staging/select_next_line_after_staging_in_two_hunk_diff.go b/pkg/integration/tests/staging/select_next_line_after_staging_in_two_hunk_diff.go deleted file mode 100644 index 8bf264ae2..000000000 --- a/pkg/integration/tests/staging/select_next_line_after_staging_in_two_hunk_diff.go +++ /dev/null @@ -1,61 +0,0 @@ -package staging - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -// Tests that after staging individual lines from a consecutive changes block, -// the cursor advances to the correct next change. The file has two separate -// hunks so that we can verify the cursor crosses hunk boundaries correctly. -var SelectNextLineAfterStagingInTwoHunkDiff = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "After staging lines from a two-hunk diff, the cursor advances correctly", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false - }, - SetupRepo: func(shell *Shell) { - // Use 7 context lines between the two change blocks so that git creates - // two separate hunks. - shell.CreateFileAndAdd("file1", "1\n2\na\nb\nc\nd\ne\nf\ng\n3\n4\n") - shell.Commit("one") - - shell.UpdateFile("file1", "1b\n2b\na\nb\nc\nd\ne\nf\ng\n3b\n4b\n") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().Staging(). - IsFocused(). - ContainsLines( - Contains("-1"), - Contains("-2"), - Contains("+1b"), - Contains("+2b"), - Contains(" a"), - Contains(" b"), - Contains(" c"), - Contains("@@"), - Contains(" e"), - Contains(" f"), - Contains(" g"), - Contains("-3"), - Contains("-4"), - Contains("+3b"), - Contains("+4b"), - ). - NavigateToLine(Contains("-2")). - PressPrimaryAction(). - SelectedLine(Contains("+1b")). - PressPrimaryAction(). - SelectedLine(Contains("+2b")). - PressPrimaryAction(). - SelectedLine(Contains("-3")) - }, -}) diff --git a/pkg/integration/tests/staging/select_next_line_after_staging_isolated_added_line.go b/pkg/integration/tests/staging/select_next_line_after_staging_isolated_added_line.go deleted file mode 100644 index 221ebba38..000000000 --- a/pkg/integration/tests/staging/select_next_line_after_staging_isolated_added_line.go +++ /dev/null @@ -1,51 +0,0 @@ -package staging - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -// Tests that after staging an isolated addition (one that is alone in its block of changes), the -// cursor stays at the first change of the next block of changes which moves up to the same line, -// even if that block starts with a deletion. -var SelectNextLineAfterStagingIsolatedAddedLine = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "After staging an isolated added line, the cursor advances to the next hunk's first change", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false - }, - SetupRepo: func(shell *Shell) { - shell.CreateFileAndAdd("file1", "1\n2\n3\n4\n5\n6\n7\n8\n9\n") - shell.Commit("one") - - shell.UpdateFile("file1", "1\n2\n3\nnew\n4\n5\n6\n7b\n8\n9\n") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().Staging(). - IsFocused(). - ContainsLines( - Contains(" 1"), - Contains(" 2"), - Contains(" 3"), - Contains("+new"), - Contains(" 4"), - Contains(" 5"), - Contains(" 6"), - Contains("-7"), - Contains("+7b"), - Contains(" 8"), - Contains(" 9"), - ). - SelectedLine(Contains("+new")). - PressPrimaryAction(). - SelectedLine(Contains("-7")) - }, -}) diff --git a/pkg/integration/tests/staging/stage_hunks.go b/pkg/integration/tests/staging/stage_hunks.go deleted file mode 100644 index 7afc3a24d..000000000 --- a/pkg/integration/tests/staging/stage_hunks.go +++ /dev/null @@ -1,120 +0,0 @@ -package staging - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var StageHunks = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Stage and unstage various hunks of a file in the staging panel", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false - }, - SetupRepo: func(shell *Shell) { - shell.CreateFileAndAdd("file1", "1a\n2a\n3a\n4a\n5a\n6a\n7a\n8a") - shell.Commit("one") - - shell.UpdateFile("file1", "1a\n2a\n3b\n4a\n5a\n6b\n7a\n8a") - - // hunk looks like: - // diff --git a/file1 b/file1 - // index 3653080..a6388b6 100644 - // --- a/file1 - // +++ b/file1 - // @@ -1,6 +1,6 @@ - // 1a - // 2a - // -3a - // +3b - // 4a - // 5a - // -6a - // +6b - // 7a - // 8a - // \ No newline at end of file - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().Staging(). - IsFocused(). - SelectedLines( - Contains("-3a"), - ). - Press(keys.Universal.NextBlock). - SelectedLines( - Contains("-6a"), - ). - Press(keys.Main.ToggleSelectHunk). - SelectedLines( - Contains("-6a"), - Contains("+6b"), - ). - // when in hunk mode, pressing up/down moves us up/down by a hunk - SelectPreviousItem(). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - SelectNextItem(). - SelectedLines( - Contains("-6a"), - Contains("+6b"), - ). - // stage the second hunk - PressPrimaryAction(). - ContainsLines( - Contains("-3a"), - Contains("+3b"), - ). - Tap(func() { - t.Views().StagingSecondary(). - ContainsLines( - Contains("-6a"), - Contains("+6b"), - ) - }). - Press(keys.Universal.TogglePanel) - - t.Views().StagingSecondary(). - IsFocused(). - // after toggling panel, we're back to only having selected a single line - SelectedLines( - Contains("-6a"), - ). - PressPrimaryAction(). - SelectedLines( - Contains("+6b"), - ). - PressPrimaryAction(). - IsEmpty() - - t.Views().Staging(). - IsFocused(). - SelectedLines( - Contains("-3a"), - ). - Press(keys.Main.ToggleSelectHunk). - SelectedLines( - Contains(`-3a`), - Contains(`+3b`), - ). - Press(keys.Universal.Remove). - Tap(func() { - t.Common().ConfirmDiscardLines() - }). - Content(DoesNotContain("-3a").DoesNotContain("+3b")). - SelectedLines( - Contains("-6a"), - Contains("+6b"), - ) - }, -}) diff --git a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go deleted file mode 100644 index 5b41073e2..000000000 --- a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go +++ /dev/null @@ -1,50 +0,0 @@ -package staging - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -// The second space is pressed before the refresh triggered by the first one -// has updated the staging panel. That refresh is what moves the selection to -// the next hunk, so the second press must not be handled until it has landed; -// handling it earlier would try to stage the first hunk a second time. -var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Stage two hunks with two space presses in rapid succession", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = true - }, - SetupRepo: func(shell *Shell) { - // Use 7 context lines between the two change blocks so that git creates - // two separate hunks. - shell.CreateFileAndAdd("file1", "1\n2\na\nb\nc\nd\ne\nf\ng\n3\n4\n") - shell.Commit("one") - - shell.UpdateFile("file1", "1b\n2b\na\nb\nc\nd\ne\nf\ng\n3b\n4b\n") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().Staging(). - IsFocused(). - PressRapidly(keys.Universal.Select, keys.Universal.Select) - - t.Views().StagingSecondary(). - IsFocused(). - ContainsLines( - Contains("+1b"), - Contains("+2b"), - ). - ContainsLines( - Contains("+3b"), - Contains("+4b"), - ) - }, -}) diff --git a/pkg/integration/tests/staging/stage_lines.go b/pkg/integration/tests/staging/stage_lines.go deleted file mode 100644 index 39ea2d0bf..000000000 --- a/pkg/integration/tests/staging/stage_lines.go +++ /dev/null @@ -1,122 +0,0 @@ -package staging - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var StageLines = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Stage and unstage various lines of a file in the staging panel", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false - }, - SetupRepo: func(shell *Shell) { - shell.CreateFileAndAdd("file1", "one\ntwo\n") - shell.Commit("one") - - shell.UpdateFile("file1", "one\ntwo\nthree\nfour\n") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().Staging(). - IsFocused(). - SelectedLines(Contains("+three")). - // stage 'three' - PressPrimaryAction(). - // 'three' moves over to the staging secondary panel - Content(DoesNotContain("+three")). - Tap(func() { - t.Views().StagingSecondary(). - ContainsLines( - Contains("+three"), - ) - }). - SelectedLines(Contains("+four")). - // stage 'four' - PressPrimaryAction(). - // nothing left in our staging panel - IsEmpty() - - // because we've staged everything we get moved to the staging secondary panel - // do the same thing as above, moving the lines back to the staging panel - t.Views().StagingSecondary(). - IsFocused(). - ContainsLines( - Contains("+three"), - Contains("+four"), - ). - SelectedLines(Contains("+three")). - PressPrimaryAction(). - Content(DoesNotContain("+three")). - Tap(func() { - t.Views().Staging(). - ContainsLines( - Contains("+three"), - ) - }). - SelectedLines(Contains("+four")). - // pressing 'remove' has the same effect as pressing space when in the staging secondary panel - Press(keys.Universal.Remove). - IsEmpty() - - // stage one line and then manually toggle to the staging secondary panel - t.Views().Staging(). - IsFocused(). - ContainsLines( - Contains("+three"), - Contains("+four"), - ). - SelectedLines(Contains("+three")). - PressPrimaryAction(). - Content(DoesNotContain("+three")). - Tap(func() { - t.Views().StagingSecondary(). - Content(Contains("+three")) - }). - Press(keys.Universal.TogglePanel) - - // manually toggle back to the staging panel - t.Views().StagingSecondary(). - IsFocused(). - Press(keys.Universal.TogglePanel) - - t.Views().Staging(). - SelectedLines(Contains("+four")). - // discard the line - Press(keys.Universal.Remove). - Tap(func() { - t.ExpectPopup().Confirmation(). - Title(Equals("Discard change")). - Content(Contains("Are you sure you want to discard this change")). - Confirm() - }). - IsEmpty() - - t.Views().StagingSecondary(). - IsFocused(). - ContainsLines( - Contains("+three"), - ). - // return to file - PressEscape() - - t.Views().Files(). - IsFocused(). - Lines( - Contains("M file1").IsSelected(), - ). - PressEnter() - - // because we only have a staged change we'll land in the staging secondary panel - t.Views().StagingSecondary(). - IsFocused() - }, -}) diff --git a/pkg/integration/tests/staging/stage_ranges.go b/pkg/integration/tests/staging/stage_ranges.go deleted file mode 100644 index 3d96d0610..000000000 --- a/pkg/integration/tests/staging/stage_ranges.go +++ /dev/null @@ -1,108 +0,0 @@ -package staging - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var StageRanges = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Stage and unstage various ranges of a file in the staging panel", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false - }, - SetupRepo: func(shell *Shell) { - shell.CreateFileAndAdd("file1", "one\ntwo\n") - shell.Commit("one") - - shell.UpdateFile("file1", "one\ntwo\nthree\nfour\nfive\nsix\n") - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Lines( - Contains("file1").IsSelected(), - ). - PressEnter() - - t.Views().Staging(). - IsFocused(). - SelectedLines( - Contains("+three"), - ). - Press(keys.Universal.ToggleRangeSelect). - NavigateToLine(Contains("+five")). - SelectedLines( - Contains("+three"), - Contains("+four"), - Contains("+five"), - ). - // stage the three lines we've just selected - PressPrimaryAction(). - SelectedLines( - Contains("+six"), - ). - ContainsLines( - Contains(" five"), - Contains("+six"), - ). - Tap(func() { - t.Views().StagingSecondary(). - ContainsLines( - Contains("+three"), - Contains("+four"), - Contains("+five"), - ) - }). - Press(keys.Universal.TogglePanel) - - t.Views().StagingSecondary(). - IsFocused(). - SelectedLines( - Contains("+three"), - ). - Press(keys.Universal.ToggleRangeSelect). - NavigateToLine(Contains("+five")). - SelectedLines( - Contains("+three"), - Contains("+four"), - Contains("+five"), - ). - // unstage the three selected lines - PressPrimaryAction(). - // nothing left in our staging secondary panel - IsEmpty(). - Tap(func() { - t.Views().Staging(). - ContainsLines( - Contains("+three"), - Contains("+four"), - Contains("+five"), - Contains("+six"), - ) - }) - - t.Views().Staging(). - IsFocused(). - // coincidentally we land at '+four' here. Maybe we should instead land - // at '+three'? given it's at the start of the hunk? - SelectedLines( - Contains("+four"), - ). - Press(keys.Universal.ToggleRangeSelect). - SelectNextItem(). - SelectedLines( - Contains("+four"), - Contains("+five"), - ). - Press(keys.Universal.Remove). - Tap(func() { - t.Common().ConfirmDiscardLines() - }). - ContainsLines( - Contains("+three"), - Contains("+six"), - ) - }, -}) diff --git a/pkg/integration/tests/stash/stash_staged_partial_file.go b/pkg/integration/tests/stash/stash_staged_partial_file.go index f77c6e16c..7e5ecfa59 100644 --- a/pkg/integration/tests/stash/stash_staged_partial_file.go +++ b/pkg/integration/tests/stash/stash_staged_partial_file.go @@ -19,9 +19,10 @@ var StashStagedPartialFile = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). - PressEnter() + Press(keys.Universal.FocusMainView) - t.Views().Staging(). + t.Views().Main(). + IsFocused(). Content( Contains(" line1\n-line2\n+line2 mod\n line3\n-line4\n+line4 mod"), ). diff --git a/pkg/integration/tests/submodule/add.go b/pkg/integration/tests/submodule/add.go index a82a71227..dbd452947 100644 --- a/pkg/integration/tests/submodule/add.go +++ b/pkg/integration/tests/submodule/add.go @@ -50,7 +50,7 @@ var Add = NewIntegrationTest(NewIntegrationTestArgs{ ). SelectNextItem(). Tap(func() { - t.Views().Main().Content( + t.Views().Secondary().Content( Contains("[submodule \"my_submodule\"]"). Contains("path = my_submodule_path"). Contains("url = ../other_repo"), @@ -58,7 +58,7 @@ var Add = NewIntegrationTest(NewIntegrationTestArgs{ }). SelectNextItem(). Tap(func() { - t.Views().Main().Content( + t.Views().Secondary().Content( Contains("Submodule my_submodule_path"). Contains("(new submodule)"), ) diff --git a/pkg/integration/tests/submodule/remove.go b/pkg/integration/tests/submodule/remove.go index ca9a90b33..84cbcca4e 100644 --- a/pkg/integration/tests/submodule/remove.go +++ b/pkg/integration/tests/submodule/remove.go @@ -41,7 +41,7 @@ var Remove = NewIntegrationTest(NewIntegrationTestArgs{ ). SelectNextItem() - t.Views().Main().Content( + t.Views().Secondary().Content( Contains("-[submodule \"my_submodule_name\"]"). Contains("- path = my_submodule_path"). Contains("- url = ../my_submodule_name"), diff --git a/pkg/integration/tests/submodule/remove_nested.go b/pkg/integration/tests/submodule/remove_nested.go index b143096cd..73e9ca476 100644 --- a/pkg/integration/tests/submodule/remove_nested.go +++ b/pkg/integration/tests/submodule/remove_nested.go @@ -46,7 +46,7 @@ var RemoveNested = NewIntegrationTest(NewIntegrationTestArgs{ ). NavigateToLine(Contains(".gitmodules")) - t.Views().Main().Content( + t.Views().Secondary().Content( Contains("-[submodule \"innerSubName\"]"). Contains("- path = modules/innerSubPath"). Contains("- url = ../innerSubmodule"), diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a4e732cf0..2c3e1dbec 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -18,12 +18,12 @@ import ( "github.com/jesseduffield/lazygit/pkg/integration/tests/filter_by_author" "github.com/jesseduffield/lazygit/pkg/integration/tests/filter_by_path" "github.com/jesseduffield/lazygit/pkg/integration/tests/interactive_rebase" + "github.com/jesseduffield/lazygit/pkg/integration/tests/main_view" "github.com/jesseduffield/lazygit/pkg/integration/tests/misc" "github.com/jesseduffield/lazygit/pkg/integration/tests/patch_building" "github.com/jesseduffield/lazygit/pkg/integration/tests/reflog" "github.com/jesseduffield/lazygit/pkg/integration/tests/remote" "github.com/jesseduffield/lazygit/pkg/integration/tests/shell_commands" - "github.com/jesseduffield/lazygit/pkg/integration/tests/staging" "github.com/jesseduffield/lazygit/pkg/integration/tests/stash" "github.com/jesseduffield/lazygit/pkg/integration/tests/status" "github.com/jesseduffield/lazygit/pkg/integration/tests/submodule" @@ -226,6 +226,7 @@ var tests = []*components.IntegrationTest{ diff.DiffAndApplyPatch, diff.DiffCommits, diff.DiffNonStickyRange, + diff.DiffRendererMetadata, diff.IgnoreWhitespace, diff.RenameSimilarityThresholdChange, file.ClickArrowToCollapse, @@ -246,6 +247,8 @@ var tests = []*components.IntegrationTest{ file.ExcludeWithoutInfoDir, file.Gitignore, file.GitignoreSpecialCharacters, + file.PaneShownAgainStartsAtTheTop, + file.PaneTakingOverStartsAtTheTop, file.RememberCommitMessageAfterFail, file.RenameSimilarityThresholdChange, file.RenamedFiles, @@ -254,6 +257,7 @@ var tests = []*components.IntegrationTest{ file.StageChildrenRangeSelect, file.StageDeletedRangeSelect, file.StageRangeSelect, + file.StagedChangesInLowerPane, filter_and_search.FilterByFileStatus, filter_and_search.FilterCommitFiles, filter_and_search.FilterCommitFilesToggleDirectory, @@ -357,6 +361,118 @@ var tests = []*components.IntegrationTest{ interactive_rebase.SwapInRebaseWithConflictAndEdit, interactive_rebase.SwapWithConflict, interactive_rebase.ViewFilesOfTodoEntries, + main_view.AdvanceAfterStagingShiftsLineNumbers, + main_view.ApplyCustomPatch, + main_view.ApplyCustomPatchInReverse, + main_view.ApplyCustomPatchInReverseWithConflict, + main_view.ApplyCustomPatchWithModifiedFile, + main_view.ApplyCustomPatchWithModifiedFileConflict, + main_view.BuildPatchFromACommitsDiff, + main_view.BuildPatchFromAReflogEntry, + main_view.BuildPatchFromAWholeCommitsDiff, + main_view.BuildPatchWithMixedSelections, + main_view.ChangeContextSizeWhileBuildingPatch, + main_view.ChangeScreenModeInFocusedDiff, + main_view.ClickSelectsDiffLine, + main_view.CommitFromMainView, + main_view.CopySelectedDiffLines, + main_view.CustomPatchGoesThroughTheDiffRenderer, + main_view.DiscardAllChanges, + main_view.DiscardDiffLines, + main_view.DiscardFromACommitOnlyWhereItCanBeRewritten, + main_view.DiscardLineFromAddedFileInCommit, + main_view.DiscardLinesFromACommit, + main_view.DragRangeWithAutoscroll, + main_view.DragSelectsDiffLineRange, + main_view.EditHistoricalDiffLine, + main_view.EditHunkInFocusedDiff, + main_view.EditSelectedDiffLine, + main_view.EnterAndDoubleClickFocusFileDiff, + main_view.EscapeDismissesSelection, + main_view.FocusFollowsAPaneEmptiedFromOutside, + main_view.FocusFollowsIntoAPaneTakingOver, + main_view.FocusFollowsStagedSide, + main_view.FocusFollowsWhenPaneGoes, + main_view.FocusLeavesAnAlwaysSplitEmptyPane, + main_view.FocusReturnsWhenSplitCollapses, + main_view.HideSelectionWhenChangesVanish, + main_view.KeepAWrappedLineCoveredAcrossARerender, + main_view.KeepBothHalvesOfAChangeSelected, + main_view.KeepPositionByTheVisibleEndOfASelection, + main_view.KeepPositionInBothPanesWhenChangingContextSize, + main_view.KeepPositionInBothPanesWhenIgnoringWhitespace, + main_view.KeepPositionInBothPanesWhenSwitchingDiffRenderers, + main_view.KeepPositionWhenChangingContextSize, + main_view.KeepPositionWhenIgnoringWhitespace, + main_view.KeepPositionWhenIgnoringWhitespaceRemovesIt, + main_view.KeepPositionWhenSwitchingDiffRenderers, + main_view.KeepPositionWhenTheSelectionIsOffScreen, + main_view.KeepScrollWhenTheDiffCantBeRead, + main_view.KeepSelectedRangeWhenChangingContextSize, + main_view.KeepSelectionAfterMovingPatchOut, + main_view.KeepSelectionVisibleWhenDiffShrinks, + main_view.MoveMultiFileRangeToIndex, + main_view.MovePartOfAdjacentAddedLinesToIndex, + main_view.MovePartialPatchToIndex, + main_view.MovePartialPatchToLaterCommit, + main_view.MovePartialPatchToNewCommit, + main_view.MovePatchFromAddedFileToEarlierCommit, + main_view.MovePatchFromAddedFileToIndexWithConflict, + main_view.MovePatchFromAddedFileToNewCommit, + main_view.MovePatchFromDeletedFileToNewCommit, + main_view.MovePatchToEarlierCommit, + main_view.MovePatchToIndex, + main_view.MovePatchToIndexWithConflict, + main_view.MovePatchToIndexWithCustomDiffConfig, + main_view.MovePatchToIndexWithModifiedFile, + main_view.MovePatchToLaterCommit, + main_view.MovePatchToNewCommit, + main_view.MovePatchToNewCommitBefore, + main_view.MovePatchToNewCommitInStackedBranch, + main_view.NavigateByHunkAndFile, + main_view.NoSelectionOverABinaryDiff, + main_view.NoSelectionOverACommitLog, + main_view.NoSelectionWhenNoChanges, + main_view.PatchMarksFollowARendererSwitch, + main_view.PatchMarksShowWhileTheDiffIsFocused, + main_view.RangeSelectDiffLines, + main_view.RawFallbackUnderAnExternalDiff, + main_view.RemoveContentChangeFromRenamedFile, + main_view.RemoveLinesFromTheCustomPatch, + main_view.RemovePartOfAddedFileFromCommit, + main_view.RemovePatchFromCommit, + main_view.ResetAPatchBuiltFromACommitsDiff, + main_view.ResetPatchWithEscape, + main_view.ResetThePatchFromThePaneShowingIt, + main_view.SearchCollapsesTheSelection, + main_view.SearchFollowsTheSelection, + main_view.SelectBelowALongCommitMessage, + main_view.SelectBelowALongDiffstat, + main_view.SelectDiffLines, + main_view.SelectHunkBelowLastChange, + main_view.SelectHunkInDiff, + main_view.SelectHunkOnFocusingMainView, + main_view.SelectInADiffReadInPart, + main_view.SelectLineWhenWholeFileIsOneHunk, + main_view.SelectNextChangeAfterStaging, + main_view.SelectNextChangeAfterUnstaging, + main_view.SelectNextDeletionAfterStagingOne, + main_view.SelectVisibleChangeOnFocusingMainView, + main_view.SelectVisibleHunkOnFocusingMainView, + main_view.SelectionCommandTooltipsFollowTheDiff, + main_view.SelectionCommandsOnlyWhereTheyApply, + main_view.SelectionOverTheCustomPatch, + main_view.StageDeletedFile, + main_view.StageDiffLines, + main_view.StageHunksWithRapidKeypresses, + main_view.StagePartialBlockOfChangesFirstLines, + main_view.StagePartialBlockOfChangesLastLines, + main_view.StagePartialBlockOfChangesMiddleLines, + main_view.StageRangeSpanningFiles, + main_view.StageUnderConformingDiffRenderer, + main_view.StageUnderUnsupportedDiffRenderer, + main_view.StartPatchFromAnotherCommit, + main_view.UnstageDiffLines, misc.ConfirmOnQuit, misc.CopyConfirmationMessageToClipboard, misc.CopyToClipboard, @@ -367,42 +483,11 @@ var tests = []*components.IntegrationTest{ misc.InitialOpen, misc.RecentReposOnLaunch, misc.StartInGitDir, - patch_building.Apply, - patch_building.ApplyInReverse, - patch_building.ApplyInReverseWithConflict, - patch_building.ApplyWithModifiedFileConflict, - patch_building.ApplyWithModifiedFileNoConflict, patch_building.CopyRenamedFileDiff, - patch_building.DiscardLinesFromCommit, - patch_building.EditLineInPatchBuildingPanel, - patch_building.MoveRangeToIndex, - patch_building.MoveToEarlierCommit, - patch_building.MoveToEarlierCommitFromAddedFile, - patch_building.MoveToIndex, - patch_building.MoveToIndexFromAddedFileWithConflict, - patch_building.MoveToIndexPartOfAdjacentAddedLines, - patch_building.MoveToIndexPartial, - patch_building.MoveToIndexWithConflict, - patch_building.MoveToIndexWithModifiedFile, - patch_building.MoveToIndexWorksEvenIfNoprefixIsSet, - patch_building.MoveToLaterCommit, - patch_building.MoveToLaterCommitPartialHunk, - patch_building.MoveToNewCommit, - patch_building.MoveToNewCommitBefore, - patch_building.MoveToNewCommitFromAddedFile, - patch_building.MoveToNewCommitFromDeletedFile, - patch_building.MoveToNewCommitInLastCommitOfStackedBranch, - patch_building.MoveToNewCommitPartialHunk, - patch_building.RemoveFromCommit, - patch_building.RemovePartsOfAddedFile, patch_building.RenameSimilarityThresholdChange, - patch_building.RenamedFilePartial, patch_building.RenamedFileWhole, - patch_building.ResetWithEscape, patch_building.SelectAllFiles, patch_building.SelectDirecoriesSharingPrefix, - patch_building.SpecificSelection, - patch_building.StartNewPatch, patch_building.ToggleDirectory, patch_building.ToggleRange, reflog.Checkout, @@ -417,19 +502,6 @@ var tests = []*components.IntegrationTest{ shell_commands.EditHistory, shell_commands.History, shell_commands.OmitFromHistory, - staging.DiffChangeScreenMode, - staging.DiffContextChange, - staging.DiscardAllChanges, - staging.Search, - staging.SelectNextLineAfterStagingInTwoHunkDiff, - staging.SelectNextLineAfterStagingIsolatedAddedLine, - staging.StageHunks, - staging.StageHunksWithRapidKeypresses, - staging.StageLines, - staging.StagePartialBlockOfChangesFirstLines, - staging.StagePartialBlockOfChangesLastLines, - staging.StagePartialBlockOfChangesMiddleLines, - staging.StageRanges, stash.Apply, stash.ApplyPatch, stash.CreateBranch, diff --git a/pkg/integration/tests/ui/range_select.go b/pkg/integration/tests/ui/range_select.go index 4c5d8420a..01a4bf260 100644 --- a/pkg/integration/tests/ui/range_select.go +++ b/pkg/integration/tests/ui/range_select.go @@ -28,20 +28,16 @@ import ( // the range. var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Verify range select works as expected in list views and in patch explorer views", + Description: "Verify range select works as expected in list views", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false + config.GetUserConfig().Gui.UseHunkModeInDiffView = false config.GetUserConfig().Gui.ExpandFocusedSidePanel = true }, SetupRepo: func(shell *Shell) { - // We're testing the commits view as our representative list context, - // as well as the staging view, and we're using the exact same code to test - // both to ensure they have the exact same behaviour (they are currently implemented - // separately) - // In both views we're going to have 10 lines starting from 'line 1' going down to - // 'line 10'. + // We're testing the commits view as our representative list context, with 10 + // items starting from "line 1" and ending at "line 10". fileContent := "staged\n" total := 10 for i := 1; i <= total; i++ { @@ -173,15 +169,6 @@ var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ assertRangeSelectBehaviour(t.Views().Commits().Focus(), func() { t.Views().Branches().Focus() }, 0) - t.Views().Files(). - Focus(). - SelectedLine( - Contains("file1"), - ). - PressEnter() - - assertRangeSelectBehaviour(t.Views().Staging().IsFocused(), func() { t.Views().Staging().PressTab() }, 6) - t.Views().Branches().Focus() t.Views().Branches(). SelectedLines( diff --git a/pkg/integration/tests/ui/range_select_with_autoscroll.go b/pkg/integration/tests/ui/range_select_with_autoscroll.go index 94a4fb4a5..e9fa664ea 100644 --- a/pkg/integration/tests/ui/range_select_with_autoscroll.go +++ b/pkg/integration/tests/ui/range_select_with_autoscroll.go @@ -14,7 +14,7 @@ var RangeSelectWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ Width: 120, Height: 30, SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Gui.UseHunkModeInStagingView = false + config.GetUserConfig().Gui.UseHunkModeInDiffView = false }, SetupRepo: func(shell *Shell) { shell.CreateNCommits(40) @@ -33,15 +33,5 @@ var RangeSelectWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ OriginYAtLeast(3). SelectedLineIdxAtLeast(3). MouseRelease() - - t.Views().Files(). - Focus(). - PressEnter() - t.Views().Staging(). - ClickAndHold(1, 6). - MouseMoveToBottom(1). - OriginYAtLeast(3). - SelectedLineIdxAtLeast(9). - MouseRelease() }, }) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index deaeee042..a383bd154 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -98,6 +98,24 @@ type ViewBufferManager struct { // what that task was owed. newContentPending atomic.Bool + // When set, the next command task puts the view back where it was once it has + // re-rendered the content, instead of showing the new render from the top (see + // RenderRestore). It is installed just before the re-render is triggered. + // + // Like newContentPending it outlives the task it was installed for, and for the + // same reason: that task can be stopped and replaced before it ever paints, and + // the replacement, rendering the same content, is then the one that owes the + // user their position. It is cleared by whichever task applies it. Guarded by + // taskIDMutex, like the task key. + restoreForNextTask *RenderRestore + + // When set, the next command task leaves the view's scroll position alone even + // though it renders a different command's output, that output being the same + // content laid out differently (see SetKeepScrollPositionForNextTask). The task + // that starts consumes it, in place of noting that new content is on its way. + // Guarded by taskIDMutex, like the task key. + keepScrollForNextTask bool + // Whether a command task is currently reading content into the view. While // this is true the content is still growing, so callers (e.g. the layout) // must not clamp the view's scroll position to the amount loaded so far. @@ -152,6 +170,124 @@ type LinesToRead struct { Then func() } +// RenderRestore puts a view back where it was when it re-renders content the user +// is already looking at, laid out differently — a different context size, whitespace +// ignored, another diff renderer — instead of showing the new render from the top. +// +// The task reads the new content into an off-screen buffer; the restore says when +// enough of it has arrived to show the remembered position (FirstPaintReady), and +// then finds that position and reveals it (Apply). It is a pair of callbacks rather +// than a scroll position because a different layout of the same content puts the +// remembered line somewhere else, and only the new content itself says where. +type RenderRestore struct { + // FirstPaintReady reports whether enough of the new content has been read for + // the restore to show what it is looking for. It is consulted after each line + // is read, on the task's own goroutine. + FirstPaintReady func() bool + + // Apply runs once, on the UI thread, at the first paint. It finds its target in + // the off-screen content, calls swapIn to promote that content to the display, + // and places the view on the target — in that order, so that the search runs + // while the previous content is still displayed, and the new content is never + // drawn at the previous render's scroll position. + // + // It must call swapIn even when it finds nothing to place the view on, in which + // case the view keeps the position the paint gave it: the offset it had, or the + // top for content the view hasn't seen. + Apply func(swapIn func()) + + // Done is called once the restore has had its render — after Apply, or when it + // is given up because the view is being shown something other than a re-render + // of what it was remembered from. It is how a caller that has to wait for the + // view to be back where it belongs knows that it either is, or never will be. + // Optional, and called on the UI thread, as Apply is. + Done func() +} + +// resolved reports that this restore's render has happened, or that there will not be +// one. Called on the UI thread, from wherever the restore ends: once, whichever way it +// ended. +func (self *RenderRestore) resolved() { + if self.Done != nil { + done := self.Done + self.Done = nil + done() + } +} + +// SetRestoreForNextTask arranges for the next command task to put the view back +// where it is now once it has re-rendered. Call it right before triggering a +// re-render of the content the view is showing; see RenderRestore. +func (self *ViewBufferManager) SetRestoreForNextTask(restore *RenderRestore) { + self.taskIDMutex.Lock() + defer self.taskIDMutex.Unlock() + + self.restoreForNextTask = restore +} + +// HasRestoreForNextTask reports whether the next command task already has a position +// waiting to be put back, for a caller that would otherwise install one of its own +// over it. +func (self *ViewBufferManager) HasRestoreForNextTask() bool { + self.taskIDMutex.Lock() + defer self.taskIDMutex.Unlock() + + return self.restoreForNextTask != nil +} + +func (self *ViewBufferManager) getRestoreForNextTask() *RenderRestore { + self.taskIDMutex.Lock() + defer self.taskIDMutex.Unlock() + + return self.restoreForNextTask +} + +// SetKeepScrollPositionForNextTask arranges for the next command task to leave the +// view's scroll position alone, rather than showing its content from the top the way a +// render of different content does. Call it right before triggering a re-render of the +// content the view is showing, when the command producing it is not the one that +// produced what is on screen — a different context size, another diff renderer. +// +// It is the coarser sibling of SetRestoreForNextTask, for the same moment. The restore +// puts the view back on the line it remembers, which it can only do when the lines of +// the new rendering can be told apart. This one says merely "the content is a +// rearrangement of what is there, so the offset into it is nearer to where the user was +// than the top is". Both can be set at once, and then the restore has the first say. +func (self *ViewBufferManager) SetKeepScrollPositionForNextTask() { + self.taskIDMutex.Lock() + defer self.taskIDMutex.Unlock() + + self.keepScrollForNextTask = true +} + +// clearRestore drops a restore once a task has applied it, so that it rides exactly +// one re-render. One installed since — the user pressing the key again while this +// task was still reading — is left alone: it belongs to the render on its way. +func (self *ViewBufferManager) clearRestore(restore *RenderRestore) { + self.taskIDMutex.Lock() + defer self.taskIDMutex.Unlock() + + if self.restoreForNextTask == restore { + self.restoreForNextTask = nil + } +} + +// DropRestoreForNextTask gives up a restore that has no render to ride, because the +// view is being given something other than a re-render of the content it was +// remembered from — a message where a diff was. Without this the restore would sit +// there and claim some later render of that view, putting the user somewhere they +// haven't been for a while. +func (self *ViewBufferManager) DropRestoreForNextTask() { + self.taskIDMutex.Lock() + restore := self.restoreForNextTask + self.restoreForNextTask = nil + self.taskIDMutex.Unlock() + + if restore != nil { + restore.resolved() + } +} + func (self *ViewBufferManager) GetTaskKey() string { self.taskIDMutex.Lock() defer self.taskIDMutex.Unlock() @@ -159,6 +295,17 @@ func (self *ViewBufferManager) GetTaskKey() string { return self.taskKey } +// ForgetRenderedContent records that the view no longer shows the render whose key it +// is holding, because it has been emptied. The key says what the view is showing, and +// the next task is compared against it to tell whether that task renders something +// new. A view with nothing in it is showing nothing, so whatever comes next is new. +func (self *ViewBufferManager) ForgetRenderedContent() { + self.taskIDMutex.Lock() + defer self.taskIDMutex.Unlock() + + self.taskKey = "" +} + func NewViewBufferManager( log *logrus.Entry, writer io.Writer, @@ -212,8 +359,20 @@ func (self *ViewBufferManager) StartLoading() { } func (self *ViewBufferManager) ReadToEnd(then func()) { - // The reading happens on the task's own goroutine, and the caller hears about - // it through then, so lazygit must not count as idle in between. + self.readHoldingATask(-1, then) +} + +// ReadLinesAndWait is ReadLines for lines lazygit is itself waiting on, rather than +// reading ahead of the user. It holds a gocui task until they have been read, so +// lazygit doesn't count as idle in the meantime (docs/dev/Busy.md). +func (self *ViewBufferManager) ReadLinesAndWait(totalLines int) { + self.readHoldingATask(totalLines, nil) +} + +// readHoldingATask asks the task to have read totalLines lines in total (-1 for all of +// them) and calls then once it has. The reading happens on the task's own goroutine and +// the caller is waiting on the result, so lazygit must not count as idle in between. +func (self *ViewBufferManager) readHoldingATask(totalLines int, then func()) { task := self.newGocuiTask() answered := func() { task.Done() @@ -222,7 +381,7 @@ func (self *ViewBufferManager) ReadToEnd(then func()) { } } - request := LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: answered} + request := LinesToRead{Total: totalLines, InitialRefreshAfter: -1, Then: answered} if !self.readRequests.enqueue(request) { // With no task reading, everything there is to read has been read. answered() @@ -258,6 +417,10 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix onFirstPageShown() } + // Whatever position is owed to the user belongs to this render: it was + // remembered just before the re-render that led here was triggered. + restore := self.getRestoreForNextTask() + if self.throttle.Load() { self.Log.Info("throttling task") time.Sleep(THROTTLE_TIME) @@ -356,7 +519,12 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // content is common (a background refresh over a repo with submodules // that have uncommitted changes, say). The pending flag isn't consumed // here; the first paint still owes the scroll reset. - if !loaded && self.newContentPending.Load() { + // + // A restore keeps the view too: it is there to make a re-render of what + // the user is looking at seamless, and blanking the view for a message + // before putting them back where they were is the flicker it exists to + // avoid. + if !loaded && restore == nil && self.newContentPending.Load() { self.beforeStart() // beforeStart cleared the previous content to show "loading...", so // put the view back at the top for it (beforeStart doesn't touch the @@ -417,10 +585,23 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix return } painted = true - self.swapInRender() + // Content the view hasn't seen is shown from the top, and this is where + // the view goes there — before the restore below, which decides where to + // put the view from where it is. The position the paint settles on is the + // restore's to move from, so it has to be the one the new content is + // about to be revealed at. if self.newContentPending.Swap(false) { self.resetOrigin() } + if restore != nil { + // The restore does the swap itself, so that it can find where the + // user was in the new content before it is revealed. + restore.Apply(self.swapInRender) + self.clearRestore(restore) + restore.resolved() + return + } + self.swapInRender() } // Set LAZYGIT_SLOW_RENDER= to sleep that long after each @@ -455,7 +636,13 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix linesToRead.Then() } } - for linesToRead.Total == -1 || linesRead < linesToRead.Total { + // A restore that hasn't painted yet keeps us reading past the lines + // asked for, all the way to the end of the input if need be. What it + // is looking for may be anywhere in the new content, and a rendering + // that has to be parsed as a diff to be searched at all can only be + // parsed whole — so stopping early would leave it nothing to find, + // and the view somewhere the user didn't put it. + for linesToRead.Total == -1 || linesRead < linesToRead.Total || (restore != nil && !painted) { if stopped() { callThen() break outer @@ -526,12 +713,22 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix time.Sleep(slowRenderPerLine) } - if linesRead == linesToRead.InitialRefreshAfter { - // We have read enough lines to fill the view, so do the first paint - // and refresh to show it. Continue reading and refresh again at the + if !painted { + // Do the first paint once we have read enough lines to fill the + // view — or, when a position is waiting to be restored, once the + // restore says it can show it, since where the view should be is + // its call. Continue reading afterwards and refresh again at the // end to make sure the scrollbar has the right size. - _ = self.onUIThread(firstPaint) - refreshViewIfStale() + var ready bool + if restore != nil { + ready = restore.FirstPaintReady() + } else { + ready = linesRead == linesToRead.InitialRefreshAfter + } + if ready { + _ = self.onUIThread(firstPaint) + refreshViewIfStale() + } } } refreshViewIfStale() @@ -658,10 +855,19 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error // newContentPending), so the previous content — left displayed until the // swap — doesn't visibly jump to the top before the new content appears. // Read taskKey directly: we already hold the mutex that guards it, and - // GetTaskKey would take it again. - if self.taskKey != key && self.resetOrigin != nil { + // GetTaskKey would take it again. A pending restore isn't dropped here + // either, even for a different command: the re-renders it rides are all + // different commands (a different context size, another diff renderer), and + // it validates itself against the content it lands in anyway. + // A task told to keep the scroll position renders the content the view is + // already showing, laid out differently, so the reset it would otherwise owe + // would take the user away from what they are reading — and the loading + // message, which the same flag governs, would blank content that is about to + // come back looking much the same. + if self.taskKey != key && self.resetOrigin != nil && !self.keepScrollForNextTask { self.newContentPending.Store(true) } + self.keepScrollForNextTask = false self.taskKey = key self.taskIDMutex.Unlock() diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index c50a54cac..4fa5d81b7 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -385,6 +385,275 @@ func TestLoadingIndicatorOnlyTakesOverForNewContent(t *testing.T) { 2*time.Second, 10*time.Millisecond) } +// A pending restore takes the first paint over: it says when enough of the new +// content has arrived to show the position it remembers, and does the swap itself so +// that it can look for that position while the previous content is still displayed. +// The scroll reset that new content is owed happens before it runs, so that where it +// puts the view is where the view stays. +func TestNewCmdTaskRestore(t *testing.T) { + writer := bytes.NewBuffer(nil) + linesWritten := func() int { return strings.Count(writer.String(), "\n") } + resetOrigin, getResetOriginCallCount := getCounter() + + swapped := false + applyCount := 0 + applyAtLines := -1 + swappedBeforeApply := false + swappedByApply := false + resetsBeforeApply := -1 + + manager := NewViewBufferManager( + utils.NewDummyLog(), + writer, + func() {}, // beforeStart + func() {}, // refreshView + func() {}, // onEndOfInput + resetOrigin, + func() {}, // beginRender + func() { swapped = true }, // swapInRender + func() gocui.Task { return gocui.NewFakeTask() }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + manager.SetRestoreForNextTask(&RenderRestore{ + // Ready once five lines have loaded — well before the view is filled (30). + FirstPaintReady: func() bool { return linesWritten() >= 5 }, + Apply: func(swapIn func()) { + applyCount++ + applyAtLines = linesWritten() + swappedBeforeApply = swappedBeforeApply || swapped + resetsBeforeApply = getResetOriginCallCount() + swapIn() + swappedByApply = swapped + }, + }) + + done := make(chan struct{}) + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 50} + } + _ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 30, nil}, func() { close(done) }), "cmd") + <-done + + assert.Equal(t, 1, applyCount, "Apply should run exactly once") + assert.False(t, swappedBeforeApply, "the off-screen render should not be swapped in before Apply runs") + assert.True(t, swappedByApply, "Apply should swap the off-screen render in via swapIn") + // The first paint was driven by the restore, not by having read enough lines to + // fill the view. + assert.GreaterOrEqual(t, applyAtLines, 5) + assert.Less(t, applyAtLines, 30) + assert.Equal(t, 1, resetsBeforeApply, "new content should be put at the top before the restore places it") + assert.Equal(t, 1, getResetOriginCallCount(), "and not reset again afterwards, over the restore") +} + +// A restore that never finds what it is looking for keeps the task reading to the +// end of its input, since the line might have been anywhere in it. Once there is no +// more content to hope for, the render is revealed with the scroll reset that new +// content is owed. +func TestNewCmdTaskRestoreThatFindsNothing(t *testing.T) { + writer := bytes.NewBuffer(nil) + linesWritten := func() int { return strings.Count(writer.String(), "\n") } + resetOrigin, getResetOriginCallCount := getCounter() + + applyCount := 0 + swappedAtLines := -1 + + manager := NewViewBufferManager( + utils.NewDummyLog(), + writer, + func() {}, // beforeStart + func() {}, // refreshView + func() {}, // onEndOfInput + resetOrigin, + func() {}, // beginRender + func() { swappedAtLines = linesWritten() }, + func() gocui.Task { return gocui.NewFakeTask() }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + manager.SetRestoreForNextTask(&RenderRestore{ + FirstPaintReady: func() bool { return false }, + Apply: func(swapIn func()) { + applyCount++ + swapIn() + }, + }) + + done := make(chan struct{}) + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 50} + } + _ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 30, nil}, func() { close(done) }), "cmd") + <-done + + assert.Equal(t, 1, applyCount, "Apply should still run, to swap the render in") + assert.Equal(t, 50, swappedAtLines, "the whole input should be read before giving up on the restore") + assert.Equal(t, 1, getResetOriginCallCount(), "new content the restore couldn't place starts at the top") +} + +// The task a restore was installed for can be stopped and replaced before it ever +// paints — a background refresh landing right after the key was pressed. The +// replacement renders the same content, so it is the one that owes the user their +// position. +func TestRestoreSurvivesTaskReplacement(t *testing.T) { + var applyCount atomic.Int32 + + manager := NewViewBufferManager( + utils.NewDummyLog(), + io.Discard, + func() {}, + func() {}, + func() {}, + func() {}, + func() {}, + func() {}, + func() gocui.Task { return gocui.NewFakeTask() }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + manager.SetRestoreForNextTask(&RenderRestore{ + FirstPaintReady: func() bool { return false }, + Apply: func(swapIn func()) { + applyCount.Add(1) + swapIn() + }, + }) + + startTask := func(reader io.Reader, onDone func()) { + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, reader + } + _ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), "cmd") + } + + // The task the restore was installed for stalls before it can paint. + stalled := BlockingLineReader{ + linesToYield: 3, + blocked: make(chan struct{}), + unblock: make(chan struct{}), + } + defer close(stalled.unblock) + startTask(&stalled, nil) + <-stalled.blocked + + done := make(chan struct{}) + startTask(&BlankLineReader{totalLinesToYield: 3}, func() { close(done) }) + <-done + + assert.EqualValues(t, 1, applyCount.Load(), "the replacement should apply the restore the stopped task couldn't") +} + +// A task told to keep the scroll position renders the content the view is showing +// under another command — the same diff with more context around it, say — so it +// neither resets the scroll nor blanks the view to say "loading...", both of which are +// for content the user hasn't seen. +func TestKeepScrollPositionForNextTask(t *testing.T) { + var beforeStartCount atomic.Int32 + resetOrigin, getResetOriginCallCount := getCounter() + + manager := NewViewBufferManager( + utils.NewDummyLog(), + io.Discard, + func() { beforeStartCount.Add(1) }, + func() {}, // refreshView + func() {}, // onEndOfInput + resetOrigin, + func() {}, // beginRender + func() {}, // swapInRender + func() gocui.Task { return gocui.NewFakeTask() }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + startTask := func(key string, reader io.Reader, onDone func()) { + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, reader + } + _ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key) + } + runTaskToCompletion := func(key string) { + done := make(chan struct{}) + startTask(key, &BlankLineReader{totalLinesToYield: 3}, func() { close(done) }) + <-done + } + + // Content the view wasn't showing, to have something to keep the position in. + runTaskToCompletion("cmd1") + assert.Equal(t, 1, getResetOriginCallCount()) + + manager.SetKeepScrollPositionForNextTask() + runTaskToCompletion("cmd2") + assert.Equal(t, 1, getResetOriginCallCount(), "the same content under another command keeps its position") + + // And the request rides one task only: the next different command is a different + // diff as far as anyone knows. + runTaskToCompletion("cmd3") + assert.Equal(t, 2, getResetOriginCallCount()) + + // The loading indicator goes by the same question, so it stays out of the way too. + manager.SetKeepScrollPositionForNextTask() + stalled := BlockingLineReader{ + blocked: make(chan struct{}), + unblock: make(chan struct{}), + } + defer close(stalled.unblock) + startTask("cmd4", &stalled, nil) + <-stalled.blocked + time.Sleep(500 * time.Millisecond) + assert.EqualValues(t, 0, beforeStartCount.Load()) +} + +// A view that has been emptied is showing nothing, so the render it was showing is no +// longer the one to compare the next task against: running the same command again is +// putting content into the view that isn't there any more, and starts from the top. +func TestForgetRenderedContent(t *testing.T) { + resetOrigin, getResetOriginCallCount := getCounter() + + manager := NewViewBufferManager( + utils.NewDummyLog(), + io.Discard, + func() {}, // beforeStart + func() {}, // refreshView + func() {}, // onEndOfInput + resetOrigin, + func() {}, // beginRender + func() {}, // swapInRender + func() gocui.Task { return gocui.NewFakeTask() }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + runTaskToCompletion := func(key string) { + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 3} + } + done := make(chan struct{}) + _ = manager.NewTask( + manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, func() { close(done) }), key) + <-done + } + + runTaskToCompletion("cmd1") + assert.Equal(t, 1, getResetOriginCallCount()) + + // Rendering the same command's output again leaves the view where it is, that being + // what it already shows. + runTaskToCompletion("cmd1") + assert.Equal(t, 1, getResetOriginCallCount()) + + manager.ForgetRenderedContent() + runTaskToCompletion("cmd1") + assert.Equal(t, 2, getResetOriginCallCount(), "an emptied view is shown its content afresh") +} + func TestNewCmdTaskRefresh(t *testing.T) { type scenario struct { name string diff --git a/schema-master/config.json b/schema-master/config.json index 45a2b9efe..bb5dae3d4 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -594,7 +594,7 @@ }, "skipDiscardChangeWarning": { "type": "boolean", - "description": "If true, do not show a warning when discarding changes in the staging view.", + "description": "If true, do not show a warning when discarding changes from a focused diff.", "default": false }, "skipStashWarning": { @@ -683,14 +683,14 @@ "description": "How the window is split when in half screen mode (i.e. after hitting '+' once).\nPossible values:\n- 'left': split the window horizontally (side panel on the left, main view on the right)\n- 'top': split the window vertically (side panel on top, main view below)", "default": "left" }, - "wrapLinesInStagingView": { + "wrapLinesInDiffView": { "type": "boolean", - "description": "If true, wrap lines in the staging view to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.", + "description": "If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.", "default": true }, - "useHunkModeInStagingView": { + "useHunkModeInDiffView": { "type": "boolean", - "description": "If true, hunk selection mode will be enabled by default when entering the staging view.", + "description": "If true, hunk selection mode will be enabled by default when focusing a diff.", "default": true }, "language": { @@ -2041,6 +2041,34 @@ "l" ] }, + "prevFile": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "N" + }, + "nextFile": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "n" + }, "toggleSelectHunk": { "oneOf": [ {