diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 456918b7e..c1b374c37 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,9 @@ jobs: test: name: Test runs-on: ubuntu-latest + strategy: + matrix: + release: [stable, nightly] steps: - uses: actions/checkout@v4 @@ -25,7 +28,7 @@ jobs: - name: Install Neovim run: | - wget https://github.com/neovim/neovim/releases/download/stable/nvim-linux64.tar.gz + wget https://github.com/neovim/neovim/releases/download/${{ matrix.release }}/nvim-linux64.tar.gz tar -zxf nvim-linux64.tar.gz sudo ln -s $(pwd)/nvim-linux64/bin/nvim /usr/local/bin diff --git a/LICENSE b/LICENSE index 09c1b7ad1..73315a6a0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2020 TimUntersberger +Copyright (c) 2020-2023 TimUntersberger +Copyright (c) 2024-2025 CKolkey Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 466a780ba..5513edb39 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - + [![Lua](https://img.shields.io/badge/Lua-blue.svg?style=for-the-badge&logo=lua)](http://www.lua.org) [![Neovim](https://img.shields.io/badge/Neovim%200.9+-green.svg?style=for-the-badge&logo=neovim)](https://neovim.io) [![MIT](https://img.shields.io/badge/MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) @@ -55,7 +55,7 @@ neogit.setup {} The `master` branch will always be compatible with the latest **stable** release of Neovim, and with the latest **nightly** build as well. -Some features may only be available using unreleased (neovim nightly) API's - to use them, set your plugin manager to track the `nightly` branch instead. +Some features may only be available using unreleased (neovim nightly) API's - to use them, set your plugin manager to track the `nightly` branch instead. The `nightly` branch has the same stability guarantees as the `master` branch. @@ -88,7 +88,7 @@ neogit.setup { }, -- "ascii" is the graph the git CLI generates -- "unicode" is the graph like https://github.com/rbong/vim-flog - graph_style = "ascii", + graph_style = "ascii", -- Used to generate URL's for branch popup action "pull request". git_services = { ["github.com"] = "https://github.com/${owner}/${repository}/compare/${branch_name}?expand=1", @@ -137,10 +137,31 @@ neogit.setup { -- Automatically show console if a command takes more than console_timeout milliseconds auto_show_console = true, status = { + show_head_commit_hash = true, recent_commit_count = 10, + HEAD_padding = 10, + mode_padding = 3, + mode_text = { + M = "modified", + N = "new file", + A = "added", + D = "deleted", + C = "copied", + U = "updated", + R = "renamed", + DD = "unmerged", + AU = "unmerged", + UD = "unmerged", + UA = "unmerged", + DU = "unmerged", + AA = "unmerged", + UU = "unmerged", + ["?"] = "", + }, }, commit_editor = { kind = "auto", + show_staged_diff = true, }, commit_select_view = { kind = "tab", @@ -245,6 +266,10 @@ neogit.setup { [""] = "Submit", [""] = "Abort", }, + commit_editor_I = { + [""] = "Submit", + [""] = "Abort", + }, rebase_editor = { ["p"] = "Pick", ["r"] = "Reword", @@ -260,6 +285,12 @@ neogit.setup { ["gj"] = "MoveDown", [""] = "Submit", [""] = "Abort", + ["[c"] = "OpenOrScrollUp", + ["]c"] = "OpenOrScrollDown", + }, + rebase_editor_I = { + [""] = "Submit", + [""] = "Abort", }, finder = { [""] = "Select", @@ -283,6 +314,7 @@ neogit.setup { ["X"] = "ResetPopup", ["Z"] = "StashPopup", ["b"] = "BranchPopup", + ["B"] = "BisectPopup", ["c"] = "CommitPopup", ["f"] = "FetchPopup", ["l"] = "LogPopup", @@ -304,6 +336,7 @@ neogit.setup { ["s"] = "Stage", ["S"] = "StageUnstaged", [""] = "StageAll", + ["K"] = "Untrack", ["u"] = "Unstage", ["U"] = "UnstageStaged", ["$"] = "CommandHistory", @@ -316,6 +349,8 @@ neogit.setup { [""] = "TabOpen", ["{"] = "GoToPreviousHunkHeader", ["}"] = "GoToNextHunkHeader", + ["[c"] = "OpenOrScrollUp", + ["]c"] = "OpenOrScrollDown", }, }, } diff --git a/doc/neogit.txt b/doc/neogit.txt index 68d7db4f8..645b3940a 100644 --- a/doc/neogit.txt +++ b/doc/neogit.txt @@ -1,66 +1,53 @@ *neogit.txt* A Magit inspired Git porcelain for Neovim *neogit* -Author: TimUntersberger -Maintainer: CKolkey -License: MIT license {{{ - - Copyright (c) 2020 Karl Yngve Lervåg - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to - deal in the Software without restriction, including without limitation the - rights to use, copy, modify, merge, publish, distribute, sublicense, and/or - sell copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS - IN THE SOFTWARE. -}}} + +Original Author: TimUntersberger +Current Author: Cameron Kolkey +Homepage: +License: MIT license ============================================================================== CONTENTS *neogit_contents* - 1. Intro |neogit_intro| - 2. Commands |neogit_commands| - 3. Mappings |neogit_mappings| - 4. Highlights |neogit_highlights| - 5. API |neogit_api| - 6. Popups |neogit_popups| - • Branch |neogit_branch_popup| - • Branch Config |neogit_branch_config_popup| - • Cherry Pick |neogit_cherry_pick_popup| - • Commit |neogit_commit_popup| - • Diff |neogit_diff_popup| - • Fetch |neogit_fetch_popup| - • Ignore |neogit_ignore_popup| - • Log |neogit_log_popup| - • Merge |neogit_merge_popup| - • Pull |neogit_pull_popup| - • Push |neogit_push_popup| - • Rebase |neogit_rebase_popup| - • Remote |neogit_remote_popup| - • Remote Config |neogit_remote_config_popup| - • Reset |neogit_reset_popup| - • Revert |neogit_revert_popup| - • Stash |neogit_stash_popup| - • Tag |neogit_tag_popup| - • Worktree |neogit_worktree_popup| - - 7. Buffers |neogit_buffers| - • Status |neogit_status_buffer| - • Editor |neogit_editor_buffer| - • Log |neogit_log_buffer| - • Reflog |neogit_reflog_buffer| - • Commit |neogit_commit_buffer| - • Rebase Todo |neogit_rebase_todo_buffer| + 1. Intro |neogit_intro| + 2. Setup *neogit_setup* + • Plugin Setup |neogit_setup_plugin| + • Mappings |neogit_setup_mappings| + • GPG Integration |neogit_setup_gpg| + 3. Commands |neogit_commands| + 4. Events |neogit_events| + 5. Highlights |neogit_highlights| + 6. API |neogit_api| + 7. Usage |neogit_usage| + 8. Popups *neogit_popups* + • Bisect |neogit_bisect_popup| + • Branch |neogit_branch_popup| + • Branch Config |neogit_branch_config_popup| + • Cherry Pick |neogit_cherry_pick_popup| + • Commit |neogit_commit_popup| + • Diff |neogit_diff_popup| + • Fetch |neogit_fetch_popup| + • Ignore |neogit_ignore_popup| + • Log |neogit_log_popup| + • Merge |neogit_merge_popup| + • Pull |neogit_pull_popup| + • Push |neogit_push_popup| + • Rebase |neogit_rebase_popup| + • Remote |neogit_remote_popup| + • Remote Config |neogit_remote_config_popup| + • Reset |neogit_reset_popup| + • Revert |neogit_revert_popup| + • Stash |neogit_stash_popup| + • Tag |neogit_tag_popup| + • Worktree |neogit_worktree_popup| + 9. Buffers *neogit_buffers* + • Status |neogit_status_buffer| + • Editor |neogit_editor_buffer| + • Log |neogit_log_buffer| + • Reflog |neogit_reflog_buffer| + • Refs |neogit_refs_buffer| + • Commit |neogit_commit_buffer| + • Rebase Todo |neogit_rebase_todo_buffer| ============================================================================== 1. Intro *neogit_intro* @@ -93,7 +80,276 @@ Though not yet feature complete, our goal is to bring the Magit git experience to Neovim users. ============================================================================== -2. Commands *neogit_commands* +2. Plugin Setup *neogit_setup_plugin* + +TODO: Detail what these do + + use_default_keymaps = true, + disable_hint = false, + disable_context_highlighting = false, + disable_signs = false, + graph_style = "ascii", + filewatcher = { + enabled = true, + }, + telescope_sorter = function() + return nil + end, + git_services = { + ["github.com"] = "https://github.com/${owner}/${repository}/compare/${branch_name}?expand=1", + ["bitbucket.org"] = "https://bitbucket.org/${owner}/${repository}/pull-requests/new?source=${branch_name}&t=1", + ["gitlab.com"] = "https://gitlab.com/${owner}/${repository}/merge_requests/new?merge_request[source_branch]=${branch_name}", + }, + highlight = { + italic = true, + bold = true, + underline = true, + }, + disable_insert_on_commit = "auto", + use_per_project_settings = true, + show_head_commit_hash = true, + remember_settings = true, + fetch_after_checkout = false, + auto_refresh = true, + sort_branches = "-committerdate", + kind = "tab", + disable_line_numbers = true, + -- The time after which an output console is shown for slow running commands + console_timeout = 2000, + -- Automatically show console if a command takes more than console_timeout milliseconds + auto_show_console = true, + notification_icon = "󰊢", + status = { + recent_commit_count = 10, + }, + commit_editor = { + kind = "tab", + }, + commit_select_view = { + kind = "tab", + }, + commit_view = { + kind = "vsplit", + verify_commit = vim.fn.executable("gpg") == 1, + }, + log_view = { + kind = "tab", + }, + rebase_editor = { + kind = "auto", + }, + reflog_view = { + kind = "tab", + }, + merge_editor = { + kind = "auto", + }, + description_editor = { + kind = "auto", + }, + tag_editor = { + kind = "auto", + }, + preview_buffer = { + kind = "split", + }, + popup = { + kind = "split", + }, + refs_view = { + kind = "tab", + }, + signs = { + hunk = { "", "" }, + item = { ">", "v" }, + section = { ">", "v" }, + }, + integrations = { + telescope = nil, + diffview = nil, + fzf_lua = nil, + }, + sections = { + sequencer = { + folded = false, + hidden = false, + }, + bisect = { + folded = false, + hidden = false, + }, + untracked = { + folded = false, + hidden = false, + }, + unstaged = { + folded = false, + hidden = false, + }, + staged = { + folded = false, + hidden = false, + }, + stashes = { + folded = true, + hidden = false, + }, + unpulled_upstream = { + folded = true, + hidden = false, + }, + unmerged_upstream = { + folded = false, + hidden = false, + }, + unpulled_pushRemote = { + folded = true, + hidden = false, + }, + unmerged_pushRemote = { + folded = false, + hidden = false, + }, + recent = { + folded = true, + hidden = false, + }, + rebase = { + folded = true, + hidden = false, + }, + }, + ignored_settings = { + "NeogitPushPopup--force-with-lease", + "NeogitPushPopup--force", + "NeogitPullPopup--rebase", + "NeogitCommitPopup--allow-empty", + } + +============================================================================== +Commit Signing / GPG Integration *neogit_setup_gpg* + +If you sign commits using gnugpg, there are a few steps that need to be taken +to properly integrate the password authentication with Neogit: + >gpg + # ~/.gnupg/gpg-agent.conf + pinentry-program /opt/homebrew/bin/pinentry-tty + allow-loopback-pinentry +< + >gpg + # ~/.gnupg/gpg.conf + pinentry-mode loopback +< + + Note: If you are not using Homebrew you may need to change the path for + `pinentry-program + +============================================================================== +Mappings *neogit_setup_mappings* + +The following mappings can all be customized via the setup function. +>lua + commit_editor = { + ["q"] = "Close", + [""] = "PrevMessage", + [""] = "NextMessage", + [""] = "ResetMessage", + [""] = "Submit", + [""] = "Abort", + } + commit_editor_I = { + [""] = "Submit", + [""] = "Abort", + } + + rebase_editor = { + ["p"] = "Pick", + ["r"] = "Reword", + ["e"] = "Edit", + ["s"] = "Squash", + ["f"] = "Fixup", + ["x"] = "Execute", + ["d"] = "Drop", + ["b"] = "Break", + ["q"] = "Close", + [""] = "OpenCommit", + ["gk"] = "MoveUp", + ["gj"] = "MoveDown", + [""] = "Submit", + [""] = "Abort", + ["[c"] = "OpenOrScrollUp", + ["]c"] = "OpenOrScrollDown", + } + rebase_editor_I = { + [""] = "Submit", + [""] = "Abort", + } + + finder = { + [""] = "Select", + [""] = "Close", + [""] = "Close", + [""] = "Next", + [""] = "Previous", + [""] = "Next", + [""] = "Previous", + [""] = "MultiselectToggleNext", + [""] = "MultiselectTogglePrevious", + } + + popup = { + ["?"] = "HelpPopup", + ["A"] = "CherryPickPopup", + ["B"] = "BisectPopup", + ["b"] = "BranchPopup", + ["c"] = "CommitPopup", + ["d"] = "DiffPopup", + ["f"] = "FetchPopup", + ["i"] = "IgnorePopup", + ["l"] = "LogPopup", + ["m"] = "MergePopup", + ["M"] = "RemotePopup", + ["p"] = "PullPopup", + ["P"] = "PushPopup", + ["r"] = "RebasePopup", + ["t"] = "TagPopup", + ["v"] = "RevertPopup", + ["w"] = "WorktreePopup", + ["X"] = "ResetPopup", + ["Z"] = "StashPopup", + } + + status = { + ["q"] = "Close", + ["I"] = "InitRepo", + ["1"] = "Depth1", + ["2"] = "Depth2", + ["3"] = "Depth3", + ["4"] = "Depth4", + [""] = "Toggle", + ["x"] = "Discard", + ["s"] = "Stage", + ["S"] = "StageUnstaged", + [""] = "StageAll", + ["u"] = "Unstage", + ["U"] = "UnstageStaged", + ["y"] = "ShowRefs", + ["$"] = "CommandHistory", + ["#"] = "Console", + ["Y"] = "YankSelected", + [""] = "RefreshBuffer", + [""] = "GoToFile", + [""] = "VSplitOpen", + [""] = "SplitOpen", + [""] = "TabOpen", + ["{"] = "GoToPreviousHunkHeader", + ["}"] = "GoToNextHunkHeader", + ["[c"] = "OpenOrScrollUp", + ["]c"] = "OpenOrScrollDown", + } +< +============================================================================== +3. Commands *neogit_commands* *:Neogit* :Neogit In a Git repository, opens a new NeogitStatus tab. @@ -102,101 +358,11 @@ to Neovim users. :NeogitResetState Performs a full reset of saved flags for all popups. ============================================================================== -3. Mappings *neogit_mappings* - - *neogit_status_maps* -Status Mappings - - *neogit_s* -s Stage file/hunk/visual-selection - - *neogit_S* -S Stage unstaged changes - - *neogit_* - Stage Everything - - *neogit_u* -u Unstage file/hunk/visual-selection - - *neogit_U* -U Unstage staged changes - - *neogit_x* -x Discard changes for file/hunk/visual-selection - - *neogit_* - Goto item at cursor - - *neogit_* - Toggle diff - - *neogit_{* -{ Goto previous hunk - - *neogit_}* -} Goto next hunk - - *neogit_* - Refresh Buffer - - *neogit_fold* -1, 2, 3, 4 Set a foldlevel - - - *neogit_popup_maps* -Popup maps ~ - - *neogit_$* -$ Command History - - *neogit_#* -# Console Output - - *neogit_L* -l Open log popup - - *neogit_p* -p Open pull popup - - *neogit_P* -P Open push popup - - *neogit_f* -f Open fetch popup - - *neogit_X* -X Open reset popup - - *neogit_A* -A Open cherry-pick popup - - *neogit_m* -m Open merge popup - - *neogit_v* -v Open revert popup - - *neogit_M* -M Open remotes popup - - *neogit_?* -? Open help popup - - *neogit_b* -b Open branch popup - - *neogit_c* -c Open commit popup - - *neogit_r* -r Open rebase popup - - *neogit_Z* -Z Open stash popup +4. Events *neogit_events* +(TODO) ============================================================================== -4. Highlights *neogit_highlights* +5. Highlights *neogit_highlights* The following highlight groups are defined by this plugin. If you set any of these yourself before the plugin loads, that will be respected. If they do not @@ -212,17 +378,17 @@ NeogitFold Folded text highlight NeogitRebaseDone Current position within rebase NeogitTagName Closest Tag name NeogitTagDistance Number of commits between the tag and HEAD - +NeogitStatusHEAD The left text in the HEAD section STATUS BUFFER SECTION HEADERS NeogitSectionHeader - NeogitUnpushedTo Linked to NeogitSectionHeader NeogitUnmergedInto ^ NeogitUnpulledFrom ^ NeogitUntrackedfiles ^ NeogitUnstagedchanges ^ NeogitUnmergedchanges ^ +NeogitUnpushedchanges ^ NeogitUnpulledchanges ^ NeogitRecentcommits ^ NeogitStagedchanges ^ @@ -230,9 +396,13 @@ NeogitStashes ^ NeogitRebasing ^ NeogitReverting ^ NeogitPicking ^ +NeogitMerging ^ +NeogitBisecting ^ +NeogitSectionHeaderCount The number, for sections with a number. STATUS BUFFER FILE -Applied to the label on the left of filenames. +Applied to the label on the left of filenames. These highlight groups are not +used directly, but linked to by other groups: NeogitChangeModified NeogitChangeAdded @@ -240,8 +410,37 @@ NeogitChangeDeleted NeogitChangeRenamed NeogitChangeUpdated NeogitChangeCopied -NeogitChangeBothModified NeogitChangeNewFile +NeogitChangeUnmerged + +Styling one of the above groups will apply to all sections, and is generally +whats recommended. However, if you want to control the style on a per-section +basis, the _actual_ highlight groups on the labels follow this pattern: + `NeogitChange
` + +Where `` is one of: (corrospinding to the git mode) + M + A + N + D + C + U + R + DD + UU + AA + DU + UD + AU + UA + +And `
` is one of: + untracked + unstaged + staged + +So, a modified and staged change would use the `NeogitChangeMstaged` highlight +group, which is linked to `NeogitChangeModified` by default. SIGNS FOR LINE HIGHLIGHTING Used to highlight different sections of the status buffer or commit buffer. @@ -256,15 +455,18 @@ SIGNS FOR LINE HIGHLIGHTING CURRENT CONTEXT These are essentially an accented version of the above highlight groups. Only applies to the current context the cursor is within. +The "Cursor" suffix applies only to the Cursor line + NeogitHunkHeaderHighlight NeogitDiffContextHighlight NeogitDiffAddHighlight NeogitDiffDeleteHighlight NeogitDiffHeaderHighlight - -NeogitCursorLine Applies a "fake" cursorline highlight because - signs will otherwise take precedence over normal - CursorLine HL group +NeogitHunkHeaderCursor +NeogitDiffContextCursor +NeogitDiffAddCursor +NeogitDiffDeleteCursor +NeogitDiffHeaderCursor COMMIT BUFFER NeogitFilePath Applied to filepath @@ -328,8 +530,11 @@ NeogitCommandTime Execution time NeogitCommandCodeNormal Applied to a successful command's exit status (0) NeogitCommandCodeError When command exits with non-zero status +COMMIT SELECT BUFFER +NeogitFloatHeader Foreground/Background for header text at top of win +NeogitFloatHeaderHighlight Emphasized text in header ============================================================================== -5. Lua API *neogit_api* *neogit-lua* +6. Lua API *neogit_api* *neogit-lua* neogit.open({*opts}) *neogit.open()* Open Neogit. Alternative to `:Neogit` >lua @@ -390,62 +595,14 @@ neogit.action({popup}, {action}, {args}) *neogit.action()* • {args} (table|nil) CLI arguments to pass to git command -============================================================================== -6. Popups *neogit_popups* - -A Neogit popup is a client interface over a git subcommand or other -git mechanism. - - *neogit_branch* -"branch" see :Man git-branch or |neogit_branch_popup| - - *neogit_branch_config* -"branch_config" Common configuration for branch/repo - - *neogit_cherry_pick* -"cherry_pick" see :Man git-cherry-pick - - *neogit_commit* -"commit" see :Man git-commit - - *neogit_diff* -"diff" see :Man git-diff - - *neogit_fetch* -"fetch" see :Man git-fetch - - *neogit_help* -"help" Overview of different popups - - *neogit_log* -"log" see :Man git-log - - *neogit_merge* -"merge" see :Man git-merge - - *neogit_pull* -"pull" see :Man git-pull - - *neogit_push* -"push" see :Man git-push - - *neogit_rebase* -"rebase" see :Man git-rebase - *neogit_remote* -"remote" see :Man git-remote - - *neogit_remote_config* -"remote_config" Common configuration for remote - - *neogit_reset* -"reset" see :Man git-reset - - *neogit_revert* -"revert" see :Man git-revert +============================================================================== +7. Usage *neogit_usage* +(TODO) - *neogit_stash* -"stash" see :Man git-stash +============================================================================== +Bisect Popup *neogit_bisect_popup* +(TODO) ============================================================================== Cherry-Pick Popup *neogit_cherry_pick_popup* @@ -830,6 +987,16 @@ Actions: *neogit_commit_popup_actions* Creates a new commit on the current branch from the currently staged changes. + • Absorb *neogit_commit_absorb* + (Requires `https://github.com/tummychow/git-absorb`) + + `git absorb` will automatically identify which commits are safe to modify, + and which staged changes belong to each of those commits. It will then + write fixup! commits for each of those changes. + + The `--with-rebase` flag is passed, meaning these fixup commits will be + automatically integrated into the corresponding ones via rebase. + • Extend *neogit_commit_extend* Amends the last commit without editing the commit message. @@ -1233,16 +1400,16 @@ Actions: *neogit_rebase_popup_actions* then a START commit. • Rebase to modify a commit *neogit_rebase_modify_commit* - (TODO) + Begins an interactive rebase, allowing you to edit a single commit. • Rebase to reword a commit *neogit_rebase_reword_commit* - (TODO) + Begins an interactive rebase, letting you reword an single older commit. • Rebase to remove a commit *neogit_rebase_remove_commit* - (TODO) + Uses rebase to remove a single commit from the history. • Rebase to autosquash *neogit_rebase_autosquash* - (TODO) + Combines `squash!` and `fixup!` commits with their target commits. When a rebase is in progress, the following actions are available instead: • Continue *neogit_rebase_continue* @@ -1313,6 +1480,17 @@ Reset Popup *neogit_reset_popup* All actions will default to the commit under cursor if there is one, or prompt the user to select one if there is not. +Variables: *neogit_reset_popup_variables* + • neogit.resetThisTo *neogit_reset_this_to* + When "Commit", will pass the commit hash to `git reset` unaltered. This has + the effect of resetting "to" the commit. + + When "Parent", will append "^" to the commit hash when calling `git reset`. + This has the effect of resetting the commit itself. + + Only applies when a commit is selected before launching the popup. Commits + chosen via the finder will be used unmodified, ignoring this setting. + Actions: *neogit_reset_popup_actions* • Mixed *neogit_reset_mixed* Resets the index but not the working tree (i.e., the changed files are @@ -1425,16 +1603,6 @@ Actions: *neogit_worktree_popup_actions* is the CWD, then CWD will be changed to the main worktree. -============================================================================== -7. Buffers *neogit_buffers* - -• Status |neogit_status_buffer| -• Editor |neogit_editor_buffer| -• Log |neogit_log_buffer| -• Reflog |neogit_reflog_buffer| -• Commit |neogit_commit_buffer| -• Rebase Todo |neogit_rebase_todo_buffer| - ============================================================================== Status Buffer *neogit_status_buffer* (TODO) @@ -1497,6 +1665,10 @@ Reflog Buffer *neogit_reflog_buffer* Commit Buffer *neogit_commit_buffer* (TODO) +============================================================================== +Refs Buffer *neogit_refs_buffer* +(TODO) + ============================================================================== Rebase Todo Buffer *neogit_rebase_todo_buffer* @@ -1513,4 +1685,6 @@ The following keys, in normal mode, will act on the commit under the cursor: • `b` Insert breakpoint • `` Open current commit in Commit Buffer -vim:tw=78:ts=8:ft=help +------------------------------------------------------------------------------ +vim:tw=78:ts=8:ft=help:norl: + diff --git a/ftplugin/NeogitGitCommandHistory.vim b/ftplugin/NeogitGitCommandHistory.vim deleted file mode 100644 index 475230fb7..000000000 --- a/ftplugin/NeogitGitCommandHistory.vim +++ /dev/null @@ -1,15 +0,0 @@ -" Only do this when not done yet for this buffer -if exists("b:did_ftplugin") - finish -endif -let b:did_ftplugin = 1 - -function! NeogitFoldFunction() - return getline(v:foldstart) -endfunction - -setlocal foldmethod=manual -setlocal foldlevel=1 -setlocal fillchars=fold:\ -setlocal foldminlines=0 -setlocal foldtext=NeogitFoldFunction() diff --git a/ftplugin/NeogitStatus.vim b/ftplugin/NeogitStatus.vim deleted file mode 100644 index 318746396..000000000 --- a/ftplugin/NeogitStatus.vim +++ /dev/null @@ -1,7 +0,0 @@ -" Only do this when not done yet for this buffer -if exists("b:did_ftplugin") - finish -endif -let b:did_ftplugin = 1 - -au BufWipeout lua require 'neogit.status'.close(true) diff --git a/lua/neogit.lua b/lua/neogit.lua index 0c45d07d2..0530601a4 100644 --- a/lua/neogit.lua +++ b/lua/neogit.lua @@ -20,14 +20,46 @@ function M.setup(opts) M.autocmd_group = vim.api.nvim_create_augroup("Neogit", { clear = false }) - M.status = require("neogit.status") - M.dispatch_reset = M.status.dispatch_reset - M.refresh = M.status.refresh - M.reset = M.status.reset - M.refresh_manually = M.status.refresh_manually - M.dispatch_refresh = M.status.dispatch_refresh - M.refresh_viml_compat = M.status.refresh_viml_compat - M.close = M.status.close + M.status = require("neogit.buffers.status") + + M.dispatch_reset = function() + local instance = M.status.instance() + if instance then + instance:dispatch_reset() + end + end + + M.refresh = function() + local instance = M.status.instance() + if instance then + instance:refresh() + end + end + + M.reset = function() + local instance = M.status.instance() + if instance then + instance:reset() + end + end + + M.dispatch_refresh = function() + local instance = M.status.instance() + if instance then + instance:dispatch_refresh() + end + end + + M.close = function() + local instance = M.status.instance() + if instance then + instance:close() + end + end + + -- TODO ? + -- M.refresh_viml_compat = M.status.refresh_viml_compat + -- M.refresh_manually = M.status.refresh_manually M.lib = require("neogit.lib") M.cli = M.lib.git.cli @@ -40,14 +72,80 @@ function M.setup(opts) signs.setup() state.setup() autocmds.setup() +end - if vim.fn.has("nvim-0.10") == 1 then - M.notification.info("The 'nightly' branch for Neogit provides support for nvim-0.10") +local function construct_opts(opts) + opts = opts or {} + + if opts.cwd and not opts.no_expand then + opts.cwd = vim.fn.expand(opts.cwd) + end + + if not opts.cwd then + local git = require("neogit.lib.git") + opts.cwd = git.cli.git_root(".") + + if opts.cwd == "" then + opts.cwd = vim.fn.getcwd() + end + end + + return opts +end + +local function open_popup(name) + local has_pop, popup = pcall(require, "neogit.popups." .. name) + if not has_pop then + M.notification.error(("Invalid popup %q"):format(name)) + else + popup.create {} end end ----@alias Popup "cherry_pick" | "commit" | "branch" | "diff" | "fetch" | "log" | "merge" | "remote" | "pull" | "push" | "rebase" | "revert" | "reset" | "stash" ---- +local function open_status_buffer(opts) + local status = require("neogit.buffers.status") + local config = require("neogit.config") + local a = require("plenary.async") + + -- We need to construct the repo instance manually here since the actual CWD may not be the directory neogit is + -- going to open into. We will use vim.fn.lcd() in the status buffer constructor, so this will eventually be + -- correct. + local repo = require("neogit.lib.git.repository").instance(opts.cwd) + + local instance = status.new(repo.state, config.values, repo.git_root):open(opts.kind, opts.cwd) + + a.run(function() + repo:refresh { + callback = function() + instance:dispatch_refresh() + end, + } + end) +end + +---@alias Popup +---| "bisect" +---| "branch" +---| "branch_config" +---| "cherry_pick" +---| "commit" +---| "diff" +---| "fetch" +---| "help" +---| "ignore" +---| "log" +---| "merge" +---| "pull" +---| "push" +---| "rebase" +---| "remote" +---| "remote_config" +---| "reset" +---| "revert" +---| "stash" +---| "tag" +---| "worktree" + ---@class OpenOpts ---@field cwd string|nil ---@field [1] Popup|nil @@ -56,38 +154,20 @@ end ---@param opts OpenOpts|nil function M.open(opts) - local a = require("plenary.async") - local lib = require("neogit.lib") - local status = require("neogit.status") - local input = require("neogit.lib.input") - local cli = require("neogit.lib.git.cli") - local logger = require("neogit.logger") local notification = require("neogit.lib.notification") - opts = opts or {} - - if opts.cwd and not opts.no_expand then - opts.cwd = vim.fn.expand(opts.cwd) - end - - if not opts.cwd then - opts.cwd = require("neogit.lib.git.cli").git_root_of_cwd() - end - if not did_setup then notification.error("Neogit has not been setup!") - logger.error("Neogit not setup!") return end - if not cli.is_inside_worktree(opts.cwd) then - if - input.get_confirmation( - string.format("Initialize repository in %s?", opts.cwd), - { values = { "&Yes", "&No" }, default = 2 } - ) - then - lib.git.init.create(opts.cwd, true) + opts = construct_opts(opts) + + local git = require("neogit.lib.git") + if not git.cli.is_inside_worktree(opts.cwd) then + local input = require("neogit.lib.input") + if input.get_permission(("Initialize repository in %s?"):format(opts.cwd)) then + git.init.create(opts.cwd, true) else notification.error("The current working directory is not a git repository") return @@ -95,23 +175,16 @@ function M.open(opts) end if opts[1] ~= nil then - local popup_name = opts[1] - local has_pop, popup = pcall(require, "neogit.popups." .. popup_name) - - if not has_pop then - vim.api.nvim_err_writeln("Invalid popup '" .. popup_name .. "'") - else - popup.create {} + local a = require("plenary.async") + local cb = function() + open_popup(opts[1]) end - else + a.run(function() - if status.status_buffer then - vim.cmd.lcd(opts.cwd) - status.refresh(nil, "open") - else - status.create(opts.kind, opts.cwd) - end + git.repo:refresh { source = "popup", callback = cb } end) + else + open_status_buffer(opts) end end @@ -124,7 +197,6 @@ end ---@param args table? CLI arguments to pass to git command ---@return function function M.action(popup, action, args) - local notification = require("neogit.lib.notification") local util = require("neogit.lib.util") local a = require("plenary.async") @@ -152,7 +224,7 @@ function M.action(popup, action, args) end, } else - notification.error( + M.notification.error( string.format( "Invalid action %s for %s popup\nValid actions are: %s", action, @@ -162,7 +234,7 @@ function M.action(popup, action, args) ) end else - notification.error("Invalid popup: " .. popup) + M.notification.error("Invalid popup: " .. popup) end end) end @@ -180,10 +252,40 @@ function M.complete(arglead) "kind=auto", } end - -- Only complete arguments that start with arglead + + if arglead:find("^cwd=") then + return { + "cwd=" .. vim.fn.getcwd(), + } + end + return vim.tbl_filter(function(arg) return arg:match("^" .. arglead) - end, { "kind=", "cwd=", "commit" }) + end, { + "kind=", + "cwd=", + "bisect", + "branch", + "branch_config", + "cherry_pick", + "commit", + "diff", + "fetch", + "help", + "ignore", + "log", + "merge", + "pull", + "push", + "rebase", + "remote", + "remote_config", + "reset", + "revert", + "stash", + "tag", + "worktree", + }) end function M.get_log_file_path() @@ -191,7 +293,7 @@ function M.get_log_file_path() end function M.get_config() - return require("neogit.config").values + return M.config.values end return M diff --git a/lua/neogit/autocmds.lua b/lua/neogit/autocmds.lua index f22b721f8..14d895014 100644 --- a/lua/neogit/autocmds.lua +++ b/lua/neogit/autocmds.lua @@ -1,9 +1,10 @@ local M = {} local api = vim.api + local a = require("plenary.async") -local status = require("neogit.status") -local fs = require("neogit.lib.fs") +local status_buffer = require("neogit.buffers.status") +local git = require("neogit.lib.git") local group = require("neogit").autocmd_group function M.setup() @@ -12,27 +13,33 @@ function M.setup() group = group, }) + local autocmd_disabled = false api.nvim_create_autocmd({ "BufWritePost", "ShellCmdPost", "VimResume" }, { - callback = function(o) - -- Skip update if the buffer is not open - if not status.status_buffer then - return - end - - -- Do not trigger on neogit buffers such as commit - if api.nvim_buf_get_option(o.buf, "filetype"):find("Neogit") then - return + callback = a.void(function(o) + if + not autocmd_disabled + and status_buffer.is_open() + and not api.nvim_get_option_value("filetype", { buf = o.buf }):match("^Neogit") + then + local path = git.files.relpath_from_repository(o.file) + if path then + status_buffer + .instance() + :dispatch_refresh({ update_diffs = { "*:" .. path } }, string.format("%s:%s", o.event, path)) + end end + end), + group = group, + }) - a.run(function() - local path = fs.relpath_from_repository(o.file) - if not path then - return - end - status.refresh({ update_diffs = { "*:" .. path } }, string.format("%s:%s", o.event, o.file)) - end, function() end) - end, + --- vimpgrep creates and deletes lots of buffers so attaching to each one will + --- waste lots of resource and even slow down vimgrep. + api.nvim_create_autocmd({ "QuickFixCmdPre", "QuickFixCmdPost" }, { group = group, + pattern = "*vimgrep*", + callback = function(args) + autocmd_disabled = args.event == "QuickFixCmdPre" + end, }) end diff --git a/lua/neogit/bootstrap.lua b/lua/neogit/bootstrap.lua deleted file mode 100644 index d066bd4b0..000000000 --- a/lua/neogit/bootstrap.lua +++ /dev/null @@ -1,18 +0,0 @@ --- This module does all necessary dependency checks and takes care of --- initializing global values and configuration. --- It MUST NOT error, as this function is called from viml and errors won't --- be caught. --- --- The module returns true if everything went well, or false if any part of --- the initialization failed. -local res, err = pcall(require, "plenary") -if not res then - print("WARNING: Neogit depends on `nvim-lua/plenary.nvim` to work, but loading the plugin failed!") - print( - "Make sure you add `nvim-lua/plenary.nvim` to your plugin manager BEFORE neogit for everything to work" - ) - print(err) -- TODO: find out how to print the error without raising it AND properly print tabs - return false -end - -return true diff --git a/lua/neogit/buffers/commit_select_view/init.lua b/lua/neogit/buffers/commit_select_view/init.lua index e52c9da96..135b385c6 100644 --- a/lua/neogit/buffers/commit_select_view/init.lua +++ b/lua/neogit/buffers/commit_select_view/init.lua @@ -7,15 +7,18 @@ local status_maps = require("neogit.config").get_reversed_status_maps() ---@class CommitSelectViewBuffer ---@field commits CommitLogEntry[] +---@field header string|nil local M = {} M.__index = M ---Opens a popup for selecting a commit ---@param commits CommitLogEntry[]|nil +---@param header? string ---@return CommitSelectViewBuffer -function M.new(commits) +function M.new(commits, header) local instance = { commits = commits, + header = header, buffer = nil, } @@ -25,14 +28,27 @@ function M.new(commits) end function M:close() - self.buffer:close() - self.buffer = nil + if self.buffer then + self.buffer:close() + self.buffer = nil + end + + M.instance = nil +end + +---@return boolean +function M.is_open() + return (M.instance and M.instance.buffer and M.instance.buffer:is_visible()) == true end ---@param action fun(commit: CommitLogEntry[]) function M:open(action) - -- TODO: Pass this in as a param instead of reading state from object - local _, item = require("neogit.status").get_current_section_item() + if M.is_open() then + M.instance.buffer:focus() + return + end + + M.instance = self ---@type fun(commit: CommitLogEntry[])|nil local action = action @@ -40,7 +56,9 @@ function M:open(action) self.buffer = Buffer.create { name = "NeogitCommitSelectView", filetype = "NeogitCommitSelectView", + status_column = "", kind = config.values.commit_select_view.kind, + header = self.header or "Select a commit with , or to abort", mappings = { v = { [""] = function() @@ -88,25 +106,11 @@ function M:open(action) end, }, }, - autocmds = { - ["BufUnload"] = function() - self.buffer = nil - if action then - action {} - end - end, - }, - after = function(buffer, win) - if win and item and item.commit then - local found = buffer.ui:find_component(function(c) - return c.options.oid == item.commit.oid - end) - - if found then - vim.api.nvim_win_set_cursor(win, { found.position.row_start, 0 }) - end + on_detach = function() + self.buffer = nil + if action then + action {} end - vim.cmd([[setlocal nowrap]]) end, render = function() return ui.View(self.commits) diff --git a/lua/neogit/buffers/commit_view/init.lua b/lua/neogit/buffers/commit_view/init.lua index 5c49b7c02..eda91a522 100644 --- a/lua/neogit/buffers/commit_view/init.lua +++ b/lua/neogit/buffers/commit_view/init.lua @@ -19,11 +19,16 @@ local api = vim.api ---@field description table ---@class CommitOverview ----@field summary string ----@field files table +---@field summary string a short summary about what happened +---@field files CommitOverviewFile[] a list of CommitOverviewFile + +---@class CommitOverviewFile +---@field path string the path to the file relative to the git root +---@field changes string how many changes were made to the file +---@field insertions string insertion count visualized as list of `+` +---@field deletions string deletion count visualized as list of `-` --- @class CommitViewBuffer ---- @field is_open boolean whether the buffer is currently shown --- @field commit_info CommitInfo --- @field commit_signature table|nil --- @field commit_overview CommitOverview @@ -39,7 +44,7 @@ local M = { ---Creates a new CommitViewBuffer ---@param commit_id string the id of the commit/tag ----@param filter string[]? Filter diffs to filepaths in table +---@param filter? string[] Filter diffs to filepaths in table ---@return CommitViewBuffer function M.new(commit_id, filter) local commit_info = @@ -51,7 +56,6 @@ function M.new(commit_id, filter) parser.parse_commit_overview(git.cli.show.stat.oneline.args(commit_id).call_sync().stdout) local instance = { - is_open = false, item_filter = filter, commit_info = commit_info, commit_overview = commit_overview, @@ -66,124 +70,121 @@ end --- Closes the CommitViewBuffer function M:close() - self.is_open = false - self.buffer:close() - self.buffer = nil + if self.buffer then + self.buffer:close() + self.buffer = nil + end + + M.instance = nil end ---Opens the CommitViewBuffer if it isn't open or performs the given action ---which is passed the window id of the commit view buffer ---@param commit_id string commit ---@param filter string[]? Filter diffs to filepaths in table ----@param action fun(window_id) -function M.open_or_run_in_window(commit_id, filter, action) - if not commit_id then - return - end - local cvb = M.instance - if cvb and cvb.is_open and cvb.commit_info.commit_arg == commit_id and cvb.buffer and cvb.buffer.handle then - local ct = vim.api.nvim_get_current_tabpage() - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(ct)) do - local buf = vim.api.nvim_win_get_buf(win) - if buf == cvb.buffer.handle then - pcall(action, win) - break - end - end +---@param cmd string vim command to run in window +function M.open_or_run_in_window(commit_id, filter, cmd) + assert(commit_id, "commit id cannot be nil") + + if M.is_open() and M.instance.commit_info.commit_arg == commit_id then + M.instance.buffer:win_exec(cmd) else - local cw = vim.api.nvim_get_current_win() + local cw = api.nvim_get_current_win() M.new(commit_id, filter):open() - vim.api.nvim_set_current_win(cw) + api.nvim_set_current_win(cw) end end +---@param commit_id string commit +---@param filter string[]? Filter diffs to filepaths in table +function M.open_or_scroll_down(commit_id, filter) + M.open_or_run_in_window(commit_id, filter, "normal! " .. vim.keycode("")) +end + +---@param commit_id string commit +---@param filter string[]? Filter diffs to filepaths in table +function M.open_or_scroll_up(commit_id, filter) + M.open_or_run_in_window(commit_id, filter, "normal! " .. vim.keycode("")) +end + +---@return boolean +function M.is_open() + return (M.instance and M.instance.buffer and M.instance.buffer:is_visible()) == true +end + ---Opens the CommitViewBuffer ---If already open will close the buffer ---@param kind? string function M:open(kind) kind = kind or config.values.commit_view.kind - if M.instance and M.instance.is_open then + if M.is_open() then M.instance:close() end M.instance = self - if self.is_open then - return - end - - self.hovered_component = nil - self.is_open = true self.buffer = Buffer.create { name = "NeogitCommitView", filetype = "NeogitCommitView", kind = kind, - context_highlight = true, - autocmds = { - ["BufUnload"] = function() - M.instance.is_open = false - end, - }, + status_column = "", + context_highlight = not config.values.disable_context_highlighting, mappings = { n = { [""] = function() - local c = self.buffer.ui:get_component_on_line(vim.fn.line(".")) - - local diff_headers - -- Check we are on top of a path on the OverviewFiles - if c.options.highlight == "NeogitFilePath" then - -- Some paths are padded for formatting purposes. We need to trim them - -- in order to use them as match patterns. - local selected_path = vim.fn.trim(c.value) - - diff_headers = {} - - -- Recursively navigate the layout until we hit NeogitDiffHeader leaves - -- Forward declaration required to avoid missing global error - local find_diff_headers - - function find_diff_headers(layout) - if layout.children then - -- One layout element may have multiple children so we need to loop - for _, val in pairs(layout.children) do - local v = find_diff_headers(val) - if v then - -- defensive trim - diff_headers[vim.fn.trim(v[1])] = v[2] - end - end - else - if layout.options.sign == "NeogitDiffHeader" then - return { layout.value, layout:row_range_abs() } + local c = self.buffer.ui:get_component_under_cursor(function(c) + return c.options.highlight == "NeogitFilePath" + end) + + if not c then + return + end + + -- Some paths are padded for formatting purposes. We need to trim them + -- in order to use them as match patterns. + local selected_path = vim.fn.trim(c.value) + + -- Recursively navigate the layout until we hit NeogitDiffHeader leaf nodes + -- Forward declaration required to avoid missing global error + local diff_headers = {} + local function find_diff_headers(layout) + if layout.children then + -- One layout element may have multiple children so we need to loop + for _, val in pairs(layout.children) do + local v = find_diff_headers(val) + if v then + -- defensive trim + diff_headers[vim.fn.trim(v[1])] = v[2] end end + else + if layout.options.line_hl == "NeogitDiffHeader" then + return { layout.value, layout:row_range_abs() } + end end + end - -- The Diffs are in the 10th element of the layout. - -- TODO: Do better than assume that we care about layout[10] - find_diff_headers(self.buffer.ui.layout[10]) - - -- Search for a match and jump if we find it - for path, line_nr in pairs(diff_headers) do - -- The gsub is to work around the fact that the OverviewFiles use - -- => in renames but the diff header uses -> - local match = string.match(path:gsub(" %-> ", " => "), selected_path) - if match then - local winid = vim.fn.win_getid() - vim.api.nvim_win_set_cursor(winid, { line_nr, 1 }) - break - end + find_diff_headers(self.buffer.ui.layout) + + -- Search for a match and jump if we find it + for path, line_nr in pairs(diff_headers) do + -- The gsub is to work around the fact that the OverviewFiles use + -- => in renames but the diff header uses -> + if path:gsub(" %-> ", " => "):match(selected_path) then + -- Save position in jumplist + vim.cmd("normal! m'") + + self.buffer:move_cursor(line_nr) + break end end end, ["{"] = function() -- Goto Previous local function previous_hunk_header(self, line) - local c = self.buffer.ui:get_component_on_line(line) - - while c and not vim.tbl_contains({ "Diff", "Hunk" }, c.options.tag) do - c = c.parent - end + local c = self.buffer.ui:get_component_on_line(line, function(c) + return c.options.tag == "Diff" or c.options.tag == "Hunk" + end) if c then local first, _ = c:row_range_abs() @@ -202,21 +203,19 @@ function M:open(kind) end end, ["}"] = function() -- Goto next - local c = self.buffer.ui:get_component_under_cursor() - - while c and not vim.tbl_contains({ "Diff", "Hunk" }, c.options.tag) do - c = c.parent - end + local c = self.buffer.ui:get_component_under_cursor(function(c) + return c.options.tag == "Diff" or c.options.tag == "Hunk" + end) if c then if c.options.tag == "Diff" then - api.nvim_win_set_cursor(0, { vim.fn.line(".") + 1, 0 }) + self.buffer:move_cursor(vim.fn.line(".") + 1) else local _, last = c:row_range_abs() if last == vim.fn.line("$") then - api.nvim_win_set_cursor(0, { last, 0 }) + self.buffer:move_cursor(last) else - api.nvim_win_set_cursor(0, { last + 1, 0 }) + self.buffer:move_cursor(last + 1) end end vim.cmd("normal! zt") @@ -252,38 +251,41 @@ function M:open(kind) p { commit = self.commit_info.oid } end), [popups.mapping_for("PullPopup")] = popups.open("pull"), - ["q"] = function() + [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) + p { + section = { name = "log" }, + item = { name = self.commit_info.oid }, + } + end), + [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) + p { commits = { self.commit_info.oid } } + end), + [status_maps["Close"]] = function() self:close() end, - [""] = function() - self.buffer.ui:print_layout_tree { collapse_hidden_components = true } + [""] = function() + self:close() end, [status_maps["YankSelected"]] = function() local yank = string.format("'%s'", self.commit_info.oid) vim.cmd.let("@+=" .. yank) vim.cmd.echo(yank) end, - [""] = function() - local c = self.buffer.ui:get_component_under_cursor() - - if c then - local c = c.parent - if c.options.tag == "HunkContent" then - c = c.parent - end - if vim.tbl_contains({ "Diff", "Hunk" }, c.options.tag) then - local first, _ = c:row_range_abs() - c.children[2]:toggle_hidden() - self.buffer.ui:update() - api.nvim_win_set_cursor(0, { first, 0 }) - end - end + [status_maps["Toggle"]] = function() + pcall(vim.cmd, "normal! za") + end, + [""] = function() + -- require("neogit.lib.ui.debug") + -- self.buffer.ui:debug_layout() end, }, }, render = function() return ui.CommitView(self.commit_info, self.commit_overview, self.commit_signature, self.item_filter) end, + after = function() + vim.cmd("normal! zR") + end, } end diff --git a/lua/neogit/buffers/commit_view/parsing.lua b/lua/neogit/buffers/commit_view/parsing.lua index 23c719bb0..869ed3e2c 100644 --- a/lua/neogit/buffers/commit_view/parsing.lua +++ b/lua/neogit/buffers/commit_view/parsing.lua @@ -2,18 +2,10 @@ local M = {} local util = require("neogit.lib.util") --- @class CommitOverviewFile --- @field path the path to the file relative to the git root --- @field changes how many changes were made to the file --- @field insertions insertion count visualized as list of `+` --- @field deletions deletion count visualized as list of `-` - --- @class CommitOverview --- @field summary a short summary about what happened --- @field files a list of CommitOverviewFile --- @see CommitOverviewFile local CommitOverview = {} +---@param raw table +---@return CommitOverview function M.parse_commit_overview(raw) local overview = { summary = util.trim(raw[#raw]), @@ -23,7 +15,14 @@ function M.parse_commit_overview(raw) for i = 2, #raw - 1 do local file = {} if raw[i] ~= "" then + -- matches: tests/specs/neogit/popups/rebase_spec.lua | 2 +- file.path, file.changes, file.insertions, file.deletions = raw[i]:match(" (.*)%s+|%s+(%d+) ?(%+*)(%-*)") + + if vim.tbl_isempty(file) then + -- matches: .../db/b8571c4f873daff059c04443077b43a703338a | Bin 0 -> 192 bytes + file.path, file.changes = raw[i]:match(" (.*)%s+|%s+(Bin .*)$") + end + table.insert(overview.files, file) end end @@ -33,10 +32,4 @@ function M.parse_commit_overview(raw) return overview end ----@return string the abbreviation of the oid ----@param commit CommitLogEntry -function M.abbrev(commit) - return commit.oid:sub(1, 7) -end - return M diff --git a/lua/neogit/buffers/commit_view/ui.lua b/lua/neogit/buffers/commit_view/ui.lua index bf2c22427..da3dcde67 100644 --- a/lua/neogit/buffers/commit_view/ui.lua +++ b/lua/neogit/buffers/commit_view/ui.lua @@ -13,17 +13,17 @@ local map = util.map function M.OverviewFile(file) return row.tag("OverviewFile") { text.highlight("NeogitFilePath")(file.path), - text(" | "), - text.highlight("Number")(file.changes), - text(" "), - text.highlight("NeogitDiffAdd")(file.insertions), - text.highlight("NeogitDiffDelete")(file.deletions), + text(" | "), + text.highlight("Number")(util.pad_left(file.changes, 5)), + text(" "), + text.highlight("NeogitDiffAdditions")(file.insertions), + text.highlight("NeogitDiffDeletetions")(file.deletions), } end local function commit_header_arg(info) if info.oid ~= info.commit_arg then - return row { text(info.commit_arg .. " "), text.highlight("Comment")(info.oid) } + return row { text(info.commit_arg .. " "), text.highlight("NeogitObjectId")(info.oid) } else return row {} end @@ -31,21 +31,29 @@ end function M.CommitHeader(info) return col { - text.sign("NeogitCommitViewHeader")("Commit " .. info.commit_arg), + text.line_hl("NeogitCommitViewHeader")("Commit " .. info.commit_arg), commit_header_arg(info), row { - text.highlight("Comment")("Author: "), + text.highlight("NeogitSubtleText")("Author: "), text((info.author_name or "") .. " <" .. (info.author_email or "") .. ">"), }, - row { text.highlight("Comment")("AuthorDate: "), text(info.author_date) }, + row { text.highlight("NeogitSubtleText")("AuthorDate: "), text(info.author_date) }, row { - text.highlight("Comment")("Committer: "), + text.highlight("NeogitSubtleText")("Committer: "), text((info.committer_name or "") .. " <" .. (info.committer_email or "") .. ">"), }, - row { text.highlight("Comment")("CommitDate: "), text(info.committer_date) }, + row { text.highlight("NeogitSubtleText")("CommitDate: "), text(info.committer_date) }, } end +function M.SignatureBlock(signature_block) + if vim.tbl_isempty(signature_block or {}) then + return text("") + end + + return col(util.merge(map(signature_block, text), { text("") }), { tag = "Signature" }) +end + function M.CommitView(info, overview, signature_block, item_filter) if item_filter then overview.files = util.filter_map(overview.files, function(file) @@ -61,15 +69,12 @@ function M.CommitView(info, overview, signature_block, item_filter) end) end - local hide_signature = vim.tbl_isempty(signature_block) - return { M.CommitHeader(info), text(""), col(map(info.description, text), { highlight = "NeogitCommitViewDescription", tag = "Description" }), text(""), - col(map(signature_block or {}, text), { tag = "Signature", hidden = hide_signature }), - text("", { hidden = hide_signature }), + M.SignatureBlock(signature_block), text(overview.summary), col(map(overview.files, M.OverviewFile), { tag = "OverviewFileList" }), text(""), diff --git a/lua/neogit/buffers/common.lua b/lua/neogit/buffers/common.lua index a6e5dd8e0..b3357296e 100644 --- a/lua/neogit/buffers/common.lua +++ b/lua/neogit/buffers/common.lua @@ -14,10 +14,18 @@ local range = util.range local M = {} -local diff_add_start = "+" -local diff_delete_start = "-" +M.EmptyLine = Component.new(function() + return col { row { text("") } } +end) M.Diff = Component.new(function(diff) + return col.tag("Diff")({ + text(string.format("%s %s", diff.kind, diff.file), { line_hl = "NeogitDiffHeader" }), + M.DiffHunks(diff), + }, { foldable = true, folded = false, context = true }) +end) + +M.DiffHunks = Component.new(function(diff) local hunk_props = map(diff.hunks, function(hunk) local header = diff.lines[hunk.diff_from] @@ -25,40 +33,64 @@ M.Diff = Component.new(function(diff) return diff.lines[i] end) + hunk.content = content + return { header = header, content = content, + hunk = hunk, + folded = hunk._folded, } end) - return col.tag("Diff") { - text(string.format("%s %s", diff.kind, diff.file), { sign = "NeogitDiffHeader" }), - col.tag("DiffContent") { - col.tag("DiffInfo")(map(diff.info, text)), - col.tag("HunkList")(map(hunk_props, M.Hunk)), - }, + return col.tag("DiffContent") { + col.tag("DiffInfo")(map(diff.info, text)), + col.tag("HunkList")(map(hunk_props, M.Hunk)), } end) -local HunkLine = Component.new(function(line) - local sign +local diff_add_start = "+" +local diff_add_start_2 = " +" +local diff_delete_start = "-" +local diff_delete_start_2 = " -" - if string.sub(line, 1, 1) == diff_add_start then - sign = "NeogitDiffAdd" - elseif string.sub(line, 1, 1) == diff_delete_start then - sign = "NeogitDiffDelete" +local HunkLine = Component.new(function(line) + local line_hl + + -- TODO: Should use file mode, not merge head + if git.repo.state.merge.head then + if + line:match("..<<<<<<<") + or line:match("..|||||||") + or line:match("..=======") + or line:match("..>>>>>>>") + then + line_hl = "NeogitHunkMergeHeader" + elseif string.sub(line, 1, 1) == diff_add_start or string.sub(line, 1, 2) == diff_add_start_2 then + line_hl = "NeogitDiffAdd" + elseif string.sub(line, 1, 1) == diff_delete_start or string.sub(line, 1, 2) == diff_delete_start_2 then + line_hl = "NeogitDiffDelete" + else + line_hl = "NeogitDiffContext" + end else - sign = "NeogitDiffContext" + if string.sub(line, 1, 1) == diff_add_start then + line_hl = "NeogitDiffAdd" + elseif string.sub(line, 1, 1) == diff_delete_start then + line_hl = "NeogitDiffDelete" + else + line_hl = "NeogitDiffContext" + end end - return text(line, { sign = sign }) + return text(line, { line_hl = line_hl }) end) M.Hunk = Component.new(function(props) - return col.tag("Hunk") { - text.sign("NeogitHunkHeader")(props.header), + return col.tag("Hunk")({ + text.line_hl("NeogitHunkHeader")(props.header), col.tag("HunkContent")(map(props.content, HunkLine)), - } + }, { foldable = true, folded = props.folded or false, context = true, hunk = props.hunk }) end) M.List = Component.new(function(props) @@ -110,14 +142,18 @@ local highlight_for_signature = { M.CommitEntry = Component.new(function(commit, args) local ref = {} + local ref_last = {} + + local info = git.log.branch_info(commit.ref_name, git.remote.list()) -- Parse out ref names if args.decorate and commit.ref_name ~= "" then - local info = git.log.branch_info(commit.ref_name, git.remote.list()) - -- Render local only branches first for name, _ in pairs(info.locals) do - if info.remotes[name] == nil then + if name:match("^refs/") then + table.insert(ref_last, text(name, { highlight = "NeogitGraphGray" })) + table.insert(ref_last, text(" ")) + elseif info.remotes[name] == nil then local branch_highlight = info.head == name and "NeogitBranchHead" or "NeogitBranch" table.insert(ref, text(name, { highlight = branch_highlight })) table.insert(ref, text(" ")) @@ -137,6 +173,8 @@ M.CommitEntry = Component.new(function(commit, args) table.insert(ref, text(name, { highlight = locally and branch_highlight or "NeogitRemote" })) table.insert(ref, text(" ")) end + + -- Render tags for _, tag in pairs(info.tags) do table.insert(ref, text(tag, { highlight = "NeogitTagName" })) table.insert(ref, text(" ")) @@ -154,10 +192,10 @@ M.CommitEntry = Component.new(function(commit, args) local details if args.details then - details = col.hidden(true).padding_left(8) { + details = col.padding_left(git.log.abbreviated_size() + 1) { row(util.merge(graph, { text(" "), - text("Author: ", { highlight = "Comment" }), + text("Author: ", { highlight = "NeogitSubtleText" }), text(commit.author_name, { highlight = "NeogitGraphAuthor" }), text(" <"), text(commit.author_email), @@ -165,12 +203,12 @@ M.CommitEntry = Component.new(function(commit, args) })), row(util.merge(graph, { text(" "), - text("AuthorDate: ", { highlight = "Comment" }), + text("AuthorDate: ", { highlight = "NeogitSubtleText" }), text(commit.author_date), })), row(util.merge(graph, { text(" "), - text("Commit: ", { highlight = "Comment" }), + text("Commit: ", { highlight = "NeogitSubtleText" }), text(commit.committer_name), text(" <"), text(commit.committer_email), @@ -178,7 +216,7 @@ M.CommitEntry = Component.new(function(commit, args) })), row(util.merge(graph, { text(" "), - text("CommitDate: ", { highlight = "Comment" }), + text("CommitDate: ", { highlight = "NeogitSubtleText" }), text(commit.committer_date), })), row(graph), @@ -208,15 +246,15 @@ M.CommitEntry = Component.new(function(commit, args) } end - return col({ + return col.tag("commit")({ row( util.merge({ - text(commit.oid:sub(1, 7), { + text(commit.abbreviated_commit, { highlight = commit.verification_flag and highlight_for_signature[commit.verification_flag] - or "Comment", + or "NeogitObjectId", }), text(" "), - }, graph, { text(" ") }, ref, { text(commit.subject) }), + }, graph, { text(" ") }, ref, ref_last, { text(commit.subject) }), { virtual_text = { { " ", "Constant" }, @@ -229,11 +267,11 @@ M.CommitEntry = Component.new(function(commit, args) } ), details, - }, { oid = commit.oid }) + }, { oid = commit.oid, foldable = args.details == true, folded = true, remote = info.remotes[1] }) end) M.CommitGraph = Component.new(function(commit, _) - return col.padding_left(8) { row(build_graph(commit.graph)) } + return col.tag("graph").padding_left(git.log.abbreviated_size() + 1) { row(build_graph(commit.graph)) } end) M.Grid = Component.new(function(props) @@ -273,14 +311,6 @@ M.Grid = Component.new(function(props) for i = 1, #props.items do local children = {} - -- TODO: seems to be a leftover from when the grid was column major - -- if i ~= 1 then - -- children = map(range(props.gap), function() - -- return text("") - -- end) - -- end - - -- current row local r = props.items[i] for j = 1, #r do diff --git a/lua/neogit/buffers/diff/init.lua b/lua/neogit/buffers/diff/init.lua new file mode 100644 index 000000000..dfbef1123 --- /dev/null +++ b/lua/neogit/buffers/diff/init.lua @@ -0,0 +1,121 @@ +local Buffer = require("neogit.lib.buffer") +local ui = require("neogit.buffers.diff.ui") +local git = require("neogit.lib.git") +local config = require("neogit.config") +local status_maps = require("neogit.config").get_reversed_status_maps() + +local api = vim.api + +---@class DiffBuffer +---@field buffer Buffer +---@field open fun(self, kind: string) +---@field close fun() +---@field stats table +---@field diffs table +---@field header string +---@see Buffer +---@see Ui +local M = {} +M.__index = M + +---@param header string +---@return DiffBuffer +function M:new(header) + local instance = { + buffer = nil, + header = header, + stats = git.diff.staged_stats(), + diffs = vim.tbl_map(function(item) + return item.diff + end, git.repo.state.staged.items), + } + + setmetatable(instance, self) + return instance +end + +--- Closes the DiffBuffer +function M:close() + if self.buffer then + self.buffer:close() + self.buffer = nil + end +end + +---Opens the DiffBuffer +---If already open will close the buffer +---@return DiffBuffer +function M:open() + if vim.tbl_isempty(self.stats.files) then + return self + end + + self.buffer = Buffer.create { + name = "NeogitDiffView", + filetype = "NeogitDiffView", + status_column = "", + kind = "split", + context_highlight = not config.values.disable_context_highlighting, + mappings = { + n = { + ["{"] = function() -- Goto Previous + local function previous_hunk_header(self, line) + local c = self.buffer.ui:get_component_on_line(line, function(c) + return c.options.tag == "Diff" or c.options.tag == "Hunk" + end) + + if c then + local first, _ = c:row_range_abs() + if vim.fn.line(".") == first then + first = previous_hunk_header(self, line - 1) + end + + return first + end + end + + local previous_header = previous_hunk_header(self, vim.fn.line(".")) + if previous_header then + api.nvim_win_set_cursor(0, { previous_header, 0 }) + vim.cmd("normal! zt") + end + end, + ["}"] = function() -- Goto next + local c = self.buffer.ui:get_component_under_cursor(function(c) + return c.options.tag == "Diff" or c.options.tag == "Hunk" + end) + + if c then + if c.options.tag == "Diff" then + self.buffer:move_cursor(vim.fn.line(".") + 1) + else + local _, last = c:row_range_abs() + if last == vim.fn.line("$") then + self.buffer:move_cursor(last) + else + self.buffer:move_cursor(last + 1) + end + end + vim.cmd("normal! zt") + end + end, + [status_maps["Toggle"]] = function() + pcall(vim.cmd, "normal! za") + end, + [status_maps["Close"]] = function() + self:close() + end, + }, + }, + render = function() + return ui.DiffView(self.header, self.stats, self.diffs) + end, + after = function() + vim.cmd("normal! zR") + end, + } + + return self +end + +return M diff --git a/lua/neogit/buffers/diff/ui.lua b/lua/neogit/buffers/diff/ui.lua new file mode 100644 index 000000000..c48bb7286 --- /dev/null +++ b/lua/neogit/buffers/diff/ui.lua @@ -0,0 +1,40 @@ +local M = {} + +local Ui = require("neogit.lib.ui") +local util = require("neogit.lib.util") +local common_ui = require("neogit.buffers.common") + +local Diff = common_ui.Diff +local EmptyLine = common_ui.EmptyLine +local text = Ui.text +local col = Ui.col +local row = Ui.row +local map = util.map + +function M.OverviewFile(file_padding) + return function(file) + return row.tag("OverviewFile") { + text.highlight("NeogitFilePath")(util.pad_right(file.path, file_padding)), + text(" | "), + text.highlight("Number")(util.pad_left(file.changes or "0", 5)), + text(" "), + text.highlight("NeogitDiffAdditions")(file.insertions), + text.highlight("NeogitDiffDeletions")(file.deletions), + } + end +end + +function M.DiffView(header, stats, diffs) + local file_padding = util.max_length(map(diffs, function(diff) + return diff.file + end)) + + return { + text.highlight("NeogitFloatHeaderHighlight")(header), + text(stats.summary), + col(map(stats.files, M.OverviewFile(file_padding)), { tag = "OverviewFileList" }), + EmptyLine(), + col(map(diffs, Diff), { tag = "DiffList" }), + } +end +return M diff --git a/lua/neogit/buffers/editor/init.lua b/lua/neogit/buffers/editor/init.lua index 54ea78984..1159b3922 100644 --- a/lua/neogit/buffers/editor/init.lua +++ b/lua/neogit/buffers/editor/init.lua @@ -3,6 +3,10 @@ local config = require("neogit.config") local input = require("neogit.lib.input") local util = require("neogit.lib.util") local git = require("neogit.lib.git") +local logger = require("neogit.logger") +local process = require("neogit.process") + +local DiffViewBuffer = require("neogit.buffers.diff") local pad = util.pad_right @@ -18,6 +22,7 @@ local filetypes = { ---@class EditorBuffer ---@field filename string filename of buffer ---@field on_unload function callback invoked when buffer is unloaded +---@field show_diff boolean show the diff view or not ---@field buffer Buffer ---@see Buffer @@ -25,8 +30,9 @@ local filetypes = { ---@param filename string the filename of buffer ---@param on_unload function the event dispatched on buffer unload ---@return EditorBuffer -function M.new(filename, on_unload) +function M.new(filename, on_unload, show_diff) local instance = { + show_diff = show_diff, filename = filename, on_unload = on_unload, buffer = nil, @@ -39,13 +45,15 @@ end function M:open(kind) assert(kind, "Editor must specify a kind") + logger.debug("[EDITOR] Opening editor as " .. kind) local mapping = config.get_reversed_commit_editor_maps() + local mapping_I = config.get_reversed_commit_editor_maps_I() local aborted = false local message_index = 1 local message_buffer = { { "" } } - local footer + local amend_header, footer, diff_view local function reflog_message(index) return git.log.reflog_message(index - 2) @@ -62,26 +70,44 @@ function M:open(kind) return message end + local filetype = filetypes[self.filename:match("[%u_]+$")] or "NeogitEditor" + logger.debug("[EDITOR] Filetype " .. filetype) + self.buffer = Buffer.create { name = self.filename, - filetype = filetypes[self.filename:match("[%u_]+$")] or "NeogitEditor", + filetype = filetype, load = true, buftype = "", kind = kind, modifiable = true, + status_column = "", readonly = false, - initialize = function(buffer) - vim.api.nvim_buf_attach(buffer.handle, false, { - on_detach = function() - pcall(vim.treesitter.stop, buffer.handle) + autocmds = { + ["QuitPre"] = function() -- For :wq compatibility + if diff_view then + diff_view:close() + diff_view = nil + end + end, + }, + on_detach = function(buffer) + logger.debug("[EDITOR] Cleaning Up") + pcall(vim.treesitter.stop, buffer.handle) - if self.on_unload then - self.on_unload(aborted and 1 or 0) - end + if self.on_unload then + logger.debug("[EDITOR] Running on_unload callback") + self.on_unload(aborted and 1 or 0) + end - require("neogit.process").defer_show_preview_buffers() - end, - }) + process.defer_show_preview_buffers() + + if diff_view then + logger.debug("[EDITOR] Closing diff view") + diff_view:close() + diff_view = nil + end + + logger.debug("[EDITOR] Done cleaning up") end, after = function(buffer) -- Populate help lines with mappings for buffer @@ -94,6 +120,8 @@ function M:open(kind) or git.config.get_global("core.commentChar"):read() or "#" + logger.debug("[EDITOR] Using comment character '" .. comment_char .. "'") + -- stylua: ignore local help_lines = { ("%s"):format(comment_char), @@ -117,6 +145,15 @@ function M:open(kind) buffer:write() buffer:move_cursor(1) + amend_header = buffer:get_lines(0, 2) + if amend_header[1]:match("^amend! %x+$") then + logger.debug("[EDITOR] Found 'amend!' header") + + buffer:set_lines(0, 2, false, {}) -- remove captured header from buffer + else + amend_header = nil + end + footer = buffer:get_lines(1, -1) -- Start insert mode if user has configured it @@ -134,19 +171,40 @@ function M:open(kind) -- Apply syntax highlighting local ok, _ = pcall(vim.treesitter.language.inspect, "gitcommit") if ok then + logger.debug("[EDITOR] Loading treesitter for gitcommit") vim.treesitter.start(buffer.handle, "gitcommit") else + logger.debug("[EDITOR] Loading syntax for gitcommit") vim.cmd.source("$VIMRUNTIME/syntax/gitcommit.vim") end + + if git.branch.current() then + vim.fn.matchadd("NeogitBranch", git.branch.current(), 100) + end + + if git.branch.upstream() then + vim.fn.matchadd("NeogitRemote", git.branch.upstream(), 100) + end + + if self.show_diff then + logger.debug("[EDITOR] Opening Diffview for staged changes") + diff_view = DiffViewBuffer:new("Staged Changes"):open() + end end, mappings = { i = { - [mapping["Submit"]] = function(buffer) + [mapping_I["Submit"]] = function(buffer) + logger.debug("[EDITOR] Action I: Submit") vim.cmd.stopinsert() + if amend_header then + buffer:set_lines(0, 0, false, amend_header) + end + buffer:write() buffer:close(true) end, - [mapping["Abort"]] = function(buffer) + [mapping_I["Abort"]] = function(buffer) + logger.debug("[EDITOR] Action I: Abort") vim.cmd.stopinsert() aborted = true buffer:write() @@ -155,6 +213,11 @@ function M:open(kind) }, n = { [mapping["Close"]] = function(buffer) + logger.debug("[EDITOR] Action N: Close") + if amend_header then + buffer:set_lines(0, 0, false, amend_header) + end + if buffer:get_option("modified") and not input.get_confirmation("Save changes?") then aborted = true end @@ -163,15 +226,22 @@ function M:open(kind) buffer:close(true) end, [mapping["Submit"]] = function(buffer) + logger.debug("[EDITOR] Action N: Submit") + if amend_header then + buffer:set_lines(0, 0, false, amend_header) + end + buffer:write() buffer:close(true) end, [mapping["Abort"]] = function(buffer) + logger.debug("[EDITOR] Action N: Abort") aborted = true buffer:write() buffer:close(true) end, [mapping["PrevMessage"]] = function(buffer) + logger.debug("[EDITOR] Action N: PrevMessage") local message = current_message(buffer) message_buffer[message_index] = message @@ -181,6 +251,7 @@ function M:open(kind) buffer:move_cursor(1) end, [mapping["NextMessage"]] = function(buffer) + logger.debug("[EDITOR] Action N: NextMessage") local message = current_message(buffer) if message_index > 1 then @@ -192,6 +263,7 @@ function M:open(kind) buffer:move_cursor(1) end, [mapping["ResetMessage"]] = function(buffer) + logger.debug("[EDITOR] Action N: ResetMessage") local message = current_message(buffer) buffer:set_lines(0, #message, false, reflog_message(message_index)) buffer:move_cursor(1) diff --git a/lua/neogit/buffers/fuzzy_finder.lua b/lua/neogit/buffers/fuzzy_finder.lua index e26a674d2..ae90a5df3 100644 --- a/lua/neogit/buffers/fuzzy_finder.lua +++ b/lua/neogit/buffers/fuzzy_finder.lua @@ -1,7 +1,7 @@ local Finder = require("neogit.lib.finder") local function buffer_height(count) - if count < (vim.fn.winheight(0) / 2) then + if count < (vim.o.lines / 2) then return count + 2 else return 0.5 @@ -30,10 +30,9 @@ function M.new(list) end function M:open(opts, action) - opts = opts or { - allow_multi = false, + opts = vim.tbl_deep_extend("keep", opts or {}, { layout_config = { height = buffer_height(#self.list) }, - } + }) Finder.create(opts):add_entries(self.list):find(action) end @@ -42,10 +41,9 @@ end ---@return any|nil --- Asynchronously prompt the user for the selection, and return the selected item or nil if aborted. function M:open_async(opts) - opts = opts or { - allow_multi = false, + opts = vim.tbl_deep_extend("keep", opts or {}, { layout_config = { height = buffer_height(#self.list) }, - } + }) return Finder.create(opts):add_entries(self.list):find_async() end diff --git a/lua/neogit/buffers/git_command_history.lua b/lua/neogit/buffers/git_command_history.lua index 83aa0ae1d..001e74b01 100644 --- a/lua/neogit/buffers/git_command_history.lua +++ b/lua/neogit/buffers/git_command_history.lua @@ -2,6 +2,7 @@ local Buffer = require("neogit.lib.buffer") local Git = require("neogit.lib.git") local Ui = require("neogit.lib.ui") local util = require("neogit.lib.util") +local status_maps = require("neogit.config").get_reversed_status_maps() local map = util.map local filter_map = util.filter_map @@ -19,7 +20,6 @@ function M:new(state) local this = { buffer = nil, state = state or Git.cli.history, - is_open = false, } setmetatable(this, { __index = M }) @@ -28,36 +28,63 @@ function M:new(state) end function M:close() - self.is_open = false - self.buffer:close() - self.buffer = nil + if self.buffer then + self.buffer:close() + self.buffer = nil + end + + M.instance = nil +end + +---@return boolean +function M.is_open() + return (M.instance and M.instance.buffer and M.instance.buffer:is_visible()) == true end function M:show() - if self.is_open then + if M.is_open() then + M.instance.buffer:focus() return end - self.is_open = true + + M.instance = self self.buffer = Buffer.create { + kind = "split", name = "NeogitGitCommandHistory", filetype = "NeogitGitCommandHistory", mappings = { n = { - ["q"] = function() + [status_maps["Close"]] = function() self:close() end, [""] = function() self:close() end, - [""] = function() - local stack = self.buffer.ui:get_component_stack_under_cursor() - local c = stack[#stack] + [""] = function() + vim.cmd("normal! zc") - if c then - c.children[2]:toggle_hidden() - self.buffer.ui:update() + vim.cmd("normal! k") + while vim.fn.foldlevel(".") == 0 do + vim.cmd("normal! k") end + + vim.cmd("normal! zo") + vim.cmd("normal! zz") + end, + [""] = function() + vim.cmd("normal! zc") + + vim.cmd("normal! j") + while vim.fn.foldlevel(".") == 0 do + vim.cmd("normal! j") + end + + vim.cmd("normal! zo") + vim.cmd("normal! zz") + end, + [""] = function() + pcall(vim.cmd, "normal! za") end, }, }, @@ -65,7 +92,7 @@ function M:show() local win_width = vim.fn.winwidth(0) return filter_map(self.state, function(item) - if item.hidden then + if item.hidden and not os.getenv("NEOGIT_DEBUG") then return end @@ -85,8 +112,11 @@ function M:show() local spacing = string.rep(" ", win_width - #code - #command - #time - #stdio - 6) - return col { + return col({ row { + text.highlight("NeogitGraphAuthor")( + os.getenv("NEOGIT_DEBUG") and (item.hidden and "H" or " ") or "" + ), text.highlight(highlight_code)(code), text(" "), text(command), @@ -96,10 +126,9 @@ function M:show() text.highlight("NeogitCommandTime")(stdio), }, col - .hidden(true) .padding_left(" | ") .highlight("NeogitCommandText")(map(util.merge(item.stdout, item.stderr), text)), - } + }, { foldable = true, folded = true }) end) end, } diff --git a/lua/neogit/buffers/log_view/init.lua b/lua/neogit/buffers/log_view/init.lua index 8c45370ab..9d5ac16dc 100644 --- a/lua/neogit/buffers/log_view/init.lua +++ b/lua/neogit/buffers/log_view/init.lua @@ -2,14 +2,18 @@ local Buffer = require("neogit.lib.buffer") local ui = require("neogit.buffers.log_view.ui") local config = require("neogit.config") local popups = require("neogit.popups") -local notification = require("neogit.lib.notification") local status_maps = require("neogit.config").get_reversed_status_maps() local CommitViewBuffer = require("neogit.buffers.commit_view") +local util = require("neogit.lib.util") +local a = require("plenary.async") ---@class LogViewBuffer ---@field commits CommitLogEntry[] ---@field internal_args table ---@field files string[] +---@field buffer Buffer +---@field fetch_func fun(offset: number): CommitLogEntry[] +---@field refresh_lock Semaphore local M = {} M.__index = M @@ -17,13 +21,16 @@ M.__index = M ---@param commits CommitLogEntry[]|nil ---@param internal_args table|nil ---@param files string[]|nil list of files to filter by +---@param fetch_func fun(offset: number): CommitLogEntry[] ---@return LogViewBuffer -function M.new(commits, internal_args, files) +function M.new(commits, internal_args, files, fetch_func) local instance = { files = files, commits = commits, internal_args = internal_args, + fetch_func = fetch_func, buffer = nil, + refresh_lock = a.control.Semaphore.new(1), } setmetatable(instance, M) @@ -31,18 +38,42 @@ function M.new(commits, internal_args, files) return instance end +function M:commit_count() + return #util.filter_map(self.commits, function(commit) + if commit.oid then + return 1 + end + end) +end + function M:close() - self.buffer:close() - self.buffer = nil + if self.buffer then + self.buffer:close() + self.buffer = nil + end + + M.instance = nil +end + +---@return boolean +function M.is_open() + return (M.instance and M.instance.buffer and M.instance.buffer:is_visible()) == true end function M:open() - local _, item = require("neogit.status").get_current_section_item() + if M.is_open() then + M.instance.buffer:focus() + return + end + + M.instance = self + self.buffer = Buffer.create { name = "NeogitLogView", filetype = "NeogitLogView", kind = config.values.log_view.kind, context_highlight = false, + status_column = "", mappings = { v = { [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) @@ -75,17 +106,21 @@ function M:open() p { commit = self.buffer.ui:get_commit_under_cursor() } end), [popups.mapping_for("PullPopup")] = popups.open("pull"), - ["d"] = function() - if not config.check_integration("diffview") then - notification.error("Diffview integration must be enabled for log diff") - return - end - - local dv = require("neogit.integrations.diffview") - dv.open("log", self.buffer.ui:get_commits_in_selection()) - end, + [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), + [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) + local items = self.buffer.ui:get_commits_in_selection() + p { + section = { name = "log" }, + item = { name = items }, + } + end), }, n = { + [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) + p { commits = { self.buffer.ui:get_commit_under_cursor() } } + end), [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) p { commits = { self.buffer.ui:get_commit_under_cursor() } } end), @@ -115,6 +150,13 @@ function M:open() [popups.mapping_for("TagPopup")] = popups.open("tag", function(p) p { commit = self.buffer.ui:get_commit_under_cursor() } end), + [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) + local item = self.buffer.ui:get_commit_under_cursor() + p { + section = { name = "log" }, + item = { name = item }, + } + end), [popups.mapping_for("PullPopup")] = popups.open("pull"), [status_maps["YankSelected"]] = function() local yank = self.buffer.ui:get_commit_under_cursor() @@ -126,101 +168,99 @@ function M:open() vim.cmd("echo ''") end end, - ["q"] = function() - self:close() - end, - [""] = function() - self:close() - end, - [""] = function() - CommitViewBuffer.new(self.buffer.ui:get_commit_under_cursor(), self.files):open() - end, - [";"] = function() - if self.buffer and self.buffer.ui then - local commit_id = self.buffer.ui:get_commit_under_cursor() - CommitViewBuffer.open_or_run_in_window(commit_id, self.files, function(window_id) - local key = vim.api.nvim_replace_termcodes("", true, false, true) - vim.fn.win_execute(window_id, "normal! " .. key) - end) + [""] = require("neogit.lib.ui.helpers").close_topmost(self), + [status_maps["Close"]] = require("neogit.lib.ui.helpers").close_topmost(self), + [status_maps["GoToFile"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.new(commit, self.files):open() end end, - [","] = function() - if self.buffer and self.buffer.ui then - local commit_id = self.buffer.ui:get_commit_under_cursor() - CommitViewBuffer.open_or_run_in_window(commit_id, self.files, function(window_id) - local key = vim.api.nvim_replace_termcodes("", true, false, true) - vim.fn.win_execute(window_id, "normal! " .. key) - end) + [status_maps["OpenOrScrollDown"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.open_or_scroll_down(commit, self.files) end end, - [""] = function(buffer) - local stack = self.buffer.ui:get_component_stack_under_cursor() - local c = stack[#stack] - c.children[2].options.hidden = true - - local t_idx = math.max(c.index - 1, 1) - local target = c.parent.children[t_idx] - while not target.children[2] do - t_idx = t_idx - 1 - target = c.parent.children[t_idx] + [status_maps["OpenOrScrollUp"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.open_or_scroll_up(commit, self.files) end + end, + [""] = function() + pcall(vim.cmd, "normal! zc") - target.children[2].options.hidden = false + vim.cmd("normal! k") + for _ = vim.fn.line("."), 0, -1 do + if vim.fn.foldlevel(".") > 0 then + break + end - buffer.ui:update() - self.buffer:move_cursor(target.position.row_start) - end, - [""] = function(buffer) - local stack = self.buffer.ui:get_component_stack_under_cursor() - local c = stack[#stack] - c.children[2].options.hidden = true - - local t_idx = math.min(c.index + 1, #c.parent.children) - local target = c.parent.children[t_idx] - while not target.children[2] do - t_idx = t_idx + 1 - target = c.parent.children[t_idx] + vim.cmd("normal! k") end - target.children[2].options.hidden = false + pcall(vim.cmd, "normal! zo") + vim.cmd("normal! zz") + end, + [""] = function() + pcall(vim.cmd, "normal! zc") + + vim.cmd("normal! j") + for _ = vim.fn.line("."), vim.fn.line("$"), 1 do + if vim.fn.foldlevel(".") > 0 then + break + end - buffer.ui:update() - buffer:move_cursor(target.position.row_start) + vim.cmd("normal! j") + end + + pcall(vim.cmd, "normal! zo") vim.cmd("normal! zz") end, + ["+"] = a.void(function() + local permit = self.refresh_lock:acquire() + + self.commits = util.merge(self.commits, self.fetch_func(self:commit_count())) + self.buffer.ui:render(unpack(ui.View(self.commits, self.internal_args))) + + permit:forget() + end), [""] = function() - local stack = self.buffer.ui:get_component_stack_under_cursor() - local c = stack[#stack] + pcall(vim.cmd, "normal! za") + end, + ["j"] = function() + if vim.v.count > 0 then + vim.cmd("norm! " .. vim.v.count .. "j") + else + vim.cmd("norm! j") + end + + while self.buffer:get_current_line()[1]:sub(1, 1) == " " do + if vim.fn.line(".") == vim.fn.line("$") then + break + end - if c.children[2] then - c.children[2]:toggle_hidden() - self.buffer.ui:update() + vim.cmd("norm! j") end end, - ["d"] = function() - if not config.check_integration("diffview") then - notification.error("Diffview integration must be enabled for log diff") - return + ["k"] = function() + if vim.v.count > 0 then + vim.cmd("norm! " .. vim.v.count .. "k") + else + vim.cmd("norm! k") end - local dv = require("neogit.integrations.diffview") - dv.open("log", self.buffer.ui:get_commit_under_cursor()) + while self.buffer:get_current_line()[1]:sub(1, 1) == " " do + if vim.fn.line(".") == 1 then + break + end + + vim.cmd("norm! k") + end end, }, }, - after = function(buffer, win) - if win and item and item.commit then - local found = buffer.ui:find_component(function(c) - return c.options.oid == item.commit.oid - end) - - if found then - vim.api.nvim_win_set_cursor(win, { found.position.row_start, 0 }) - end - end - - vim.cmd([[setlocal nowrap]]) - end, render = function() return ui.View(self.commits, self.internal_args) end, diff --git a/lua/neogit/buffers/log_view/ui.lua b/lua/neogit/buffers/log_view/ui.lua index 6907ad702..9b1426534 100644 --- a/lua/neogit/buffers/log_view/ui.lua +++ b/lua/neogit/buffers/log_view/ui.lua @@ -3,6 +3,11 @@ local util = require("neogit.lib.util") local Commit = require("neogit.buffers.common").CommitEntry local Graph = require("neogit.buffers.common").CommitGraph +local Ui = require("neogit.lib.ui") +local text = Ui.text +local col = Ui.col +local row = Ui.row + local M = {} ---@param commits CommitLogEntry[] @@ -11,13 +16,26 @@ local M = {} function M.View(commits, args) args.details = true - return util.filter_map(commits, function(commit) + local graph = util.filter_map(commits, function(commit) if commit.oid then return Commit(commit, args) elseif args.graph then return Graph(commit) end end) + + table.insert( + graph, + col { + row { + text.highlight("NeogitGraphBoldBlue")("Type"), + text.highlight("NeogitGraphBoldCyan")(" + "), + text.highlight("NeogitGraphBoldBlue")("to show more history"), + }, + } + ) + + return graph end return M diff --git a/lua/neogit/buffers/process/init.lua b/lua/neogit/buffers/process/init.lua new file mode 100644 index 000000000..c41e2ee4e --- /dev/null +++ b/lua/neogit/buffers/process/init.lua @@ -0,0 +1,107 @@ +local Buffer = require("neogit.lib.buffer") +local config = require("neogit.config") +local status_maps = require("neogit.config").get_reversed_status_maps() + +---@class ProcessBuffer +---@field buffer Buffer +---@field open fun(self) +---@field hide fun(self) +---@field close fun(self) +---@field focus fun(self) +---@field show fun(self) +---@field is_visible fun(self): boolean +---@field append fun(self, data: string) +---@field new fun(self): ProcessBuffer +---@see Buffer +---@see Ui +local M = {} +M.__index = M + +---@return ProcessBuffer +---@param process Process +function M:new(process) + local instance = { + content = string.format("> %s\r\n", table.concat(process.cmd, " ")), + process = process, + buffer = nil, + } + + setmetatable(instance, self) + return instance +end + +function M:hide() + if self.buffer then + self.buffer:hide() + end +end + +function M:close() + if self.buffer then + self.buffer:close() + self.buffer = nil + end +end + +function M:focus() + assert(self.buffer, "Create a buffer first") + self.buffer:focus() +end + +function M:show() + if not self.buffer then + self:open() + end + + self.buffer:chan_send(self.content) + self.buffer:show() + self.buffer:call(vim.cmd.normal, "G") +end + +function M:is_visible() + return self.buffer and self.buffer:is_visible() +end + +function M:append(data) + vim.schedule(function() + self.content = table.concat({ self.content, data }, "\r\n") + end) +end + +---@return ProcessBuffer +function M:open() + if self.buffer then + return self + end + + self.buffer = Buffer.create { + name = "NeogitConsole", + filetype = "NeogitConsole", + bufhidden = "hide", + open = false, + buftype = false, + kind = config.values.preview_buffer.kind, + on_detach = function() + self.buffer = nil + end, + autocmds = { + ["WinLeave"] = function() + pcall(self.close, self) + end, + }, + mappings = { + n = { + [status_maps["Close"]] = function() + self:hide() + end, + [""] = function() + self:hide() + end, + }, + }, + } + + return self +end + +return M diff --git a/lua/neogit/buffers/rebase_editor/init.lua b/lua/neogit/buffers/rebase_editor/init.lua index f42de49ae..2df65b635 100644 --- a/lua/neogit/buffers/rebase_editor/init.lua +++ b/lua/neogit/buffers/rebase_editor/init.lua @@ -66,6 +66,7 @@ function M:open(kind) or "#" local mapping = config.get_reversed_rebase_editor_maps() + local mapping_I = config.get_reversed_rebase_editor_maps_I() local aborted = false self.buffer = Buffer.create { @@ -73,23 +74,20 @@ function M:open(kind) load = true, filetype = "NeogitRebaseTodo", buftype = "", + status_column = "", kind = kind, modifiable = true, disable_line_numbers = config.values.disable_line_numbers, disable_relative_line_numbers = config.values.disable_relative_line_numbers, readonly = false, - initialize = function(buffer) - vim.api.nvim_buf_attach(buffer.handle, false, { - on_detach = function() - pcall(vim.treesitter.stop, buffer.handle) + on_detach = function(buffer) + pcall(vim.treesitter.stop, buffer.handle) - if self.on_unload then - self.on_unload(aborted and 1 or 0) - end + if self.on_unload then + self.on_unload(aborted and 1 or 0) + end - require("neogit.process").defer_show_preview_buffers() - end, - }) + require("neogit.process").defer_show_preview_buffers() end, after = function(buffer) local padding = util.max_length(util.flatten(vim.tbl_values(mapping))) @@ -146,12 +144,12 @@ function M:open(kind) end, mappings = { i = { - [mapping["Submit"]] = function(buffer) + [mapping_I["Submit"]] = function(buffer) vim.cmd.stopinsert() buffer:write() buffer:close(true) end, - [mapping["Abort"]] = function(buffer) + [mapping_I["Abort"]] = function(buffer) vim.cmd.stopinsert() aborted = true buffer:write() @@ -208,11 +206,26 @@ function M:open(kind) vim.cmd("move +1") end, [mapping["OpenCommit"]] = function() - local oid = vim.api.nvim_get_current_line():match("(%x%x%x%x%x%x%x)") + local oid = + vim.api.nvim_get_current_line():match("(" .. string.rep("%x", git.log.abbreviated_size()) .. ")") if oid then CommitViewBuffer.new(oid):open("tab") end end, + [mapping["OpenOrScrollDown"]] = function() + local oid = + vim.api.nvim_get_current_line():match("(" .. string.rep("%x", git.log.abbreviated_size()) .. ")") + if oid then + CommitViewBuffer.open_or_scroll_down(oid) + end + end, + [mapping["OpenOrScrollUp"]] = function() + local oid = + vim.api.nvim_get_current_line():match("(" .. string.rep("%x", git.log.abbreviated_size()) .. ")") + if oid then + CommitViewBuffer.open_or_scroll_up(oid) + end + end, }, }, } diff --git a/lua/neogit/buffers/reflog_view/init.lua b/lua/neogit/buffers/reflog_view/init.lua index 92eae898b..85958b82f 100644 --- a/lua/neogit/buffers/reflog_view/init.lua +++ b/lua/neogit/buffers/reflog_view/init.lua @@ -2,7 +2,6 @@ local Buffer = require("neogit.lib.buffer") local ui = require("neogit.buffers.reflog_view.ui") local config = require("neogit.config") local popups = require("neogit.popups") -local notification = require("neogit.lib.notification") local status_maps = require("neogit.config").get_reversed_status_maps() local CommitViewBuffer = require("neogit.buffers.commit_view") @@ -25,15 +24,32 @@ function M.new(entries) end function M:close() - self.buffer:close() - self.buffer = nil + if self.buffer then + self.buffer:close() + self.buffer = nil + end + + M.instance = nil +end + +---@return boolean +function M.is_open() + return (M.instance and M.instance.buffer and M.instance.buffer:is_visible()) == true end function M:open(_) + if M.is_open() then + M.instance.buffer:focus() + return + end + + M.instance = self + self.buffer = Buffer.create { name = "NeogitReflogView", filetype = "NeogitReflogView", kind = config.values.reflog_view.kind, + status_column = "", context_highlight = true, mappings = { v = { @@ -66,8 +82,21 @@ function M:open(_) p { commit = self.buffer.ui:get_commit_under_cursor() } end), [popups.mapping_for("PullPopup")] = popups.open("pull"), + [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) + local items = self.buffer.ui:get_commits_in_selection() + p { + section = { name = "log" }, + item = { name = items }, + } + end), + [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), }, n = { + [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) + p { commits = { self.buffer.ui:get_commit_under_cursor() } } + end), [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) p { commits = { self.buffer.ui:get_commit_under_cursor() } } end), @@ -98,6 +127,13 @@ function M:open(_) p { commit = self.buffer.ui:get_commit_under_cursor() } end), [popups.mapping_for("PullPopup")] = popups.open("pull"), + [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) + local item = self.buffer.ui:get_commit_under_cursor() + p { + section = { name = "log" }, + item = { name = item }, + } + end), [status_maps["YankSelected"]] = function() local yank = self.buffer.ui:get_commit_under_cursor() if yank then @@ -108,30 +144,28 @@ function M:open(_) vim.cmd("echo ''") end end, - ["q"] = function() - self:close() - end, - [""] = function() - self:close() + [""] = require("neogit.lib.ui.helpers").close_topmost(self), + [status_maps["Close"]] = require("neogit.lib.ui.helpers").close_topmost(self), + [status_maps["GoToFile"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.new(commit):open() + end end, - [""] = function() - local stack = self.buffer.ui:get_component_stack_under_cursor() - CommitViewBuffer.new(stack[#stack].options.oid):open() + [status_maps["OpenOrScrollDown"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.open_or_scroll_down(commit) + end end, - [popups.mapping_for("DiffPopup")] = function() - if not config.check_integration("diffview") then - notification.error("Diffview integration must be enabled for reflog diff") - return + [status_maps["OpenOrScrollUp"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.open_or_scroll_up(commit) end - - local dv = require("neogit.integrations.diffview") - dv.open("log", self.buffer.ui:get_commit_under_cursor()) end, }, }, - after = function() - vim.cmd([[setlocal nowrap]]) - end, render = function() return ui.View(self.entries) end, diff --git a/lua/neogit/buffers/reflog_view/ui.lua b/lua/neogit/buffers/reflog_view/ui.lua index ab342d375..cefd97a3e 100644 --- a/lua/neogit/buffers/reflog_view/ui.lua +++ b/lua/neogit/buffers/reflog_view/ui.lua @@ -29,7 +29,7 @@ M.Entry = Component.new(function(entry, total) return col({ row({ - text(entry.oid:sub(1, 7), { highlight = "Comment" }), + text(entry.oid:sub(1, 7), { highlight = "NeogitObjectId" }), text(" "), text(tostring(entry.index), { align_right = #tostring(total) + 1 }), text(entry.type, { highlight = highlight_for_type(entry.type), align_right = 16 }), diff --git a/lua/neogit/buffers/refs_view/init.lua b/lua/neogit/buffers/refs_view/init.lua new file mode 100644 index 000000000..3a0617085 --- /dev/null +++ b/lua/neogit/buffers/refs_view/init.lua @@ -0,0 +1,235 @@ +local Buffer = require("neogit.lib.buffer") +local config = require("neogit.config") +local ui = require("neogit.buffers.refs_view.ui") +local popups = require("neogit.popups") +local status_maps = require("neogit.config").get_reversed_status_maps() +local CommitViewBuffer = require("neogit.buffers.commit_view") + +--- @class RefsViewBuffer +--- @field buffer Buffer +--- @field open fun() +--- @field close fun() +--- @see RefsInfo +--- @see Buffer +--- @see Ui +local M = { + instance = nil, +} + +--- Creates a new RefsViewBuffer +--- @return RefsViewBuffer +function M.new(refs) + local instance = { + refs = refs, + head = "HEAD", + buffer = nil, + } + + setmetatable(instance, { __index = M }) + return instance +end + +function M:close() + if self.buffer then + self.buffer:close() + self.buffer = nil + end + + M.instance = nil +end + +---@return boolean +function M.is_open() + return (M.instance and M.instance.buffer and M.instance.buffer:is_visible()) == true +end + +--- Opens the RefsViewBuffer +function M:open() + if M.is_open() then + M.instance.buffer:focus() + return + end + + M.instance = self + + self.buffer = Buffer.create { + name = "NeogitRefsView", + filetype = "NeogitRefsView", + kind = config.values.refs_view.kind, + context_highlight = false, + mappings = { + v = { + [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), + [popups.mapping_for("BranchPopup")] = popups.open("branch", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), + [popups.mapping_for("CommitPopup")] = popups.open("commit", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("PushPopup")] = popups.open("push", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("RebasePopup")] = popups.open("rebase", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("RevertPopup")] = popups.open("revert", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), + [popups.mapping_for("ResetPopup")] = popups.open("reset", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("RemotePopup")] = popups.open("remote", function(p) + p() + -- p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("TagPopup")] = popups.open("tag", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("PullPopup")] = popups.open("pull"), + [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), + [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) + local items = self.buffer.ui:get_commits_in_selection() + p { + section = { name = "log" }, + item = { name = items }, + } + end), + }, + n = { + [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), + [popups.mapping_for("BranchPopup")] = popups.open("branch", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), + [popups.mapping_for("CommitPopup")] = popups.open("commit", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("PushPopup")] = popups.open("push", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("RebasePopup")] = popups.open("rebase", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("RevertPopup")] = popups.open("revert", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end), + [popups.mapping_for("ResetPopup")] = popups.open("reset", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("RemotePopup")] = popups.open("remote", function(p) + p() + -- p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) + p { commits = { self.buffer.ui:get_commit_under_cursor() } } + end), + [popups.mapping_for("TagPopup")] = popups.open("tag", function(p) + p { commit = self.buffer.ui:get_commits_in_selection()[1] } + end), + [popups.mapping_for("PullPopup")] = popups.open("pull"), + [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) + local item = self.buffer.ui:get_commit_under_cursor() + p { + section = { name = "log" }, + item = { name = item }, + } + end), + ["j"] = function() + if vim.v.count > 0 then + vim.cmd("norm! " .. vim.v.count .. "j") + else + vim.cmd("norm! j") + end + + if self.buffer:get_current_line()[1] == " " then + vim.cmd("norm! j") + end + end, + ["k"] = function() + if vim.v.count > 0 then + vim.cmd("norm! " .. vim.v.count .. "k") + else + vim.cmd("norm! k") + end + + if self.buffer:get_current_line()[1] == " " then + vim.cmd("norm! k") + end + end, + [""] = require("neogit.lib.ui.helpers").close_topmost(self), + [status_maps["Close"]] = require("neogit.lib.ui.helpers").close_topmost(self), + [status_maps["GoToFile"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.new(commit):open() + end + end, + [status_maps["OpenOrScrollDown"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.open_or_scroll_down(commit, self.files) + end + end, + [status_maps["OpenOrScrollUp"]] = function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + CommitViewBuffer.open_or_scroll_up(commit, self.files) + end + end, + -- ["{"] = function() + -- pcall(vim.cmd, "normal! zc") + -- + -- vim.cmd("normal! k") + -- for _ = vim.fn.line("."), 0, -1 do + -- if vim.fn.foldlevel(".") > 0 then + -- break + -- end + -- + -- vim.cmd("normal! k") + -- end + -- + -- pcall(vim.cmd, "normal! zo") + -- vim.cmd("normal! zz") + -- end, + -- ["}"] = function() + -- pcall(vim.cmd, "normal! zc") + -- + -- vim.cmd("normal! j") + -- for _ = vim.fn.line("."), vim.fn.line("$"), 1 do + -- if vim.fn.foldlevel(".") > 0 then + -- break + -- end + -- + -- vim.cmd("normal! j") + -- end + -- + -- pcall(vim.cmd, "normal! zo") + -- vim.cmd("normal! zz") + -- end, + [""] = function() + local fold = self.buffer.ui:get_fold_under_cursor() + if fold then + if fold.options.on_open then + fold.options.on_open(fold, self.buffer.ui) + else + local ok, _ = pcall(vim.cmd, "normal! za") + if ok then + fold.options.folded = not fold.options.folded + end + end + end + end, + }, + }, + render = function() + return ui.RefsView(self.refs, self.head) + end, + } +end + +return M diff --git a/lua/neogit/buffers/refs_view/ui.lua b/lua/neogit/buffers/refs_view/ui.lua new file mode 100644 index 000000000..902f45dfc --- /dev/null +++ b/lua/neogit/buffers/refs_view/ui.lua @@ -0,0 +1,144 @@ +local M = {} + +local a = require("plenary.async") +local Ui = require("neogit.lib.ui") +local util = require("neogit.lib.util") +local git = require("neogit.lib.git") + +local text = Ui.text +local col = Ui.col +local row = Ui.row + +local highlights = { + local_branch = "NeogitBranch", + remote_branch = "NeogitRemote", + tag = "NeogitTagName", + ["+"] = "NeogitGraphCyan", + ["-"] = "NeogitGraphPurple", + ["<>"] = "NeogitGraphYellow", + ["="] = "NeogitGraphGreen", + ["<"] = "NeogitGraphPurple", + [">"] = "NeogitGraphCyan", + [""] = "NeogitGraphRed", +} + +local function Cherries(ref, head) + local cherries = util.map(git.cherry.list(head, ref.oid), function(cherry) + return row({ + text.highlight(highlights[cherry.status])(cherry.status), + text(" "), + text.highlight("NeogitObjectId")(cherry.oid:sub(1, git.log.abbreviated_size())), + text(" "), + text.highlight("NeogitGraphWhite")(cherry.subject), + }, { oid = cherry.oid }) + end) + + if cherries[1] then + table.insert(cherries, row { text("") }) + end + + return col.padding_left(2)(cherries) +end + +local function Ref(ref) + return row { + text.highlight("NeogitGraphBoldPurple")(ref.head and "@ " or " "), + text.highlight(highlights[ref.type])(util.str_truncate(ref.name, 34), { align_right = 35 }), + text.highlight(highlights[ref.upstream_status])(ref.upstream_name), + text(ref.upstream_name ~= "" and " " or ""), + text(ref.subject), + } +end + +local function section(refs, heading, head) + local rows = {} + for _, ref in ipairs(refs) do + table.insert( + rows, + col.tag("Ref")({ Ref(ref) }, { + oid = ref.oid, + foldable = true, + ---@param this Component + ---@param ui Ui + on_open = a.void(function(this, ui) + vim.cmd( + string.format("echomsg 'Getting cherries for %s'", ref.oid:sub(1, git.log.abbreviated_size())) + ) + + local cherries = Cherries(ref, head) + if cherries.children[1] then + this.options.on_open = nil -- Don't call this again + this.options.foldable = true + this.options.folded = false + + vim.cmd("norm! zE") -- Eliminate all existing folds + this:append(cherries) + ui:update() + + vim.cmd( + string.format( + "redraw | echomsg 'Got %d cherries for %s'", + #cherries.children - 1, + ref.oid:sub(1, git.log.abbreviated_size()) + ) + ) + else + vim.cmd( + string.format( + "redraw | echomsg 'No cherries found for %s'", + ref.oid:sub(1, git.log.abbreviated_size()) + ) + ) + end + end), + }) + ) + end + + table.insert(rows, row { text("") }) + + return col.tag("Section")({ + row.tag("SectionHeading")( + util.merge(heading, { text.highlight("NeogitGraphWhite")(string.format(" (%d)", #refs)) }) + ), + col.tag("SectionBody")(rows), + }, { foldable = true, folded = false }) +end + +function M.Branches(branches, head) + return { section(branches, { text.highlight("NeogitBranch")("Branches") }, head) } +end + +function M.Remotes(remotes, head) + local out = {} + local max_len = util.max_length(vim.tbl_keys(remotes)) + + for name, branches in pairs(remotes) do + table.insert( + out, + section(branches, { + text.highlight("NeogitBranch")("Remote "), + text.highlight("NeogitRemote")(name, { align_right = max_len }), + text.highlight("NeogitBranch")( + string.format(" (%s)", git.config.get(string.format("remote.%s.url", name)):read()) + ), + }, head) + ) + end + + return out +end + +function M.Tags(tags, head) + return { section(tags, { text.highlight("NeogitBranch")("Tags") }, head) } +end + +function M.RefsView(refs, head) + return util.merge( + M.Branches(refs.local_branch, head), + M.Remotes(refs.remote_branch, head), + M.Tags(refs.tag, head) + ) +end + +return M diff --git a/lua/neogit/buffers/status/actions.lua b/lua/neogit/buffers/status/actions.lua new file mode 100644 index 000000000..7d2f2e61b --- /dev/null +++ b/lua/neogit/buffers/status/actions.lua @@ -0,0 +1,1296 @@ +-- NOTE: `v_` prefix stands for visual mode actions, `n_` for normal mode. +-- +local a = require("plenary.async") +local git = require("neogit.lib.git") +local popups = require("neogit.popups") +local Buffer = require("neogit.lib.buffer") +local logger = require("neogit.logger") +local input = require("neogit.lib.input") +local notification = require("neogit.lib.notification") +local util = require("neogit.lib.util") + +local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") + +local fn = vim.fn +local api = vim.api + +local function cleanup_items(...) + if vim.in_fast_event() then + a.util.scheduler() + end + + for _, item in ipairs { ... } do + local bufnr = fn.bufexists(item.name) + if bufnr and bufnr > 0 and api.nvim_buf_is_valid(bufnr) then + api.nvim_buf_delete(bufnr, { force = true }) + end + + fn.delete(item.escaped_path) + end +end + +---@param self StatusBuffer +---@param item StatusItem +---@return table|nil +local function translate_cursor_location(self, item) + if rawget(item, "diff") then + local line = self.buffer:cursor_line() + + for _, hunk in ipairs(item.diff.hunks) do + if line >= hunk.first and line <= hunk.last then + local offset = line - hunk.first + local row = hunk.disk_from + offset - 1 + + for i = 1, offset do + -- If the line is a deletion, we need to adjust the row + if string.sub(hunk.lines[i], 1, 1) == "-" then + row = row - 1 + end + end + + return { row, 0 } + end + end + end +end + +local function open(type, path, cursor) + vim.cmd(("silent! %s %s | %s | norm! zz"):format(type, fn.fnameescape(path), cursor and cursor[1] or "1")) +end + +local M = {} + +---@param self StatusBuffer +M.v_discard = function(self) + return a.void(function() + local selection = self.buffer.ui:get_selection() + + local discard_message = "Discard selection?" + local hunk_count = 0 + local file_count = 0 + + local patches = {} + local untracked_files = {} + local unstaged_files = {} + local new_files = {} + local staged_files_modified = {} + local stashes = {} + + for _, section in ipairs(selection.sections) do + if section.name == "untracked" or section.name == "unstaged" or section.name == "staged" then + file_count = file_count + #section.items + + for _, item in ipairs(section.items) do + local hunks = self.buffer.ui:item_hunks(item, selection.first_line, selection.last_line, true) + + if #hunks > 0 then + logger.debug(("Discarding %d hunks from %q"):format(#hunks, item.name)) + + hunk_count = hunk_count + #hunks + if hunk_count > 1 then + discard_message = ("Discard %s hunks?"):format(hunk_count) + end + + for _, hunk in ipairs(hunks) do + table.insert(patches, function() + local patch = git.index.generate_patch(item, hunk, hunk.from, hunk.to, true) + + logger.debug(("Discarding Patch: %s"):format(patch)) + + git.index.apply(patch, { + index = section.name == "staged", + reverse = true, + }) + end) + end + else + discard_message = ("Discard %s files?"):format(file_count) + logger.debug(("Discarding in section %s %s"):format(section.name, item.name)) + + if section.name == "untracked" then + table.insert(untracked_files, item.escaped_path) + elseif section.name == "unstaged" then + if item.mode == "A" then + table.insert(new_files, item.escaped_path) + else + table.insert(unstaged_files, item.escaped_path) + end + elseif section.name == "staged" then + if item.mode == "N" then + table.insert(new_files, item.escaped_path) + else + table.insert(staged_files_modified, item.escaped_path) + end + end + end + end + elseif section.name == "stashes" then + discard_message = ("Discard %s stashes?"):format(#selection.items) + + for _, stash in ipairs(selection.items) do + table.insert(stashes, stash.name:match("(stash@{%d+})")) + end + end + end + + if input.get_permission(discard_message) then + if #patches > 0 then + for _, patch in ipairs(patches) do + patch() + end + end + + if #untracked_files > 0 then + cleanup_items(unpack(untracked_files)) + end + + if #unstaged_files > 0 then + git.index.checkout(unstaged_files) + end + + if #new_files > 0 then + git.index.reset(new_files) + cleanup_items(unpack(new_files)) + end + + if #staged_files_modified > 0 then + git.index.reset(staged_files_modified) + git.index.checkout(staged_files_modified) + end + + if #stashes > 0 then + for _, stash in ipairs(stashes) do + git.stash.drop(stash) + end + end + + self:refresh() + end + end) +end + +---@param self StatusBuffer +M.v_stage = function(self) + return a.void(function() + local selection = self.buffer.ui:get_selection() + + local untracked_files = {} + local unstaged_files = {} + local patches = {} + + for _, section in ipairs(selection.sections) do + if section.name == "unstaged" or section.name == "untracked" then + for _, item in ipairs(section.items) do + if item.mode == "UU" then + notification.info("Conflicts must be resolved before staging lines") + return + end + + local hunks = self.buffer.ui:item_hunks(item, selection.first_line, selection.last_line, true) + + if #hunks > 0 then + for _, hunk in ipairs(hunks) do + table.insert(patches, git.index.generate_patch(item, hunk, hunk.from, hunk.to)) + end + else + if section.name == "unstaged" then + table.insert(unstaged_files, item.escaped_path) + else + table.insert(untracked_files, item.escaped_path) + end + end + end + end + end + + if #untracked_files > 0 then + git.index.add(untracked_files) + end + + if #unstaged_files > 0 then + git.status.stage(unstaged_files) + end + + if #patches > 0 then + for _, patch in ipairs(patches) do + git.index.apply(patch, { cached = true }) + end + end + + if #untracked_files > 0 or #unstaged_files > 0 or #patches > 0 then + self:refresh() + end + end) +end + +---@param self StatusBuffer +M.v_unstage = function(self) + return a.void(function() + local selection = self.buffer.ui:get_selection() + + local files = {} + local patches = {} + + for _, section in ipairs(selection.sections) do + if section.name == "staged" then + for _, item in ipairs(section.items) do + local hunks = self.buffer.ui:item_hunks(item, selection.first_line, selection.last_line, true) + + if #hunks > 0 then + for _, hunk in ipairs(hunks) do + table.insert(patches, git.index.generate_patch(item, hunk, hunk.from, hunk.to)) + end + else + table.insert(files, item.escaped_path) + end + end + end + end + + if #files > 0 then + git.status.unstage(files) + end + + if #patches > 0 then + for _, patch in ipairs(patches) do + git.index.apply(patch, { cached = true, reverse = true }) + end + end + + if #files > 0 or #patches > 0 then + self:refresh { update_diffs = { "staged:*" } } + end + end) +end + +---@param self StatusBuffer +M.v_branch_popup = function(self) + return popups.open("branch", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end) +end + +---@param self StatusBuffer +M.v_cherry_pick_popup = function(self) + return popups.open("cherry_pick", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end) +end + +---@param self StatusBuffer +M.v_commit_popup = function(self) + return popups.open("commit", function(p) + local commits = self.buffer.ui:get_commits_in_selection() + if #commits == 1 then + p { commit = commits[1] } + end + end) +end + +---@param self StatusBuffer +M.v_merge_popup = function(self) + return popups.open("merge", function(p) + local commits = self.buffer.ui:get_commits_in_selection() + if #commits == 1 then + p { commit = commits[1] } + end + end) +end + +---@param self StatusBuffer +M.v_push_popup = function(self) + return popups.open("push", function(p) + local commits = self.buffer.ui:get_commits_in_selection() + if #commits == 1 then + p { commit = commits[1] } + end + end) +end + +---@param self StatusBuffer +M.v_rebase_popup = function(self) + return popups.open("rebase", function(p) + local commits = self.buffer.ui:get_commits_in_selection() + if #commits == 1 then + p { commit = commits[1] } + end + end) +end + +---@param self StatusBuffer +M.v_revert_popup = function(self) + return popups.open("revert", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end) +end + +---@param self StatusBuffer +M.v_reset_popup = function(self) + return popups.open("reset", function(p) + local commits = self.buffer.ui:get_commits_in_selection() + if #commits == 1 then + p { commit = commits[1] } + end + end) +end + +---@param self StatusBuffer +M.v_tag_popup = function(self) + return popups.open("tag", function(p) + local commits = self.buffer.ui:get_commits_in_selection() + if #commits == 1 then + p { commit = commits[1] } + end + end) +end + +---@param self StatusBuffer +M.v_stash_popup = function(self) + return popups.open("stash", function(p) + local stash = self.buffer.ui:get_yankable_under_cursor() + p { name = stash and stash:match("^stash@{%d+}") } + end) +end + +---@param self StatusBuffer +M.v_diff_popup = function(self) + return popups.open("diff", function(p) + local section = self.buffer.ui:get_selection().section + local item = self.buffer.ui:get_yankable_under_cursor() + p { section = { name = section and section.name }, item = { name = item } } + end) +end + +---@param self StatusBuffer +M.v_ignore_popup = function(self) + return popups.open("ignore", function(p) + p { paths = self.buffer.ui:get_filepaths_in_selection(), git_root = git.repo.git_root } + end) +end + +---@param self StatusBuffer +M.v_bisect_popup = function(self) + return popups.open("bisect", function(p) + p { commits = self.buffer.ui:get_commits_in_selection() } + end) +end + +---@param self StatusBuffer +M.v_remote_popup = function(self) + return popups.open("remote") +end + +---@param self StatusBuffer +M.v_fetch_popup = function(self) + return popups.open("fetch") +end + +---@param self StatusBuffer +M.v_pull_popup = function(self) + return popups.open("pull") +end + +---@param self StatusBuffer +M.v_help_popup = function(self) + return popups.open("help") +end + +---@param self StatusBuffer +M.v_log_popup = function(self) + return popups.open("log") +end + +---@param self StatusBuffer +M.v_worktree_popup = function(self) + return popups.open("worktree") +end + +---@param self StatusBuffer +M.n_down = function(self) + return function() + if vim.v.count > 0 then + vim.cmd("norm! " .. vim.v.count .. "j") + else + vim.cmd("norm! j") + end + + if self.buffer:get_current_line()[1] == "" then + vim.cmd("norm! j") + end + end +end + +---@param self StatusBuffer +M.n_up = function(self) + return function() + if vim.v.count > 0 then + vim.cmd("norm! " .. vim.v.count .. "k") + else + vim.cmd("norm! k") + end + + if self.buffer:get_current_line()[1] == "" then + vim.cmd("norm! k") + end + end +end + +---@param self StatusBuffer +M.n_toggle = function(self) + return function() + local fold = self.buffer.ui:get_fold_under_cursor() + if fold then + if fold.options.on_open then + fold.options.on_open(fold, self.buffer.ui) + else + local start, _ = fold:row_range_abs() + local ok, _ = pcall(vim.cmd, "normal! za") + if ok then + self.buffer:move_cursor(start) + fold.options.folded = not fold.options.folded + end + end + end + end +end + +---@param self StatusBuffer +M.n_close = function(self) + return require("neogit.lib.ui.helpers").close_topmost(self) +end + +---@param self StatusBuffer +M.n_open_or_scroll_down = function(self) + return function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + require("neogit.buffers.commit_view").open_or_scroll_down(commit) + end + end +end + +---@param self StatusBuffer +M.n_open_or_scroll_up = function(self) + return function() + local commit = self.buffer.ui:get_commit_under_cursor() + if commit then + require("neogit.buffers.commit_view").open_or_scroll_up(commit) + end + end +end + +---@param self StatusBuffer +M.n_refresh_buffer = function(self) + return a.void(function() + self:refresh() + end) +end + +---@param self StatusBuffer +M.n_depth1 = function(self) + return function() + local section = self.buffer.ui:get_current_section() + if section then + local start, last = section:row_range_abs() + if self.buffer:cursor_line() < start or self.buffer:cursor_line() >= last then + return + end + + self.buffer:move_cursor(start) + section:close_all_folds(self.buffer.ui) + + self.buffer.ui:update() + end + end +end + +---@param self StatusBuffer +M.n_depth2 = function(self) + return function() + local section = self.buffer.ui:get_current_section() + local row = self.buffer.ui:get_component_under_cursor() + + if section then + local start, last = section:row_range_abs() + if self.buffer:cursor_line() < start or self.buffer:cursor_line() >= last then + return + end + + self.buffer:move_cursor(start) + + section:close_all_folds(self.buffer.ui) + section:open_all_folds(self.buffer.ui, 1) + + self.buffer.ui:update() + + if row then + local start, _ = row:row_range_abs() + self.buffer:move_cursor(start) + end + end + end +end + +---@param self StatusBuffer +M.n_depth3 = function(self) + return function() + local section = self.buffer.ui:get_current_section() + local context = self.buffer.ui:get_cursor_context() + + if section then + local start, last = section:row_range_abs() + if self.buffer:cursor_line() < start or self.buffer:cursor_line() >= last then + return + end + + self.buffer:move_cursor(start) + + section:close_all_folds(self.buffer.ui) + section:open_all_folds(self.buffer.ui, 2) + section:close_all_folds(self.buffer.ui) + section:open_all_folds(self.buffer.ui, 2) + + self.buffer.ui:update() + + if context then + local start, _ = context:row_range_abs() + self.buffer:move_cursor(start) + end + end + end +end + +---@param self StatusBuffer +M.n_depth4 = function(self) + return function() + local section = self.buffer.ui:get_current_section() + local context = self.buffer.ui:get_cursor_context() + + if section then + local start, last = section:row_range_abs() + if self.buffer:cursor_line() < start or self.buffer:cursor_line() >= last then + return + end + + self.buffer:move_cursor(start) + section:close_all_folds(self.buffer.ui) + section:open_all_folds(self.buffer.ui, 3) + + self.buffer.ui:update() + + if context then + local start, _ = context:row_range_abs() + self.buffer:move_cursor(start) + end + end + end +end + +---@param self StatusBuffer +M.n_command_history = function(self) + return a.void(function() + require("neogit.buffers.git_command_history"):new():show() + end) +end + +---@param self StatusBuffer +M.n_show_refs = function(self) + return a.void(function() + require("neogit.buffers.refs_view").new(git.refs.list_parsed()):open() + end) +end + +---@param self StatusBuffer +M.n_yank_selected = function(self) + return function() + local yank = self.buffer.ui:get_yankable_under_cursor() + if yank then + if yank:match("^stash@{%d+}") then + yank = git.rev_parse.oid(yank:match("^(stash@{%d+})")) + end + + yank = string.format("'%s'", yank) + vim.cmd.let("@+=" .. yank) + vim.cmd.echo(yank) + else + vim.cmd("echo ''") + end + end +end + +---@param self StatusBuffer +M.n_discard = function(self) + return a.void(function() + git.index.update() + + local selection = self.buffer.ui:get_selection() + if not selection.section then + return + end + + local section = selection.section.name + local action, message, choices + local refresh = {} + + if selection.item and selection.item.first == fn.line(".") then -- Discard File + if section == "untracked" then + message = ("Discard %q?"):format(selection.item.name) + action = function() + cleanup_items(selection.item) + end + refresh = { update_diffs = { "untracked:" .. selection.item.name } } + elseif section == "unstaged" then + if selection.item.mode:match("^[UAD][UAD]") then + choices = { "&ours", "&theirs", "&conflict", "&abort" } + action = function() + local choice = + input.get_choice("Discard conflict by taking...", { values = choices, default = #choices }) + + if choice == "o" then + git.cli.checkout.ours.files(selection.item.absolute_path).call_sync() + git.status.stage { selection.item.name } + elseif choice == "t" then + git.cli.checkout.theirs.files(selection.item.absolute_path).call_sync() + git.status.stage { selection.item.name } + elseif choice == "c" then + git.cli.checkout.merge.files(selection.item.absolute_path).call_sync() + git.status.stage { selection.item.name } + end + end + refresh = { update_diffs = { "unstaged:" .. selection.item.name } } + else + message = ("Discard %q?"):format(selection.item.name) + action = function() + if selection.item.mode == "A" then + git.index.reset { selection.item.escaped_path } + cleanup_items(selection.item) + else + git.index.checkout { selection.item.name } + end + end + end + refresh = { update_diffs = { "unstaged:" .. selection.item.name } } + elseif section == "staged" then + if selection.item.mode:match("^[UAD][UAD]") then + choices = { "&ours", "&theirs", "&conflict", "&abort" } + action = function() + local choice = + input.get_choice("Discard conflict by taking...", { values = choices, default = #choices }) + + if choice == "o" then + git.cli.checkout.ours.files(selection.item.absolute_path).call_sync() + git.status.stage { selection.item.name } + elseif choice == "t" then + git.cli.checkout.theirs.files(selection.item.absolute_path).call_sync() + git.status.stage { selection.item.name } + elseif choice == "c" then + git.cli.checkout.merge.files(selection.item.absolute_path).call_sync() + git.status.stage { selection.item.name } + end + end + refresh = { update_diffs = { "unstaged:" .. selection.item.name } } + else + message = ("Discard %q?"):format(selection.item.name) + action = function() + if selection.item.mode == "N" then + git.index.reset { selection.item.escaped_path } + cleanup_items(selection.item) + elseif selection.item.mode == "M" then + git.index.reset { selection.item.escaped_path } + git.index.checkout { selection.item.escaped_path } + elseif selection.item.mode == "R" then + git.index.reset_HEAD(selection.item.name, selection.item.original_name) + git.index.checkout { selection.item.original_name } + cleanup_items(selection.item) + elseif selection.item.mode == "D" then + git.index.reset_HEAD(selection.item.escaped_path) + git.index.checkout { selection.item.escaped_path } + else + error( + ("Unhandled file mode %q for %q"):format(selection.item.mode, selection.item.escaped_path) + ) + end + end + refresh = { update_diffs = { "staged:" .. selection.item.name } } + end + elseif section == "stashes" then + message = ("Discard %q?"):format(selection.item.name) + action = function() + git.stash.drop(selection.item.name:match("(stash@{%d+})")) + end + refresh = {} + end + elseif selection.item then -- Discard Hunk + if selection.item.mode == "UU" then + -- TODO: https://github.com/emacs-mirror/emacs/blob/master/lisp/vc/smerge-mode.el + notification.warn("Resolve conflicts in file before discarding hunks.") + return + end + + local hunk = + self.buffer.ui:item_hunks(selection.item, selection.first_line, selection.last_line, false)[1] + + local patch = git.index.generate_patch(selection.item, hunk, hunk.from, hunk.to, true) + + if section == "untracked" then + message = "Discard hunk?" + action = function() + local hunks = + self.buffer.ui:item_hunks(selection.item, selection.first_line, selection.last_line, false) + + local patch = git.index.generate_patch(selection.item, hunks[1], hunks[1].from, hunks[1].to, true) + + git.index.apply(patch, { reverse = true }) + git.index.apply(patch, { reverse = true }) + end + refresh = { update_diffs = { "untracked:" .. selection.item.name } } + elseif section == "unstaged" then + message = "Discard hunk?" + action = function() + git.index.apply(patch, { reverse = true }) + end + refresh = { update_diffs = { "unstaged:" .. selection.item.name } } + elseif section == "staged" then + message = "Discard hunk?" + action = function() + git.index.apply(patch, { index = true, reverse = true }) + end + refresh = { update_diffs = { "staged:" .. selection.item.name } } + end + else -- Discard Section + if section == "untracked" then + message = ("Discard %s files?"):format(#selection.section.items) + action = function() + cleanup_items(unpack(selection.section.items)) + end + refresh = { update_diffs = { "untracked:*" } } + elseif section == "unstaged" then + local conflict = false + for _, item in ipairs(selection.section.items) do + if item.mode == "UU" then + conflict = true + break + end + end + + if conflict then + -- TODO: https://github.com/magit/magit/blob/28bcd29db547ab73002fb81b05579e4a2e90f048/lisp/magit-apply.el#Lair + notification.warn("Resolve conflicts before discarding section.") + return + else + message = ("Discard %s files?"):format(#selection.section.items) + action = function() + git.index.checkout_unstaged() + end + refresh = { update_diffs = { "unstaged:*" } } + end + elseif section == "staged" then + message = ("Discard %s files?"):format(#selection.section.items) + action = function() + local new_files = {} + local staged_files_modified = {} + local staged_files_renamed = {} + local staged_files_deleted = {} + + for _, item in ipairs(selection.section.items) do + if item.mode == "N" or item.mode == "A" then + table.insert(new_files, item.escaped_path) + elseif item.mode == "M" then + table.insert(staged_files_modified, item.escaped_path) + elseif item.mode == "R" then + table.insert(staged_files_renamed, item) + elseif item.mode == "D" then + table.insert(staged_files_deleted, item.escaped_path) + else + error(("Unknown file mode %q for %q"):format(item.mode, item.escaped_path)) + end + end + + if #new_files > 0 then + -- ensure the file is deleted + git.index.reset(new_files) + cleanup_items(unpack(new_files)) + end + + if #staged_files_modified > 0 then + git.index.reset(staged_files_modified) + git.index.checkout(staged_files_modified) + end + + if #staged_files_renamed > 0 then + for _, item in ipairs(staged_files_renamed) do + git.index.reset_HEAD(item.name, item.original_name) + git.index.checkout { item.original_name } + fn.delete(item.escaped_path) + end + end + + if #staged_files_deleted > 0 then + git.index.reset_HEAD(unpack(staged_files_deleted)) + git.index.checkout(staged_files_deleted) + end + end + refresh = { update_diffs = { "staged:*" } } + elseif section == "stashes" then + message = ("Discard %s stashes?"):format(#selection.section.items) + action = function() + for _, stash in ipairs(selection.section.items) do + git.stash.drop(stash.name:match("(stash@{%d+})")) + end + end + end + end + + if action and (choices or input.get_permission(message)) then + action() + self:refresh(refresh) + end + end) +end + +---@param self StatusBuffer +M.n_go_to_next_hunk_header = function(self) + return function() + local c = self.buffer.ui:get_component_under_cursor(function(c) + return c.options.tag == "Diff" or c.options.tag == "Hunk" or c.options.tag == "Item" + end) + local section = self.buffer.ui:get_current_section() + + if c and section then + local _, section_last = section:row_range_abs() + local next_location + + if c.options.tag == "Diff" then + next_location = fn.line(".") + 1 + elseif c.options.tag == "Item" then + vim.cmd("normal! zo") + next_location = fn.line(".") + 1 + elseif c.options.tag == "Hunk" then + local _, last = c:row_range_abs() + next_location = last + 1 + end + + if next_location < section_last then + self.buffer:move_cursor(next_location) + end + + vim.cmd("normal! zt") + end + end +end + +---@param self StatusBuffer +M.n_go_to_previous_hunk_header = function(self) + return function() + local function previous_hunk_header(self, line) + local c = self.buffer.ui:get_component_on_line(line, function(c) + return c.options.tag == "Diff" or c.options.tag == "Hunk" or c.options.tag == "Item" + end) + + if c then + local first, _ = c:row_range_abs() + if fn.line(".") == first then + first = previous_hunk_header(self, line - 1) + end + + return first + end + end + + local previous_header = previous_hunk_header(self, fn.line(".")) + if previous_header then + self.buffer:move_cursor(previous_header) + vim.cmd("normal! zt") + end + end +end + +---@param self StatusBuffer +M.n_init_repo = function(self) + return function() + git.init.init_repo() + end +end + +---@param self StatusBuffer +M.n_untrack = function(self) + return a.void(function() + local selection = self.buffer.ui:get_selection() + local paths = git.files.all_tree() + + if + selection.item + and selection.item.escaped_path + and git.files.is_tracked(selection.item.escaped_path) + then + paths = util.deduplicate(util.merge({ selection.item.escaped_path }, paths)) + end + + local selected = FuzzyFinderBuffer.new(paths) + :open_async { prompt_prefix = "Untrack file(s)", allow_multi = true } + if selected and #selected > 0 and git.files.untrack(selected) then + local message + if #selected > 1 then + message = ("%s files untracked"):format(#selected) + else + message = ("%q untracked"):format(selected[1]) + end + + notification.info(message) + self:refresh() + end + end) +end + +---@param self StatusBuffer +M.v_untrack = function(self) + return a.void(function() + local selection = self.buffer.ui:get_selection() + local selected_paths = util.filter_map(selection.items or {}, function(item) + if git.files.is_tracked(item.escaped_path) then + return item.escaped_path + end + end) + + local paths = util.deduplicate(util.merge(selected_paths, git.files.all_tree())) + local selected = FuzzyFinderBuffer.new(paths) + :open_async { prompt_prefix = "Untrack file(s)", allow_multi = true } + if selected and #selected > 0 and git.files.untrack(selected) then + local message + if #selected > 1 then + message = ("Untracked %s files"):format(#selected) + else + message = ("%q untracked"):format(selected[1]) + end + + notification.info(message) + self:refresh() + end + end) +end + +---@param self StatusBuffer +M.n_stage = function(self) + return a.void(function() + local stagable = self.buffer.ui:get_hunk_or_filename_under_cursor() + local section = self.buffer.ui:get_current_section() + + if stagable and section then + if section.options.section == "staged" then + return + end + + if stagable.hunk then + local item = self.buffer.ui:get_item_under_cursor() + assert(item, "Item cannot be nil") + + if item.mode == "UU" then + notification.info("Conflicts must be resolved before staging hunks") + return + end + + local patch = git.index.generate_patch(item, stagable.hunk, stagable.hunk.from, stagable.hunk.to) + + git.index.apply(patch, { cached = true }) + self:refresh { update_diffs = { "*:" .. item.escaped_path } } + elseif stagable.filename then + if section.options.section == "unstaged" then + git.status.stage { stagable.filename } + self:refresh { update_diffs = { "unstaged:" .. stagable.filename } } + elseif section.options.section == "untracked" then + git.index.add { stagable.filename } + self:refresh { update_diffs = { "untracked:" .. stagable.filename } } + end + end + elseif section then + if section.options.section == "untracked" then + git.status.stage_untracked() + self:refresh { update_diffs = { "untracked:*" } } + elseif section.options.section == "unstaged" then + git.status.stage_modified() + self:refresh { update_diffs = { "unstaged:*" } } + end + end + end) +end + +---@param self StatusBuffer +M.n_stage_all = function(self) + return a.void(function() + git.status.stage_all() + self:refresh() + end) +end + +---@param self StatusBuffer +M.n_stage_unstaged = function(self) + return a.void(function() + git.status.stage_modified() + self:refresh { update_diffs = { "unstaged:*" } } + end) +end + +---@param self StatusBuffer +M.n_unstage = function(self) + return a.void(function() + local unstagable = self.buffer.ui:get_hunk_or_filename_under_cursor() + + local section = self.buffer.ui:get_current_section() + if section and section.options.section ~= "staged" then + return + end + + if unstagable then + if unstagable.hunk then + local item = self.buffer.ui:get_item_under_cursor() + assert(item, "Item cannot be nil") + local patch = + git.index.generate_patch(item, unstagable.hunk, unstagable.hunk.from, unstagable.hunk.to, true) + + git.index.apply(patch, { cached = true, reverse = true }) + self:refresh { update_diffs = { "*:" .. item.escaped_path } } + elseif unstagable.filename then + git.status.unstage { unstagable.filename } + self:refresh { update_diffs = { "*:" .. unstagable.filename } } + end + elseif section then + git.status.unstage_all() + self:refresh { update_diffs = { "staged:*" } } + end + end) +end + +---@param self StatusBuffer +M.n_unstage_staged = function(self) + return a.void(function() + git.status.unstage_all() + self:refresh { update_diffs = { "staged:*" } } + end) +end + +---@param self StatusBuffer +M.n_goto_file = function(self) + return function() + local item = self.buffer.ui:get_item_under_cursor() + + -- Goto FILE + if item and item.absolute_path then + local cursor = translate_cursor_location(self, item) + self:close() + open("edit", item.absolute_path, cursor) + return + end + + -- Goto COMMIT + local ref = self.buffer.ui:get_yankable_under_cursor() + if ref then + require("neogit.buffers.commit_view").new(ref):open() + end + end +end + +---@param self StatusBuffer +M.n_tab_open = function(self) + return function() + local item = self.buffer.ui:get_item_under_cursor() + + if item and item.absolute_path then + open("tabedit", item.absolute_path, translate_cursor_location(self, item)) + end + end +end + +---@param self StatusBuffer +M.n_split_open = function(self) + return function() + local item = self.buffer.ui:get_item_under_cursor() + + if item and item.absolute_path then + open("split", item.absolute_path, translate_cursor_location(self, item)) + end + end +end + +---@param self StatusBuffer +M.n_vertical_split_open = function(self) + return function() + local item = self.buffer.ui:get_item_under_cursor() + + if item and item.absolute_path then + open("vsplit", item.absolute_path, translate_cursor_location(self, item)) + end + end +end + +---@param self StatusBuffer +M.n_branch_popup = function(self) + return popups.open("branch", function(p) + p { commits = { self.buffer.ui:get_commit_under_cursor() } } + end) +end + +---@param self StatusBuffer +M.n_bisect_popup = function(self) + return popups.open("bisect", function(p) + p { commits = { self.buffer.ui:get_commit_under_cursor() } } + end) +end + +---@param self StatusBuffer +M.n_cherry_pick_popup = function(self) + return popups.open("cherry_pick", function(p) + p { commits = { self.buffer.ui:get_commit_under_cursor() } } + end) +end + +---@param self StatusBuffer +M.n_commit_popup = function(self) + return popups.open("commit", function(p) + p { commit = self.buffer.ui:get_commit_under_cursor() } + end) +end + +---@param self StatusBuffer +M.n_merge_popup = function(self) + return popups.open("merge", function(p) + p { commit = self.buffer.ui:get_commit_under_cursor() } + end) +end + +---@param self StatusBuffer +M.n_push_popup = function(self) + return popups.open("push", function(p) + p { commit = self.buffer.ui:get_commit_under_cursor() } + end) +end + +---@param self StatusBuffer +M.n_rebase_popup = function(self) + return popups.open("rebase", function(p) + p { commit = self.buffer.ui:get_commit_under_cursor() } + end) +end + +---@param self StatusBuffer +M.n_revert_popup = function(self) + return popups.open("revert", function(p) + p { commits = { self.buffer.ui:get_commit_under_cursor() } } + end) +end + +---@param self StatusBuffer +M.n_reset_popup = function(self) + return popups.open("reset", function(p) + p { commit = self.buffer.ui:get_commit_under_cursor() } + end) +end + +---@param self StatusBuffer +M.n_tag_popup = function(self) + return popups.open("tag", function(p) + p { commit = self.buffer.ui:get_commit_under_cursor() } + end) +end + +---@param self StatusBuffer +M.n_stash_popup = function(self) + return popups.open("stash", function(p) + local stash = self.buffer.ui:get_yankable_under_cursor() + p { name = stash and stash:match("^stash@{%d+}") } + end) +end + +---@param self StatusBuffer +M.n_diff_popup = function(self) + return popups.open("diff", function(p) + local section = self.buffer.ui:get_selection().section + local item = self.buffer.ui:get_yankable_under_cursor() + p { + section = { name = section and section.name }, + item = { name = item }, + } + end) +end + +---@param self StatusBuffer +M.n_ignore_popup = function(self) + return popups.open("ignore", function(p) + local path = self.buffer.ui:get_hunk_or_filename_under_cursor() + p { + paths = { path and path.escaped_path }, + git_root = git.repo.git_root, + } + end) +end + +---@param self StatusBuffer +M.n_help_popup = function(self) + return popups.open("help", function(p) + -- Since any other popup can be launched from help, build an ENV for any of them. + local path = self.buffer.ui:get_hunk_or_filename_under_cursor() + local section = self.buffer.ui:get_selection().section + if section then + section = section.name + end + + local item = self.buffer.ui:get_yankable_under_cursor() + local stash = self.buffer.ui:get_yankable_under_cursor() + local commit = self.buffer.ui:get_commit_under_cursor() + local commits = { commit } + + -- TODO: Pass selection here so we can stage/unstage etc stuff + p { + branch = { commits = commits }, + cherry_pick = { commits = commits }, + commit = { commit = commit }, + merge = { commit = commit }, + push = { commit = commit }, + rebase = { commit = commit }, + revert = { commits = commits }, + bisect = { commits = commits }, + reset = { commit = commit }, + tag = { commit = commit }, + stash = { name = stash and stash:match("^stash@{%d+}") }, + diff = { + section = { name = section }, + item = { name = item }, + }, + ignore = { + paths = { path and path.escaped_path }, + git_root = git.repo.git_root, + }, + remote = {}, + fetch = {}, + pull = {}, + log = {}, + worktree = {}, + } + end) +end + +---@param self StatusBuffer +M.n_remote_popup = function(self) + return popups.open("remote") +end + +---@param self StatusBuffer +M.n_fetch_popup = function(self) + return popups.open("fetch") +end + +---@param self StatusBuffer +M.n_pull_popup = function(self) + return popups.open("pull") +end + +---@param self StatusBuffer +M.n_log_popup = function(self) + return popups.open("log") +end + +---@param self StatusBuffer +M.n_worktree_popup = function(self) + return popups.open("worktree") +end + +return M diff --git a/lua/neogit/buffers/status/init.lua b/lua/neogit/buffers/status/init.lua index 08bc4d8b5..52677a265 100644 --- a/lua/neogit/buffers/status/init.lua +++ b/lua/neogit/buffers/status/init.lua @@ -1,41 +1,314 @@ +local config = require("neogit.config") local Buffer = require("neogit.lib.buffer") local ui = require("neogit.buffers.status.ui") +local popups = require("neogit.popups") +local git = require("neogit.lib.git") +local Watcher = require("neogit.watcher") +local a = require("plenary.async") +local logger = require("neogit.logger") -- TODO: Add logging +local util = require("neogit.lib.util") +local api = vim.api + +---@class Semaphore +---@field permits number +---@field acquire function + +---@class StatusBuffer +---@field buffer Buffer instance +---@field state NeogitRepo +---@field config NeogitConfig +---@field root string +---@field refresh_lock Semaphore local M = {} +M.__index = M --- @class StatusBuffer --- @field is_open whether the buffer is currently visible --- @field buffer buffer instance --- @field state StatusState --- @see Buffer --- @see StatusState +local instances = {} + +function M.register(instance, dir) + instances[dir] = instance +end -function M.new(state) - local x = { - is_open = false, +---@param dir? string +---@return StatusBuffer +function M.instance(dir) + return instances[dir or vim.uv.cwd()] +end + +---@param state NeogitRepo +---@param config NeogitConfig +---@param root string +---@return StatusBuffer +function M.new(state, config, root) + local instance = { state = state, + config = config, + root = root, buffer = nil, + watcher = nil, + refresh_lock = a.control.Semaphore.new(1), } - setmetatable(x, { __index = M }) - return x + + setmetatable(instance, M) + + return instance +end + +---@return boolean +function M.is_open() + return (M.instance() and M.instance().buffer and M.instance().buffer:is_visible()) == true +end + +function M:_action(name) + local action = require("neogit.buffers.status.actions")[name] + assert(action, ("Status Buffer action %q is undefined"):format(name)) + + return action(self) end -function M:open(kind) - kind = kind or "tab" +---@param kind string<"floating" | "split" | "tab" | "split" | "vsplit">|nil +---@param cwd string +---@return StatusBuffer +function M:open(kind, cwd) + if M.is_open() then + logger.debug("[STATUS] An Instance is already open - focusing it") + M.instance():focus() + return M.instance() + end + + M.register(self, cwd) + + local mappings = config.get_reversed_status_maps() self.buffer = Buffer.create { - name = "NeogitStatusNew", - filetype = "NeogitStatusNew", - kind = kind, + name = "NeogitStatus", + filetype = "NeogitStatus", + cwd = cwd, + context_highlight = not config.values.disable_context_highlighting, + kind = kind or config.values.kind, + disable_line_numbers = config.values.disable_line_numbers, + foldmarkers = not config.values.disable_signs, + on_detach = function() + if self.watcher then + self.watcher:stop() + end + + vim.o.autochdir = self.prev_autochdir + end, + --stylua: ignore start + mappings = { + v = { + [mappings["Discard"]] = self:_action("v_discard"), + [mappings["Stage"]] = self:_action("v_stage"), + [mappings["Unstage"]] = self:_action("v_unstage"), + [mappings["Untrack"]] = self:_action("v_untrack"), + [popups.mapping_for("BisectPopup")] = self:_action("v_bisect_popup"), + [popups.mapping_for("BranchPopup")] = self:_action("v_branch_popup"), + [popups.mapping_for("CherryPickPopup")] = self:_action("v_cherry_pick_popup"), + [popups.mapping_for("CommitPopup")] = self:_action("v_commit_popup"), + [popups.mapping_for("DiffPopup")] = self:_action("v_diff_popup"), + [popups.mapping_for("FetchPopup")] = self:_action("v_fetch_popup"), + [popups.mapping_for("HelpPopup")] = self:_action("v_help_popup"), + [popups.mapping_for("IgnorePopup")] = self:_action("v_ignore_popup"), + [popups.mapping_for("LogPopup")] = self:_action("v_log_popup"), + [popups.mapping_for("MergePopup")] = self:_action("v_merge_popup"), + [popups.mapping_for("PullPopup")] = self:_action("v_pull_popup"), + [popups.mapping_for("PushPopup")] = self:_action("v_push_popup"), + [popups.mapping_for("RebasePopup")] = self:_action("v_rebase_popup"), + [popups.mapping_for("RemotePopup")] = self:_action("v_remote_popup"), + [popups.mapping_for("ResetPopup")] = self:_action("v_reset_popup"), + [popups.mapping_for("RevertPopup")] = self:_action("v_revert_popup"), + [popups.mapping_for("StashPopup")] = self:_action("v_stash_popup"), + [popups.mapping_for("TagPopup")] = self:_action("v_tag_popup"), + [popups.mapping_for("WorktreePopup")] = self:_action("v_worktree_popup"), + }, + n = { + ["j"] = self:_action("n_down"), + ["k"] = self:_action("n_up"), + [mappings["Untrack"]] = self:_action("n_untrack"), + [mappings["Toggle"]] = self:_action("n_toggle"), + [mappings["Close"]] = self:_action("n_close"), + [mappings["OpenOrScrollDown"]] = self:_action("n_open_or_scroll_down"), + [mappings["OpenOrScrollUp"]] = self:_action("n_open_or_scroll_up"), + [mappings["RefreshBuffer"]] = self:_action("n_refresh_buffer"), + [mappings["Depth1"]] = self:_action("n_depth1"), + [mappings["Depth2"]] = self:_action("n_depth2"), + [mappings["Depth3"]] = self:_action("n_depth3"), + [mappings["Depth4"]] = self:_action("n_depth4"), + [mappings["CommandHistory"]] = self:_action("n_command_history"), + [mappings["ShowRefs"]] = self:_action("n_show_refs"), + [mappings["YankSelected"]] = self:_action("n_yank_selected"), + [mappings["Discard"]] = self:_action("n_discard"), + [mappings["GoToNextHunkHeader"]] = self:_action("n_go_to_next_hunk_header"), + [mappings["GoToPreviousHunkHeader"]] = self:_action("n_go_to_previous_hunk_header"), + [mappings["InitRepo"]] = self:_action("n_init_repo"), + [mappings["Stage"]] = self:_action("n_stage"), + [mappings["StageAll"]] = self:_action("n_stage_all"), + [mappings["StageUnstaged"]] = self:_action("n_stage_unstaged"), + [mappings["Unstage"]] = self:_action("n_unstage"), + [mappings["UnstageStaged"]] = self:_action("n_unstage_staged"), + [mappings["GoToFile"]] = self:_action("n_goto_file"), + [mappings["TabOpen"]] = self:_action("n_tab_open"), + [mappings["SplitOpen"]] = self:_action("n_split_open"), + [mappings["VSplitOpen"]] = self:_action("n_vertical_split_open"), + [popups.mapping_for("BisectPopup")] = self:_action("n_bisect_popup"), + [popups.mapping_for("BranchPopup")] = self:_action("n_branch_popup"), + [popups.mapping_for("CherryPickPopup")] = self:_action("n_cherry_pick_popup"), + [popups.mapping_for("CommitPopup")] = self:_action("n_commit_popup"), + [popups.mapping_for("DiffPopup")] = self:_action("n_diff_popup"), + [popups.mapping_for("FetchPopup")] = self:_action("n_fetch_popup"), + [popups.mapping_for("HelpPopup")] = self:_action("n_help_popup"), + [popups.mapping_for("IgnorePopup")] = self:_action("n_ignore_popup"), + [popups.mapping_for("LogPopup")] = self:_action("n_log_popup"), + [popups.mapping_for("MergePopup")] = self:_action("n_merge_popup"), + [popups.mapping_for("PullPopup")] = self:_action("n_pull_popup"), + [popups.mapping_for("PushPopup")] = self:_action("n_push_popup"), + [popups.mapping_for("RebasePopup")] = self:_action("n_rebase_popup"), + [popups.mapping_for("RemotePopup")] = self:_action("n_remote_popup"), + [popups.mapping_for("ResetPopup")] = self:_action("n_reset_popup"), + [popups.mapping_for("RevertPopup")] = self:_action("n_revert_popup"), + [popups.mapping_for("StashPopup")] = self:_action("n_stash_popup"), + [popups.mapping_for("TagPopup")] = self:_action("n_tag_popup"), + [popups.mapping_for("WorktreePopup")] = self:_action("n_worktree_popup"), + }, + }, + --stylua: ignore end initialize = function() self.prev_autochdir = vim.o.autochdir - vim.o.autochdir = false end, render = function() - return ui.Status(self.state) + if self.state.initialized then + return ui.Status(self.state, self.config) + else + return {} + end + end, + ---@param buffer Buffer + ---@param _win any + after = function(buffer, _win) + if config.values.filewatcher.enabled then + logger.debug("[STATUS] Starting file watcher") + self.watcher = Watcher.new(self, self.root):start() + end + + buffer:move_cursor(buffer.ui:first_section().first) end, } + + return self +end + +function M:close() + if self.buffer then + logger.debug("[STATUS] Closing Buffer") + self.buffer:close() + self.buffer = nil + end + + if self.watcher then + logger.debug("[STATUS] Stopping Watcher") + self.watcher:stop() + end + + if self.prev_autochdir then + vim.o.autochdir = self.prev_autochdir + end +end + +function M:chdir(dir) + local destination = require("plenary.path").new(dir) + vim.wait(5000, function() + return destination:exists() + end) + + logger.debug("[STATUS] Changing Dir: " .. dir) + vim.api.nvim_set_current_dir(dir) + self:dispatch_reset() +end + +function M:focus() + if self.buffer then + logger.debug("[STATUS] Focusing Buffer") + self.buffer:focus() + end +end + +function M:refresh(partial, reason) + logger.debug("[STATUS] Beginning refresh from " .. (reason or "unknown")) + local permit = self:_get_refresh_lock(reason) + + git.repo:refresh { + source = "status", + partial = partial, + callback = function() + logger.debug("[STATUS][Refresh Callback] Running") + if not self.buffer then + logger.debug("[STATUS][Refresh Callback] Buffer no longer exists - bail") + return + end + + local cursor, view + if self.buffer:is_focused() then + cursor = self.buffer.ui:get_cursor_location() + view = self.buffer:save_view() + end + + logger.debug("[STATUS][Refresh Callback] Rendering UI") + self.buffer.ui:render(unpack(ui.Status(self.state, self.config))) + + if cursor and view then + self.buffer:restore_view(view, self.buffer.ui:resolve_cursor_location(cursor)) + end + + api.nvim_exec_autocmds("User", { pattern = "NeogitStatusRefreshed", modeline = false }) + + permit:forget() + logger.info("[STATUS] Refresh lock is now free") + end, + } +end + +M.dispatch_refresh = util.debounce_trailing( + 100, + a.void(function(self, partial, reason) + if self:_is_refresh_locked() then + logger.debug("[STATUS] Refresh lock is active. Skipping refresh from " .. (reason or "unknown")) + else + logger.debug("[STATUS] Dispatching Refresh") + self:refresh(partial, reason) + end + end) +) + +function M:reset() + logger.debug("[STATUS] Resetting repo and refreshing") + git.repo:reset() + self:refresh(nil, "reset") +end + +function M:dispatch_reset() + a.run(function() + self:reset() + end) +end + +function M:_is_refresh_locked() + return self.refresh_lock.permits == 0 +end + +function M:_get_refresh_lock(reason) + local permit = self.refresh_lock:acquire() + logger.debug(("[STATUS]: Acquired refresh lock:"):format(reason or "unknown")) + + vim.defer_fn(function() + if self:_is_refresh_locked() then + permit:forget() + logger.debug(("[STATUS]: Refresh lock for %s expired after 10 seconds"):format(reason or "unknown")) + end + end, 10000) + + return permit end return M diff --git a/lua/neogit/buffers/status/ui.lua b/lua/neogit/buffers/status/ui.lua index 13d5c5180..276d700ee 100755 --- a/lua/neogit/buffers/status/ui.lua +++ b/lua/neogit/buffers/status/ui.lua @@ -2,6 +2,7 @@ local Ui = require("neogit.lib.ui") local Component = require("neogit.lib.ui.component") local util = require("neogit.lib.util") local common = require("neogit.buffers.common") +local a = require("plenary.async") local col = Ui.col local row = Ui.row @@ -9,135 +10,670 @@ local text = Ui.text local map = util.map +local EmptyLine = common.EmptyLine local List = common.List +local DiffHunks = common.DiffHunks local M = {} -local RemoteHeader = Component.new(function(props) +local HINT = Component.new(function(props) + ---@return table + local function reversed_lookup(tbl) + local result = {} + for k, v in pairs(tbl) do + if v then + local current = result[v] + if current then + table.insert(current, k) + else + result[v] = { k } + end + end + end + + return result + end + + local reversed_status_map = reversed_lookup(props.config.mappings.status) + local reversed_popup_map = reversed_lookup(props.config.mappings.popup) + + local entry = function(name, hint) + local keys = reversed_status_map[name] or reversed_popup_map[name] + local key_hint + + if keys and #keys > 0 then + key_hint = table.concat(keys, " ") + else + key_hint = "" + end + + return row { + text.highlight("NeogitPopupActionKey")(key_hint), + text(" "), + text(hint), + } + end + return row { - text(props.name), - text(": "), - text(props.branch), + text.highlight("NeogitSubtleText")("Hint: "), + entry("Toggle", "toggle"), + text.highlight("NeogitSubtleText")(" | "), + entry("Stage", "stage"), + text.highlight("NeogitSubtleText")(" | "), + entry("Unstage", "unstage"), + text.highlight("NeogitSubtleText")(" | "), + entry("Discard", "discard"), + text.highlight("NeogitSubtleText")(" | "), + entry("CommitPopup", "commit"), + text.highlight("NeogitSubtleText")(" | "), + entry("HelpPopup", "help"), + } +end) + +local HEAD = Component.new(function(props) + local show_oid = props.show_oid + local highlight, ref + if props.branch == "(detached)" then + highlight = "NeogitBranch" + ref = props.branch + show_oid = true + elseif props.remote then + highlight = "NeogitRemote" + ref = ("%s/%s"):format(props.remote, props.branch) + else + highlight = "NeogitBranch" + ref = props.branch + end + + local oid = props.yankable + if not oid or oid == "(initial)" then + oid = "0000000" + else + oid = oid:sub(1, 7) + end + + return row({ + text.highlight("NeogitStatusHEAD")(util.pad_right(props.name .. ": ", props.HEAD_padding)), + text.highlight("NeogitObjectId")(show_oid and oid or ""), + text(show_oid and " " or ""), + text.highlight(highlight)(ref), text(" "), text(props.msg or "(no commits)"), + }, { yankable = props.yankable }) +end) + +local Tag = Component.new(function(props) + if props.distance then + return row({ + text.highlight("NeogitStatusHEAD")(util.pad_right("Tag: ", props.HEAD_padding)), + text.highlight("NeogitTagName")(props.name), + text(" ("), + text.highlight("NeogitTagDistance")(props.distance), + text(")"), + }, { yankable = props.yankable }) + else + return row({ + text(util.pad_right("Tag: ", props.HEAD_padding)), + text.highlight("NeogitTagName")(props.name), + }, { yankable = props.yankable }) + end +end) + +local SectionTitle = Component.new(function(props) + return { text.highlight(props.highlight or "NeogitSectionHeader")(props.title) } +end) + +local SectionTitleRemote = Component.new(function(props) + return { + text.highlight(props.highlight or "NeogitSectionHeader")(props.title), + text(" "), + text.highlight("NeogitRemote")(props.ref), + } +end) + +local SectionTitleRebase = Component.new(function(props) + if props.onto then + return { + text.highlight(props.highlight or "NeogitSectionHeader")(props.title), + text(" "), + text.highlight("NeogitBranch")(props.head), + text.highlight("NeogitSectionHeader")(" onto "), + text.highlight(props.is_remote_ref and "NeogitRemote" or "NeogitBranch")(props.onto), + } + else + return { + text.highlight(props.highlight or "NeogitSectionHeader")(props.title), + text(" "), + text.highlight("NeogitBranch")(props.head), + } + end +end) + +local SectionTitleMerge = Component.new(function(props) + return { + text.highlight(props.highlight or "NeogitSectionHeader")(props.title), + text(" "), + text.highlight("NeogitBranch")(props.branch), } end) local Section = Component.new(function(props) - return col { - row { - text(props.title), + local count + if props.count then + count = { text(" ("), text.highlight("NeogitSectionHeaderCount")(#props.items), text(")") } + end + + return col.tag("Section")({ + row(util.merge(props.title, count or {})), + col(map(props.items, props.render)), + EmptyLine(), + }, { + foldable = true, + folded = props.folded, + section = props.name, + id = props.name, + }) +end) + +local SequencerSection = Component.new(function(props) + return col.tag("Section")({ + row(util.merge(props.title)), + col(map(props.items, props.render)), + EmptyLine(), + }, { + foldable = true, + folded = props.folded, + section = props.name, + id = props.name, + }) +end) + +local RebaseSection = Component.new(function(props) + return col.tag("Section")({ + row(util.merge(props.title, { text(" ("), - text(#props.items), + text(props.current), + text("/"), + text(#props.items - 1), text(")"), + })), + col(map(props.items, props.render)), + EmptyLine(), + }, { + foldable = true, + folded = props.folded, + section = props.name, + id = props.name, + }) +end) + +local SectionItemFile = function(section, config) + return Component.new(function(item) + local load_diff = function(item) + ---@param this Component + ---@param ui Ui + ---@param prefix string|nil + return a.void(function(this, ui, prefix) + this.options.on_open = nil + this.options.folded = false + + local row, _ = this:row_range_abs() + row = row + 1 -- Filename row + + local diff = item.diff + for _, hunk in ipairs(diff.hunks) do + hunk.first = row + hunk.last = row + hunk.length + row = hunk.last + 1 + + -- Set fold state when called from ui:update() + if prefix then + local key = ("%s--%s"):format(prefix, hunk.hash) + if ui._node_fold_state and ui._node_fold_state[key] then + hunk._folded = ui._node_fold_state[key].folded + end + end + end + + this:append(DiffHunks(diff)) + ui:update() + end) + end + + local mode = config.status.mode_text[item.mode] + local mode_text + if mode == "" then + mode_text = "" + elseif config.status.mode_padding > 0 then + mode_text = util.pad_right( + mode, + util.max_length(vim.tbl_values(config.status.mode_text)) + config.status.mode_padding + ) + end + + local unmerged_types = { + ["DD"] = " (both deleted)", + ["DU"] = " (deleted by us)", + ["UD"] = " (deleted by them)", + ["AA"] = " (both added)", + ["AU"] = " (added by us)", + ["UA"] = " (added by them)", + } + + local name = item.original_name and ("%s -> %s"):format(item.original_name, item.name) or item.name + local highlight = ("NeogitChange%s%s"):format(item.mode:gsub("%?", "Untracked"), section) + + return col.tag("Item")({ + row { + text.highlight(highlight)(mode_text), + text(name), + text.highlight("NeogitSubtleText")(unmerged_types[item.mode] or ""), + }, + }, { + foldable = true, + folded = true, + on_open = load_diff(item), + context = true, + id = ("%s--%s"):format(section, item.name), + yankable = item.name, + filename = item.name, + item = item, + }) + end) +end + +local SectionItemStash = Component.new(function(item) + local name = ("stash@{%s}"):format(item.idx) + return row({ + text.highlight("NeogitSubtleText")(name), + text.highlight("NeogitSubtleText")(": "), + text(item.message), + }, { yankable = name, item = item }) +end) + +local SectionItemCommit = Component.new(function(item) + local ref = {} + local ref_last = {} + + if item.commit.ref_name ~= "" then + -- Render local only branches first + for name, _ in pairs(item.decoration.locals) do + if name:match("^refs/") then + table.insert(ref_last, text(name, { highlight = "NeogitGraphGray" })) + table.insert(ref_last, text(" ")) + elseif item.decoration.remotes[name] == nil then + local branch_highlight = item.decoration.head == name and "NeogitBranchHead" or "NeogitBranch" + table.insert(ref, text(name, { highlight = branch_highlight })) + table.insert(ref, text(" ")) + end + end + + -- Render tracked (local+remote) branches next + for name, remotes in pairs(item.decoration.remotes) do + if #remotes == 1 then + table.insert(ref, text(remotes[1] .. "/", { highlight = "NeogitRemote" })) + end + + if #remotes > 1 then + table.insert(ref, text("{" .. table.concat(remotes, ",") .. "}/", { highlight = "NeogitRemote" })) + end + + local branch_highlight = item.decoration.head == name and "NeogitBranchHead" or "NeogitBranch" + local locally = item.decoration.locals[name] ~= nil + table.insert(ref, text(name, { highlight = locally and branch_highlight or "NeogitRemote" })) + table.insert(ref, text(" ")) + end + + -- Render tags + for _, tag in pairs(item.decoration.tags) do + table.insert(ref, text(tag, { highlight = "NeogitTagName" })) + table.insert(ref, text(" ")) + end + end + + return row( + util.merge( + { text.highlight("NeogitObjectId")(item.commit.abbreviated_commit) }, + { text(" ") }, + ref, + ref_last, + { text(item.commit.subject) } + ), + { oid = item.commit.oid, yankable = item.commit.oid, item = item } + ) +end) + +local SectionItemRebase = Component.new(function(item) + if item.oid then + local action_hl = (item.done and "NeogitRebaseDone") + or (item.action == "onto" and "NeogitGraphBlue") + or "NeogitGraphOrange" + + return row({ + text(item.stopped and "> " or " "), + text.highlight(action_hl)(util.pad_right(item.action, 6)), + text(" "), + text.highlight("NeogitRebaseDone")(item.abbreviated_commit), + text(" "), + text.highlight(item.done and "NeogitRebaseDone")(item.subject), + }, { yankable = item.oid, oid = item.oid }) + else + return row { + text.highlight("NeogitGraphOrange")(item.action), + text(" "), + text(item.subject), + } + end +end) + +local SectionItemSequencer = Component.new(function(item) + local action_hl = (item.action == "join" and "NeogitGraphRed") + or (item.action == "onto" and "NeogitGraphBlue") + or "NeogitGraphOrange" + + local show_action = #item.action > 0 + local action = show_action and util.pad_right(item.action, 6) or "" + + return row({ + text.highlight(action_hl)(action), + text(show_action and " " or ""), + text.highlight("NeogitObjectId")(item.abbreviated_commit), + text(" "), + text(item.subject), + }, { yankable = item.oid, oid = item.oid }) +end) + +local SectionItemBisect = Component.new(function(item) + local highlight + if item.action == "good" then + highlight = "NeogitGraphGreen" + elseif item.action == "bad" then + highlight = "NeogitGraphRed" + elseif item.finished then + highlight = "NeogitGraphBoldOrange" + end + + return row({ + text(item.finished and "> " or " "), + text.highlight(highlight)(util.pad_right(item.action, 5)), + text(" "), + text.highlight("NeogitObjectId")(item.abbreviated_commit), + text(" "), + text(item.subject), + }, { yankable = item.oid, oid = item.oid }) +end) + +local BisectDetailsSection = Component.new(function(props) + return col.tag("Section")({ + row(util.merge(props.title, { text(" "), text.highlight("NeogitObjectId")(props.commit.oid) })), + row { + text.highlight("NeogitSubtleText")("Author: "), + text((props.commit.author_name or "") .. " <" .. (props.commit.author_email or "") .. ">"), }, - col(props.items), - } + row { text.highlight("NeogitSubtleText")("AuthorDate: "), text(props.commit.author_date) }, + row { + text.highlight("NeogitSubtleText")("Committer: "), + text((props.commit.committer_name or "") .. " <" .. (props.commit.committer_email or "") .. ">"), + }, + row { text.highlight("NeogitSubtleText")("CommitDate: "), text(props.commit.committer_date) }, + EmptyLine(), + col( + map(props.commit.description, text), + { highlight = "NeogitCommitViewDescription", tag = "Description" } + ), + EmptyLine(), + }, { + foldable = true, + folded = props.folded, + section = props.name, + yankable = props.commit.oid, + id = props.name, + }) end) -function M.Status(state) +function M.Status(state, config) + -- stylua: ignore start + local show_hint = not config.disable_hint + + local show_upstream = state.upstream.ref + and state.head.branch ~= "(detached)" + + local show_pushRemote = state.pushRemote.ref + and state.head.branch ~= "(detached)" + + local show_tag = state.head.tag.name + + local show_tag_distance = state.head.tag.name + and state.head.branch ~= "(detached)" + + local show_merge = state.merge.head + and not config.sections.sequencer.hidden + + local show_rebase = #state.rebase.items > 0 + and not config.sections.rebase.hidden + + local show_cherry_pick = state.sequencer.cherry_pick + and not config.sections.sequencer.hidden + + local show_revert = state.sequencer.revert + and not config.sections.sequencer.hidden + + local show_bisect = #state.bisect.items > 0 + and not config.sections.bisect.hidden + + local show_untracked = #state.untracked.items > 0 + and not config.sections.untracked.hidden + + local show_unstaged = #state.unstaged.items > 0 + and not config.sections.unstaged.hidden + + local show_staged = #state.staged.items > 0 + and not config.sections.staged.hidden + + local show_upstream_unpulled = #state.upstream.unpulled.items > 0 + and not config.sections.unpulled_upstream.hidden + + local show_pushRemote_unpulled = #state.pushRemote.unpulled.items > 0 + and state.pushRemote.ref ~= state.upstream.ref + and not config.sections.unpulled_pushRemote.hidden + + local show_upstream_unmerged = #state.upstream.unmerged.items > 0 + and not config.sections.unmerged_upstream.hidden + + local show_pushRemote_unmerged = #state.pushRemote.unmerged.items > 0 + and state.pushRemote.ref ~= state.upstream.ref + and not config.sections.unmerged_pushRemote.hidden + + local show_stashes = #state.stashes.items > 0 + and not config.sections.stashes.hidden + + local show_recent = #state.recent.items > 0 + and not config.sections.recent.hidden + return { List { - separator = " ", items = { - col { - RemoteHeader { - name = "Head", - branch = state.head.branch, - msg = state.head.commit_message, + show_hint and HINT { config = config }, + show_hint and EmptyLine(), + HEAD { + name = "Head", + branch = state.head.branch, + oid = state.head.abbrev, + msg = state.head.commit_message, + yankable = state.head.oid, + show_oid = config.status.show_head_commit_hash, + HEAD_padding = config.status.HEAD_padding, + }, + show_upstream and HEAD { + name = "Merge", + branch = state.upstream.branch, + remote = state.upstream.remote, + msg = state.upstream.commit_message, + yankable = state.upstream.oid, + show_oid = config.status.show_head_commit_hash, + HEAD_padding = config.status.HEAD_padding, + }, + show_pushRemote and HEAD { + name = "Push", + branch = state.pushRemote.branch, + remote = state.pushRemote.remote, + msg = state.pushRemote.commit_message, + yankable = state.pushRemote.oid, + show_oid = config.status.show_head_commit_hash, + HEAD_padding = config.status.HEAD_padding, + }, + show_tag and Tag { + name = state.head.tag.name, + distance = show_tag_distance and state.head.tag.distance, + yankable = state.head.tag.oid, + HEAD_padding = config.status.HEAD_padding, + }, + EmptyLine(), + show_merge and SequencerSection { + title = SectionTitleMerge { + title = "Merging", + branch = state.merge.branch, + highlight = "NeogitMerging", }, - state.upstream.ref and RemoteHeader { - name = "Upstream", - branch = state.upstream.ref, - msg = state.upstream.commit_message, + render = SectionItemSequencer, + items = { { action = "", oid = state.merge.head, subject = state.merge.subject } }, + folded = config.sections.sequencer.folded, + name = "merge", + }, + show_rebase and RebaseSection { + title = SectionTitleRebase { + title = "Rebasing", + head = state.rebase.head, + onto = state.rebase.onto.ref, + oid = state.rebase.onto.oid, + is_remote_ref = state.rebase.onto.is_remote, + highlight = "NeogitRebasing", }, + render = SectionItemRebase, + current = state.rebase.current, + items = state.rebase.items, + folded = config.sections.rebase.folded, + name = "rebase", + }, + show_cherry_pick and SequencerSection { + title = SectionTitle { title = "Cherry Picking", highlight = "NeogitPicking" }, + render = SectionItemSequencer, + items = util.reverse(state.sequencer.items), + folded = config.sections.sequencer.folded, + name = "cherry_pick", + }, + show_revert and SequencerSection { + title = SectionTitle { title = "Reverting", highlight = "NeogitReverting" }, + render = SectionItemSequencer, + items = util.reverse(state.sequencer.items), + folded = config.sections.sequencer.folded, + name = "revert", + }, + show_bisect and BisectDetailsSection { + title = SectionTitle { title = "Bisecting at", highlight = "NeogitBisecting" }, + commit = state.bisect.current, + folded = config.sections.bisect.folded, + name = "bisect_details", + }, + show_bisect and SequencerSection { + title = SectionTitle { title = "Bisecting Log", highlight = "NeogitBisecting" }, + render = SectionItemBisect, + items = state.bisect.items, + folded = config.sections.bisect.folded, + name = "bisect", + }, + show_untracked and Section { + title = SectionTitle { title = "Untracked files", highlight = "NeogitUntrackedfiles" }, + count = true, + render = SectionItemFile("untracked", config), + items = state.untracked.items, + folded = config.sections.untracked.folded, + name = "untracked", }, - -- #state.untracked_files > 0 and Section { - -- title = "Untracked files", - -- items = map(state.untracked_files, Diff) - -- }, - -- #state.unstaged_changes > 0 and Section { - -- title = "Unstaged changes", - -- items = map(state.unstaged_changes, Diff) - -- }, - -- #state.staged_changes > 0 and Section { - -- title = "Staged changes", - -- items = map(state.staged_changes, Diff) - -- }, - #state.stashes > 0 - and Section { - title = "Stashes", - items = map(state.stashes, function(s) - return row { - text.highlight("Comment")("stash@{", s.idx, "}: "), - text(s.message), - } - end), + show_unstaged and Section { + title = SectionTitle { title = "Unstaged changes", highlight = "NeogitUnstagedchanges" }, + count = true, + render = SectionItemFile("unstaged", config), + items = state.unstaged.items, + folded = config.sections.unstaged.folded, + name = "unstaged", + }, + show_staged and Section { + title = SectionTitle { title = "Staged changes", highlight = "NeogitStagedchanges" }, + count = true, + render = SectionItemFile("staged", config), + items = state.staged.items, + folded = config.sections.staged.folded, + name = "staged", + }, + show_stashes and Section { + title = SectionTitle { title = "Stashes", highlight = "NeogitStashes" }, + count = true, + render = SectionItemStash, + items = state.stashes.items, + folded = config.sections.stashes.folded, + name = "stashes", + }, + show_upstream_unmerged and Section { + title = SectionTitleRemote { + title = "Unmerged into", + ref = state.upstream.ref, + highlight = "NeogitUnmergedchanges", + }, + count = true, + render = SectionItemCommit, + items = state.upstream.unmerged.items, + folded = config.sections.unmerged_upstream.folded, + name = "upstream_unmerged", + }, + show_pushRemote_unmerged and Section { + title = SectionTitleRemote { + title = "Unpushed to", + ref = state.pushRemote.ref, + highlight = "NeogitUnpushedchanges", + }, + count = true, + render = SectionItemCommit, + items = state.pushRemote.unmerged.items, + folded = config.sections.unmerged_pushRemote.folded, + name = "pushRemote_unmerged", + }, + not show_upstream_unmerged and show_recent and Section { + title = SectionTitle { title = "Recent Commits", highlight = "NeogitRecentcommits" }, + count = false, + render = SectionItemCommit, + items = state.recent.items, + folded = config.sections.recent.folded, + name = "recent", + }, + show_upstream_unpulled and Section { + title = SectionTitleRemote { + title = "Unpulled from", + ref = state.upstream.ref, + highlight = "NeogitUnpulledchanges", + }, + count = true, + render = SectionItemCommit, + items = state.upstream.unpulled.items, + folded = config.sections.unpulled_upstream.folded, + name = "upstream_unpulled", + }, + show_pushRemote_unpulled and Section { + title = SectionTitleRemote { + title = "Unpulled from", + ref = state.pushRemote.ref, + highlight = "NeogitUnpulledchanges", }, - -- #state.unpulled_changes > 0 and Section { - -- title = "Unpulled changes", - -- items = map(state.unpulled_changes, Diff) - -- }, - -- #state.unmerged_changes > 0 and Section { - -- title = "Unmerged changes", - -- items = map(state.unmerged_changes, Diff) - -- }, + count = true, + render = SectionItemCommit, + items = state.pushRemote.unpulled.items, + folded = config.sections.unpulled_pushRemote.folded, + name = "pushRemote_unpulled", + }, }, }, } end - --- function _load_diffs(repo) --- local cli = require("neogit.lib.git.cli") - --- local unstaged_jobs = map(repo.unstaged.items, function(f) --- return cli.diff.shortstat.patch.files(f.name).to_job() --- end) - --- local staged_jobs = map(repo.staged.items, function(f) --- return cli.diff.cached.shortstat.patch.files(f.name).to_job() --- end) - --- local jobs = {} - --- for _, x in ipairs { unstaged_jobs, staged_jobs } do --- for _, j in ipairs(x) do --- table.insert(jobs, j) --- end --- end - --- Job.start_all(jobs) --- Job.wait_all(jobs) - --- for i, j in ipairs(unstaged_jobs) do --- repo.unstaged.items[i].diff = difflib.parse(j.stdout, true) --- end - --- for i, j in ipairs(staged_jobs) do --- repo.staged.items[i].diff = difflib.parse(j.stdout, true) --- end --- end - -function M._TEST() - local repo = require("neogit.lib.git.repository").create() - - require("neogit.buffers.status") - .new({ - head = repo.head, - upstream = repo.upstream, - untracked_files = repo.untracked.items, - unstaged_changes = map(repo.unstaged.items, function(f) - return f.diff - end), - staged_changes = map(repo.staged.items, function(f) - return f.diff - end), - stashes = repo.stashes.items, - unpulled_changes = repo.upstream.unpulled.items, - unmerged_changes = repo.upstream.unmerged.items, - recent_changes = repo.recent.items, - }) - :open() -end +-- stylua: ignore end return M diff --git a/lua/neogit/client.lua b/lua/neogit/client.lua index 8167fdce7..a52c954ab 100644 --- a/lua/neogit/client.lua +++ b/lua/neogit/client.lua @@ -7,14 +7,15 @@ local fmt = string.format local M = {} -function M.get_nvim_remote_editor() +function M.get_nvim_remote_editor(show_diff) local neogit_path = debug.getinfo(1, "S").source:sub(2, -#"lua/neogit/client.lua" - 2) local nvim_path = fn.shellescape(vim.v.progpath) logger.debug("[CLIENT] Neogit path: " .. neogit_path) logger.debug("[CLIENT] Neovim path: " .. nvim_path) local runtimepath_cmd = fn.shellescape(fmt("set runtimepath^=%s", fn.fnameescape(tostring(neogit_path)))) - local lua_cmd = fn.shellescape("lua require('neogit.client').client()") + local lua_cmd = + fn.shellescape("lua require('neogit.client').client({ show_diff = " .. tostring(show_diff) .. " })") local shell_cmd = { nvim_path, @@ -32,29 +33,39 @@ function M.get_nvim_remote_editor() return table.concat(shell_cmd, " ") end -function M.get_envs_git_editor() - local nvim_cmd = M.get_nvim_remote_editor() - return { +function M.get_envs_git_editor(show_diff) + local nvim_cmd = M.get_nvim_remote_editor(show_diff) + + local env = { GIT_SEQUENCE_EDITOR = nvim_cmd, GIT_EDITOR = nvim_cmd, } + + if os.getenv("NEOGIT_DEBUG") then + env.NEOGIT_LOG_LEVEL = "debug" + env.NEOGIT_LOG_FILE = "true" + env.NEOGIT_DEBUG = true + end + + return env end --- Entry point for the headless client. --- Starts a server and connects to the parent process rpc, opening an editor -function M.client() +function M.client(opts) local nvim_server = vim.env.NVIM if not nvim_server then error("NVIM server address not set") end local file_target = fn.fnamemodify(fn.argv()[1], ":p") - logger.fmt_debug("[CLIENT] File target: %s", file_target) + logger.debug(("[CLIENT] File target: %s"):format(file_target)) local client = fn.serverstart() - logger.fmt_debug("[CLIENT] Client address: %s", client) + logger.debug(("[CLIENT] Client address: %s"):format(client)) - local lua_cmd = fmt('lua require("neogit.client").editor("%s", "%s")', file_target, client) + local lua_cmd = + fmt('lua require("neogit.client").editor("%s", "%s", %s)', file_target, client, opts.show_diff) if vim.loop.os_uname().sysname == "Windows_NT" then lua_cmd = lua_cmd:gsub("\\", "/") @@ -67,8 +78,9 @@ end --- Invoked by the `client` and starts the appropriate file editor ---@param target string Filename to open ---@param client string Address returned from vim.fn.serverstart() -function M.editor(target, client) - logger.fmt_debug("[CLIENT] Invoked editor with target: %s, from: %s", target, client) +---@param show_diff boolean +function M.editor(target, client, show_diff) + logger.debug(("[CLIENT] Invoked editor with target: %s, from: %s"):format(target, client)) require("neogit.process").hide_preview_buffers() local rpc_client = RPC.create_connection(client) @@ -107,7 +119,7 @@ function M.editor(target, client) editor = require("neogit.buffers.editor") end - editor.new(target, send_client_quit):open(kind) + editor.new(target, send_client_quit, show_diff):open(kind) end ---@class NotifyMsg @@ -118,6 +130,7 @@ end ---@class WrapOpts ---@field autocmd string ---@field msg NotifyMsg +---@field show_diff boolean? ---@field interactive boolean? ---@param cmd any @@ -133,15 +146,17 @@ function M.wrap(cmd, opts) notification.info(opts.msg.setup) end - local c = cmd.env(M.get_envs_git_editor()):in_pty(true) + local c = cmd.env(M.get_envs_git_editor(opts.show_diff)):in_pty(true) local call_cmd = c.call if opts.interactive then call_cmd = c.call_interactive end + logger.debug("[CLIENT] Calling editor command") local result = call_cmd { verbose = true } a.util.scheduler() + logger.debug("[CLIENT] DONE editor command") if result.code == 0 then if opts.msg.success then @@ -153,6 +168,7 @@ function M.wrap(cmd, opts) notification.warn(opts.msg.fail, { dismiss = true }) end end + return result.code end diff --git a/lua/neogit/config.lua b/lua/neogit/config.lua index b4916154e..9f4c6ed2b 100644 --- a/lua/neogit/config.lua +++ b/lua/neogit/config.lua @@ -1,71 +1,63 @@ local util = require("neogit.lib.util") local M = {} +local mappings = {} + +---Returns a map of commands, mapped to the list of keys which trigger them. ---@return table ---- Returns a map of commands, mapped to the list of keys which trigger them. -local function get_reversed_maps(tbl) - local result = {} - for k, v in pairs(tbl) do - -- If `v == false` the mapping is disabled - if v then - local current = result[v] - if current then - table.insert(current, k) - else - result[v] = { k } +local function get_reversed_maps(set) + if not mappings[set] then + local result = {} + for k, v in pairs(M.values.mappings[set]) do + -- If `v == false` the mapping is disabled + if v then + local current = result[v] + if current then + table.insert(current, k) + else + result[v] = { k } + end end end + + mappings[set] = result end - return result + return mappings[set] end -local reversed_status_maps ---@return table ---- Returns a map of commands, mapped to the list of keys which trigger them. function M.get_reversed_status_maps() - if not reversed_status_maps then - reversed_status_maps = get_reversed_maps(M.values.mappings.status) - end - - return reversed_status_maps + return get_reversed_maps("status") end -local reversed_popup_maps ---@return table ---- Returns a map of commands, mapped to the list of keys which trigger them. function M.get_reversed_popup_maps() - if not reversed_popup_maps then - reversed_popup_maps = get_reversed_maps(M.values.mappings.popup) - end - - return reversed_popup_maps + return get_reversed_maps("popup") end -local reversed_rebase_editor_maps ---@return table ---- Returns a map of commands, mapped to the list of keys which trigger them. function M.get_reversed_rebase_editor_maps() - if not reversed_rebase_editor_maps then - reversed_rebase_editor_maps = get_reversed_maps(M.values.mappings.rebase_editor) - end + return get_reversed_maps("rebase_editor") +end - return reversed_rebase_editor_maps +---@return table +function M.get_reversed_rebase_editor_maps_I() + return get_reversed_maps("rebase_editor_I") end -local reversed_commit_editor_maps ---@return table ---- Returns a map of commands, mapped to the list of keys which trigger them. function M.get_reversed_commit_editor_maps() - if not reversed_commit_editor_maps then - reversed_commit_editor_maps = get_reversed_maps(M.values.mappings.commit_editor) - end + return get_reversed_maps("commit_editor") +end - return reversed_commit_editor_maps +---@return table +function M.get_reversed_commit_editor_maps_I() + return get_reversed_maps("commit_editor_I") end ---@alias WindowKind ----|"split" Open in a split +---| "split" Open in a split ---| "vsplit" Open in a vertical split ---| "floating" Open in a floating window ---| "tab" Open in a new tab @@ -77,6 +69,10 @@ end ---@class NeogitConfigPopup Popup window options ---@field kind WindowKind The type of window that should be opened +---@class NeogitCommitEditorConfigPopup Popup window options +---@field kind WindowKind The type of window that should be opened +---@field show_staged_diff? boolean Display staged changes in a buffer when committing + ---@alias NeogitConfigSignsIcon { [1]: string, [2]: string } ---@class NeogitConfigSigns @@ -100,6 +96,7 @@ end ---@field recent NeogitConfigSection|nil ---@field rebase NeogitConfigSection|nil ---@field sequencer NeogitConfigSection|nil +---@field bisect NeogitConfigSection|nil ---@class HighlightOptions ---@field italic? boolean @@ -107,28 +104,132 @@ end ---@field underline? boolean ---@class NeogitFilewatcherConfig ----@field interval number ---@field enabled boolean ---@field filewatcher NeogitFilewatcherConfig|nil ----@alias NeogitConfigMappingsFinder "Select" | "Close" | "Next" | "Previous" | "MultiselectToggleNext" | "MultiselectTogglePrevious" | "NOP" | false - ----@alias NeogitConfigMappingsStatus "Close" | "Depth1" | "Depth2" | "Depth3" | "Depth4" | "Toggle" | "Discard" | "Stage" | "StageUnstaged" | "StageAll" | "Unstage" | "UnstageStaged" | "RefreshBuffer" | "GoToFile" | "VSplitOpen" | "SplitOpen" | "TabOpen" | "GoToPreviousHunkHeader" | "GoToNextHunkHeader" | "Console" | "CommandHistory" | "InitRepo" | "YankSelected" | false | fun() - ----@alias NeogitConfigMappingsPopup "HelpPopup" | "DiffPopup" | "PullPopup" | "RebasePopup" | "MergePopup" | "PushPopup" | "CommitPopup" | "LogPopup" | "RevertPopup" | "StashPopup" | "IgnorePopup" | "CherryPickPopup" | "BranchPopup" | "FetchPopup" | "ResetPopup" | "RemotePopup" | "TagPopup" | "WorktreePopup" | false - ----@alias NeogitConfigMappingsRebaseEditor "Pick" | "Reword" | "Edit" | "Squash" | "Fixup" | "Execute" | "Drop" | "Break" | "MoveUp" | "MoveDown" | "Close" | "OpenCommit" | "Submit" | "Abort" | false | fun() ---- ----@alias NeogitConfigMappingsCommitEditor "Close" | "Submit" | "Abort" | "PrevMessage" | "ResetMessage" | "NextMessage" | false | fun() +---@alias NeogitConfigMappingsFinder +---| "Select" +---| "Close" +---| "Next" +---| "Previous" +---| "MultiselectToggleNext" +---| "MultiselectTogglePrevious" +---| "NOP" +---| false + +---@alias NeogitConfigMappingsStatus +---| "Close" +---| "Depth1" +---| "Depth2" +---| "Depth3" +---| "Depth4" +---| "Toggle" +---| "Discard" +---| "Stage" +---| "StageUnstaged" +---| "StageAll" +---| "Unstage" +---| "UnstageStaged" +---| "Untrack" +---| "RefreshBuffer" +---| "GoToFile" +---| "VSplitOpen" +---| "SplitOpen" +---| "TabOpen" +---| "GoToPreviousHunkHeader" +---| "GoToNextHunkHeader" +---| "CommandHistory" +---| "ShowRefs" +---| "InitRepo" +---| "YankSelected" +---| "OpenOrScrollUp" +---| "OpenOrScrollDown" +---| false +---| fun() + +---@alias NeogitConfigMappingsPopup +---| "HelpPopup" +---| "DiffPopup" +---| "PullPopup" +---| "RebasePopup" +---| "MergePopup" +---| "PushPopup" +---| "CommitPopup" +---| "LogPopup" +---| "RevertPopup" +---| "StashPopup" +---| "IgnorePopup" +---| "CherryPickPopup" +---| "BisectPopup" +---| "BranchPopup" +---| "FetchPopup" +---| "ResetPopup" +---| "RemotePopup" +---| "TagPopup" +---| "WorktreePopup" +---| false + +---@alias NeogitConfigMappingsRebaseEditor +---| "Pick" +---| "Reword" +---| "Edit" +---| "Squash" +---| "Fixup" +---| "Execute" +---| "Drop" +---| "Break" +---| "MoveUp" +---| "MoveDown" +---| "Close" +---| "OpenCommit" +---| "Submit" +---| "Abort" +---| "OpenOrScrollUp" +---| "OpenOrScrollDown" +---| false +---| fun() + +---@alias NeogitConfigMappingsCommitEditor +---| "Close" +---| "Submit" +---| "Abort" +---| "PrevMessage" +---| "ResetMessage" +---| "NextMessage" +---| false +---| fun() + +---@alias NeogitConfigMappingsCommitEditor_I +---| "Submit" +---| "Abort" +---| false +---| fun() + +---@alias NeogitConfigMappingsRebaseEditor_I +---| "Submit" +---| "Abort" +---| false +---| fun() + +---@alias NeogitGraphStyle +---| "ascii" +---| "unicode" + +---@class NeogitConfigStatusOptions +---@field recent_commit_count? integer The number of recent commits to display +---@field mode_padding? integer The amount of padding to add to the right of the mode column +---@field HEAD_padding? integer The amount of padding to add to the right of the HEAD label +---@field mode_text? { [string]: string } The text to display for each mode +---@field show_head_commit_hash? boolean Show the commit hash for HEADs in the status buffer ---@class NeogitConfigMappings Consult the config file or documentation for values ---@field finder? { [string]: NeogitConfigMappingsFinder } A dictionary that uses finder commands to set multiple keybinds ---@field status? { [string]: NeogitConfigMappingsStatus } A dictionary that uses status commands to set a single keybind ---@field popup? { [string]: NeogitConfigMappingsPopup } A dictionary that uses popup commands to set a single keybind ---@field rebase_editor? { [string]: NeogitConfigMappingsRebaseEditor } A dictionary that uses Rebase editor commands to set a single keybind +---@field rebase_editor_I? { [string]: NeogitConfigMappingsRebaseEditor_I } A dictionary that uses Rebase editor commands to set a single keybind ---@field commit_editor? { [string]: NeogitConfigMappingsCommitEditor } A dictionary that uses Commit editor commands to set a single keybind - ----@alias NeogitGraphStyle "ascii" | "unicode" +---@field commit_editor_I? { [string]: NeogitConfigMappingsCommitEditor_I } A dictionary that uses Commit editor commands to set a single keybind ---@class NeogitConfig Neogit configuration settings ---@field filewatcher? NeogitFilewatcherConfig Values for filewatcher @@ -142,20 +243,20 @@ end ---@field disable_insert_on_commit? boolean|"auto" Disable automatically entering insert mode in commit dialogues ---@field use_per_project_settings? boolean Scope persisted settings on a per-project basis ---@field remember_settings? boolean Whether neogit should persist flags from popups, e.g. git push flags ----@field auto_refresh? boolean Automatically refresh to detect git modifications without manual intervention ---@field sort_branches? string Value used for `--sort` for the `git branch` command ---@field kind? WindowKind The default type of window neogit should open in ---@field disable_line_numbers? boolean Whether to disable line numbers ---@field disable_relative_line_numbers? boolean Whether to disable line numbers ---@field console_timeout? integer Time in milliseconds after a console is created for long running commands ---@field auto_show_console? boolean Automatically show the console if a command takes longer than console_timeout ----@field status? { recent_commit_count: integer } Status buffer options ----@field commit_editor? NeogitConfigPopup Commit editor options +---@field status? NeogitConfigStatusOptions Status buffer options +---@field commit_editor? NeogitCommitEditorConfigPopup Commit editor options ---@field commit_select_view? NeogitConfigPopup Commit select view options ---@field commit_view? NeogitCommitBufferConfig Commit buffer options ---@field log_view? NeogitConfigPopup Log view options ---@field rebase_editor? NeogitConfigPopup Rebase editor options ---@field reflog_view? NeogitConfigPopup Reflog view options +---@field refs_view? NeogitConfigPopup Refs view options ---@field merge_editor? NeogitConfigPopup Merge editor options ---@field description_editor? NeogitConfigPopup Merge editor options ---@field tag_editor? NeogitConfigPopup Tag editor options @@ -180,8 +281,7 @@ function M.get_default_values() disable_signs = false, graph_style = "ascii", filewatcher = { - interval = 1000, - enabled = false, + enabled = true, }, telescope_sorter = function() return nil @@ -200,7 +300,6 @@ function M.get_default_values() use_per_project_settings = true, remember_settings = true, fetch_after_checkout = false, - auto_refresh = true, sort_branches = "-committerdate", kind = "tab", disable_line_numbers = true, @@ -211,10 +310,31 @@ function M.get_default_values() auto_show_console = true, notification_icon = "󰊢", status = { + show_head_commit_hash = true, recent_commit_count = 10, + HEAD_padding = 10, + mode_padding = 3, + mode_text = { + M = "modified", + N = "new file", + A = "added", + D = "deleted", + C = "copied", + U = "updated", + R = "renamed", + DD = "unmerged", + AU = "unmerged", + UD = "unmerged", + UA = "unmerged", + DU = "unmerged", + AA = "unmerged", + UU = "unmerged", + ["?"] = "", + }, }, commit_editor = { - kind = "auto", + kind = "tab", + show_staged_diff = true, }, commit_select_view = { kind = "tab", @@ -242,11 +362,14 @@ function M.get_default_values() kind = "auto", }, preview_buffer = { - kind = "split", + kind = "floating", }, popup = { kind = "split", }, + refs_view = { + kind = "tab", + }, signs = { hunk = { "", "" }, item = { ">", "v" }, @@ -262,6 +385,10 @@ function M.get_default_values() folded = false, hidden = false, }, + bisect = { + folded = false, + hidden = false, + }, untracked = { folded = false, hidden = false, @@ -318,6 +445,10 @@ function M.get_default_values() [""] = "NextMessage", [""] = "ResetMessage", }, + commit_editor_I = { + [""] = "Submit", + [""] = "Abort", + }, rebase_editor = { ["p"] = "Pick", ["r"] = "Reword", @@ -333,6 +464,12 @@ function M.get_default_values() ["gj"] = "MoveDown", [""] = "Submit", [""] = "Abort", + ["[c"] = "OpenOrScrollUp", + ["]c"] = "OpenOrScrollDown", + }, + rebase_editor_I = { + [""] = "Submit", + [""] = "Abort", }, finder = { [""] = "Select", @@ -345,6 +482,12 @@ function M.get_default_values() [""] = "MultiselectToggleNext", [""] = "MultiselectTogglePrevious", [""] = "NOP", + [""] = "ScrollWheelDown", + [""] = "ScrollWheelUp", + [""] = "NOP", + [""] = "NOP", + [""] = "MouseClick", + ["<2-LeftMouse>"] = "NOP", }, popup = { ["?"] = "HelpPopup", @@ -357,6 +500,7 @@ function M.get_default_values() ["i"] = "IgnorePopup", ["t"] = "TagPopup", ["b"] = "BranchPopup", + ["B"] = "BisectPopup", ["w"] = "WorktreePopup", ["c"] = "CommitPopup", ["f"] = "FetchPopup", @@ -379,17 +523,20 @@ function M.get_default_values() ["S"] = "StageUnstaged", [""] = "StageAll", ["u"] = "Unstage", + ["K"] = "Untrack", ["U"] = "UnstageStaged", + ["y"] = "ShowRefs", ["$"] = "CommandHistory", - ["#"] = "Console", ["Y"] = "YankSelected", [""] = "RefreshBuffer", - [""] = "GoToFile", + [""] = "GoToFile", [""] = "VSplitOpen", [""] = "SplitOpen", [""] = "TabOpen", ["{"] = "GoToPreviousHunkHeader", ["}"] = "GoToNextHunkHeader", + ["[c"] = "OpenOrScrollUp", + ["]c"] = "OpenOrScrollDown", }, }, } @@ -722,6 +869,78 @@ function M.validate_config() end end + local valid_rebase_editor_I_commands = { + false, + } + + for _, cmd in pairs(M.get_default_values().mappings.rebase_editor_I) do + table.insert(valid_rebase_editor_I_commands, cmd) + end + + if validate_type(config.mappings.rebase_editor_I, "mappings.rebase_editor_I", "table") then + for key, command in pairs(config.mappings.rebase_editor_I) do + if + validate_type(key, "mappings.rebase_editor_I -> " .. vim.inspect(key), "string") + and validate_type( + command, + string.format("mappings.rebase_editor_I['%s']", key), + { "string", "boolean", "function" } + ) + then + if type(command) == "string" and not vim.tbl_contains(valid_rebase_editor_I_commands, command) then + local valid_rebase_editor_I_commands = util.map(valid_rebase_editor_I_commands, function(command) + return vim.inspect(command) + end) + + err( + string.format("mappings.rebase_editor_I['%s']", key), + string.format( + "Expected a valid rebase_editor_I command, got '%s'. Valid rebase_editor_I commands: { %s }", + command, + table.concat(valid_rebase_editor_I_commands, ", ") + ) + ) + end + end + end + end + + local valid_commit_editor_I_commands = { + false, + } + + for _, cmd in pairs(M.get_default_values().mappings.commit_editor_I) do + table.insert(valid_commit_editor_I_commands, cmd) + end + + if validate_type(config.mappings.commit_editor_I, "mappings.commit_editor_I", "table") then + for key, command in pairs(config.mappings.commit_editor_I) do + if + validate_type(key, "mappings.commit_editor_I -> " .. vim.inspect(key), "string") + and validate_type( + command, + string.format("mappings.commit_editor_I['%s']", key), + { "string", "boolean", "function" } + ) + then + if type(command) == "string" and not vim.tbl_contains(valid_commit_editor_I_commands, command) then + local valid_commit_editor_I_commands = util.map(valid_commit_editor_I_commands, function(command) + return vim.inspect(command) + end) + + err( + string.format("mappings.commit_editor_I['%s']", key), + string.format( + "Expected a valid commit_editor_I command, got '%s'. Valid commit_editor_I commands: { %s }", + command, + table.concat(valid_commit_editor_I_commands, ", ") + ) + ) + end + end + end + end + local valid_commit_editor_commands = { false, } @@ -766,7 +985,6 @@ function M.validate_config() validate_type(config.telescope_sorter, "telescope_sorter", "function") validate_type(config.use_per_project_settings, "use_per_project_settings", "boolean") validate_type(config.remember_settings, "remember_settings", "boolean") - validate_type(config.auto_refresh, "auto_refresh", "boolean") validate_type(config.sort_branches, "sort_branches", "string") validate_type(config.notification_icon, "notification_icon", "string") validate_type(config.console_timeout, "console_timeout", "number") @@ -775,12 +993,17 @@ function M.validate_config() validate_type(config.disable_relative_line_numbers, "disable_relative_line_numbers", "boolean") validate_type(config.auto_show_console, "auto_show_console", "boolean") if validate_type(config.status, "status", "table") then + validate_type(config.status.show_head_commit_hash, "status.show_head_commit_hash", "boolean") validate_type(config.status.recent_commit_count, "status.recent_commit_count", "number") + validate_type(config.status.mode_padding, "status.mode_padding", "number") + validate_type(config.status.HEAD_padding, "status.HEAD_padding", "number") + validate_type(config.status.mode_text, "status.mode_text", "table") end validate_signs() validate_trinary_auto(config.disable_insert_on_commit, "disable_insert_on_commit") -- Commit Editor if validate_type(config.commit_editor, "commit_editor", "table") then + validate_type(config.commit_editor.show_staged_diff, "show_staged_diff", "boolean") validate_kind(config.commit_editor.kind, "commit_editor") end -- Commit Select View @@ -803,6 +1026,10 @@ function M.validate_config() if validate_type(config.reflog_view, "reflog_view", "table") then validate_kind(config.reflog_view.kind, "reflog_view.kind") end + -- refs view + if validate_type(config.refs_view, "refs_view", "table") then + validate_kind(config.refs_view.kind, "refs_view.kind") + end -- Merge Editor if validate_type(config.merge_editor, "merge_editor", "table") then validate_kind(config.merge_editor.kind, "merge_editor.kind") @@ -833,11 +1060,11 @@ function M.check_integration(name) if enabled == nil or enabled == "auto" then local success, _ = pcall(require, name) - logger.fmt_info("[CONFIG] Found auto integration '%s = %s'", name, success) + logger.info(("[CONFIG] Found auto integration '%s = %s'"):format(name, success)) return success end - logger.fmt_info("[CONFIG] Found explicit integration '%s' = %s", name, enabled) + logger.info(("[CONFIG] Found explicit integration '%s' = %s"):format(name, enabled)) return enabled end diff --git a/lua/neogit/integrations/diffview.lua b/lua/neogit/integrations/diffview.lua index d3db8752c..3de0ccd16 100644 --- a/lua/neogit/integrations/diffview.lua +++ b/lua/neogit/integrations/diffview.lua @@ -9,8 +9,8 @@ local dv_lib = require("diffview.lib") local dv_utils = require("diffview.utils") local neogit = require("neogit") -local repo = require("neogit.lib.git.repository") -local status = require("neogit.status") +local git = require("neogit.lib.git") +local status = require("neogit.buffers.status") local a = require("plenary.async") local old_config @@ -18,7 +18,7 @@ local old_config M.diffview_mappings = { close = function() vim.cmd("tabclose") - neogit.dispatch_refresh(nil, "diffview_close") + neogit.dispatch_refresh() dv.setup(old_config) end, } @@ -42,10 +42,10 @@ local function get_local_diff_view(section_name, item_name, opts) conflicting = { items = vim.tbl_filter(function(o) return o.mode and o.mode:sub(2, 2) == "U" - end, repo.untracked.items), + end, git.repo.state.untracked.items), }, - working = repo.unstaged, - staged = repo.staged, + working = git.repo.state.unstaged, + staged = git.repo.state.staged, } for kind, section in pairs(sections) do @@ -80,7 +80,7 @@ local function get_local_diff_view(section_name, item_name, opts) local files = update_files() local view = CDiffView { - git_root = repo.git_root, + git_root = git.repo.git_root, left = left, right = right, files = files, @@ -92,16 +92,19 @@ local function get_local_diff_view(section_name, item_name, opts) table.insert(args, "HEAD") end - return neogit.cli.show.file(unpack(args)).call_sync({ trim = false }).stdout + return git.cli.show.file(unpack(args)).call_sync({ trim = false }).stdout elseif kind == "working" then - local fdata = neogit.cli.show.file(path).call_sync({ trim = false }).stdout + local fdata = git.cli.show.file(path).call_sync({ trim = false }).stdout return side == "left" and fdata end end, } view:on_files_staged(a.void(function(_) - status.refresh({ update_diffs = true }, "on_files_staged") + if status.is_open() then + status.instance():dispatch_refresh({ update_diffs = true }, "on_files_staged") + end + view:update_files() end)) @@ -139,8 +142,10 @@ function M.open(section_name, item_name, opts) local range if type(item_name) == "table" then range = string.format("%s..%s", item_name[1], item_name[#item_name]) - else + elseif item_name ~= nil then range = string.format("%s^!", item_name:match("[a-f0-9]+")) + else + return end view = dv_lib.diffview_open(dv_utils.tbl_pack(range)) @@ -153,8 +158,10 @@ function M.open(section_name, item_name, opts) view = dv_lib.diffview_open(dv_utils.tbl_pack(stash_id .. "^!")) elseif section_name == "commit" then view = dv_lib.diffview_open(dv_utils.tbl_pack(item_name .. "^!")) - else + elseif section_name ~= nil then view = get_local_diff_view(section_name, item_name, opts) + else + view = dv_lib.diffview_open(dv_utils.tbl_pack(item_name .. "^!")) end if view then diff --git a/lua/neogit/lib.lua b/lua/neogit/lib.lua index 0981d3456..676c748ca 100644 --- a/lua/neogit/lib.lua +++ b/lua/neogit/lib.lua @@ -2,5 +2,4 @@ return { git = require("neogit.lib.git"), popup = require("neogit.lib.popup"), notification = require("neogit.lib.notification"), - mappings_manager = require("neogit.lib.mappings_manager"), } diff --git a/lua/neogit/lib/buffer.lua b/lua/neogit/lib/buffer.lua index f91560ce3..c5dc0cf3b 100644 --- a/lua/neogit/lib/buffer.lua +++ b/lua/neogit/lib/buffer.lua @@ -1,15 +1,18 @@ local api = vim.api local fn = vim.fn -package.loaded["neogit.buffer"] = nil +local logger = require("neogit.logger") +local util = require("neogit.lib.util") -__BUFFER_AUTOCMD_STORE = {} - -local mappings_manager = require("neogit.lib.mappings_manager") +local signs = require("neogit.lib.signs") local Ui = require("neogit.lib.ui") +local Path = require("plenary.path") + ---@class Buffer ---@field handle number ----@field mmanager MappingsManager +---@field win_handle number +---@field namespaces table +---@field autocmd_group number ---@field ui Ui ---@field kind string ---@field disable_line_numbers boolean @@ -22,18 +25,18 @@ local Buffer = { Buffer.__index = Buffer ---@param handle number +---@param win_handle number ---@return Buffer -function Buffer:new(handle) +function Buffer:new(handle, win_handle) local this = { + autocmd_group = api.nvim_create_augroup("Neogit-augroup-" .. handle, { clear = true }), handle = handle, + win_handle = win_handle, border = nil, - mmanager = mappings_manager.new(handle), kind = nil, -- how the buffer was opened. For more information look at the create function - namespace = api.nvim_create_namespace("neogit-buffer-" .. handle), - line_buffer = {}, - hl_buffer = {}, - sign_buffer = {}, - ext_buffer = {}, + namespaces = { + default = api.nvim_create_namespace("neogit-buffer-" .. handle), + }, } this.ui = Ui.new(this) @@ -47,7 +50,7 @@ end function Buffer:focus() local windows = fn.win_findbuf(self.handle) - if #windows == 0 then + if not windows or not windows[1] then return nil end @@ -55,31 +58,46 @@ function Buffer:focus() return windows[1] end +---@return boolean function Buffer:is_focused() return api.nvim_win_get_buf(0) == self.handle end +---@return number function Buffer:get_changedtick() return api.nvim_buf_get_changedtick(self.handle) end function Buffer:lock() - self:set_option("readonly", true) - self:set_option("modifiable", false) -end - -function Buffer:define_autocmd(events, script) - vim.cmd(string.format("au %s %s", events, self.handle, script)) + self:set_buffer_option("readonly", true) + self:set_buffer_option("modifiable", false) end function Buffer:clear() api.nvim_buf_set_lines(self.handle, 0, -1, false, {}) end +---@return table +function Buffer:save_view() + local view = fn.winsaveview() + return { + topline = view.topline, + leftcol = 0, + } +end + +---@param view table output of Buffer:save_view() +---@param cursor? number +function Buffer:restore_view(view, cursor) + if cursor then + view.lnum = math.min(fn.line("$"), cursor) + end + + fn.winrestview(view) +end + function Buffer:write() - self:call(function() - vim.cmd("silent w!") - end) + self:win_exec("silent w!") end function Buffer:get_lines(first, last, strict) @@ -103,54 +121,59 @@ function Buffer:insert_line(line) api.nvim_buf_set_lines(self.handle, line_nr, line_nr, false, { line }) end -function Buffer:buffered_set_line(line) - table.insert(self.line_buffer, line) +function Buffer:resize(length) + api.nvim_buf_set_lines(self.handle, length, -1, false, {}) end -function Buffer:buffered_add_highlight(...) - table.insert(self.hl_buffer, { ... }) +function Buffer:set_highlights(highlights) + for _, highlight in ipairs(highlights) do + self:add_highlight(unpack(highlight)) + end end -function Buffer:buffered_place_sign(...) - table.insert(self.sign_buffer, { ... }) +function Buffer:set_extmarks(extmarks) + for _, ext in ipairs(extmarks) do + self:set_extmark(unpack(ext)) + end end -function Buffer:buffered_set_extmark(...) - table.insert(self.ext_buffer, { ... }) +function Buffer:set_line_highlights(highlights) + for _, hl in ipairs(highlights) do + self:add_line_highlight(unpack(hl)) + end end -function Buffer:resize(length) - api.nvim_buf_set_lines(self.handle, length, -1, false, {}) -end +function Buffer:set_folds(folds) + self:set_window_option("foldmethod", "manual") -function Buffer:flush_buffers() - self:clear_namespace(self.namespace) + for _, fold in ipairs(folds) do + self:create_fold(unpack(fold)) + self:set_fold_state(unpack(fold)) + end +end - api.nvim_buf_set_lines(self.handle, 0, -1, false, self.line_buffer) - self.line_buffer = {} +function Buffer:set_text(first_line, last_line, first_col, last_col, lines) + api.nvim_buf_set_text(self.handle, first_line, first_col, last_line, last_col, lines) +end - for _, sign in ipairs(self.sign_buffer) do - self:place_sign(unpack(sign)) +---@param line nil|number|number[] +function Buffer:move_cursor(line) + if not line then + return end - self.sign_buffer = {} - for _, hl in ipairs(self.hl_buffer) do - self:add_highlight(unpack(hl)) - end - self.hl_buffer = {} + local position = { line, 0 } - for _, ext in ipairs(self.ext_buffer) do - self:set_extmark(unpack(ext)) + if type(line) == "table" then + position = line end - self.ext_buffer = {} -end -function Buffer:set_text(first_line, last_line, first_col, last_col, lines) - api.nvim_buf_set_text(self.handle, first_line, first_col, last_line, last_col, lines) + -- pcall used in case the line is out of bounds + pcall(api.nvim_win_set_cursor, self.win_handle, position) end -function Buffer:move_cursor(line) - api.nvim_win_set_cursor(0, { line, 0 }) +function Buffer:cursor_line() + return api.nvim_win_get_cursor(0)[1] end function Buffer:close(force) @@ -163,12 +186,25 @@ function Buffer:close(force) return end + if self.kind == "tab" then + local ok, _ = pcall(vim.cmd, "tabclose") + if not ok then + vim.cmd("tabnew") + vim.cmd("tabclose #") + end + + return + end + if api.nvim_buf_is_valid(self.handle) then local winnr = fn.bufwinnr(self.handle) if winnr ~= -1 then local winid = fn.win_getid(winnr) - if not pcall(api.nvim_win_close, winid, force) then - vim.cmd("b#") + local ok, _ = pcall(api.nvim_win_close, winid, force) + if not ok then + vim.schedule(function() + vim.cmd("b#") + end) end else api.nvim_buf_delete(self.handle, { force = force }) @@ -188,12 +224,17 @@ function Buffer:hide() elseif self.kind == "replace" then if self.old_buf and api.nvim_buf_is_loaded(self.old_buf) then api.nvim_set_current_buf(self.old_buf) + self.old_buf = nil end else - api.nvim_win_close(0, {}) + api.nvim_win_close(0, true) end end +function Buffer:is_visible() + return #fn.win_findbuf(self.handle) > 0 +end + ---@return number function Buffer:show() local windows = fn.win_findbuf(self.handle) @@ -214,26 +255,17 @@ function Buffer:show() local win local kind = self.kind + -- https://github.com/nvim-telescope/telescope.nvim/blame/49650f5d749fef3d1e6cf52ba031c02163a59158/lua/telescope/actions/set.lua#L93 if kind == "replace" then self.old_buf = api.nvim_get_current_buf() - api.nvim_set_current_buf(self.handle) - win = api.nvim_get_current_win() elseif kind == "tab" then - vim.cmd("tab split") - api.nvim_set_current_buf(self.handle) - win = api.nvim_get_current_win() + vim.cmd("tabnew") elseif kind == "split" then - vim.cmd("below split") - api.nvim_set_current_buf(self.handle) - win = api.nvim_get_current_win() + vim.cmd("new") elseif kind == "split_above" then - vim.cmd("top split") - api.nvim_set_current_buf(self.handle) - win = api.nvim_get_current_win() + vim.cmd("top new") elseif kind == "vsplit" then - vim.cmd("bot vsplit") - api.nvim_set_current_buf(self.handle) - win = api.nvim_get_current_win() + vim.cmd("vnew") elseif kind == "floating" then -- Creates the border window local vim_height = vim.o.lines @@ -252,13 +284,18 @@ function Buffer:show() row = row, style = "minimal", focusable = false, - border = "single", + border = "rounded", }) api.nvim_win_set_cursor(content_window, { 1, 0 }) win = content_window end + if kind ~= "floating" then + api.nvim_set_current_buf(self.handle) + win = api.nvim_get_current_win() + end + if self.disable_line_numbers then vim.cmd("setlocal nonu") end @@ -267,6 +304,13 @@ function Buffer:show() vim.cmd("setlocal nornu") end + -- Workaround UFO getting folds wrong. + local ufo, _ = pcall(require, "ufo") + if ufo then + require("ufo").detach() + end + + self.win_handle = win return win end @@ -274,114 +318,141 @@ function Buffer:is_valid() return api.nvim_buf_is_valid(self.handle) end -function Buffer:put(lines, after, follow) - self:focus() - api.nvim_put(lines, "l", after, follow) +function Buffer:create_fold(first, last, _) + self:win_exec(string.format("%d,%dfold", first, last)) end -function Buffer:create_fold(first, last) - vim.cmd(string.format(self.handle .. "bufdo %d,%dfold", first, last)) +function Buffer:set_fold_state(first, last, open) + self:win_exec(string.format("%d,%dfold%s", first, last, open and "open" or "close")) end function Buffer:unlock() - self:set_option("readonly", false) - self:set_option("modifiable", true) + self:set_buffer_option("readonly", false) + self:set_buffer_option("modifiable", true) end function Buffer:get_option(name) - return api.nvim_buf_get_option(self.handle, name) + if self.handle ~= nil then + return api.nvim_get_option_value(name, { buf = self.handle }) + end end -function Buffer:set_option(name, value) - api.nvim_buf_set_option(self.handle, name, value) +function Buffer:get_window_option(name) + if self.win_handle ~= nil then + return api.nvim_get_option_value(name, { win = self.win_handle }) + end end -function Buffer:set_name(name) - api.nvim_buf_set_name(self.handle, name) +function Buffer:set_buffer_option(name, value) + if self.handle ~= nil then + api.nvim_set_option_value(name, value, { buf = self.handle }) + end end -function Buffer:set_foldlevel(level) - vim.cmd("setlocal foldlevel=" .. level) +function Buffer:set_window_option(name, value) + if self.win_handle ~= nil then + api.nvim_set_option_value(name, value, { win = self.win_handle }) + end +end + +function Buffer:set_name(name) + api.nvim_buf_set_name(self.handle, name) end function Buffer:replace_content_with(lines) api.nvim_buf_set_lines(self.handle, 0, -1, false, lines) + self:write() end -function Buffer:open_fold(line, reset_pos) - local pos - if reset_pos == true then - pos = fn.getpos() +function Buffer:add_highlight(line, col_start, col_end, name, namespace) + local ns_id = self:get_namespace_id(namespace) + if ns_id then + api.nvim_buf_add_highlight(self.handle, ns_id, name, line, col_start, col_end) end +end - fn.setpos(".", { self.handle, line, 0, 0 }) - vim.cmd("normal zo") +function Buffer:place_sign(line, name, opts) + opts = opts or {} - if reset_pos == true then - fn.setpos(".", pos) + local ns_id = self:get_namespace_id(opts.namespace) + if ns_id then + api.nvim_buf_set_extmark(self.handle, ns_id, line - 1, 0, { + sign_text = signs.get(name), + sign_hl_group = opts.highlight, + cursorline_hl_group = opts.cursor_hl, + }) end end -function Buffer:add_highlight(line, col_start, col_end, name, ns_id) - local ns_id = ns_id or self.namespace +function Buffer:add_line_highlight(line, hl_group, opts) + opts = opts or {} - api.nvim_buf_add_highlight(self.handle, ns_id, name, line, col_start, col_end) -end - -function Buffer:unplace_sign(id) - vim.cmd("sign unplace " .. id) + local ns_id = self:get_namespace_id(opts.namespace) + if ns_id then + api.nvim_buf_set_extmark( + self.handle, + ns_id, + line, + 0, + { line_hl_group = hl_group, priority = opts.priority or 190 } + ) + end end -function Buffer:place_sign(line, name, group, id) - -- Sign IDs should be unique within a group, however there's no downside as - -- long as we don't want to uniquely identify the placed sign later. Thus, - -- we leave the choice to the caller - local sign_id = id or 1 +function Buffer:clear_namespace(name) + assert(name, "Cannot clear namespace without specifying which") - -- There's an equivalent function sign_place() which can automatically use - -- a free ID, but is considerable slower, so we use the command for now - local cmd = { - string.format("sign place %d", sign_id), - string.format("line=%d", line), - string.format("name=%s", name), - } + if not self:is_focused() then + return + end - if group then - table.insert(cmd, string.format("group=%s", group)) + local ns_id = self:get_namespace_id(name) + if ns_id then + api.nvim_buf_clear_namespace(self.handle, ns_id, 0, -1) end +end + +function Buffer:create_namespace(name) + assert(name, "Namespace must have a name") - table.insert(cmd, string.format("buffer=%d", self.handle)) + local namespace = "neogit-buffer-" .. self.handle .. "-" .. name + if not self.namespaces[namespace] then + self.namespaces[namespace] = api.nvim_create_namespace(namespace) + end - vim.cmd(table.concat(cmd, " ")) - return sign_id + return self.namespaces[namespace] end -function Buffer:get_sign_at_line(line, group) - group = group or "*" - return fn.sign_getplaced(self.handle, { - group = group, - lnum = line, - })[1] -end +---@param name string +---@return number|nil +function Buffer:get_namespace_id(name) + local ns_id + if name and name ~= "default" then + ns_id = self.namespaces["neogit-buffer-" .. self.handle .. "-" .. name] + else + ns_id = self.namespaces.default + end -function Buffer:clear_sign_group(group) - vim.cmd(string.format("sign unplace * group=%s buffer=%s", group, self.handle)) + return ns_id end -function Buffer:clear_namespace(namespace) - api.nvim_buf_clear_namespace(self.handle, namespace, 0, -1) +function Buffer:set_filetype(ft) + self:set_buffer_option("filetype", ft) end -function Buffer:create_namespace(name) - return api.nvim_create_namespace(name) +function Buffer:call(f, ...) + local args = { ... } + api.nvim_buf_call(self.handle, function() + f(unpack(args)) + end) end -function Buffer:set_filetype(ft) - api.nvim_buf_set_option(self.handle, "filetype", ft) +function Buffer:chan_send(data) + api.nvim_chan_send(api.nvim_open_term(self.handle, {}), data) end -function Buffer:call(f) - api.nvim_buf_call(self.handle, f) +function Buffer:win_exec(cmd) + fn.win_execute(self.win_handle, cmd) end function Buffer:exists() @@ -392,169 +463,317 @@ function Buffer:set_extmark(...) return api.nvim_buf_set_extmark(self.handle, ...) end -function Buffer:get_extmark(ns, id) - return api.nvim_buf_get_extmark_by_id(self.handle, ns, id, { details = true }) -end - -function Buffer:del_extmark(ns, id) - return api.nvim_buf_del_extmark(self.handle, ns, id) -end - function Buffer:set_decorations(namespace, opts) - return api.nvim_set_decoration_provider(namespace, opts) + local ns_id = self:get_namespace_id(namespace) + if ns_id then + return api.nvim_set_decoration_provider(ns_id, opts) + end end -local uv_utils = require("neogit.lib.uv") +function Buffer:set_header(text) + -- Create a blank line at the top of the buffer so our floating window doesn't + -- hide any content + self:set_extmark(self:get_namespace_id("default"), 0, 0, { + virt_lines = { { { "", "NeogitObjectId" } } }, + virt_lines_above = true, + }) + + -- Create a new buffer with the header text + local buf = api.nvim_create_buf(false, true) + api.nvim_buf_set_lines(buf, 0, -1, false, { (" %s"):format(text) }) + vim.bo[buf].undolevels = -1 + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].modified = false + + -- Display the buffer in a floating window + local winid = api.nvim_open_win(buf, false, { + relative = "win", + width = vim.o.columns, + height = 1, + row = 0, + col = 0, + focusable = false, + style = "minimal", + noautocmd = true, + }) + vim.wo[winid].wrap = false + vim.wo[winid].winhl = "NormalFloat:NeogitFloatHeader" + + fn.matchadd("NeogitFloatHeaderHighlight", [[\v\|\]], 100, -1, { window = winid }) + + -- Scroll the buffer viewport to the top so the header is visible + self:call(function() + api.nvim_input("") + end) +end ---@class BufferConfig ---@field name string ----@field load boolean ----@field bufhidden string|nil ----@field buftype string|nil ----@field swapfile boolean +---@field kind string ---@field filetype string|nil +---@field bufhidden string|nil +---@field header string|nil +---@field buftype string|nil|boolean +---@field cwd string|nil +---@field status_column string|nil +---@field load boolean|nil +---@field context_highlight boolean|nil +---@field open boolean|nil ---@field disable_line_numbers boolean|nil ---@field disable_relative_line_numbers boolean|nil +---@field disable_signs boolean|nil +---@field swapfile boolean|nil +---@field modifiable boolean|nil +---@field readonly boolean|nil +---@field mappings table|nil +---@field autocmds table|nil +---@field initialize function|nil +---@field after function|nil +---@field on_detach function|nil +---@field render function|nil +---@field foldmarkers boolean|nil + +---@param config BufferConfig ---@return Buffer function Buffer.create(config) - config = config or {} - local kind = config.kind or "split" - local disable_line_numbers = (config.disable_line_numbers == nil) and true or config.disable_line_numbers - local disable_relative_line_numbers = (config.disable_relative_line_numbers == nil) and true - or config.disable_relative_line_numbers - --- This reuses a buffer with the same name - local buffer = fn.bufnr(config.name) + assert(config, "Buffers work better if you configure them") - if buffer == -1 then - buffer = api.nvim_create_buf(false, false) - api.nvim_buf_set_name(buffer, config.name) - end + local buffer = Buffer.from_name(config.name) + + buffer.kind = config.kind or "split" + buffer.disable_line_numbers = (config.disable_line_numbers == nil) or config.disable_line_numbers + buffer.disable_relative_line_numbers = (config.disable_relative_line_numbers == nil) + or config.disable_relative_line_numbers if config.load then - local content = uv_utils.read_file_sync(config.name) - api.nvim_buf_set_lines(buffer, 0, -1, false, content) - api.nvim_buf_call(buffer, function() - vim.cmd("silent w!") - end) + logger.debug("[BUFFER:" .. buffer.handle .. "] Loading content from file: " .. config.name) + buffer:replace_content_with(Path:new(config.name):readlines()) end - local buffer = Buffer:new(buffer) - buffer.kind = kind - buffer.disable_line_numbers = disable_line_numbers - buffer.disable_relative_line_numbers = disable_relative_line_numbers - local win if config.open ~= false then win = buffer:show() + logger.debug("[BUFFER:" .. buffer.handle .. "] Showing buffer in window " .. win) + end + + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting buffer options") + buffer:set_buffer_option("swapfile", false) + buffer:set_buffer_option("bufhidden", config.bufhidden or "wipe") + buffer:set_buffer_option("modifiable", config.modifiable or false) + buffer:set_buffer_option("modified", config.modifiable or false) + buffer:set_buffer_option("readonly", config.readonly or false) + + if config.buftype ~= false then + buffer:set_buffer_option("buftype", config.buftype or "nofile") end - buffer:set_option("bufhidden", config.bufhidden or "wipe") - buffer:set_option("buftype", config.buftype or "nofile") - buffer:set_option("swapfile", false) + if vim.fn.has("nvim-0.10") ~= 1 then + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting foldtext function for nvim < 0.10") + -- selene: allow(global_usage) + _G.NeogitFoldText = function() + return vim.fn.getline(vim.v.foldstart) + end + + buffer:set_buffer_option("foldtext", "v:lua._G.NeogitFoldText()") + end if config.filetype then + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting filetype: " .. config.filetype) buffer:set_filetype(config.filetype) end if config.mappings then + logger.debug("[BUFFER:" .. buffer.handle .. "] Building mappings") for mode, val in pairs(config.mappings) do for key, cb in pairs(val) do - if type(key) == "string" then - buffer.mmanager.mappings[mode][key] = function() - cb(buffer) - end - elseif type(key) == "table" then - for _, k in ipairs(key) do - buffer.mmanager.mappings[mode][k] = function() - cb(buffer) - end + local fn = function() + cb(buffer) + + if mode == "v" then + api.nvim_feedkeys(api.nvim_replace_termcodes("", true, false, true), "n", false) end end + + local opts = { buffer = buffer.handle, silent = true, nowait = true } + + for _, k in ipairs(util.tbl_wrap(key)) do + vim.keymap.set(mode, k, fn, opts) + end end end end if config.initialize then + logger.debug("[BUFFER:" .. buffer.handle .. "] Initializing buffer") config.initialize(buffer, win) end + if win then + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting window options") + + buffer:set_window_option("foldenable", true) + buffer:set_window_option("foldlevel", 99) + buffer:set_window_option("foldminlines", 0) + buffer:set_window_option("foldtext", "") + buffer:set_window_option("listchars", "") + buffer:set_window_option("list", false) + buffer:call(function() + vim.opt_local.winhl:append("Folded:NeogitFold") + vim.opt_local.winhl:append("Normal:NeogitNormal") + vim.opt_local.winhl:append("WinSeparator:NeogitWinSeparator") + vim.opt_local.winhl:append("CursorLineNr:NeogitCursorLineNr") + vim.opt_local.fillchars:append("fold: ") + end) + + if vim.fn.has("nvim-0.10") == 1 then + buffer:set_window_option("spell", false) + buffer:set_window_option("wrap", false) + buffer:set_window_option("foldmethod", "manual") + -- TODO: Need to find a way to turn this off properly when unloading plugin + -- buffer:set_window_option("winfixbuf", true) + end + end + if config.render then + logger.debug("[BUFFER:" .. buffer.handle .. "] Rendering buffer") buffer.ui:render(unpack(config.render(buffer))) end - local neogit_augroup = require("neogit").autocmd_group for event, callback in pairs(config.autocmds or {}) do - api.nvim_create_autocmd(event, { callback = callback, buffer = buffer.handle, group = neogit_augroup }) - end - - buffer.mmanager.register() - - if not config.modifiable then - buffer:set_option("modifiable", false) - buffer:set_option("modified", false) - end + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting autocmd for: " .. event) + api.nvim_create_autocmd(event, { + callback = callback, + buffer = buffer.handle, + group = buffer.autocmd_group, + }) - if config.readonly == true then - buffer:set_option("readonly", true) + api.nvim_buf_attach(buffer.handle, false, { + on_detach = function() + logger.debug("[BUFFER:" .. buffer.handle .. "] Clearing autocmd group") + api.nvim_del_augroup_by_id(buffer.autocmd_group) + end, + }) end if config.after then + logger.debug("[BUFFER:" .. buffer.handle .. "] Running config.after callback") buffer:call(function() config.after(buffer, win) end) end - buffer:call(function() - -- Set fold styling for Neogit windows while preserving user styling - vim.opt_local.winhl:append("Folded:NeogitFold") - - -- Set signcolumn unless disabled by user settings - if not config.disable_signs then - vim.opt_local.signcolumn = "auto" - end - end) + if config.on_detach then + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting up on_detach callback") + api.nvim_buf_attach(buffer.handle, false, { + on_detach = function() + logger.debug("[BUFFER:" .. buffer.handle .. "] Running on_detach") + config.on_detach(buffer) + end, + }) + end if config.context_highlight then - buffer:call(function() - local decor_ns = api.nvim_create_namespace("NeogitBufferViewDecor" .. config.name) - local context_ns = api.nvim_create_namespace("NeogitBufferitViewContext" .. config.name) + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting up context highlighting") + buffer:create_namespace("ViewContext") + buffer:set_decorations("ViewContext", { + on_start = function() + return buffer:exists() and buffer:is_valid() and buffer:is_focused() + end, + on_win = function() + buffer:clear_namespace("ViewContext") + + local context = buffer.ui:get_cursor_context() + if not context then + return + end - local function on_start() - return buffer:exists() and buffer:is_focused() - end + local cursor = fn.line(".") + local start = math.max(context.position.row_start, fn.line("w0")) + local stop = math.min(context.position.row_end, fn.line("w$")) - local function on_win() - buffer:clear_namespace(context_ns) + for line = start, stop do + local line_hl = ("%s%s"):format( + buffer.ui:get_line_highlight(line) or "NeogitDiffContext", + line == cursor and "Cursor" or "Highlight" + ) - -- TODO: this is WAY to slow to be called so frequently, especially in a large buffer - local stack = buffer.ui:get_component_stack_under_cursor() - if not stack then - return + buffer:add_line_highlight(line - 1, line_hl, { + priority = 200, + namespace = "ViewContext", + }) end + end, + }) + end - local hovered_component = stack[2] or stack[1] - local first, last = hovered_component:row_range_abs() - local top_level = hovered_component.parent and not hovered_component.parent.parent + if config.status_column then + vim.opt_local.statuscolumn = config.status_column + vim.opt_local.signcolumn = "no" + end + if config.foldmarkers and not config.disable_signs then + vim.opt_local.signcolumn = "auto" + + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting up foldmarkers") + buffer:create_namespace("FoldSigns") + buffer:set_decorations("FoldSigns", { + on_start = function() + return buffer:exists() and buffer:is_valid() and buffer:is_focused() + end, + on_win = function() + buffer:clear_namespace("FoldSigns") + local foldmarkers = buffer.ui.statuscolumn.foldmarkers for line = fn.line("w0"), fn.line("w$") do - if first and last and line >= first and line <= last and not top_level then - local sign = buffer.ui:get_component_stack_on_line(line)[1].options.sign - - buffer:set_extmark( - context_ns, - line - 1, - 0, - { line_hl_group = (sign or "NeogitDiffContext") .. "Highlight", priority = 10 } - ) + if foldmarkers[line] then + local fold + + if fn.foldclosed(line) == -1 then + fold = "NeogitOpen" + else + fold = "NeogitClosed" + end + + buffer:place_sign(line, fold .. string.lower(foldmarkers[line]), { + namespace = "FoldSigns", + highlight = "NeogitSubtleText", + cursor_hl = "NeogitCursorLine", + }) + else + buffer:place_sign(line, "NeogitBlank", { + namespace = "FoldSigns", + cursor_hl = "NeogitCursorLine", + }) end end - end + end, + }) + end - buffer:set_decorations(decor_ns, { on_start = on_start, on_win = on_win }) - end) + if config.header then + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting header") + buffer:set_header(config.header) + end + + if config.cwd then + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting CWD to: " .. config.cwd) + buffer:win_exec("lcd " .. config.cwd) end return buffer end +---@param name string +---@return Buffer +function Buffer.from_name(name) + local buffer_handle = fn.bufnr(name) + if buffer_handle == -1 then + buffer_handle = api.nvim_create_buf(false, false) + api.nvim_buf_set_name(buffer_handle, name) + end + + local window_handle = fn.win_findbuf(buffer_handle) + + return Buffer:new(buffer_handle, window_handle[1]) +end + return Buffer diff --git a/lua/neogit/lib/finder.lua b/lua/neogit/lib/finder.lua index 139293faa..df3ddd4f3 100644 --- a/lua/neogit/lib/finder.lua +++ b/lua/neogit/lib/finder.lua @@ -2,10 +2,10 @@ local config = require("neogit.config") local a = require("plenary.async") local function refocus_status_buffer() - local status = require("neogit.status") - if status.status_buffer then - status.status_buffer:focus() - status.dispatch_refresh(nil, "finder.refocus") + local status = require("neogit.buffers.status") + if status.instance() then + status.instance():focus() + status.instance():dispatch_refresh(nil, "finder.refocus") end end @@ -71,12 +71,21 @@ local function telescope_mappings(on_select, allow_multi, refocus_status) ["MultiselectTogglePrevious"] = actions.toggle_selection + actions.move_selection_better, } + -- Telescope HEAD has mouse click support, but not the latest tag. Need to check if the user has + -- support for mouse click, while avoiding the error that the metatable raises. + -- stylua: ignore + if pcall(function() return actions.mouse_click and true end) then + commands.ScrollWheelDown = actions.move_selection_next + commands.ScrollWheelUp = actions.move_selection_previous + commands.MouseClick = actions.mouse_click + end + for mapping, command in pairs(config.values.mappings.finder) do - if command:match("^Multiselect") then + if command and command:match("^Multiselect") then if allow_multi then map({ "i" }, mapping, commands[command]) end - else + elseif command then map({ "i" }, mapping, commands[command]) end end diff --git a/lua/neogit/lib/fs.lua b/lua/neogit/lib/fs.lua deleted file mode 100644 index c05f5a678..000000000 --- a/lua/neogit/lib/fs.lua +++ /dev/null @@ -1,14 +0,0 @@ -local cli = require("neogit.lib.git.cli") - -local M = {} - -function M.relpath_from_repository(path) - local result = cli["ls-files"].others.cached.modified.deleted.full_name - .args(path) - .show_popup(false) - .call { hidden = true } - - return result.stdout[1] -end - -return M diff --git a/lua/neogit/lib/functional.lua b/lua/neogit/lib/functional.lua deleted file mode 100644 index c248daeeb..000000000 --- a/lua/neogit/lib/functional.lua +++ /dev/null @@ -1,33 +0,0 @@ -local util = require("neogit.lib.util") -local collect = require("neogit.lib.collection") -local M = {} - -function M.dot(chain) - local parts = collect(util.split(chain, "%.")) - return function(tbl) - parts:each(function(p) - if tbl then - tbl = tbl[p] - end - end) - return tbl - end -end - -function M.compose(...) - local funcs = collect { ... } - return function(...) - return funcs:reduce(function(cur, ...) - return cur(...) - end, ...) - end -end -M.C = M.compose - -function M.eq(a) - return function(b) - return a == b - end -end - -return M diff --git a/lua/neogit/lib/git.lua b/lua/neogit/lib/git.lua index 00b7a98ae..3f0a7f669 100644 --- a/lua/neogit/lib/git.lua +++ b/lua/neogit/lib/git.lua @@ -1,28 +1,42 @@ -return { - repo = require("neogit.lib.git.repository"), - rev_parse = require("neogit.lib.git.rev_parse"), - cli = require("neogit.lib.git.cli"), - init = require("neogit.lib.git.init"), - status = require("neogit.lib.git.status"), - stash = require("neogit.lib.git.stash"), - files = require("neogit.lib.git.files"), - fetch = require("neogit.lib.git.fetch"), - log = require("neogit.lib.git.log"), - refs = require("neogit.lib.git.refs"), - tag = require("neogit.lib.git.tag"), - reflog = require("neogit.lib.git.reflog"), - branch = require("neogit.lib.git.branch"), - diff = require("neogit.lib.git.diff"), - rebase = require("neogit.lib.git.rebase"), - merge = require("neogit.lib.git.merge"), - cherry_pick = require("neogit.lib.git.cherry_pick"), - reset = require("neogit.lib.git.reset"), - revert = require("neogit.lib.git.revert"), - remote = require("neogit.lib.git.remote"), - config = require("neogit.lib.git.config"), - sequencer = require("neogit.lib.git.sequencer"), - pull = require("neogit.lib.git.pull"), - push = require("neogit.lib.git.push"), - index = require("neogit.lib.git.index"), - worktree = require("neogit.lib.git.worktree"), -} +---@class NeogitGitLib +---@field repo NeogitRepo +---@field bisect NeogitGitBisect +---@field branch NeogitGitBranch +---@field cherry NeogitGitCherry +---@field cherry_pick NeogitGitCherryPick +---@field cli NeogitGitCLI +---@field config NeogitGitConfig +---@field diff NeogitGitDiff +---@field fetch NeogitGitFetch +---@field files NeogitGitFiles +---@field index NeogitGitIndex +---@field init NeogitGitInit +---@field log NeogitGitLog +---@field merge NeogitGitMerge +---@field pull NeogitGitPull +---@field push NeogitGitPush +---@field rebase NeogitGitRebase +---@field reflog NeogitGitReflog +---@field refs NeogitGitRefs +---@field remote NeogitGitRemote +---@field reset NeogitGitReset +---@field rev_parse NeogitGitRevParse +---@field revert NeogitGitRevert +---@field sequencer NeogitGitSequencer +---@field stash NeogitGitStash +---@field status NeogitGitStatus +---@field tag NeogitGitTag +---@field worktree NeogitGitWorktree +local Git = {} + +setmetatable(Git, { + __index = function(_, k) + if k == "repo" then + return require("neogit.lib.git.repository").instance() + else + return require("neogit.lib.git." .. k) + end + end, +}) + +return Git diff --git a/lua/neogit/lib/git/bisect.lua b/lua/neogit/lib/git/bisect.lua new file mode 100644 index 000000000..3062c0474 --- /dev/null +++ b/lua/neogit/lib/git/bisect.lua @@ -0,0 +1,106 @@ +local git = require("neogit.lib.git") + +---@class NeogitGitBisect +local M = {} + +local function fire_bisect_event(data) + vim.api.nvim_exec_autocmds("User", { pattern = "NeogitBisect", modeline = false, data = data }) +end + +---@param cmd string +local function bisect(cmd) + local result = git.cli.bisect.args(cmd).call() + + if result.code == 0 then + fire_bisect_event { type = cmd } + end +end + +function M.in_progress() + return git.repo:git_path("BISECT_LOG"):exists() +end + +function M.is_finished() + return git.repo.state.bisect.finished +end + +---@param bad_revision string +---@param good_revision string +---@param args? table +function M.start(bad_revision, good_revision, args) + local result = git.cli.bisect.args("start").arg_list(args).args(bad_revision, good_revision).call() + + if result.code == 0 then + fire_bisect_event { type = "start" } + end +end + +function M.good() + bisect("good") +end + +function M.bad() + bisect("bad") +end + +function M.skip() + bisect("skip") +end + +function M.reset() + bisect("reset") +end + +---@param command string +function M.run(command) + git.cli.bisect.args("run", command).call() +end + +---@class BisectItem +---@field action string +---@field oid string +---@field subject string +---@field abbreviated_commit string +---@field finished boolean + +M.register = function(meta) + meta.update_bisect_information = function(state) + state.bisect = { items = {}, finished = false, current = {} } + + if not M.in_progress() then + return + end + + local finished + + for line in git.repo:git_path("BISECT_LOG"):iter() do + if line:match("^#") and line ~= "" then + local action, oid, subject = line:match("^# ([^:]+): %[(.+)%] (.+)") + + finished = action == "first bad commit" + if finished then + fire_bisect_event { type = "finished", oid = oid } + end + + ---@type BisectItem + local item = { + finished = finished, + action = action, + subject = subject, + oid = oid, + abbreviated_commit = oid:sub(1, git.log.abbreviated_size()), + } + + table.insert(state.bisect.items, item) + end + end + + local expected = vim.trim(git.repo:git_path("BISECT_EXPECTED_REV"):read()) + state.bisect.current = + git.log.parse(git.cli.show.format("fuller").args(expected).call_sync({ trim = false }).stdout)[1] + + state.bisect.finished = finished + end +end + +return M diff --git a/lua/neogit/lib/git/branch.lua b/lua/neogit/lib/git/branch.lua index ec406ff42..0f6a0c1b8 100644 --- a/lua/neogit/lib/git/branch.lua +++ b/lua/neogit/lib/git/branch.lua @@ -1,10 +1,10 @@ -local cli = require("neogit.lib.git.cli") -local config_lib = require("neogit.lib.git.config") +local git = require("neogit.lib.git") local config = require("neogit.config") local util = require("neogit.lib.util") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") +---@class NeogitGitBranch local M = {} local function parse_branches(branches, include_current) @@ -15,13 +15,20 @@ local function parse_branches(branches, include_current) local head = "^(.*)/HEAD" local ref = " %-> " local detached = "^%(HEAD detached at %x%x%x%x%x%x%x" + local no_branch = "^%(no branch," local pattern = include_current and "^[* ] (.+)" or "^ (.+)" for _, b in ipairs(branches) do local branch_name = b:match(pattern) if branch_name then local name = branch_name:match(remotes) or branch_name - if name and not name:match(ref) and not name:match(head) and not name:match(detached) then + if + name + and not name:match(ref) + and not name:match(head) + and not name:match(detached) + and not name:match(no_branch) + then table.insert(other_branches, name) end end @@ -34,7 +41,7 @@ function M.get_recent_local_branches() local valid_branches = M.get_local_branches() local branches = util.filter_map( - cli.reflog.show.format("%gs").date("relative").call_sync().stdout, + git.cli.reflog.show.format("%gs").date("relative").call_sync().stdout, function(ref) local name = ref:match("^checkout: moving from .* to (.*)$") if vim.tbl_contains(valid_branches, name) then @@ -47,41 +54,40 @@ function M.get_recent_local_branches() end function M.checkout(name, args) - cli.checkout.branch(name).arg_list(args or {}).call_sync() + git.cli.checkout.branch(name).arg_list(args or {}).call_sync() if config.values.fetch_after_checkout then - local fetch = require("neogit.lib.git.fetch") local pushRemote = M.pushRemote_ref(name) local upstream = M.upstream(name) if upstream and upstream == pushRemote then local remote, branch = M.parse_remote_branch(upstream) - fetch.fetch(remote, branch) + git.fetch.fetch(remote, branch) else if upstream then local remote, branch = M.parse_remote_branch(upstream) - fetch.fetch(remote, branch) + git.fetch.fetch(remote, branch) end if pushRemote then local remote, branch = M.parse_remote_branch(pushRemote) - fetch.fetch(remote, branch) + git.fetch.fetch(remote, branch) end end end end function M.track(name, args) - cli.checkout.track(name).arg_list(args or {}).call_sync() + git.cli.checkout.track(name).arg_list(args or {}).call_sync() end function M.get_local_branches(include_current) - local branches = cli.branch.list(config.values.sort_branches).call_sync().stdout + local branches = git.cli.branch.list(config.values.sort_branches).call_sync().stdout return parse_branches(branches, include_current) end function M.get_remote_branches(include_current) - local branches = cli.branch.remotes.list(config.values.sort_branches).call_sync().stdout + local branches = git.cli.branch.remotes.list(config.values.sort_branches).call_sync().stdout return parse_branches(branches, include_current) end @@ -90,11 +96,11 @@ function M.get_all_branches(include_current) end function M.is_unmerged(branch, base) - return cli.cherry.arg_list({ base or M.base_branch(), branch }).call_sync().stdout[1] ~= nil + return git.cli.cherry.arg_list({ base or M.base_branch(), branch }).call_sync().stdout[1] ~= nil end function M.base_branch() - local value = config_lib.get("neogit.baseBranch") + local value = git.config.get("neogit.baseBranch") if value:is_set() then return value:read() else @@ -110,7 +116,7 @@ end ---@param branch string ---@return boolean function M.exists(branch) - local result = cli["rev-parse"].verify.quiet + local result = git.cli["rev-parse"].verify.quiet .args(string.format("refs/heads/%s", branch)) .call_sync { hidden = true, ignore_error = true } @@ -133,7 +139,7 @@ end ---@param name string ---@param base_branch? string function M.create(name, base_branch) - cli.branch.args(name, base_branch).call() + git.cli.branch.args(name, base_branch).call() end function M.delete(name) @@ -141,16 +147,12 @@ function M.delete(name) local result if M.is_unmerged(name) then - if - input.get_confirmation( - string.format("'%s' contains unmerged commits! Are you sure you want to delete it?", name), - { values = { "&Yes", "&No" }, default = 2 } - ) - then - result = cli.branch.delete.force.name(name).call_sync() + local message = ("'%s' contains unmerged commits! Are you sure you want to delete it?"):format(name) + if input.get_permission(message) then + result = git.cli.branch.delete.force.name(name).call_sync() end else - result = cli.branch.delete.name(name).call_sync() + result = git.cli.branch.delete.name(name).call_sync() end return result and result.code == 0 or false @@ -159,11 +161,11 @@ end ---Returns current branch name, or nil if detached HEAD ---@return string|nil function M.current() - local head = require("neogit.lib.git").repo.head.branch + local head = git.repo.state.head.branch if head and head ~= "(detached)" then return head else - local branch_name = cli.branch.current.call_sync().stdout + local branch_name = git.cli.branch.current.call_sync().stdout if #branch_name > 0 then return branch_name[1] end @@ -175,7 +177,7 @@ end function M.current_full_name() local current = M.current() if current then - return cli["rev-parse"].symbolic_full_name.args(current).call_sync().stdout[1] + return git.cli["rev-parse"].symbolic_full_name.args(current).call_sync().stdout[1] end end @@ -183,7 +185,7 @@ function M.pushRemote(branch) branch = branch or M.current() if branch then - local remote = config_lib.get("branch." .. branch .. ".pushRemote") + local remote = git.config.get("branch." .. branch .. ".pushRemote") if remote:is_set() then return remote.value end @@ -208,12 +210,12 @@ function M.pushRemote_remote_label() end function M.is_detached() - return require("neogit.lib.git").repo.head.branch == "(detached)" + return git.repo.state.head.branch == "(detached)" end function M.set_pushRemote() - local remotes = require("neogit.lib.git").remote.list() - local pushDefault = require("neogit.lib.git").config.get("remote.pushDefault") + local remotes = git.remote.list() + local pushDefault = git.config.get("remote.pushDefault") local pushRemote if #remotes == 1 then @@ -225,7 +227,7 @@ function M.set_pushRemote() end if pushRemote then - config_lib.set(string.format("branch.%s.pushRemote", M.current()), pushRemote) + git.config.set(string.format("branch.%s.pushRemote", M.current()), pushRemote) end return pushRemote @@ -237,7 +239,7 @@ end ---@return string|nil function M.upstream(name) if name then - local result = cli["rev-parse"].symbolic_full_name + local result = git.cli["rev-parse"].symbolic_full_name .abbrev_ref() .args(name .. "@{upstream}") .call { ignore_error = true } @@ -246,7 +248,7 @@ function M.upstream(name) return result.stdout[1] end else - return require("neogit.lib.git").repo.upstream.ref + return git.repo.state.upstream.ref end end @@ -259,8 +261,7 @@ function M.upstream_remote_label() end function M.upstream_remote() - local git = require("neogit.lib.git") - local remote = git.repo.upstream.remote + local remote = git.repo.state.upstream.remote if not remote then local remotes = git.remote.list() @@ -275,8 +276,6 @@ function M.upstream_remote() end local function update_branch_information(state) - local git = require("neogit.lib.git") - if state.head.oid ~= "(initial)" then state.head.commit_message = git.log.message(state.head.oid) @@ -284,15 +283,22 @@ local function update_branch_information(state) local commit = git.log.list({ state.upstream.ref, "--max-count=1" }, nil, {}, true)[1] -- May be done earlier by `update_status`, but this function can be called separately if commit then + state.upstream.oid = commit.oid state.upstream.commit_message = commit.subject state.upstream.abbrev = git.rev_parse.abbreviate_commit(commit.oid) end end - local pushRemote = require("neogit.lib.git").branch.pushRemote_ref() + local pushRemote = git.branch.pushRemote_ref() if pushRemote and not git.branch.is_detached() then + local remote, branch = unpack(vim.split(pushRemote, "/")) + state.pushRemote.ref = pushRemote + state.pushRemote.remote = remote + state.pushRemote.branch = branch + local commit = git.log.list({ pushRemote, "--max-count=1" }, nil, {}, true)[1] if commit then + state.pushRemote.oid = commit.oid state.pushRemote.commit_message = commit.subject state.pushRemote.abbrev = git.rev_parse.abbreviate_commit(commit.oid) end diff --git a/lua/neogit/lib/git/cherry.lua b/lua/neogit/lib/git/cherry.lua new file mode 100644 index 000000000..ff7af819f --- /dev/null +++ b/lua/neogit/lib/git/cherry.lua @@ -0,0 +1,15 @@ +local git = require("neogit.lib.git") +local util = require("neogit.lib.util") + +---@class NeogitGitCherry +local M = {} + +function M.list(upstream, head) + local result = git.cli.cherry.verbose.args(upstream, head).call().stdout + return util.reverse(util.map(result, function(cherry) + local status, oid, subject = cherry:match("([%+%-]) (%x+) (.*)") + return { status = status, oid = oid, subject = subject } + end)) +end + +return M diff --git a/lua/neogit/lib/git/cherry_pick.lua b/lua/neogit/lib/git/cherry_pick.lua index 17793c881..a70cd964e 100644 --- a/lua/neogit/lib/git/cherry_pick.lua +++ b/lua/neogit/lib/git/cherry_pick.lua @@ -1,7 +1,8 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local notification = require("neogit.lib.notification") local util = require("neogit.lib.util") +---@class NeogitGitCherryPick local M = {} local function fire_cherrypick_event(data) @@ -9,7 +10,7 @@ local function fire_cherrypick_event(data) end function M.pick(commits, args) - local result = cli["cherry-pick"].arg_list(util.merge(args, commits)).call() + local result = git.cli["cherry-pick"].arg_list(util.merge(args, commits)).call() if result.code ~= 0 then notification.error("Cherry Pick failed. Resolve conflicts before continuing") else @@ -24,7 +25,7 @@ function M.apply(commits, args) end end) - local result = cli["cherry-pick"].no_commit.arg_list(util.merge(args, commits)).call() + local result = git.cli["cherry-pick"].no_commit.arg_list(util.merge(args, commits)).call() if result.code ~= 0 then notification.error("Cherry Pick failed. Resolve conflicts before continuing") else @@ -33,15 +34,15 @@ function M.apply(commits, args) end function M.continue() - cli["cherry-pick"].continue.call_sync() + git.cli["cherry-pick"].continue.call_sync() end function M.skip() - cli["cherry-pick"].skip.call_sync() + git.cli["cherry-pick"].skip.call_sync() end function M.abort() - cli["cherry-pick"].abort.call_sync() + git.cli["cherry-pick"].abort.call_sync() end return M diff --git a/lua/neogit/lib/git/cli.lua b/lua/neogit/lib/git/cli.lua index f8ee425ba..b16626ccf 100644 --- a/lua/neogit/lib/git/cli.lua +++ b/lua/neogit/lib/git/cli.lua @@ -1,7 +1,9 @@ local logger = require("neogit.logger") +local git = require("neogit.lib.git") local process = require("neogit.process") local util = require("neogit.lib.util") local Path = require("plenary.path") +local input = require("neogit.lib.input") local function config(setup) setup = setup or {} @@ -31,8 +33,26 @@ local configurations = { }, }, + ["name-rev"] = config { + flags = { + name_only = "--name-only", + no_undefined = "--no-undefined", + }, + options = { + refs = "--refs", + exclude = "--exclude", + }, + }, + init = config {}, + ["checkout-index"] = config { + flags = { + all = "--all", + force = "--force", + }, + }, + worktree = config { flags = { add = "add", @@ -42,6 +62,12 @@ local configurations = { }, }, + rm = config { + flags = { + cached = "--cached", + }, + }, + status = config { flags = { short = "-s", @@ -116,6 +142,7 @@ local configurations = { diff = config { flags = { cached = "--cached", + stat = "--stat", shortstat = "--shortstat", patch = "--patch", name_only = "--name-only", @@ -218,6 +245,7 @@ local configurations = { detach = "--detach", ours = "--ours", theirs = "--theirs", + merge = "--merge", }, aliases = { track = function(tbl) @@ -250,11 +278,6 @@ local configurations = { return tbl.args(branch, start_point).b() end end, - file = function(tbl) - return function(file) - return tbl.args(file) - end - end, }, }, @@ -295,6 +318,20 @@ local configurations = { }, }, + absorb = config { + flags = { + verbose = "--verbose", + and_rebase = "--and-rebase", + }, + aliases = { + base = function(tbl) + return function(commit) + return tbl.args("--base", commit) + end + end, + }, + }, + commit = config { flags = { all = "--all", @@ -309,7 +346,7 @@ local configurations = { aliases = { with_message = function(tbl) return function(message) - return tbl.args("-F", "-").input(message) + return tbl.args("-F", "-").input(message .. "\04") end end, message = function(tbl) @@ -497,6 +534,7 @@ local configurations = { deduplicate = "--deduplicate", exclude_standard = "--exclude-standard", full_name = "--full-name", + error_unmatch = "--error-unmatch", }, }, @@ -524,6 +562,7 @@ local configurations = { ["for-each-ref"] = config { options = { format = "--format", + sort = "--sort", }, }, @@ -564,52 +603,24 @@ local configurations = { }, ["verify-commit"] = config {}, + + ["bisect"] = config {}, } --- NOTE: Use require("neogit.lib.git.repository").git_root instead of calling this function. +-- NOTE: Use require("neogit.lib.git").repo.git_root instead of calling this function. -- repository.git_root is used by all other library functions, so it's most likely the one you want to use. -- git_root_of_cwd() returns the git repo of the cwd, which can change anytime -- after git_root_of_cwd() has been called. -local function git_root_of_cwd() - local job = require("plenary.job") - local args = { "rev-parse", "--show-toplevel" } - local gitdir = Path:new(vim.fn.getcwd()):absolute() -- default to current directory - job - :new({ - command = "git", - args = args, - on_exit = function(job_output, return_val) - if return_val == 0 then - -- Replace directory with the output of the git toplevel directory - gitdir = Path:new(job_output:result()):absolute() - else - logger.warn("[CLI]: ", job_output:result()) - end - end, - }) - :sync() - return gitdir +local function git_root(dir) + local cmd = { "git", "-C", dir, "rev-parse", "--show-toplevel" } + local result = vim.system(cmd, { text = true }):wait() + return Path:new(vim.trim(result.stdout)):absolute() end -local is_inside_worktree = function(cwd) - local job = require("plenary.job") - local args = { "rev-parse", "--is-inside-work-tree" } - local returnval = false - if cwd then - args = { "-C", cwd, "rev-parse", "--is-inside-work-tree" } - end - job - :new({ - command = "git", - args = args, - on_exit = function(_, return_val) - if return_val == 0 then - returnval = true - end - end, - }) - :sync() - return returnval +local function is_inside_worktree(dir) + local cmd = { "git", "-C", dir, "rev-parse", "--is-inside-work-tree" } + local result = vim.system(cmd):wait() + return result.code == 0 end local history = {} @@ -779,60 +790,69 @@ local mt_builder = { end, } +---@param line string +---@return string +local function handle_interactive_authenticity(line) + logger.debug("[CLI]: Confirming whether to continue with unauthenticated host") + + local prompt = line + return input.get_user_input( + "The authenticity of the host can't be established." .. prompt .. "", + { cancel = "__CANCEL__" } + ) or "__CANCEL__" +end + +---@param line string +---@return string +local function handle_interactive_username(line) + logger.debug("[CLI]: Asking for username") + + local prompt = line:match("(.*:?):.*") + return input.get_user_input(prompt, { cancel = "__CANCEL__" }) or "__CANCEL__" +end + +---@param line string +---@return string +local function handle_interactive_password(line) + logger.debug("[CLI]: Asking for password") + + local prompt = line:match("(.*:?):.*") + return input.get_secret_user_input(prompt, { cancel = "__CANCEL__" }) or "__CANCEL__" +end + ---@param p Process ---@param line string -local function handle_interactive_password_questions(p, line) - process.hide_preview_buffers() +---@return boolean +local function handle_line_interactive(p, line) logger.debug(string.format("Matching interactive cmd output: '%s'", line)) - if vim.startswith(line, "Are you sure you want to continue connecting ") then - logger.debug("[CLI]: Confirming whether to continue with unauthenticated host") - local prompt = line - local value = vim.fn.input { - prompt = "The authenticity of the host can't be established. " .. prompt .. " ", - cancelreturn = "__CANCEL__", - } - if value ~= "__CANCEL__" then - logger.debug("[CLI]: Received answer") - p:send(value .. "\r\n") - else + + local handler + if line:match("^Are you sure you want to continue connecting ") then + handler = handle_interactive_authenticity + elseif line:match("^Username for ") then + handler = handle_interactive_username + elseif line:match("^Enter passphrase") or line:match("^Password for") then + handler = handle_interactive_password + end + + if handler then + process.hide_preview_buffers() + + local value = handler(line) + if value == "__CANCEL__" then logger.debug("[CLI]: Cancelling the interactive cmd") p:stop() - end - elseif vim.startswith(line, "Username for ") then - logger.debug("[CLI]: Asking for username") - local prompt = line:match("(.*:?):.*") - local value = vim.fn.input { - prompt = prompt .. " ", - cancelreturn = "__CANCEL__", - } - if value ~= "__CANCEL__" then - logger.debug("[CLI]: Received username") - p:send(value .. "\r\n") else - logger.debug("[CLI]: Cancelling the interactive cmd") - p:stop() - end - elseif vim.startswith(line, "Enter passphrase") or vim.startswith(line, "Password for") then - logger.debug("[CLI]: Asking for password") - local prompt = line:match("(.*:?):.*") - local value = vim.fn.inputsecret { - prompt = prompt .. " ", - cancelreturn = "__CANCEL__", - } - if value ~= "__CANCEL__" then - logger.debug("[CLI]: Received password") + logger.debug("[CLI]: Sending user input") p:send(value .. "\r\n") - else - logger.debug("[CLI]: Cancelling the interactive cmd") - p:stop() end + + process.defer_show_preview_buffers() + return true else process.defer_show_preview_buffers() return false end - - process.defer_show_preview_buffers() - return true end local function new_builder(subcommand) @@ -892,10 +912,9 @@ local function new_builder(subcommand) logger.trace(string.format("[CLI]: Executing '%s': '%s'", subcommand, table.concat(cmd, " "))) - local repo = require("neogit.lib.git.repository") return process.new { cmd = cmd, - cwd = repo.git_root, + cwd = git.repo.git_root, env = state.env, pty = state.in_pty, verbose = opts.verbose, @@ -911,10 +930,17 @@ local function new_builder(subcommand) call_interactive = function(options) local opts = options or {} - local handle_line = opts.handle_line or handle_interactive_password_questions + local handle_line = opts.handle_line or handle_line_interactive local p = to_process { verbose = opts.verbose, - on_error = function(_res) + on_error = function(res) + -- When aborting, don't alert the user. exit(1) is expected. + for _, line in ipairs(res.stdout) do + if line:match("^hint: Waiting for your editor to close the file...") then + return false + end + end + return true end, } @@ -955,9 +981,18 @@ local function new_builder(subcommand) local p = to_process { verbose = opts.verbose, on_error = function(res) - local commit_aborted_msg = "hint: Waiting for your editor to close the file..." + -- When aborting, don't alert the user. exit(1) is expected. + for _, line in ipairs(res.stdout) do + if line:match("^hint: Waiting for your editor to close the file...") then + return false + end + end - if vim.startswith(res.stdout[1], commit_aborted_msg) then + -- When opening in a brand new repo, HEAD will cause an error. + if + res.stderr[1] + == "fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree." + then return false end @@ -1040,10 +1075,11 @@ local meta = { end, } +---@class NeogitGitCLI local cli = setmetatable({ history = history, insert = handle_new_cmd, - git_root_of_cwd = git_root_of_cwd, + git_root = git_root, is_inside_worktree = is_inside_worktree, }, meta) diff --git a/lua/neogit/lib/git/config.lua b/lua/neogit/lib/git/config.lua index 46915c16c..8a4fbd169 100644 --- a/lua/neogit/lib/git/config.lua +++ b/lua/neogit/lib/git/config.lua @@ -1,6 +1,7 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local logger = require("neogit.logger") +---@class NeogitGitConfig local M = {} ---@class ConfigEntry @@ -72,8 +73,7 @@ local config_cache = {} local cache_key = nil local function make_cache_key() - local repo = require("neogit.lib.git.repository") - local stat = vim.loop.fs_stat(repo.git_root .. "/.git/config") + local stat = vim.loop.fs_stat(git.repo:git_path("config"):absolute()) if stat then return stat.mtime.sec end @@ -82,8 +82,10 @@ end local function build_config() local result = {} - local out = - vim.split(table.concat(cli.config.list.null._local.call_sync({ hidden = true }).stdout_raw, "\0"), "\n") + local out = vim.split( + table.concat(git.cli.config.list.null._local.call_sync({ hidden = true }).stdout_raw, "\0"), + "\n" + ) for _, option in ipairs(out) do local key, value = unpack(vim.split(option, "\0")) @@ -112,7 +114,7 @@ end ---@return ConfigEntry function M.get_global(key) - local result = cli.config.global.get(key).call_sync({ ignore_error = true }).stdout[1] + local result = git.cli.config.get(key).call_sync({ ignore_error = true }).stdout[1] return ConfigEntry.new(key, result, "global") end @@ -133,7 +135,7 @@ function M.set(key, value) if not value or value == "" then M.unset(key) else - cli.config.set(key, value).call_sync() + git.cli.config.set(key, value).call_sync() end end @@ -144,7 +146,7 @@ function M.unset(key) end cache_key = nil - cli.config.unset(key).call_sync() + git.cli.config.unset(key).call_sync() end return M diff --git a/lua/neogit/lib/git/diff.lua b/lua/neogit/lib/git/diff.lua index bcc0d3bf4..7146e6ead 100644 --- a/lua/neogit/lib/git/diff.lua +++ b/lua/neogit/lib/git/diff.lua @@ -1,7 +1,7 @@ local a = require("plenary.async") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") local logger = require("neogit.logger") -local cli = require("neogit.lib.git.cli") local ItemFilter = require("neogit.lib.item_filter") @@ -108,6 +108,8 @@ end ---@field index_len number ---@field diff_from number ---@field diff_to number +---@field first number First line number in buffer +---@field last number Last line number in buffer ---@return Hunk local function build_hunks(lines) @@ -159,6 +161,15 @@ local function build_hunks(lines) insert(hunks, hunk) end + for _, hunk in ipairs(hunks) do + hunk.lines = {} + for i = hunk.diff_from + 1, hunk.diff_to do + table.insert(hunk.lines, lines[i]) + end + + hunk.length = hunk.diff_to - hunk.diff_from + end + return hunks end @@ -193,14 +204,12 @@ local function build_metatable(f, raw_output_fn) end end, }) - - f.has_diff = true end -- Doing a git-diff with untracked files will exit(1) if a difference is observed, which we can ignore. local function raw_untracked(name) return function() - local diff = cli.diff.no_ext_diff.no_index + local diff = git.cli.diff.no_ext_diff.no_index .files("/dev/null", name) .call({ hidden = true, ignore_error = true }).stdout local stats = {} @@ -211,8 +220,17 @@ end local function raw_unstaged(name) return function() - local diff = cli.diff.no_ext_diff.files(name).call({ hidden = true }).stdout - local stats = cli.diff.no_ext_diff.shortstat.files(name).call({ hidden = true }).stdout + local diff = git.cli.diff.no_ext_diff.files(name).call({ hidden = true }).stdout + local stats = git.cli.diff.no_ext_diff.shortstat.files(name).call({ hidden = true }).stdout + + return { diff, stats } + end +end + +local function raw_staged_unmerged(name) + return function() + local diff = git.cli.diff.no_ext_diff.files(name).call({ hidden = true }).stdout + local stats = git.cli.diff.no_ext_diff.shortstat.files(name).call({ hidden = true }).stdout return { diff, stats } end @@ -220,8 +238,8 @@ end local function raw_staged(name) return function() - local diff = cli.diff.no_ext_diff.cached.files(name).call({ hidden = true }).stdout - local stats = cli.diff.no_ext_diff.cached.shortstat.files(name).call({ hidden = true }).stdout + local diff = git.cli.diff.no_ext_diff.cached.files(name).call({ hidden = true }).stdout + local stats = git.cli.diff.no_ext_diff.cached.shortstat.files(name).call({ hidden = true }).stdout return { diff, stats } end @@ -229,8 +247,9 @@ end local function raw_staged_renamed(name, original) return function() - local diff = cli.diff.no_ext_diff.cached.files(name, original).call({ hidden = true }).stdout - local stats = cli.diff.no_ext_diff.cached.shortstat.files(name, original).call({ hidden = true }).stdout + local diff = git.cli.diff.no_ext_diff.cached.files(name, original).call({ hidden = true }).stdout + local stats = + git.cli.diff.no_ext_diff.cached.shortstat.files(name, original).call({ hidden = true }).stdout return { diff, stats } end @@ -238,13 +257,54 @@ end local function invalidate_diff(filter, section, item) if not filter or filter:accepts(section, item.name) then - logger.fmt_debug("[DIFF] Invalidating cached diff for: %s", item.name) + logger.debug(("[DIFF] Invalidating cached diff for: %s"):format(item.name)) item.diff = nil end end +---@class NeogitGitDiff return { parse = parse_diff, + staged_stats = function() + local raw = git.cli.diff.no_ext_diff.cached.stat.call_sync({ hidden = true }).stdout + local files = {} + local summary + + local idx = 1 + local function advance() + idx = idx + 1 + end + + local function peek() + return raw[idx] + end + + while true do + local line = peek() + if not line then + break + end + + if line:match("^ %d+ file[s ]+changed,") then + summary = vim.trim(line) + break + else + table.insert(files, { + path = vim.trim(line:match("^ ([^ ]+)")), + changes = line:match("|%s+(%d+)"), + insertions = line:match("|%s+%d+ (%+*)"), + deletions = line:match("|%s+%d+ %+*(%-*)$"), + }) + + advance() + end + end + + return { + summary = summary, + files = files, + } + end, register = function(meta) meta.update_diffs = function(repo, filter) filter = filter or false @@ -266,6 +326,8 @@ return { invalidate_diff(filter, "staged", f) if f.mode == "R" then build_metatable(f, raw_staged_renamed(f.name, f.original_name)) + elseif f.mode:match("^[UAD][UAD]") then + build_metatable(f, raw_staged_unmerged(f.name)) else build_metatable(f, raw_staged(f.name)) end diff --git a/lua/neogit/lib/git/fetch.lua b/lua/neogit/lib/git/fetch.lua index 997444332..943ec33b4 100644 --- a/lua/neogit/lib/git/fetch.lua +++ b/lua/neogit/lib/git/fetch.lua @@ -1,5 +1,6 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") +---@class NeogitGitFetch local M = {} ---Fetches from the remote and handles password questions @@ -8,11 +9,11 @@ local M = {} ---@param args string[] ---@return ProcessResult function M.fetch_interactive(remote, branch, args) - return cli.fetch.args(remote or "", branch or "").arg_list(args).call_interactive() + return git.cli.fetch.args(remote or "", branch or "").arg_list(args).call_interactive() end function M.fetch(remote, branch) - cli.fetch.args(remote, branch).call { ignore_error = true } + git.cli.fetch.args(remote, branch).call { ignore_error = true } end return M diff --git a/lua/neogit/lib/git/files.lua b/lua/neogit/lib/git/files.lua index 5fe7762e3..362ba8ba7 100644 --- a/lua/neogit/lib/git/files.lua +++ b/lua/neogit/lib/git/files.lua @@ -1,21 +1,41 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") +---@class NeogitGitFiles local M = {} function M.all() - return cli["ls-files"].full_name.deleted.modified.exclude_standard.deduplicate.call_sync({ hidden = true }).stdout + return git.cli["ls-files"].full_name.deleted.modified.exclude_standard.deduplicate.call_sync({ + hidden = true, + }).stdout end function M.untracked() - return cli["ls-files"].others.exclude_standard.call_sync({ hidden = true }).stdout + return git.cli["ls-files"].others.exclude_standard.call_sync({ hidden = true }).stdout end function M.all_tree() - return cli["ls-tree"].full_tree.name_only.recursive.args("HEAD").call_sync({ hidden = true }).stdout + return git.cli["ls-tree"].full_tree.name_only.recursive.args("HEAD").call_sync({ hidden = true }).stdout end function M.diff(commit) - return cli.diff.name_only.args(commit .. "...").call_sync({ hidden = true }).stdout + return git.cli.diff.name_only.args(commit .. "...").call_sync({ hidden = true }).stdout +end + +function M.relpath_from_repository(path) + local result = git.cli["ls-files"].others.cached.modified.deleted.full_name + .args(path) + .show_popup(false) + .call { hidden = true } + + return result.stdout[1] +end + +function M.is_tracked(path) + return git.cli["ls-files"].error_unmatch.files(path).call({ hidden = true, ignore_error = true }).code == 0 +end + +function M.untrack(paths) + return git.cli.rm.cached.files(unpack(paths)).call({ hidden = true }).code == 0 end return M diff --git a/lua/neogit/lib/git/index.lua b/lua/neogit/lib/git/index.lua index 512bb8ad2..6116d55d4 100644 --- a/lua/neogit/lib/git/index.lua +++ b/lua/neogit/lib/git/index.lua @@ -1,7 +1,8 @@ -local cli = require("neogit.lib.git.cli") -local repo = require("neogit.lib.git.repository") +local git = require("neogit.lib.git") local Path = require("plenary.path") +local util = require("neogit.lib.util") +---@class NeogitGitIndex local M = {} ---Generates a patch that can be applied to index @@ -66,7 +67,7 @@ function M.generate_patch(item, hunk, from, to, reverse) string.format("@@ -%d,%d +%d,%d @@", hunk.index_from, len_start, hunk.index_from, len_start + len_offset) ) - local git_root = repo.git_root + local git_root = git.repo.git_root assert(item.absolute_path, "Item is not a path") local path = Path:new(item.absolute_path):make_relative(git_root) @@ -84,7 +85,7 @@ end function M.apply(patch, opts) opts = opts or { reverse = false, cached = false, index = false } - local cmd = cli.apply + local cmd = git.cli.apply if opts.reverse then cmd = cmd.reverse @@ -102,15 +103,44 @@ function M.apply(patch, opts) end function M.add(files) - return cli.add.files(unpack(files)).call() + return git.cli.add.files(unpack(files)).call() end function M.checkout(files) - return cli.checkout.files(unpack(files)).call() + return git.cli.checkout.files(unpack(files)).call() end function M.reset(files) - return cli.reset.files(unpack(files)).call() + return git.cli.reset.files(unpack(files)).call() +end + +function M.reset_HEAD(...) + return git.cli.reset.args("HEAD").arg_list({ ... }).call() +end + +function M.checkout_unstaged() + local items = util.map(git.repo.state.unstaged.items, function(item) + return item.escaped_path + end) + + return git.cli.checkout.files(unpack(items)).call() +end + +---Creates a temp index from a revision and calls the provided function with the index path +---@param revision string Revision to create a temp index from +---@param fn fun(index: string): nil +function M.with_temp_index(revision, fn) + assert(revision, "temp index requires a revision") + assert(fn, "Pass a function to call with temp index") + + local tmp_index = Path:new(vim.uv.os_tmpdir(), ("index.neogit.%s"):format(revision)) + git.cli["read-tree"].args(revision).index_output(tmp_index:absolute()).call { hidden = true } + assert(tmp_index:exists(), "Failed to create temp index") + + fn(tmp_index:absolute()) + + tmp_index:rm() + assert(not tmp_index:exists(), "Failed to remove temp index") end -- Make sure the index is in sync as git-status skips it diff --git a/lua/neogit/lib/git/init.lua b/lua/neogit/lib/git/init.lua index 01808959f..041e158e6 100644 --- a/lua/neogit/lib/git/init.lua +++ b/lua/neogit/lib/git/init.lua @@ -1,16 +1,17 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local notification = require("neogit.lib.notification") local input = require("neogit.lib.input") +---@class NeogitGitInit local M = {} M.create = function(directory, sync) sync = sync or false if sync then - cli.init.args(directory).call_sync() + git.cli.init.args(directory).call_sync() else - cli.init.args(directory).call() + git.cli.init.args(directory).call() end end @@ -28,25 +29,21 @@ M.init_repo = function() notification.error("Invalid Directory") return end + local status = require("neogit.buffers.status") + if status.is_open() then + status.instance():chdir(directory) + end - local status = require("neogit.status") - status.cwd_changed = true - vim.cmd.lcd(directory) - - if cli.is_inside_worktree() then - if - not input.get_confirmation( - string.format("Reinitialize existing repository %s?", directory), - { values = { "&Yes", "&No" }, default = 2 } - ) - then + if git.cli.is_inside_worktree() then + if not input.get_permission(("Reinitialize existing repository %s?"):format(directory)) then return end end M.create(directory) - - status.refresh(nil, "InitRepo") + if status.is_open() then + status.instance():dispatch_refresh(nil, "InitRepo") + end end return M diff --git a/lua/neogit/lib/git/log.lua b/lua/neogit/lib/git/log.lua index 1b576d711..259dcd711 100644 --- a/lua/neogit/lib/git/log.lua +++ b/lua/neogit/lib/git/log.lua @@ -1,9 +1,9 @@ -local cli = require("neogit.lib.git.cli") -local diff_lib = require("neogit.lib.git.diff") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") local config = require("neogit.config") local record = require("neogit.lib.record") +---@class NeogitGitLog local M = {} local commit_header_pat = "([| ]*)(%*?)([| ]*)commit (%w+)" @@ -134,7 +134,7 @@ function M.parse(raw) if not line or vim.startswith(line, "diff") then -- There was a previous diff, parse it if in_diff then - table.insert(commit.diffs, diff_lib.parse(current_diff)) + table.insert(commit.diffs, git.diff.parse(current_diff)) current_diff = {} end @@ -142,7 +142,7 @@ function M.parse(raw) elseif line == "" then -- A blank line signifies end of diffs -- Parse the last diff, consume the blankline, and exit if in_diff then - table.insert(commit.diffs, diff_lib.parse(current_diff)) + table.insert(commit.diffs, git.diff.parse(current_diff)) current_diff = {} end @@ -301,7 +301,7 @@ M.graph = util.memoize(function(options, files, color) options = ensure_max(options or {}) files = files or {} - local result = cli.log + local result = git.cli.log .format("%x1E%H%x00").graph.color .arg_list(options) .files(unpack(files)) @@ -341,7 +341,7 @@ local function format(show_signature) fields.verification_flag = "%G?" end - return record.encode(fields) + return record.encode(fields, "log") end ---@param options? string[] @@ -359,7 +359,7 @@ M.list = util.memoize(function(options, graph, files, hidden, graph_color) options = determine_order(options, graph) options, signature = show_signature(options) - local output = cli.log + local output = git.cli.log .format(format(signature)) .args("--no-patch") .arg_list(options) @@ -388,49 +388,64 @@ M.list = util.memoize(function(options, graph, files, hidden, graph_color) end) ---Determines if commit a is an ancestor of commit b ----@param a string commit hash ----@param b string commit hash +---@param ancestor string commit hash +---@param descendant string commit hash ---@return boolean -function M.is_ancestor(a, b) - return cli["merge-base"].is_ancestor.args(a, b).call_sync({ ignore_error = true, hidden = true }).code == 0 +function M.is_ancestor(ancestor, descendant) + return git.cli["merge-base"].is_ancestor + .args(ancestor, descendant) + .call_sync({ ignore_error = true, hidden = true }).code == 0 end ---Finds parent commit of a commit. If no parent exists, will return nil ---@param commit string ---@return string|nil function M.parent(commit) - return vim.split(cli["rev-list"].max_count(1).parents.args(commit).call({ hidden = true }).stdout[1], " ")[2] -end - -local function update_recent(state) - local count = config.values.status.recent_commit_count - if count < 1 then - return - end - - state.recent.items = - util.filter_map(M.list({ "--max-count=" .. tostring(count) }, nil, {}, true), M.present_commit) + return vim.split( + git.cli["rev-list"].max_count(1).parents.args(commit).call({ hidden = true }).stdout[1], + " " + )[2] end function M.register(meta) - meta.update_recent = update_recent + meta.update_recent = function(state) + state.recent = { items = {} } + + local count = config.values.status.recent_commit_count + if count > 0 then + state.recent.items = + util.filter_map(M.list({ "--max-count=" .. tostring(count) }, nil, {}, true), M.present_commit) + end + end end function M.update_ref(from, to) - cli["update-ref"].message(string.format("reset: moving to %s", to)).args(from, to).call() + git.cli["update-ref"].message(string.format("reset: moving to %s", to)).args(from, to).call() end function M.message(commit) - return cli.log.max_count(1).format("%s").args(commit).call({ hidden = true }).stdout[1] + return git.cli.log.max_count(1).format("%s").args(commit).call({ hidden = true }).stdout[1] end +function M.full_message(commit) + return git.cli.log.max_count(1).format("%B").args(commit).call({ hidden = true }).stdout +end + +---@class CommitItem +---@field name string +---@field oid string +---@field decoration CommitBranchInfo +---@field commit CommitLogEntry[] + +---@return nil|CommitItem function M.present_commit(commit) if not commit.oid then return end return { - name = string.format("%s %s", commit.oid:sub(1, 7), commit.subject or ""), + name = string.format("%s %s", commit.abbreviated_commit, commit.subject or ""), + decoration = M.branch_info(commit.ref_name, git.remote.list()), oid = commit.oid, commit = commit, } @@ -440,7 +455,7 @@ end ---@param commit string Hash of commit ---@return string The stderr output of the command function M.verify_commit(commit) - return cli["verify-commit"].args(commit).call_sync({ ignore_error = true }).stderr + return git.cli["verify-commit"].args(commit).call_sync({ ignore_error = true }).stderr end ---@class CommitBranchInfo @@ -453,7 +468,7 @@ end ---@param ref string comma separated list of branches, tags and remotes, e.g.: --- * "origin/main, main, origin/HEAD, tag: 1.2.3, fork/develop" --- * "HEAD -> main, origin/main, origin/HEAD, tag: 1.2.3, fork/develop" ----@param remotes string[] list of remote names, e.g. by calling `require("neogit.lib.git.remote").list()` +---@param remotes string[] list of remote names, e.g. by calling `require("neogit.lib.git").remote.list()` ---@return CommitBranchInfo M.branch_info = util.memoize(function(ref, remotes) local parts = vim.split(ref, ", ") @@ -498,7 +513,11 @@ M.branch_info = util.memoize(function(ref, remotes) end table.insert(result.remotes[name], remote) else - result.locals[name] = true + if name == "HEAD" then + result.locals["@"] = true + elseif name ~= "" then + result.locals[name] = true + end end end end @@ -507,11 +526,20 @@ M.branch_info = util.memoize(function(ref, remotes) end) function M.reflog_message(skip) - return cli.log + return git.cli.log .format("%B") .max_count(1) .args("--reflog", "--no-merges", "--skip=" .. tostring(skip)) .call_sync({ ignore_error = true }).stdout end +M.abbreviated_size = util.memoize(function() + local commits = M.list({ "HEAD", "--max-count=1" }, {}, {}, true) + if vim.tbl_isempty(commits) then + return 7 + else + return string.len(commits[1].abbreviated_commit) + end +end, { timeout = math.huge }) + return M diff --git a/lua/neogit/lib/git/merge.lua b/lua/neogit/lib/git/merge.lua index 2ef6d01c2..a359dc9ec 100644 --- a/lua/neogit/lib/git/merge.lua +++ b/lua/neogit/lib/git/merge.lua @@ -1,12 +1,12 @@ local client = require("neogit.client") +local git = require("neogit.lib.git") local notification = require("neogit.lib.notification") -local cli = require("neogit.lib.git.cli") -local branch_lib = require("neogit.lib.git.branch") - -local M = {} local a = require("plenary.async") +---@class NeogitGitMerge +local M = {} + local function merge_command(cmd) local envs = client.get_envs_git_editor() return cmd.env(envs).show_popup(true):in_pty(true).call { verbose = true } @@ -18,47 +18,45 @@ end function M.merge(branch, args) a.util.scheduler() - local result = merge_command(cli.merge.args(branch).arg_list(args)) + local result = merge_command(git.cli.merge.args(branch).arg_list(args)) if result.code ~= 0 then notification.error("Merging failed. Resolve conflicts before continuing") fire_merge_event { branch = branch, args = args, status = "conflict" } else - notification.info("Merged '" .. branch .. "' into '" .. branch_lib.current() .. "'") + notification.info("Merged '" .. branch .. "' into '" .. git.branch.current() .. "'") fire_merge_event { branch = branch, args = args, status = "ok" } end end function M.continue() - return merge_command(cli.merge.continue) + return merge_command(git.cli.merge.continue) end function M.abort() - return merge_command(cli.merge.abort) + return merge_command(git.cli.merge.abort) end -function M.update_merge_status(state) - local repo = require("neogit.lib.git.repository") - if repo.git_root == "" then - return - end - - state.merge = { head = nil, msg = "", items = {} } - - local merge_head = repo:git_path("MERGE_HEAD") - if not merge_head:exists() then - return - end - - state.merge.head = merge_head:read():match("([^\r\n]+)") - - local message = repo:git_path("MERGE_MSG") - if message:exists() then - state.merge.msg = message:read():match("([^\r\n]+)") -- we need \r? to support windows - end -end +---@class MergeItem +---Not used, just for a consistent interface M.register = function(meta) - meta.update_merge_status = M.update_merge_status + meta.update_merge_status = function(state) + state.merge = { head = nil, branch = nil, msg = "", items = {} } + + local merge_head = git.repo:git_path("MERGE_HEAD") + if not merge_head:exists() then + return + end + + state.merge.head = merge_head:read():match("([^\r\n]+)") + state.merge.subject = git.log.message(state.merge.head) + + local message = git.repo:git_path("MERGE_MSG") + if message:exists() then + state.merge.msg = message:read():match("([^\r\n]+)") -- we need \r? to support windows + state.merge.branch = state.merge.msg:match("Merge branch '(.*)'$") + end + end end return M diff --git a/lua/neogit/lib/git/pull.lua b/lua/neogit/lib/git/pull.lua index d21028011..04edbe443 100644 --- a/lua/neogit/lib/git/pull.lua +++ b/lua/neogit/lib/git/pull.lua @@ -1,13 +1,13 @@ -local cli = require("neogit.lib.git.cli") -local log = require("neogit.lib.git.log") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") +---@class NeogitGitPull local M = {} function M.pull_interactive(remote, branch, args) local client = require("neogit.client") local envs = client.get_envs_git_editor() - return cli.pull.env(envs).args(remote or "", branch or "").arg_list(args).call_interactive() + return git.cli.pull.env(envs).args(remote or "", branch or "").arg_list(args).call_interactive() end local function update_unpulled(state) @@ -20,13 +20,15 @@ local function update_unpulled(state) if state.upstream.ref then state.upstream.unpulled.items = - util.filter_map(log.list({ "..@{upstream}" }, nil, {}, true), log.present_commit) + util.filter_map(git.log.list({ "..@{upstream}" }, nil, {}, true), git.log.present_commit) end local pushRemote = require("neogit.lib.git").branch.pushRemote_ref() if pushRemote then - state.pushRemote.unpulled.items = - util.filter_map(log.list({ string.format("..%s", pushRemote) }, nil, {}, true), log.present_commit) + state.pushRemote.unpulled.items = util.filter_map( + git.log.list({ string.format("..%s", pushRemote) }, nil, {}, true), + git.log.present_commit + ) end end diff --git a/lua/neogit/lib/git/push.lua b/lua/neogit/lib/git/push.lua index f76fa8fd7..0f3d6fe0c 100644 --- a/lua/neogit/lib/git/push.lua +++ b/lua/neogit/lib/git/push.lua @@ -1,7 +1,7 @@ -local cli = require("neogit.lib.git.cli") -local log = require("neogit.lib.git.log") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") +---@class NeogitGitPush local M = {} ---Pushes to the remote and handles password questions @@ -10,7 +10,7 @@ local M = {} ---@param args string[] ---@return ProcessResult function M.push_interactive(remote, branch, args) - return cli.push.args(remote or "", branch or "").arg_list(args).call_interactive() + return git.cli.push.args(remote or "", branch or "").arg_list(args).call_interactive() end local function update_unmerged(state) @@ -23,13 +23,13 @@ local function update_unmerged(state) if state.upstream.ref then state.upstream.unmerged.items = - util.filter_map(log.list({ "@{upstream}.." }, nil, {}, true), log.present_commit) + util.filter_map(git.log.list({ "@{upstream}.." }, nil, {}, true), git.log.present_commit) end local pushRemote = require("neogit.lib.git").branch.pushRemote_ref() if pushRemote then state.pushRemote.unmerged.items = - util.filter_map(log.list({ pushRemote .. ".." }, nil, {}, true), log.present_commit) + util.filter_map(git.log.list({ pushRemote .. ".." }, nil, {}, true), git.log.present_commit) end end diff --git a/lua/neogit/lib/git/rebase.lua b/lua/neogit/lib/git/rebase.lua index cfa2d6b46..28509c8b5 100644 --- a/lua/neogit/lib/git/rebase.lua +++ b/lua/neogit/lib/git/rebase.lua @@ -1,8 +1,9 @@ local logger = require("neogit.logger") +local git = require("neogit.lib.git") local client = require("neogit.client") local notification = require("neogit.lib.notification") -local cli = require("neogit.lib.git.cli") +---@class NeogitGitRebase local M = {} local a = require("plenary.async") @@ -18,11 +19,14 @@ end ---Instant rebase. This is a way to rebase without using the interactive editor ---@param commit string ----@param args string[] list of arguments to pass to git rebase +---@param args? string[] list of arguments to pass to git rebase ---@return ProcessResult function M.instantly(commit, args) - local result = - cli.rebase.env({ GIT_SEQUENCE_EDITOR = ":" }).interactive.arg_list(args).commit(commit).call() + local result = git.cli.rebase + .env({ GIT_SEQUENCE_EDITOR = ":" }).interactive.autostash.autosquash + .arg_list(args or {}) + .commit(commit) + .call() if result.code ~= 0 then fire_rebase_event { commit = commit, status = "failed" } @@ -38,7 +42,7 @@ function M.rebase_interactive(commit, args) commit = "" end - local result = rebase_command(cli.rebase.interactive.arg_list(args).args(commit)) + local result = rebase_command(git.cli.rebase.interactive.arg_list(args).args(commit)) if result.code ~= 0 then if result.stdout[1]:match("^hint: Waiting for your editor to close the file%.%.%. error") then notification.info("Rebase aborted") @@ -54,7 +58,7 @@ function M.rebase_interactive(commit, args) end function M.onto_branch(branch, args) - local result = rebase_command(cli.rebase.args(branch).arg_list(args)) + local result = rebase_command(git.cli.rebase.args(branch).arg_list(args)) if result.code ~= 0 then notification.error("Rebasing failed. Resolve conflicts before continuing") fire_rebase_event("conflict") @@ -65,7 +69,7 @@ function M.onto_branch(branch, args) end function M.onto(start, newbase, args) - local result = rebase_command(cli.rebase.onto.args(newbase, start).arg_list(args)) + local result = rebase_command(git.cli.rebase.onto.args(newbase, start).arg_list(args)) if result.code ~= 0 then notification.error("Rebasing failed. Resolve conflicts before continuing") fire_rebase_event("conflict") @@ -76,26 +80,28 @@ function M.onto(start, newbase, args) end ---@param commit string rev name of the commit to reword ----@param message string new message to put onto `commit` ----@return nil -function M.reword(commit, message) - local result = cli.commit.allow_empty.only.message("amend! " .. commit .. "\n\n" .. message).call() - if result.code ~= 0 then - return +---@return ProcessResult|nil +function M.reword(commit) + local message = table.concat(git.log.full_message(commit), "\n") + local status = client.wrap( + git.cli.commit.only.allow_empty.edit.with_message(("amend! %s\n\n%s"):format(commit, message)), + { + autocmd = "NeogitCommitComplete", + msg = { + success = "Commit Updated", + }, + } + ) + + if status == 0 then + return M.instantly(commit) end - - result = - cli.rebase.env({ GIT_SEQUENCE_EDITOR = ":" }).interactive.autosquash.autostash.commit(commit).call() - if result.code ~= 0 then - return - end - fire_rebase_event("ok") end function M.modify(commit) - local short_commit = require("neogit.lib.git").rev_parse.abbreviate_commit(commit) + local short_commit = git.rev_parse.abbreviate_commit(commit) local editor = "nvim -c '%s/^pick \\(" .. short_commit .. ".*\\)/edit \\1/' -c 'wq'" - local result = cli.rebase + local result = git.cli.rebase .env({ GIT_SEQUENCE_EDITOR = editor }).interactive.autosquash.autostash .in_pty(true) .commit(commit) @@ -107,9 +113,9 @@ function M.modify(commit) end function M.drop(commit) - local short_commit = require("neogit.lib.git").rev_parse.abbreviate_commit(commit) + local short_commit = git.rev_parse.abbreviate_commit(commit) local editor = "nvim -c '%s/^pick \\(" .. short_commit .. ".*\\)/drop \\1/' -c 'wq'" - local result = cli.rebase + local result = git.cli.rebase .env({ GIT_SEQUENCE_EDITOR = editor }).interactive.autosquash.autostash .in_pty(true) .commit(commit) @@ -121,39 +127,47 @@ function M.drop(commit) end function M.continue() - return rebase_command(cli.rebase.continue) + return rebase_command(git.cli.rebase.continue) end function M.skip() - return rebase_command(cli.rebase.skip) + return rebase_command(git.cli.rebase.skip) end function M.edit() - return rebase_command(cli.rebase.edit_todo) + return rebase_command(git.cli.rebase.edit_todo) end -local function line_oid(line) - return vim.split(line, " ")[2] +---Find the merge base for HEAD and it's upstream +---@return string|nil +function M.merge_base_HEAD() + local result = + git.cli["merge-base"].args("HEAD", "HEAD@{upstream}").call { ignore_error = true, hidden = true } + if result.code == 0 then + return result.stdout[1] + end end -local function format_line(line) - local sections = vim.split(line, " ") - sections[2] = sections[2]:sub(1, 7) +---@class RebaseItem +---@field action string +---@field oid string +---@field abbreviated_commit string +---@field subject string +---@field done boolean +---@field stopped boolean - return table.concat(sections, " ") -end +---@class RebaseOnto +---@field oid string +---@field subject string +---@field ref string +---@field is_remote boolean function M.update_rebase_status(state) - local repo = require("neogit.lib.git.repository") - if repo.git_root == "" then - return - end - - state.rebase = { items = {}, head = nil, current = nil } + state.rebase = { items = {}, onto = {}, head = nil, current = nil } local rebase_file - local rebase_merge = repo:git_path("rebase-merge") - local rebase_apply = repo:git_path("rebase-apply") + local rebase_merge = git.repo:git_path("rebase-merge") + local rebase_apply = git.repo:git_path("rebase-apply") if rebase_merge:exists() then rebase_file = rebase_merge @@ -164,17 +178,37 @@ function M.update_rebase_status(state) if rebase_file then local head = rebase_file:joinpath("head-name") if not head:exists() then - logger.error("Failed to read rebase-merge head") + logger.error("Failed to read rebase-merge head-name") return end state.rebase.head = head:read():match("refs/heads/([^\r\n]+)") + local onto = rebase_file:joinpath("onto") + if onto:exists() then + state.rebase.onto.oid = vim.trim(onto:read()) + state.rebase.onto.subject = git.log.message(state.rebase.onto.oid) + state.rebase.onto.ref = git.cli["name-rev"].name_only.no_undefined + .refs("refs/heads/*") + .exclude("*/HEAD") + .exclude("*/refs/heads/*") + .args(state.rebase.onto.oid) + .call({ hidden = true }).stdout[1] + state.rebase.onto.is_remote = not git.branch.exists(state.rebase.onto.ref) + end + local done = rebase_file:joinpath("done") if done:exists() then for line in done:iter() do if line:match("^[^#]") and line ~= "" then - table.insert(state.rebase.items, { name = format_line(line), oid = line_oid(line), done = true }) + local oid = line:match("^%w+ (%x+)") + table.insert(state.rebase.items, { + action = line:match("^(%w+) "), + oid = oid, + abbreviated_commit = oid:sub(1, git.log.abbreviated_size()), + subject = line:match("^%w+ %x+ (.+)$"), + done = true, + }) end end end @@ -190,10 +224,27 @@ function M.update_rebase_status(state) if todo:exists() then for line in todo:iter() do if line:match("^[^#]") and line ~= "" then - table.insert(state.rebase.items, { name = format_line(line), oid = line_oid(line) }) + local oid = line:match("^%w+ (%x+)") + table.insert(state.rebase.items, { + done = false, + action = line:match("^(%w+) "), + oid = oid, + abbreviated_commit = oid:sub(1, git.log.abbreviated_size()), + subject = line:match("^%w+ %x+ (.+)$"), + }) end end end + + if onto:exists() then + table.insert(state.rebase.items, { + done = false, + action = "onto", + oid = state.rebase.onto.oid, + abbreviated_commit = state.rebase.onto.oid:sub(1, git.log.abbreviated_size()), + subject = state.rebase.onto.subject, + }) + end end end diff --git a/lua/neogit/lib/git/reflog.lua b/lua/neogit/lib/git/reflog.lua index 36941cef2..7681940a1 100644 --- a/lua/neogit/lib/git/reflog.lua +++ b/lua/neogit/lib/git/reflog.lua @@ -1,6 +1,7 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") +---@class NeogitGitReflog local M = {} ---@class ReflogEntry @@ -26,8 +27,7 @@ local function parse(entries) message = command:match("^merge (.*)") .. ": " .. message command = "merge" elseif command:match("^rebase") then - local type = command:match("%((.-)%)") - command = "rebase " .. type + command = "rebase " .. (command:match("%((.-)%)") or command) elseif command:match("commit %(.-%)") then -- amend and merge command = command:match("%((.-)%)") end @@ -54,7 +54,7 @@ function M.list(refname, options) }, "%x1E") return parse( - cli.reflog.show.format(format).date("raw").arg_list(options or {}).args(refname, "--").call().stdout + git.cli.reflog.show.format(format).date("raw").arg_list(options or {}).args(refname, "--").call().stdout ) end diff --git a/lua/neogit/lib/git/refs.lua b/lua/neogit/lib/git/refs.lua index 144f48713..2da8e1ace 100644 --- a/lua/neogit/lib/git/refs.lua +++ b/lua/neogit/lib/git/refs.lua @@ -1,30 +1,116 @@ -local cli = require("neogit.lib.git.cli") -local repo = require("neogit.lib.git.repository") +local git = require("neogit.lib.git") +local config = require("neogit.config") +local record = require("neogit.lib.record") +local util = require("neogit.lib.util") +---@class NeogitGitRefs local M = {} ---- Lists revisions ----@return table -function M.list() - local revisions = cli["for-each-ref"].format('"%(refname:short)"').call().stdout - for i, str in ipairs(revisions) do - revisions[i] = string.sub(str, 2, -2) +---@return fun(format?: string, sortby?: string, filter?: table): string[] +local refs = util.memoize(function(format, sortby, filter) + return git.cli["for-each-ref"] + .format(format or "%(refname)") + .sort(sortby or config.values.sort_branches) + .arg_list(filter or {}) + .call({ hidden = true }).stdout +end) + +---@return string[] +function M.list(namespaces, format, sortby) + local filter = util.map(namespaces or {}, function(namespace) + return namespace:sub(2, -1) + end) + + return util.map(refs(format, sortby, filter), function(revision) + local name, _ = revision:gsub("^refs/[^/]*/", "") + return name + end) +end + +---@return string[] +function M.list_tags() + return M.list { "^refs/tags/" } +end + +---@return string[] +function M.list_branches() + return util.merge(M.list_local_branches(), M.list_remote_branches()) +end + +---@return string[] +function M.list_local_branches() + return M.list { "^refs/heads/" } +end + +---@param remote? string Filter branches by remote +---@return string[] +function M.list_remote_branches(remote) + local remote_branches = M.list { "^refs/remotes/" } + + if remote then + return vim.tbl_filter(function(ref) + return ref:match("^" .. remote .. "/") + end, remote_branches) + else + return remote_branches end - return revisions +end + +local record_template = record.encode({ + head = "%(HEAD)", + oid = "%(objectname)", + ref = "%(refname)", + name = "%(refname:short)", + upstream_status = "%(upstream:trackshort)", + upstream_name = "%(upstream:short)", + subject = "%(subject)", +}, "ref") + +function M.list_parsed() + local result = record.decode(refs(record_template)) + + local output = { + local_branch = {}, + remote_branch = {}, + tag = {}, + } + + for _, ref in ipairs(result) do + ref.head = ref.head == "*" + + if ref.ref:match("^refs/heads/") then + ref.type = "local_branch" + table.insert(output.local_branch, ref) + elseif ref.ref:match("^refs/remotes/") then + local remote, branch = ref.ref:match("^refs/remotes/([^/]*)/(.*)$") + if not output.remote_branch[remote] then + output.remote_branch[remote] = {} + end + + ref.type = "remote_branch" + ref.name = branch + table.insert(output.remote_branch[remote], ref) + elseif ref.ref:match("^refs/tags/") then + ref.type = "tag" + table.insert(output.tag, ref) + end + end + + return output end -- TODO: Use in more places --- Determines what HEAD's exist in repo, and enumerates them -function M.heads() +M.heads = util.memoize(function() local heads = { "HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD" } local present = {} for _, head in ipairs(heads) do - if repo:git_path(head):exists() then + if git.repo:git_path(head):exists() then table.insert(present, head) end end return present -end +end) return M diff --git a/lua/neogit/lib/git/remote.lua b/lua/neogit/lib/git/remote.lua index e148caf9e..2c56ca7fa 100644 --- a/lua/neogit/lib/git/remote.lua +++ b/lua/neogit/lib/git/remote.lua @@ -1,12 +1,11 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") +---@class NeogitGitRemote local M = {} -- https://github.com/magit/magit/blob/main/lisp/magit-remote.el#LL141C32-L141C32 local function cleanup_push_variables(remote, new_name) - local git = require("neogit.lib.git") - if remote == git.config.get("remote.pushDefault").value then git.config.set("remote.pushDefault", new_name) end @@ -23,11 +22,11 @@ local function cleanup_push_variables(remote, new_name) end function M.add(name, url, args) - return cli.remote.add.arg_list(args).args(name, url).call().code == 0 + return git.cli.remote.add.arg_list(args).args(name, url).call().code == 0 end function M.rename(from, to) - local result = cli.remote.rename.arg_list({ from, to }).call_sync() + local result = git.cli.remote.rename.arg_list({ from, to }).call_sync() if result.code == 0 then cleanup_push_variables(from, to) end @@ -36,7 +35,7 @@ function M.rename(from, to) end function M.remove(name) - local result = cli.remote.rm.args(name).call_sync() + local result = git.cli.remote.rm.args(name).call_sync() if result.code == 0 then cleanup_push_variables(name) end @@ -45,15 +44,15 @@ function M.remove(name) end function M.prune(name) - return cli.remote.prune.args(name).call().code == 0 + return git.cli.remote.prune.args(name).call().code == 0 end M.list = util.memoize(function() - return cli.remote.call_sync({ hidden = false }).stdout + return git.cli.remote.call_sync({ hidden = false }).stdout end) function M.get_url(name) - return cli.remote.get_url(name).call({ hidden = true }).stdout + return git.cli.remote.get_url(name).call({ hidden = true }).stdout end ---@class RemoteInfo diff --git a/lua/neogit/lib/git/repository.lua b/lua/neogit/lib/git/repository.lua index 80cdc797a..a72eecc8b 100644 --- a/lua/neogit/lib/git/repository.lua +++ b/lua/neogit/lib/git/repository.lua @@ -1,16 +1,105 @@ local a = require("plenary.async") local logger = require("neogit.logger") -local Path = require("plenary.path") -local cli = require("neogit.lib.git.cli") +local Path = require("plenary.path") ---@class Path +local git = require("neogit.lib.git") + +local modules = { + "status", + "branch", + "diff", + "stash", + "pull", + "push", + "log", + "rebase", + "sequencer", + "merge", + "bisect", +} +---@class NeogitRepo +---@field git_path fun(self, ...):Path +---@field refresh fun(self, table) +---@field initialized boolean +---@field git_root string +---@field head NeogitRepoHead +---@field upstream NeogitRepoRemote +---@field pushRemote NeogitRepoRemote +---@field untracked NeogitRepoIndex +---@field unstaged NeogitRepoIndex +---@field staged NeogitRepoIndex +---@field stashes NeogitRepoStash +---@field recent NeogitRepoRecent +---@field sequencer NeogitRepoSequencer +---@field rebase NeogitRepoRebase +---@field merge NeogitRepoMerge +---@field bisect NeogitRepoBisect +--- +---@class NeogitRepoHead +---@field branch string|nil +---@field oid string|nil +---@field commit_message string|nil +---@field tag NeogitRepoHeadTag +--- +---@class NeogitRepoHeadTag +---@field name string|nil +---@field oid string|nil +---@field distance number|nil +--- +---@class NeogitRepoRemote +---@field branch string|nil +---@field commit_message string|nil +---@field remote string|nil +---@field ref string|nil +---@field oid string|nil +---@field unmerged NeogitRepoIndex +---@field unpulled NeogitRepoIndex +--- +---@class NeogitRepoIndex +---@field items StatusItem[] +--- +---@class NeogitRepoStash +---@field items StashItem[] +--- +---@class NeogitRepoRecent +---@field items CommitItem[] +--- +---@class NeogitRepoSequencer +---@field items SequencerItem[] +---@field head string|nil +---@field head_oid string|nil +---@field revert boolean +---@field cherry_pick boolean +--- +---@class NeogitRepoRebase +---@field items RebaseItem[] +---@field onto RebaseOnto +---@field head string|nil +---@field current string|nil +--- +---@class NeogitRepoMerge +---@field items MergeItem[] +---@field head string|nil +---@field msg string +---@field branch string|nil +--- +---@class NeogitRepoBisect +---@field items BisectItem[] +---@field finished boolean +---@field current CommitLogEntry + +---@return NeogitRepo local function empty_state() return { - git_root = cli.git_root_of_cwd(), + initialized = false, + git_root = "", head = { branch = nil, + oid = nil, commit_message = nil, tag = { name = nil, + oid = nil, distance = nil, }, }, @@ -19,11 +108,16 @@ local function empty_state() commit_message = nil, remote = nil, ref = nil, + oid = nil, unmerged = { items = {} }, unpulled = { items = {} }, }, pushRemote = { + branch = nil, commit_message = nil, + remote = nil, + ref = nil, + oid = nil, unmerged = { items = {} }, unpulled = { items = {} }, }, @@ -32,104 +126,128 @@ local function empty_state() staged = { items = {} }, stashes = { items = {} }, recent = { items = {} }, - rebase = { items = {}, head = nil }, - sequencer = { items = {}, head = nil }, - merge = { items = {}, head = nil, msg = nil }, + rebase = { + items = {}, + onto = {}, + head = nil, + current = nil, + }, + sequencer = { + items = {}, + head = nil, + head_oid = nil, + revert = false, + cherry_pick = false, + }, + merge = { + items = {}, + head = nil, + msg = "", + branch = nil, + }, + bisect = { + items = {}, + finished = false, + current = {}, + }, } end -local meta = { - __index = function(self, method) - return self.state[method] - end, -} +---@class NeogitRepo +local Repo = {} +Repo.__index = Repo -local M = {} +local instances = {} -M.state = empty_state() -M.lib = {} -M.updates = {} +function Repo.instance(dir) + local cwd = dir or vim.loop.cwd() + if cwd and not instances[cwd] then + instances[cwd] = Repo.new(cwd) + end -function M.reset(self) - self.state = empty_state() + return instances[cwd] end -function M.refresh(self, opts) - opts = opts or {} - logger.fmt_info("[REPO]: Refreshing START (source: %s)", opts.source or "UNKNOWN") +-- Use Repo.instance when calling directly to ensure it's registered +function Repo.new(dir) + logger.debug("[REPO]: Initializing Repository") - local cleanup = function() - logger.debug("[REPO]: Refreshes complete") + local instance = { + lib = {}, + updates = {}, + state = empty_state(), + git_root = git.cli.git_root(dir), + } - if opts.callback then - logger.debug("[REPO]: Running refresh callback") - opts.callback() + instance.state.git_root = instance.git_root + + setmetatable(instance, Repo) + + for _, m in ipairs(modules) do + require("neogit.lib.git." .. m).register(instance.lib) + end + + for name, fn in pairs(instance.lib) do + if name ~= "update_status" then + table.insert(instance.updates, function() + logger.debug(("[REPO]: Refreshing %s"):format(name)) + fn(instance.state) + end) end end + return instance +end + +function Repo:reset() + self.state = empty_state() +end + +function Repo:git_path(...) + return Path.new(self.git_root):joinpath(".git", ...) +end + +function Repo:refresh(opts) + if self.git_root == "" then + logger.debug("[REPO] No git root found - skipping refresh") + return + end + + self.state.initialized = true + opts = opts or {} + logger.info(("[REPO]: Refreshing START (source: %s)"):format(opts.source or "UNKNOWN")) + -- Needed until Process doesn't use vim.fn.* a.util.scheduler() - self.state.git_root = cli.git_root_of_cwd() - -- This needs to be run before all others, because libs like Pull and Push depend on it setting some state. logger.debug("[REPO]: Refreshing 'update_status'") self.lib.update_status(self.state) local tasks = {} if opts.partial then - for name, fn in pairs(M.lib) do + for name, fn in pairs(self.lib) do if opts.partial[name] then local filter = type(opts.partial[name]) == "table" and opts.partial[name] table.insert(tasks, function() - logger.fmt_debug("[REPO]: Refreshing %s", name) - fn(M.state, filter) + logger.debug(("[REPO]: Refreshing %s"):format(name)) + fn(self.state, filter) end) end end else - tasks = M.updates + tasks = self.updates end - a.util.run_all(tasks, cleanup) -end - -function M.git_path(self, ...) - return Path.new(self.state.git_root):joinpath(".git", ...) -end - -if not M.initialized then - logger.debug("[REPO]: Initializing Repository") - M.initialized = true - - setmetatable(M, meta) - - local modules = { - "status", - "branch", - "diff", - "stash", - "pull", - "push", - "log", - "rebase", - "sequencer", - "merge", - } - - for _, m in ipairs(modules) do - require("neogit.lib.git." .. m).register(M.lib) - end + a.util.run_all(tasks, function() + logger.debug("[REPO]: Refreshes complete") - for name, fn in pairs(M.lib) do - if name ~= "update_status" then - table.insert(M.updates, function() - logger.fmt_debug("[REPO]: Refreshing %s", name) - fn(M.state) - end) + if opts.callback then + logger.debug("[REPO]: Running refresh callback") + opts.callback() end - end + end) end -return M +return Repo diff --git a/lua/neogit/lib/git/reset.lua b/lua/neogit/lib/git/reset.lua index 5c923910b..7e9527608 100644 --- a/lua/neogit/lib/git/reset.lua +++ b/lua/neogit/lib/git/reset.lua @@ -1,7 +1,8 @@ -local cli = require("neogit.lib.git.cli") local notification = require("neogit.lib.notification") +local git = require("neogit.lib.git") local a = require("plenary.async") +---@class NeogitGitReset local M = {} local function fire_reset_event(data) @@ -11,7 +12,7 @@ end function M.mixed(commit) a.util.scheduler() - local result = cli.reset.mixed.args(commit).call() + local result = git.cli.reset.mixed.args(commit).call() if result.code ~= 0 then notification.error("Reset Failed") else @@ -23,7 +24,7 @@ end function M.soft(commit) a.util.scheduler() - local result = cli.reset.soft.args(commit).call() + local result = git.cli.reset.soft.args(commit).call() if result.code ~= 0 then notification.error("Reset Failed") else @@ -35,7 +36,7 @@ end function M.hard(commit) a.util.scheduler() - local result = cli.reset.hard.args(commit).call() + local result = git.cli.reset.hard.args(commit).call() if result.code ~= 0 then notification.error("Reset Failed") else @@ -47,7 +48,7 @@ end function M.keep(commit) a.util.scheduler() - local result = cli.reset.keep.args(commit).call() + local result = git.cli.reset.keep.args(commit).call() if result.code ~= 0 then notification.error("Reset Failed") else @@ -59,7 +60,7 @@ end function M.index(commit) a.util.scheduler() - local result = cli.reset.args(commit).files(".").call() + local result = git.cli.reset.args(commit).files(".").call() if result.code ~= 0 then notification.error("Reset Failed") else @@ -79,7 +80,7 @@ end -- end function M.file(commit, files) - local result = cli.checkout.rev(commit).files(unpack(files)).call_sync() + local result = git.cli.checkout.rev(commit).files(unpack(files)).call_sync() if result.code ~= 0 then notification.error("Reset Failed") else diff --git a/lua/neogit/lib/git/rev_parse.lua b/lua/neogit/lib/git/rev_parse.lua index 23484639e..f695c1314 100644 --- a/lua/neogit/lib/git/rev_parse.lua +++ b/lua/neogit/lib/git/rev_parse.lua @@ -1,8 +1,9 @@ -local M = {} - -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") +---@class NeogitGitRevParse +local M = {} + ---@param oid string ---@return string ---@async @@ -12,7 +13,7 @@ M.abbreviate_commit = util.memoize(function(oid) if oid == "(initial)" then return "(initial)" else - return cli["rev-parse"].short.args(oid).call({ hidden = true, ignore_error = true }).stdout[1] + return git.cli["rev-parse"].short.args(oid).call({ hidden = true, ignore_error = true }).stdout[1] end end, { timeout = math.huge }) @@ -20,7 +21,7 @@ end, { timeout = math.huge }) ---@return string ---@async function M.oid(rev) - return cli["rev-parse"].args(rev).call_sync({ hidden = true, ignore_error = true }).stdout[1] + return git.cli["rev-parse"].args(rev).call_sync({ hidden = true, ignore_error = true }).stdout[1] end return M diff --git a/lua/neogit/lib/git/revert.lua b/lua/neogit/lib/git/revert.lua index 4a573dfe7..e6f19de60 100644 --- a/lua/neogit/lib/git/revert.lua +++ b/lua/neogit/lib/git/revert.lua @@ -1,22 +1,23 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") +---@class NeogitGitRevert local M = {} function M.commits(commits, args) - return cli.revert.no_commit.arg_list(util.merge(args, commits)).call().code == 0 + return git.cli.revert.no_commit.arg_list(util.merge(args, commits)).call().code == 0 end function M.continue() - cli.revert.continue.call_sync() + git.cli.revert.continue.call_sync() end function M.skip() - cli.revert.skip.call_sync() + git.cli.revert.skip.call_sync() end function M.abort() - cli.revert.abort.call_sync() + git.cli.revert.abort.call_sync() end return M diff --git a/lua/neogit/lib/git/sequencer.lua b/lua/neogit/lib/git/sequencer.lua index 6cb1764b2..c5fc385e8 100644 --- a/lua/neogit/lib/git/sequencer.lua +++ b/lua/neogit/lib/git/sequencer.lua @@ -1,3 +1,6 @@ +local git = require("neogit.lib.git") + +---@class NeogitGitSequencer local M = {} -- .git/sequencer/todo does not exist when there is only one commit left. @@ -6,50 +9,68 @@ local M = {} -- And REVERT_HEAD does not exist when a conflict happens while reverting a series of commits with --no-commit. -- function M.pick_or_revert_in_progress() - local git = require("neogit.lib.git") local pick_or_revert_todo = false - for _, item in ipairs(git.repo.sequencer.items) do - if item.name:match("^pick") or item.name:match("^revert") then + for _, item in ipairs(git.repo.state.sequencer.items) do + if item.action == "pick" or item.action == "revert" then pick_or_revert_todo = true break end end - return git.repo.sequencer.head or pick_or_revert_todo + return git.repo.state.sequencer.head or pick_or_revert_todo end +---@class SequencerItem +---@field action string +---@field oid string +---@field abbreviated_commit string +---@field subject string + function M.update_sequencer_status(state) - local repo = require("neogit.lib.git.repository") - state.sequencer = { items = {}, head = nil, head_oid = nil } + state.sequencer = { items = {}, head = nil, head_oid = nil, revert = false, cherry_pick = false } - local revert_head = repo:git_path("REVERT_HEAD") - local cherry_head = repo:git_path("CHERRY_PICK_HEAD") + local revert_head = git.repo:git_path("REVERT_HEAD") + local cherry_head = git.repo:git_path("CHERRY_PICK_HEAD") if cherry_head:exists() then state.sequencer.head = "CHERRY_PICK_HEAD" - state.sequencer.head_oid = repo:git_path("CHERRY_PICK_HEAD"):read() + state.sequencer.head_oid = vim.trim(git.repo:git_path("CHERRY_PICK_HEAD"):read()) state.sequencer.cherry_pick = true elseif revert_head:exists() then state.sequencer.head = "REVERT_HEAD" - state.sequencer.head_oid = repo:git_path("REVERT_HEAD"):read() + state.sequencer.head_oid = vim.trim(git.repo:git_path("REVERT_HEAD"):read()) state.sequencer.revert = true end - local todo = repo:git_path("sequencer/todo") - local orig = repo:git_path("ORIG_HEAD") + local HEAD_oid = git.rev_parse.oid("HEAD") + table.insert(state.sequencer.items, { + action = "onto", + oid = HEAD_oid, + abbreviated_commit = HEAD_oid:sub(1, git.log.abbreviated_size()), + subject = git.log.message(HEAD_oid), + }) + + local todo = git.repo:git_path("sequencer/todo") if todo:exists() then for line in todo:iter() do - if not line:match("^#") then - table.insert(state.sequencer.items, { name = line }) + if line:match("^[^#]") and line ~= "" then + local oid = line:match("^%w+ (%x+)") + table.insert(state.sequencer.items, { + action = line:match("^(%w+) "), + oid = oid, + abbreviated_commit = oid:sub(1, git.log.abbreviated_size()), + subject = line:match("^%w+ %x+ (.+)$"), + }) end end - elseif state.sequencer.head_oid and orig:exists() then - local head = state.sequencer.head_oid:sub(1, 7) - orig = orig:read():sub(1, 7) - local git = require("neogit.lib.git") - table.insert(state.sequencer.items, { name = string.format("work %s %s", orig, git.log.message(orig)) }) - table.insert(state.sequencer.items, { name = string.format("onto %s %s", head, git.log.message(head)) }) + elseif state.sequencer.cherry_pick or state.sequencer.revert then + table.insert(state.sequencer.items, { + action = "join", + oid = state.sequencer.head_oid, + abbreviated_commit = state.sequencer.head_oid:sub(1, git.log.abbreviated_size()), + subject = git.log.message(state.sequencer.head_oid), + }) end end diff --git a/lua/neogit/lib/git/stash.lua b/lua/neogit/lib/git/stash.lua index 2663ec415..bbab8db38 100644 --- a/lua/neogit/lib/git/stash.lua +++ b/lua/neogit/lib/git/stash.lua @@ -1,8 +1,8 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local input = require("neogit.lib.input") -local rev_parse = require("neogit.lib.git.rev_parse") local util = require("neogit.lib.util") +---@class NeogitGitStash local M = {} local function perform_stash(include) @@ -11,19 +11,19 @@ local function perform_stash(include) end local index = - cli["commit-tree"].no_gpg_sign.parent("HEAD").tree(cli["write-tree"].call().stdout).call().stdout + git.cli["commit-tree"].no_gpg_sign.parent("HEAD").tree(git.cli["write-tree"].call().stdout).call().stdout - cli["read-tree"].merge.index_output(".git/NEOGIT_TMP_INDEX").args(index).call() + git.cli["read-tree"].merge.index_output(".git/NEOGIT_TMP_INDEX").args(index).call() if include.worktree then - local files = cli.diff.no_ext_diff.name_only + local files = git.cli.diff.no_ext_diff.name_only .args("HEAD") .env({ GIT_INDEX_FILE = ".git/NEOGIT_TMP_INDEX", }) .call() - cli["update-index"].add.remove + git.cli["update-index"].add.remove .files(unpack(files)) .env({ GIT_INDEX_FILE = ".git/NEOGIT_TMP_INDEX", @@ -31,15 +31,15 @@ local function perform_stash(include) .call() end - local tree = cli["commit-tree"].no_gpg_sign + local tree = git.cli["commit-tree"].no_gpg_sign .parents("HEAD", index) - .tree(cli["write-tree"].call()) + .tree(git.cli["write-tree"].call()) .env({ GIT_INDEX_FILE = ".git/NEOGIT_TMP_INDEX", }) .call() - cli["update-ref"].create_reflog.args("refs/stash", tree).call() + git.cli["update-ref"].create_reflog.args("refs/stash", tree).call() -- selene: allow(empty_if) if include.worktree and include.index then @@ -52,16 +52,15 @@ local function perform_stash(include) --.commit('HEAD') --.call() elseif include.index then - local diff = cli.diff.no_ext_diff.cached.call().stdout[1] .. "\n" + local diff = git.cli.diff.no_ext_diff.cached.call().stdout[1] .. "\n" - cli.apply.reverse.cached.input(diff).call() - - cli.apply.reverse.input(diff).call() + git.cli.apply.reverse.cached.input(diff).call() + git.cli.apply.reverse.input(diff).call() end end function M.list_refs() - local result = cli.reflog.show.format("%h").args("stash").call { ignore_error = true } + local result = git.cli.reflog.show.format("%h").args("stash").call { ignore_error = true } if result.code > 0 then return {} else @@ -70,7 +69,7 @@ function M.list_refs() end function M.stash_all(args) - cli.stash.arg_list(args).call() + git.cli.stash.arg_list(args).call() -- this should work, but for some reason doesn't. --return perform_stash({ worktree = true, index = true }) end @@ -80,46 +79,49 @@ function M.stash_index() end function M.push(args, files) - cli.stash.push.arg_list(args).files(unpack(files)).call() + git.cli.stash.push.arg_list(args).files(unpack(files)).call() end function M.pop(stash) - local result = cli.stash.apply.index.args(stash).show_popup(false).call() + local result = git.cli.stash.apply.index.args(stash).show_popup(false).call() if result.code == 0 then - cli.stash.drop.args(stash).call() + git.cli.stash.drop.args(stash).call() else - cli.stash.apply.args(stash).call() + git.cli.stash.apply.args(stash).call() end end function M.apply(stash) - local result = cli.stash.apply.index.args(stash).show_popup(false).call() + local result = git.cli.stash.apply.index.args(stash).show_popup(false).call() if result.code ~= 0 then - cli.stash.apply.args(stash).call() + git.cli.stash.apply.args(stash).call() end end function M.drop(stash) - cli.stash.drop.args(stash).call() + git.cli.stash.drop.args(stash).call() end function M.list() - return cli.stash.args("list").call({ hidden = true }).stdout + return git.cli.stash.args("list").call({ hidden = true }).stdout end function M.rename(stash) local message = input.get_user_input("New name") - if message == nil then - -- User pressed ESC or entered empty message, dont drop stash - return + if message then + local oid = git.rev_parse.abbreviate_commit(stash) + git.cli.stash.drop.args(stash).call() + git.cli.stash.store.message(message).args(oid).call() end - local oid = rev_parse.abbreviate_commit(stash) - cli.stash.drop.args(stash).call() - cli.stash.store.message(message).args(oid).call() end +---@class StashItem +---@field idx number +---@field name string +---@field message string + function M.register(meta) meta.update_stashes = function(state) state.stashes.items = util.map(M.list(), function(line) diff --git a/lua/neogit/lib/git/status.lua b/lua/neogit/lib/git/status.lua index 6e2a34908..6b9203278 100644 --- a/lua/neogit/lib/git/status.lua +++ b/lua/neogit/lib/git/status.lua @@ -1,21 +1,23 @@ local Path = require("plenary.path") +local git = require("neogit.lib.git") +local util = require("neogit.lib.util") local Collection = require("neogit.lib.collection") ----@class File: StatusItem +---@class StatusItem ---@field mode string ----@field has_diff boolean ---@field diff string[] ---@field absolute_path string +---@field escaped_path string +---@field original_name string|nil +---@return StatusItem local function update_file(cwd, file, mode, name, original_name) - local mt, diff, has_diff - local absolute_path = Path:new(cwd, name):absolute() + local escaped_path = vim.fn.fnameescape(vim.fn.fnamemodify(absolute_path, ":~:.")) + local mt, diff if file then mt = getmetatable(file) - has_diff = file.has_diff - if rawget(file, "diff") then diff = file.diff end @@ -25,20 +27,20 @@ local function update_file(cwd, file, mode, name, original_name) mode = mode, name = name, original_name = original_name, - has_diff = has_diff, diff = diff, absolute_path = absolute_path, + escaped_path = escaped_path, }, mt or {}) end local tag_pattern = "(.-)%-([0-9]+)%-g%x+$" +local match_header = "# ([%w%.]+) (.+)" local match_kind = "(.) (.+)" local match_u = "(..) (....) (%d+) (%d+) (%d+) (%d+) (%w+) (%w+) (%w+) (.+)" local match_1 = "(.)(.) (....) (%d+) (%d+) (%d+) (%w+) (%w+) (.+)" local match_2 = "(.)(.) (....) (%d+) (%d+) (%d+) (%w+) (%w+) (%a%d+) ([^\t]+)\t?(.+)" local function update_status(state) - local git = require("neogit.lib.git") local cwd = state.git_root local head = {} @@ -53,23 +55,20 @@ local function update_status(state) local result = git.cli.status.null_separated.porcelain(2).branch.call { hidden = true } result = vim.split(result.stdout_raw[1], "\n") + result = util.collect(result, function(line, collection) + if line == "" then + return + end - local collection = {} - local line_nr = 1 - local prev_line = nil - repeat - local line = result[line_nr] - if line:match("^[12u]%s[MTADRCU%s%.%?!][MTADRCU%s%.%?!]%s") or line:match("^[%?!#]%s") then + if line ~= "" and (line:match("^[12u]%s[%u%s%.%?!][%u%s%.%?!]%s") or line:match("^[%?!#]%s")) then table.insert(collection, line) - elseif prev_line and prev_line:match("^2%sR") then + else collection[#collection] = ("%s\t%s"):format(collection[#collection], line) end - line_nr = line_nr + 1 - prev_line = line - until line_nr > #result + end) - for _, l in ipairs(collection) do - local header, value = l:match("# ([%w%.]+) (.+)") + for _, l in ipairs(result) do + local header, value = l:match(match_header) if header then if header == "branch.head" then head.branch = value @@ -104,11 +103,15 @@ local function update_status(state) table.insert(unstaged_files, update_file(cwd, old_files_hash.unstaged_files[name], mode, name)) elseif kind == "?" then - table.insert(untracked_files, update_file(cwd, old_files_hash.untracked_files[rest], nil, rest)) + table.insert(untracked_files, update_file(cwd, old_files_hash.untracked_files[rest], "?", rest)) elseif kind == "1" then - local mode_staged, mode_unstaged, _, _, _, _, _, _, name = rest:match(match_1) + local mode_staged, mode_unstaged, _, _, _, _, hH, _, name = rest:match(match_1) if mode_staged ~= "." then + if hH:match("^0+$") then + mode_staged = "N" + end + table.insert(staged_files, update_file(cwd, old_files_hash.staged_files[name], mode_staged, name)) end @@ -160,12 +163,12 @@ local function update_status(state) if #tag == 1 then local tag, distance = tostring(tag[1]):match(tag_pattern) if tag and distance then - head.tag = { name = tag, distance = tonumber(distance) } + head.tag = { name = tag, distance = tonumber(distance), oid = git.rev_parse.oid(tag) } else - head.tag = { name = nil, distance = nil } + head.tag = { name = nil, distance = nil, oid = nil } end else - head.tag = { name = nil, distance = nil } + head.tag = { name = nil, distance = nil, oid = nil } end state.head = head @@ -175,7 +178,7 @@ local function update_status(state) state.staged.items = staged_files end -local git = { cli = require("neogit.lib.git.cli") } +---@class NeogitGitStatus local status = { stage = function(files) git.cli.add.files(unpack(files)).call() @@ -183,6 +186,13 @@ local status = { stage_modified = function() git.cli.add.update.call() end, + stage_untracked = function() + local paths = util.map(git.repo.state.untracked.items, function(item) + return item.escaped_path + end) + + git.cli.add.files(unpack(paths)).call() + end, stage_all = function() git.cli.add.all.call() end, @@ -193,14 +203,13 @@ local status = { git.cli.reset.call() end, is_dirty = function() - local repo = require("neogit.lib.git.repository") - return #repo.staged.items > 0 or #repo.unstaged.items > 0 + return #git.repo.state.staged.items > 0 or #git.repo.state.unstaged.items > 0 end, anything_staged = function() - return #require("neogit.lib.git.repository").staged.items > 0 + return #git.repo.state.staged.items > 0 end, anything_unstaged = function() - return #require("neogit.lib.git.repository").unstaged.items > 0 + return #git.repo.state.unstaged.items > 0 end, } diff --git a/lua/neogit/lib/git/tag.lua b/lua/neogit/lib/git/tag.lua index e098e7a6f..6a43cdfcb 100644 --- a/lua/neogit/lib/git/tag.lua +++ b/lua/neogit/lib/git/tag.lua @@ -1,18 +1,19 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") +---@class NeogitGitTag local M = {} --- Outputs a list of tags locally ---@return table List of tags. function M.list() - return cli.tag.list.call().stdout + return git.cli.tag.list.call().stdout end --- Deletes a list of tags ---@param tags table List of tags ---@return boolean Successfully deleted function M.delete(tags) - local result = cli.tag.delete.arg_list(tags).call() + local result = git.cli.tag.delete.arg_list(tags).call() return result.code == 0 end @@ -20,7 +21,7 @@ end ---@param remote string ---@return table function M.list_remote(remote) - return cli["ls-remote"].tags.args(remote).call().stdout + return git.cli["ls-remote"].tags.args(remote).call().stdout end return M diff --git a/lua/neogit/lib/git/worktree.lua b/lua/neogit/lib/git/worktree.lua index d5ea23c52..575cfd22c 100644 --- a/lua/neogit/lib/git/worktree.lua +++ b/lua/neogit/lib/git/worktree.lua @@ -1,7 +1,8 @@ -local cli = require("neogit.lib.git.cli") +local git = require("neogit.lib.git") local util = require("neogit.lib.util") local Path = require("plenary.path") +---@class NeogitGitWorktree local M = {} ---Creates new worktree at path for ref @@ -9,7 +10,7 @@ local M = {} ---@param path string absolute path ---@return boolean function M.add(ref, path, params) - local result = cli.worktree.add.arg_list(params or {}).args(path, ref).call_sync() + local result = git.cli.worktree.add.arg_list(params or {}).args(path, ref).call_sync() return result.code == 0 end @@ -18,7 +19,7 @@ end ---@param destination string absolute path for where to move worktree ---@return boolean function M.move(worktree, destination) - local result = cli.worktree.move.args(worktree, destination).call() + local result = git.cli.worktree.move.args(worktree, destination).call() return result.code == 0 end @@ -27,7 +28,7 @@ end ---@param args? table ---@return boolean function M.remove(worktree, args) - local result = cli.worktree.remove.args(worktree).arg_list(args or {}).call { ignore_error = true } + local result = git.cli.worktree.remove.args(worktree).arg_list(args or {}).call { ignore_error = true } return result.code == 0 end @@ -43,7 +44,7 @@ end ---@return Worktree[] function M.list(opts) opts = opts or { include_main = true } - local list = vim.split(cli.worktree.list.args("--porcelain", "-z").call().stdout_raw[1], "\n\n") + local list = vim.split(git.cli.worktree.list.args("--porcelain", "-z").call().stdout_raw[1], "\n\n") return util.filter_map(list, function(w) local path, head, type, ref = w:match("^worktree (.-)\nHEAD (.-)\n([^ ]+) (.+)$") diff --git a/lua/neogit/lib/hl.lua b/lua/neogit/lib/hl.lua index fc45c29c9..90d600b91 100644 --- a/lua/neogit/lib/hl.lua +++ b/lua/neogit/lib/hl.lua @@ -18,6 +18,8 @@ local Color = require("neogit.lib.color").Color local hl_store local M = {} +---@param dec number +---@return string local function to_hex(dec) local hex = string.format("%x", dec) if #hex < 6 then @@ -28,6 +30,7 @@ local function to_hex(dec) end ---@param name string Syntax group name. +---@return string|nil local function get_fg(name) local color = vim.api.nvim_get_hl(0, { name = name }) if color["link"] then @@ -40,6 +43,7 @@ local function get_fg(name) end ---@param name string Syntax group name. +---@return string|nil local function get_bg(name) local color = vim.api.nvim_get_hl(0, { name = name }) if color["link"] then @@ -55,6 +59,7 @@ end -- stylua: ignore start local function make_palette() local bg = Color.from_hex(get_bg("Normal") or (vim.o.bg == "dark" and "#22252A" or "#eeeeee")) + local fg = Color.from_hex((vim.o.bg == "dark" and "#fcfcfc" or "#22252A")) local red = Color.from_hex(get_fg("Error") or "#E06C75") local orange = Color.from_hex(get_fg("SpecialChar") or "#ffcb6b") local yellow = Color.from_hex(get_fg("PreProc") or "#FFE082") @@ -73,6 +78,7 @@ local function make_palette() bg2 = bg:shade(bg_factor * 0.065):to_css(), bg3 = bg:shade(bg_factor * 0.11):to_css(), grey = bg:shade(bg_factor * 0.4):to_css(), + white = fg:to_css(), red = red:to_css(), bg_red = red:shade(bg_factor * -0.18):to_css(), line_red = get_bg("DiffDelete") or red:shade(bg_factor * -0.6):set_saturation(0.4):to_css(), @@ -112,99 +118,161 @@ end function M.setup() local palette = make_palette() - -- stylua: ignore start + -- stylua: ignore hl_store = { - NeogitGraphAuthor = { fg = palette.orange }, - NeogitGraphRed = { fg = palette.red }, - NeogitGraphWhite = { fg = palette.white }, - NeogitGraphYellow = { fg = palette.yellow }, - NeogitGraphGreen = { fg = palette.green }, - NeogitGraphCyan = { fg = palette.cyan }, - NeogitGraphBlue = { fg = palette.blue }, - NeogitGraphPurple = { fg = palette.purple }, - NeogitGraphGray = { fg = palette.grey }, - NeogitGraphOrange = { fg = palette.orange }, - NeogitGraphBoldRed = { fg = palette.red, bold = palette.bold }, - NeogitGraphBoldWhite = { fg = palette.white, bold = palette.bold }, - NeogitGraphBoldYellow = { fg = palette.yellow, bold = palette.bold }, - NeogitGraphBoldGreen = { fg = palette.green, bold = palette.bold }, - NeogitGraphBoldCyan = { fg = palette.cyan, bold = palette.bold }, - NeogitGraphBoldBlue = { fg = palette.blue, bold = palette.bold }, - NeogitGraphBoldPurple = { fg = palette.purple, bold = palette.bold }, - NeogitGraphBoldGray = { fg = palette.grey, bold = palette.bold }, - NeogitSignatureGood = { link = "NeogitGraphGreen" }, - NeogitSignatureBad = { link = "NeogitGraphBoldRed" }, - NeogitSignatureMissing = { link = "NeogitGraphPurple" }, - NeogitSignatureNone = { link = "Comment" }, - NeogitSignatureGoodUnknown = { link = "NeogitGraphBlue" }, - NeogitSignatureGoodExpired = { link = "NeogitGraphOrange" }, + NeogitGraphAuthor = { fg = palette.orange }, + NeogitGraphRed = { fg = palette.red }, + NeogitGraphWhite = { fg = palette.white }, + NeogitGraphYellow = { fg = palette.yellow }, + NeogitGraphGreen = { fg = palette.green }, + NeogitGraphCyan = { fg = palette.cyan }, + NeogitGraphBlue = { fg = palette.blue }, + NeogitGraphPurple = { fg = palette.purple }, + NeogitGraphGray = { fg = palette.grey }, + NeogitGraphOrange = { fg = palette.orange }, + NeogitGraphBoldOrange = { fg = palette.orange, bold = palette.bold }, + NeogitGraphBoldRed = { fg = palette.red, bold = palette.bold }, + NeogitGraphBoldWhite = { fg = palette.white, bold = palette.bold }, + NeogitGraphBoldYellow = { fg = palette.yellow, bold = palette.bold }, + NeogitGraphBoldGreen = { fg = palette.green, bold = palette.bold }, + NeogitGraphBoldCyan = { fg = palette.cyan, bold = palette.bold }, + NeogitGraphBoldBlue = { fg = palette.blue, bold = palette.bold }, + NeogitGraphBoldPurple = { fg = palette.purple, bold = palette.bold }, + NeogitGraphBoldGray = { fg = palette.grey, bold = palette.bold }, + NeogitSubtleText = { link = "Comment" }, + NeogitSignatureGood = { link = "NeogitGraphGreen" }, + NeogitSignatureBad = { link = "NeogitGraphBoldRed" }, + NeogitSignatureMissing = { link = "NeogitGraphPurple" }, + NeogitSignatureNone = { link = "NeogitSubtleText" }, + NeogitSignatureGoodUnknown = { link = "NeogitGraphBlue" }, + NeogitSignatureGoodExpired = { link = "NeogitGraphOrange" }, NeogitSignatureGoodExpiredKey = { link = "NeogitGraphYellow" }, NeogitSignatureGoodRevokedKey = { link = "NeogitGraphRed" }, - NeogitHunkHeader = { fg = palette.bg0, bg = palette.grey, bold = palette.bold }, - NeogitHunkHeaderHighlight = { fg = palette.bg0, bg = palette.md_purple, bold = palette.bold }, - NeogitDiffContext = { bg = palette.bg1 }, - NeogitDiffContextHighlight = { bg = palette.bg2 }, - NeogitDiffAdd = { bg = palette.line_green, fg = palette.bg_green }, - NeogitDiffAddHighlight = { bg = palette.line_green, fg = palette.green }, - NeogitDiffDelete = { bg = palette.line_red, fg = palette.bg_red }, - NeogitDiffDeleteHighlight = { bg = palette.line_red, fg = palette.red }, - NeogitPopupSectionTitle = { link = "Function" }, - NeogitPopupBranchName = { link = "String" }, - NeogitPopupBold = { bold = palette.bold }, - NeogitPopupSwitchKey = { fg = palette.purple }, - NeogitPopupSwitchEnabled = { link = "SpecialChar" }, - NeogitPopupSwitchDisabled = { link = "Comment" }, - NeogitPopupOptionKey = { fg = palette.purple }, - NeogitPopupOptionEnabled = { link = "SpecialChar" }, - NeogitPopupOptionDisabled = { link = "Comment" }, - NeogitPopupConfigKey = { fg = palette.purple }, - NeogitPopupConfigEnabled = { link = "SpecialChar" }, - NeogitPopupConfigDisabled = { link = "Comment" }, - NeogitPopupActionKey = { fg = palette.purple }, - NeogitPopupActionDisabled = { link = "Comment" }, - NeogitFilePath = { fg = palette.blue, italic = palette.italic }, - NeogitCommitViewHeader = { bg = palette.bg_cyan, fg = palette.bg0 }, - NeogitCommitViewDescription = { link = "String" }, - NeogitDiffHeader = { bg = palette.bg3, fg = palette.blue, bold = palette.bold }, - NeogitDiffHeaderHighlight = { bg = palette.bg3, fg = palette.orange, bold = palette.bold }, - NeogitCommandText = { link = "Comment" }, - NeogitCommandTime = { link = "Comment" }, - NeogitCommandCodeNormal = { link = "String" }, - NeogitCommandCodeError = { link = "Error" }, - NeogitBranch = { fg = palette.blue, bold = palette.bold }, - NeogitBranchHead = { fg = palette.blue, bold = palette.bold, underline = palette.underline }, - NeogitRemote = { fg = palette.green, bold = palette.bold }, - NeogitUnmergedInto = { fg = palette.bg_purple, bold = palette.bold }, - NeogitUnpushedTo = { fg = palette.bg_purple, bold = palette.bold }, - NeogitUnpulledFrom = { fg = palette.bg_purple, bold = palette.bold }, - NeogitObjectId = { link = "Comment" }, - NeogitStash = { link = "Comment" }, - NeogitRebaseDone = { link = "Comment" }, - NeogitCursorLine = { bg = palette.bg1 }, - NeogitFold = { fg = "None", bg = "None" }, - NeogitChangeModified = { fg = palette.bg_blue, bold = palette.bold, italic = palette.italic }, - NeogitChangeAdded = { fg = palette.bg_green, bold = palette.bold, italic = palette.italic }, - NeogitChangeDeleted = { fg = palette.bg_red, bold = palette.bold, italic = palette.italic }, - NeogitChangeRenamed = { fg = palette.bg_purple, bold = palette.bold, italic = palette.italic }, - NeogitChangeUpdated = { fg = palette.bg_orange, bold = palette.bold, italic = palette.italic }, - NeogitChangeCopied = { fg = palette.bg_cyan, bold = palette.bold, italic = palette.italic }, - NeogitChangeBothModified = { fg = palette.bg_yellow, bold = palette.bold, italic = palette.italic }, - NeogitChangeNewFile = { fg = palette.bg_green, bold = palette.bold, italic = palette.italic }, - NeogitSectionHeader = { fg = palette.bg_purple, bold = palette.bold }, - NeogitUntrackedfiles = { link = "NeogitSectionHeader" }, - NeogitUnstagedchanges = { link = "NeogitSectionHeader" }, - NeogitUnmergedchanges = { link = "NeogitSectionHeader" }, - NeogitUnpulledchanges = { link = "NeogitSectionHeader" }, - NeogitRecentcommits = { link = "NeogitSectionHeader" }, - NeogitStagedchanges = { link = "NeogitSectionHeader" }, - NeogitStashes = { link = "NeogitSectionHeader" }, - NeogitRebasing = { link = "NeogitSectionHeader" }, - NeogitPicking = { link = "NeogitSectionHeader" }, - NeogitReverting = { link = "NeogitSectionHeader" }, - NeogitTagName = { fg = palette.yellow }, - NeogitTagDistance = { fg = palette.cyan } + NeogitCursorLine = { link = "CursorLine" }, + NeogitHunkMergeHeader = { fg = palette.bg2, bg = palette.grey, bold = palette.bold }, + NeogitHunkMergeHeaderHighlight= { fg = palette.bg0, bg = palette.bg_cyan, bold = palette.bold }, + NeogitHunkMergeHeaderCursor = { fg = palette.bg0, bg = palette.bg_cyan, bold = palette.bold }, + NeogitHunkHeader = { fg = palette.bg0, bg = palette.grey, bold = palette.bold }, + NeogitHunkHeaderHighlight = { fg = palette.bg0, bg = palette.md_purple, bold = palette.bold }, + NeogitHunkHeaderCursor = { fg = palette.bg0, bg = palette.md_purple, bold = palette.bold }, + NeogitDiffContext = { bg = palette.bg1 }, + NeogitDiffContextHighlight = { bg = palette.bg2 }, + NeogitDiffContextCursor = { bg = palette.bg1 }, + NeogitDiffAdditions = { fg = palette.bg_green }, + NeogitDiffAdd = { bg = palette.line_green, fg = palette.bg_green }, + NeogitDiffAddHighlight = { bg = palette.line_green, fg = palette.green }, + NeogitDiffAddCursor = { bg = palette.bg1, fg = palette.green }, + NeogitDiffDeletions = { fg = palette.bg_red }, + NeogitDiffDelete = { bg = palette.line_red, fg = palette.bg_red }, + NeogitDiffDeleteHighlight = { bg = palette.line_red, fg = palette.red }, + NeogitDiffDeleteCursor = { bg = palette.bg1, fg = palette.red }, + NeogitPopupSectionTitle = { link = "Function" }, + NeogitPopupBranchName = { link = "String" }, + NeogitPopupBold = { bold = palette.bold }, + NeogitPopupSwitchKey = { fg = palette.purple }, + NeogitPopupSwitchEnabled = { link = "SpecialChar" }, + NeogitPopupSwitchDisabled = { link = "NeogitSubtleText" }, + NeogitPopupOptionKey = { fg = palette.purple }, + NeogitPopupOptionEnabled = { link = "SpecialChar" }, + NeogitPopupOptionDisabled = { link = "NeogitSubtleText" }, + NeogitPopupConfigKey = { fg = palette.purple }, + NeogitPopupConfigEnabled = { link = "SpecialChar" }, + NeogitPopupConfigDisabled = { link = "NeogitSubtleText" }, + NeogitPopupActionKey = { fg = palette.purple }, + NeogitPopupActionDisabled = { link = "NeogitSubtleText" }, + NeogitFilePath = { fg = palette.blue, italic = palette.italic }, + NeogitCommitViewHeader = { bg = palette.bg_cyan, fg = palette.bg0 }, + NeogitCommitViewDescription = { link = "String" }, + NeogitDiffHeader = { bg = palette.bg3, fg = palette.blue, bold = palette.bold }, + NeogitDiffHeaderHighlight = { bg = palette.bg3, fg = palette.orange, bold = palette.bold }, + NeogitCommandText = { link = "NeogitSubtleText" }, + NeogitCommandTime = { link = "NeogitSubtleText" }, + NeogitCommandCodeNormal = { link = "String" }, + NeogitCommandCodeError = { link = "Error" }, + NeogitBranch = { fg = palette.blue, bold = palette.bold }, + NeogitBranchHead = { fg = palette.blue, bold = palette.bold, underline = palette.underline }, + NeogitRemote = { fg = palette.green, bold = palette.bold }, + NeogitUnmergedInto = { fg = palette.bg_purple, bold = palette.bold }, + NeogitUnpushedTo = { fg = palette.bg_purple, bold = palette.bold }, + NeogitUnpulledFrom = { fg = palette.bg_purple, bold = palette.bold }, + NeogitStatusHEAD = {}, + NeogitObjectId = { link = "NeogitSubtleText" }, + NeogitStash = { link = "NeogitSubtleText" }, + NeogitRebaseDone = { link = "NeogitSubtleText" }, + NeogitFold = { fg = "None", bg = "None" }, + NeogitChangeMuntracked = { link = "NeogitChangeModified" }, + NeogitChangeAuntracked = { link = "NeogitChangeAdded" }, + NeogitChangeNuntracked = { link = "NeogitChangeNewFile" }, + NeogitChangeDuntracked = { link = "NeogitChangeDeleted" }, + NeogitChangeCuntracked = { link = "NeogitChangeCopied" }, + NeogitChangeUuntracked = { link = "NeogitChangeUpdated" }, + NeogitChangeRuntracked = { link = "NeogitChangeRenamed" }, + NeogitChangeDDuntracked = { link = "NeogitChangeUnmerged" }, + NeogitChangeUUuntracked = { link = "NeogitChangeUnmerged" }, + NeogitChangeAAuntracked = { link = "NeogitChangeUnmerged" }, + NeogitChangeDUuntracked = { link = "NeogitChangeUnmerged" }, + NeogitChangeUDuntracked = { link = "NeogitChangeUnmerged" }, + NeogitChangeAUuntracked = { link = "NeogitChangeUnmerged" }, + NeogitChangeUAuntracked = { link = "NeogitChangeUnmerged" }, + NeogitChangeUntrackeduntracked = { fg = "None" }, + NeogitChangeMunstaged = { link = "NeogitChangeModified" }, + NeogitChangeAunstaged = { link = "NeogitChangeAdded" }, + NeogitChangeNunstaged = { link = "NeogitChangeNewFile" }, + NeogitChangeDunstaged = { link = "NeogitChangeDeleted" }, + NeogitChangeCunstaged = { link = "NeogitChangeCopied" }, + NeogitChangeUunstaged = { link = "NeogitChangeUpdated" }, + NeogitChangeRunstaged = { link = "NeogitChangeRenamed" }, + NeogitChangeDDunstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeUUunstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeAAunstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeDUunstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeUDunstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeAUunstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeUAunstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeUntrackedunstaged = { fg = "None" }, + NeogitChangeMstaged = { link = "NeogitChangeModified" }, + NeogitChangeAstaged = { link = "NeogitChangeAdded" }, + NeogitChangeNstaged = { link = "NeogitChangeNewFile" }, + NeogitChangeDstaged = { link = "NeogitChangeDeleted" }, + NeogitChangeCstaged = { link = "NeogitChangeCopied" }, + NeogitChangeUstaged = { link = "NeogitChangeUpdated" }, + NeogitChangeRstaged = { link = "NeogitChangeRenamed" }, + NeogitChangeDDstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeUUstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeAAstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeDUstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeUDstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeAUstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeUAstaged = { link = "NeogitChangeUnmerged" }, + NeogitChangeUntrackedstaged = { fg = "None" }, + NeogitChangeModified = { fg = palette.bg_blue, bold = palette.bold, italic = palette.italic }, + NeogitChangeAdded = { fg = palette.bg_green, bold = palette.bold, italic = palette.italic }, + NeogitChangeDeleted = { fg = palette.bg_red, bold = palette.bold, italic = palette.italic }, + NeogitChangeRenamed = { fg = palette.bg_purple, bold = palette.bold, italic = palette.italic }, + NeogitChangeUpdated = { fg = palette.bg_orange, bold = palette.bold, italic = palette.italic }, + NeogitChangeCopied = { fg = palette.bg_cyan, bold = palette.bold, italic = palette.italic }, + NeogitChangeUnmerged = { fg = palette.bg_yellow, bold = palette.bold, italic = palette.italic }, + NeogitChangeNewFile = { fg = palette.bg_green, bold = palette.bold, italic = palette.italic }, + NeogitSectionHeader = { fg = palette.bg_purple, bold = palette.bold }, + NeogitSectionHeaderCount = {}, + NeogitUntrackedfiles = { link = "NeogitSectionHeader" }, + NeogitUnstagedchanges = { link = "NeogitSectionHeader" }, + NeogitUnmergedchanges = { link = "NeogitSectionHeader" }, + NeogitUnpulledchanges = { link = "NeogitSectionHeader" }, + NeogitUnpushedchanges = { link = "NeogitSectionHeader" }, + NeogitRecentcommits = { link = "NeogitSectionHeader" }, + NeogitStagedchanges = { link = "NeogitSectionHeader" }, + NeogitStashes = { link = "NeogitSectionHeader" }, + NeogitMerging = { link = "NeogitSectionHeader" }, + NeogitBisecting = { link = "NeogitSectionHeader" }, + NeogitRebasing = { link = "NeogitSectionHeader" }, + NeogitPicking = { link = "NeogitSectionHeader" }, + NeogitReverting = { link = "NeogitSectionHeader" }, + NeogitTagName = { fg = palette.yellow }, + NeogitTagDistance = { fg = palette.cyan }, + NeogitFloatHeader = { bg = palette.bg0, bold = palette.bold }, + NeogitFloatHeaderHighlight = { bg = palette.bg2, fg = palette.cyan, bold = palette.bold }, } - -- stylua: ignore end for group, hl in pairs(hl_store) do if not is_set(group) then diff --git a/lua/neogit/lib/input.lua b/lua/neogit/lib/input.lua index e15ab0b2e..923e82a44 100644 --- a/lua/neogit/lib/input.lua +++ b/lua/neogit/lib/input.lua @@ -12,6 +12,18 @@ function M.get_confirmation(msg, options) return vim.fn.confirm(msg, table.concat(options.values, "\n"), options.default) == 1 end +--- Provides the user with a confirmation. Like get_confirmation, but defaults to false +---@param msg string Prompt to use for confirmation +---@param options table|nil +---@return boolean Confirmation (Yes/No) +function M.get_permission(msg, options) + options = options or {} + options.values = options.values or { "&Yes", "&No" } + options.default = options.default or 2 + + return vim.fn.confirm(msg, table.concat(options.values, "\n"), options.default) == 1 +end + ---@class UserChoiceOptions ---@field values table List of choices prefixed with '&' ---@field default integer Default choice to select @@ -22,6 +34,12 @@ end ---@return string First letter of the selected choice function M.get_choice(msg, options) local choice = vim.fn.confirm(msg, table.concat(options.values, "\n"), options.default) + vim.cmd("redraw") + + if choice == 0 then -- User cancelled + choice = options.default + end + return options.values[choice]:match("&(.)") end @@ -30,6 +48,7 @@ end ---@field default any? Default value ---@field completion string? ---@field separator string? +---@field cancel string? ---@param prompt string Prompt to use for user input ---@param opts GetUserInputOpts? Options table @@ -43,6 +62,7 @@ function M.get_user_input(prompt, opts) prompt = ("%s%s"):format(prompt, opts.separator), default = opts.default, completion = opts.completion, + cancelreturn = opts.cancel, }) vim.fn.inputrestore() @@ -61,11 +81,19 @@ function M.get_user_input(prompt, opts) return result end -function M.get_secret_user_input(prompt) +---@param prompt string +---@param opts? table +---@return string|nil +function M.get_secret_user_input(prompt, opts) + opts = vim.tbl_extend("keep", opts or {}, { separator = ": " }) + vim.fn.inputsave() - local status, result = pcall(vim.fn.inputsecret, prompt) - vim.fn.inputrestore() + local status, result = pcall(vim.fn.inputsecret, { + prompt = ("%s%s"):format(prompt, opts.separator), + cancelreturn = opts.cancel, + }) + vim.fn.inputrestore() if not status then return nil end diff --git a/lua/neogit/lib/line_buffer.lua b/lua/neogit/lib/line_buffer.lua deleted file mode 100644 index 524eee45d..000000000 --- a/lua/neogit/lib/line_buffer.lua +++ /dev/null @@ -1,25 +0,0 @@ -local M = {} - -function M.new(initial_value) - initial_value = initial_value or {} - if type(initial_value) ~= "table" then - error("Initial value must be a table", 2) - end - - return setmetatable(initial_value, { __index = M }) -end - -function M.append(tbl, data) - if type(data) == "string" then - table.insert(tbl, data) - elseif type(data) == "table" then - for _, r in ipairs(data) do - table.insert(tbl, r) - end - else - error("invalid data type: " .. type(data), 2) - end - return tbl -end - -return M diff --git a/lua/neogit/lib/mappings_manager.lua b/lua/neogit/lib/mappings_manager.lua deleted file mode 100644 index 191880451..000000000 --- a/lua/neogit/lib/mappings_manager.lua +++ /dev/null @@ -1,61 +0,0 @@ -local managers = {} - ----@alias Mapping string|function|MappingTable - ----@class MappingTable ----@field [1] string mode ----@field [2] string|function func - ----@class MappingsManager ----@field mappings table -local MappingsManager = {} - -function MappingsManager.invoke(id, map_id) - managers[id].callbacks[map_id]() -end - -function MappingsManager.build_call_string(id, k, mode) - return string.format( - "lua require('neogit.lib.mappings_manager').invoke(%d, %d)%s", - id, - k, - mode == "v" and "" or "" - ) -end - -function MappingsManager.delete(id) - managers[id] = nil -end - ----@return MappingsManager -function MappingsManager.new(id) - local mappings = { n = {}, v = {}, i = {} } - local callbacks = {} - local map_id = 1 - local manager = { - id = id, - callbacks = callbacks, - mappings = mappings, - register = function() - for mode, mode_mappings in pairs(mappings) do - for k, mapping in pairs(mode_mappings) do - vim.keymap.set( - mode, - k, - MappingsManager.build_call_string(id, map_id, mode), - { buffer = id, nowait = true, silent = true, noremap = true } - ) - - callbacks[map_id] = mapping - map_id = map_id + 1 - end - end - end, - } - - managers[id] = manager - - return manager -end - -return MappingsManager diff --git a/lua/neogit/lib/popup/builder.lua b/lua/neogit/lib/popup/builder.lua index 3ec67252c..5036697e2 100644 --- a/lua/neogit/lib/popup/builder.lua +++ b/lua/neogit/lib/popup/builder.lua @@ -1,14 +1,11 @@ -local a = require("plenary.async") +local git = require("neogit.lib.git") local state = require("neogit.lib.state") -local config = require("neogit.lib.git.config") local util = require("neogit.lib.util") local notification = require("neogit.lib.notification") -local logger = require("neogit.logger") -local watcher = require("neogit.watcher") local M = {} ----@class Popup +---@class PopupData ---@field state PopupState ---@class PopupState @@ -68,6 +65,29 @@ local M = {} ---@field description string ---@field callback function +---@class PopupSwitchOpts +---@field enabled boolean Controls if the switch should default to 'on' state +---@field internal boolean Whether the switch is internal to neogit or should be included in the cli command. If `true` we don't include it in the cli command. +---@field incompatible table A table of strings that represent other cli flags that this one cannot be used with +---@field key_prefix string Allows overwriting the default '-' to toggle switch +---@field cli_prefix string Allows overwriting the default '--' thats used to create the cli flag. Sometimes you may want to use '++' or '-'. +---@field cli_suffix string +---@field options table +---@field value string Allows for pre-building cli flags that can be customised by user input +---@field user_input boolean If true, allows user to customise the value of the cli flag +---@field dependant string[] other switches with a state dependency on this one + +---@class PopupOptionsOpts +---@field key_prefix string Allows overwriting the default '=' to set option +---@field cli_prefix string Allows overwriting the default '--' cli prefix +---@field choices table Table of predefined choices that a user can select for option +---@field default string|integer|boolean Default value for option, if the user attempts to unset value + +---@class PopupConfigOpts +---@field options { display: string, value: string, config: function? } +---@field passive boolean Controls if this config setting can be manipulated directly, or if it is managed by git, and should just be shown in UI +-- A 'condition' key with function value can also be present in the option, which controls if the option gets shown by returning boolean. + function M.new(builder_fn) local instance = { state = { @@ -92,21 +112,21 @@ function M:name(x) end function M:env(x) - self.state.env = x + self.state.env = x or {} return self end --- Adds new column to actions section of popup ----@param heading string|nil +---Adds new column to actions section of popup +---@param heading string? ---@return self function M:new_action_group(heading) table.insert(self.state.actions, { { heading = heading or "" } }) return self end --- Conditionally adds new column to actions section of popup +---Conditionally adds new column to actions section of popup ---@param cond boolean ----@param heading string|nil +---@param heading string? ---@return self function M:new_action_group_if(cond, heading) if cond then @@ -116,7 +136,7 @@ function M:new_action_group_if(cond, heading) return self end --- Adds new heading to current column within actions section of popup +---Adds new heading to current column within actions section of popup ---@param heading string ---@return self function M:group_heading(heading) @@ -139,16 +159,7 @@ end ---@param key string Which key triggers switch ---@param cli string Git cli flag to use ---@param description string Description text to show user ----@param opts table|nil A table of options for the switch ----@param opts.enabled boolean Controls if the switch should default to 'on' state ----@param opts.internal boolean Whether the switch is internal to neogit or should be included in the cli command. --- If `true` we don't include it in the cli command. ----@param opts.incompatible table A table of strings that represent other cli flags that this one cannot be used with ----@param opts.key_prefix string Allows overwriting the default '-' to toggle switch ----@param opts.cli_prefix string Allows overwriting the default '--' thats used to create the cli flag. Sometimes you may want --- to use '++' or '-'. ----@param opts.value string Allows for pre-building cli flags that can be customised by user input ----@param opts.user_input boolean If true, allows user to customise the value of the cli flag +---@param opts PopupSwitchOpts? ---@return self function M:switch(key, cli, description, opts) opts = opts or {} @@ -223,6 +234,10 @@ end -- Conditionally adds a switch. ---@see M:switch ---@param cond boolean +---@param key string Which key triggers switch +---@param cli string Git cli flag to use +---@param description string Description text to show user +---@param opts PopupSwitchOpts? ---@return self function M:switch_if(cond, key, cli, description, opts) if cond then @@ -236,11 +251,6 @@ end ---@param cli string CLI value used ---@param value string Current value of option ---@param description string Description of option, presented to user ----@param opts table|nil ----@param opts.key_prefix string Allows overwriting the default '=' to set option ----@param opts.cli_prefix string Allows overwriting the default '--' cli prefix ----@param opts.choices table Table of predefined choices that a user can select for option ----@param opts.default string|integer|boolean Default value for option, if the user attempts to unset value function M:option(key, cli, value, description, opts) opts = opts or {} @@ -283,7 +293,6 @@ end ---@param heading string Heading to show ---@return self function M:arg_heading(heading) - ---@type PopupHeading table.insert(self.state.args, { type = "heading", heading = heading }) return self end @@ -308,14 +317,10 @@ end ---@param key string Key for user to use that engages config ---@param name string Name of config ----@param options table|nil ----@param options.options table Table of tables, each consisting of `{ display = "", value = "" }` --- where 'display' is what is shown to the user, and 'value' is what gets used by the cli. --- A 'condition' key with function value can also be present in the option, which controls if the option gets shown by returning boolean. ----@param options.passive boolean Controls if this config setting can be manipulated directly, or if it is managed by git, and should just be shown in UI +---@param options PopupConfigOpts? ---@return self function M:config(key, name, options) - local entry = config.get(name) + local entry = git.config.get(name) ---@type PopupConfig local variable = { @@ -348,9 +353,6 @@ function M:config_if(cond, key, name, options) return self end --- Allow user actions to be queued -local action_lock = a.control.Semaphore.new(1) - ---@param keys string|string[] Key or list of keys for the user to press that runs the action ---@param description string Description of action in UI ---@param callback function Function that gets run in async context @@ -365,30 +367,14 @@ function M:action(keys, description, callback) notification.error(string.format("[POPUP] Duplicate key mapping %q", key)) return self end - self.state.keys[key] = true - end - - local callback_fn - if callback then - callback_fn = a.void(function(...) - local permit = action_lock:acquire() - logger.debug(string.format("[ACTION] Running action from %s", self.state.name)) - watcher.pause() - callback(...) - watcher.resume() - - permit:forget() - - logger.debug("[ACTION] Dispatching Refresh") - require("neogit.status").dispatch_refresh(nil, "action") - end) + self.state.keys[key] = true end table.insert(self.state.actions[#self.state.actions], { keys = keys, description = description, - callback = callback_fn, + callback = callback, }) return self diff --git a/lua/neogit/lib/popup/init.lua b/lua/neogit/lib/popup/init.lua index b85f6cf84..bc2d7fe6c 100644 --- a/lua/neogit/lib/popup/init.lua +++ b/lua/neogit/lib/popup/init.lua @@ -1,7 +1,6 @@ local PopupBuilder = require("neogit.lib.popup.builder") +local status = require("neogit.buffers.status") local Buffer = require("neogit.lib.buffer") -local common = require("neogit.buffers.common") -local Ui = require("neogit.lib.ui") local logger = require("neogit.logger") local util = require("neogit.lib.util") local config = require("neogit.config") @@ -9,31 +8,37 @@ local state = require("neogit.lib.state") local input = require("neogit.lib.input") local notification = require("neogit.lib.notification") +local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") + local git = require("neogit.lib.git") -local col = Ui.col -local row = Ui.row -local text = Ui.text -local Component = Ui.Component -local map = util.map +local a = require("plenary.async") + local filter_map = util.filter_map local build_reverse_lookup = util.build_reverse_lookup -local intersperse = util.intersperse -local List = common.List -local Grid = common.Grid +local ui = require("neogit.lib.popup.ui") + +---@class PopupState + +---@class PopupData +---@field state PopupState +---@field buffer Buffer local M = {} function M.builder() return PopupBuilder.new(M.new) end +---@param state PopupState +---@return PopupData function M.new(state) local instance = { state = state, buffer = nil, } setmetatable(instance, { __index = M }) + return instance end @@ -50,7 +55,7 @@ function M:get_arguments() table.insert(flags, arg.cli_prefix .. arg.cli .. arg.cli_suffix) end - if arg.type == "option" and arg.cli ~= "" and #arg.value ~= 0 and not arg.internal then + if arg.type == "option" and arg.cli ~= "" and (arg.value and #arg.value ~= 0) and not arg.internal then table.insert(flags, arg.cli_prefix .. arg.cli .. "=" .. arg.value) end end @@ -85,135 +90,6 @@ function M:close() end end --- Determines the correct highlight group for a switch based on it's state. ----@return string -local function get_highlight_for_switch(switch) - if switch.enabled then - return "NeogitPopupSwitchEnabled" - end - - return "NeogitPopupSwitchDisabled" -end - --- Determines the correct highlight group for an option based on it's state. ----@return string -local function get_highlight_for_option(option) - if option.value ~= nil and option.value ~= "" then - return "NeogitPopupOptionEnabled" - end - - return "NeogitPopupOptionDisabled" -end - --- Determines the correct highlight group for a config based on it's type and state. ----@return string -local function get_highlight_for_config(config) - if config.value and config.value ~= "" then - return config.type or "NeogitPopupConfigEnabled" - end - - return "NeogitPopupConfigDisabled" -end - --- Builds config component to be rendered ----@return table -local function construct_config_options(config, prefix, suffix) - local set = false - local options = filter_map(config.options, function(option) - if option.display == "" then - return - end - - if option.condition and not option.condition() then - return - end - - local highlight - if config.value == option.value then - set = true - highlight = "NeogitPopupConfigEnabled" - else - highlight = "NeogitPopupConfigDisabled" - end - - return text.highlight(highlight)(option.display) - end) - - local value = intersperse(options, text.highlight("NeogitPopupConfigDisabled")("|")) - table.insert(value, 1, text.highlight("NeogitPopupConfigDisabled")("[")) - table.insert(value, #value + 1, text.highlight("NeogitPopupConfigDisabled")("]")) - - if prefix then - table.insert( - value, - 1, - text.highlight(set and "NeogitPopupConfigEnabled" or "NeogitPopupConfigDisabled")(prefix) - ) - end - - if suffix then - table.insert( - value, - #value + 1, - text.highlight(set and "NeogitPopupConfigEnabled" or "NeogitPopupConfigDisabled")(suffix) - ) - end - - return value -end - ----@param id integer ID of component to be updated ----@param highlight string New highlight group for value ----@param value string|table New value to display ----@return nil -function M:update_component(id, highlight, value) - local component = self.buffer.ui:find_component(function(c) - return c.options.id == id - end) - - assert(component, "Component not found! Cannot update.") - - if highlight then - if component.options.highlight then - component.options.highlight = highlight - elseif component.children then - component.children[1].options.highlight = highlight - end - end - - if type(value) == "string" then - local new - if value == "" then - local last_child = component.children[#component.children - 1] - if (last_child and last_child.value == "=") or component.options.id == "--" then - -- Check if this is a CLI option - the value should get blanked out for these - new = "" - else - -- If the component is NOT a cli option, use "unset" string - new = "unset" - end - else - new = value - end - - component.children[#component.children].value = new - elseif type(value) == "table" then - -- Remove last n children from row - for _ = 1, #value do - table.remove(component.children) - end - - -- insert new items to row - for _, text in ipairs(value) do - table.insert(component.children, text) - end - else - logger.error(string.format("[POPUP]: Unhandled component value type! (%s)", type(value))) - end - - self.buffer.ui:update() -end - -- Toggle a switch on/off ---@param switch table ---@return nil @@ -230,15 +106,8 @@ function M:toggle_switch(switch) local index = options[switch.cli or ""] switch.cli = options[(index + 1)] or options[1] switch.value = switch.cli - switch.enabled = switch.cli ~= "" - state.set({ self.state.name, switch.cli_suffix }, switch.cli) - self:update_component( - switch.id, - get_highlight_for_switch(switch), - construct_config_options(switch, switch.cli_prefix, switch.cli_suffix) - ) return end @@ -257,9 +126,7 @@ function M:toggle_switch(switch) end end - -- Update internal state and UI. state.set({ self.state.name, switch.cli }, switch.enabled) - self:update_component(switch.id, get_highlight_for_switch(switch), switch.cli) -- Ensure that other switches that are incompatible with this one are disabled if switch.enabled and #switch.incompatible > 0 then @@ -267,7 +134,6 @@ function M:toggle_switch(switch) if var.type == "switch" and var.enabled and switch.incompatible[var.cli] then var.enabled = false state.set({ self.state.name, var.cli }, var.enabled) - self:update_component(var.id, get_highlight_for_switch(var)) end end end @@ -278,7 +144,6 @@ function M:toggle_switch(switch) if var.type == "switch" and var.enabled and switch.dependant[var.cli] then var.enabled = false state.set({ self.state.name, var.cli }, var.enabled) - self:update_component(var.id, get_highlight_for_switch(var)) end end end @@ -288,38 +153,39 @@ end ---@param option table ---@return nil function M:set_option(option) - local set = function(value) - option.value = value - state.set({ self.state.name, option.cli }, option.value) - self:update_component(option.id, get_highlight_for_option(option), option.value) - end - -- Prompt user to select from predetermined choices if option.choices then if not option.value or option.value == "" then - -- TODO: Use input.get_choice here instead - vim.ui.select(option.choices, { prompt = option.description }, set) + local choice = FuzzyFinderBuffer.new(option.choices):open_async { + prompt_prefix = option.description, + } + if choice then + option.value = choice + else + option.value = "" + end else - set("") + option.value = "" end elseif option.fn then - option.fn(self, option, set) + option.value = option.fn(self, option) else - -- ...Otherwise get the value via input. - local input = vim.fn.input { - prompt = option.cli .. "=", + local input = input.get_user_input(option.cli, { + separator = "=", default = option.value, - cancelreturn = option.value, - } + cancel = option.value, + }) -- If the option specifies a default value, and the user set the value to be empty, defer to default value. -- This is handy to prevent the user from accidentally loading thousands of log entries by accident. if option.default and input == "" then - set(option.default) + option.value = option.default else - set(input) + option.value = input end end + + state.set({ self.state.name, option.cli }, option.value) end -- Set a config value @@ -337,181 +203,34 @@ function M:set_config(config) local index = options[config.value or ""] config.value = options[(index + 1)] or options[1] + git.config.set(config.name, config.value) elseif config.fn then - config.fn(self, config) - return + config.value = config.fn(self, config) else - local result = vim.fn.input { - prompt = config.name .. " > ", - default = config.value, - cancelreturn = config.value, - } + local result = input.get_user_input(config.name, { default = config.value, cancel = config.value }) config.value = result + git.config.set(config.name, config.value) end - git.config.set(config.name, config.value) - - self:repaint_config() - - if config.callback then - config.callback(self, config) - end -end - -function M:repaint_config() for _, var in ipairs(self.state.config) do if var.passive then local c_value = git.config.get(var.name) if c_value:is_set() then var.value = c_value.value - self:update_component(var.id, nil, var.value) end - elseif var.options then - self:update_component(var.id, nil, construct_config_options(var)) - else - self:update_component(var.id, get_highlight_for_config(var), var.value) end end -end - -local Switch = Component.new(function(switch) - local value - if switch.options then - value = row.id(switch.id)(construct_config_options(switch, switch.cli_prefix, switch.cli_suffix)) - else - value = row - .id(switch.id) - .highlight(get_highlight_for_switch(switch)) { text(switch.cli_prefix), text(switch.cli) } - end - - return row.tag("Switch").value(switch) { - row.highlight("NeogitPopupSwitchKey") { - text(switch.key_prefix), - text(switch.key), - }, - text(" "), - text(switch.description), - text(" ("), - value, - text(")"), - } -end) - -local Option = Component.new(function(option) - return row.tag("Option").value(option) { - row.highlight("NeogitPopupOptionKey") { - text(option.key_prefix), - text(option.key), - }, - text(" "), - text(option.description), - text(" ("), - row.id(option.id).highlight(get_highlight_for_option(option)) { - text(option.cli_prefix), - text(option.cli), - text(option.separator), - text(option.value or ""), - }, - text(")"), - } -end) - -local Section = Component.new(function(title, items) - return col { - text.highlight("NeogitPopupSectionTitle")(title), - col(items), - } -end) - -local Config = Component.new(function(props) - local c = {} - - if not props.state[1].heading then - table.insert(c, text.highlight("NeogitPopupSectionTitle")("Variables")) - end - - table.insert( - c, - col(map(props.state, function(config) - if config.heading then - return row.highlight("NeogitPopupSectionTitle") { text(config.heading) } - end - - local value - if config.options then - value = construct_config_options(config) - else - local value_text - if not config.value or config.value == "" then - value_text = "unset" - else - value_text = config.value - end - - value = { text.highlight(get_highlight_for_config(config))(value_text) } - end - - local key - if config.passive then - key = " " - elseif #config.key > 1 then - key = table.concat(vim.split(config.key, ""), " ") - else - key = config.key - end - return row.tag("Config").value(config) { - row.highlight("NeogitPopupConfigKey") { text(key) }, - text(" " .. config.name .. " "), - row.id(config.id) { unpack(value) }, - } - end)) - ) - - return col(c) -end) - -local function render_action(action) - local items = {} - - -- selene: allow(empty_if) - if action.keys == nil then - -- Action group heading - elseif #action.keys == 0 then - table.insert(items, text.highlight("NeogitPopupActionDisabled")("_")) - else - for i, key in ipairs(action.keys) do - table.insert(items, text.highlight("NeogitPopupActionKey")(key)) - if i < #action.keys then - table.insert(items, text(",")) - end - end + if config.callback then + config.callback(self, config) end - table.insert(items, text(" ")) - table.insert(items, text(action.description)) - return items end -local Actions = Component.new(function(props) - return col { - Grid.padding_left(1) { - items = props.state, - gap = 3, - render_item = function(item) - if item.heading then - return row.highlight("NeogitPopupSectionTitle") { text(item.heading) } - elseif not item.callback then - return row.highlight("NeogitPopupActionDisabled")(render_action(item)) - else - return row(render_action(item)) - end - end, - }, - } -end) +-- Allow user actions to be queued +local action_lock = a.control.Semaphore.new(1) -function M:show() +function M:mappings() local mappings = { n = { ["q"] = function() @@ -521,20 +240,20 @@ function M:show() self:close() end, [""] = function() - local stack = self.buffer.ui:get_component_stack_under_cursor() - - for _, x in ipairs(stack) do - if x.options.tag == "Switch" then - self:toggle_switch(x.options.value) - break - elseif x.options.tag == "Config" then - self:set_config(x.options.value) - break - elseif x.options.tag == "Option" then - self:set_option(x.options.value) - break - end + local component = self.buffer.ui:get_interactive_component_under_cursor() + if not component then + return + end + + if component.options.tag == "Switch" then + self:toggle_switch(component.options.value) + elseif component.options.tag == "Config" then + self:set_config(component.options.value) + elseif component.options.tag == "Option" then + self:set_option(component.options.value) end + + self:refresh() end, }, } @@ -543,15 +262,18 @@ function M:show() for _, arg in pairs(self.state.args) do if arg.id then arg_prefixes[arg.key_prefix] = true - mappings.n[arg.id] = function() + mappings.n[arg.id] = a.void(function() if arg.type == "switch" then self:toggle_switch(arg) elseif arg.type == "option" then self:set_option(arg) end - end + + self:refresh() + end) end end + for prefix, _ in pairs(arg_prefixes) do mappings.n[prefix] = function() local c = vim.fn.getcharstr() @@ -566,9 +288,10 @@ function M:show() if config.heading then -- nothing elseif not config.passive then - mappings.n[config.id] = function() + mappings.n[config.id] = a.void(function() self:set_config(config) - end + self:refresh() + end) end end @@ -579,11 +302,20 @@ function M:show() -- nothing elseif action.callback then for _, key in ipairs(action.keys) do - mappings.n[key] = function() - logger.debug(string.format("[POPUP]: Invoking action '%s' of %s", key, self.state.name)) - action.callback(self) + mappings.n[key] = a.void(function() + local permit = action_lock:acquire() + logger.debug(string.format("[POPUP]: Invoking action %q of %s", key, self.state.name)) + self:close() - end + action.callback(self) + + if status.instance() then + logger.debug("[ACTION] Dispatching Refresh to Status Buffer") + status.instance():dispatch_refresh(nil, "action") + end + + permit:forget() + end) end else for _, key in ipairs(action.keys) do @@ -595,52 +327,51 @@ function M:show() end end - local items = {} - - if self.state.config[1] then - table.insert(items, Config { state = self.state.config }) - end + return mappings +end - if self.state.args[1] then - local section = {} - local name = "Arguments" - for _, item in ipairs(self.state.args) do - if item.type == "option" then - table.insert(section, Option(item)) - elseif item.type == "switch" then - table.insert(section, Switch(item)) - elseif item.type == "heading" then - if section[1] then -- If there are items in the section, flush to items table with current name - table.insert(items, Section(name, section)) - section = {} - end +function M:refresh() + self.buffer:focus() + self.buffer.ui:render(unpack(ui.Popup(self.state))) +end - name = item.heading - end - end +---@return boolean +function M.is_open() + return (M.instance and M.instance.buffer and M.instance.buffer:is_visible()) == true +end - table.insert(items, Section(name, section)) +function M:show() + if M.is_open() then + logger.debug("[POPUP] An Instance is already open - closing it") + M.instance:close() end - if self.state.actions[1] then - table.insert(items, Actions { state = self.state.actions }) - end + M.instance = self self.buffer = Buffer.create { name = self.state.name, filetype = "NeogitPopup", kind = config.values.popup.kind, - mappings = mappings, - after = function(buf, win) - vim.api.nvim_set_option_value("cursorline", false, { win = win }) - vim.api.nvim_set_option_value("list", false, { win = win }) + mappings = self:mappings(), + status_column = " ", + autocmds = { + ["WinLeave"] = function() + if self.buffer and self.buffer.kind == "floating" then + -- We pcall this because it's possible the window was closed by a command invocation, e.g. "cc" for commits + pcall(self.close, self) + end + end, + }, + after = function(buf, _win) + buf:set_window_option("cursorline", false) + buf:set_window_option("list", false) if self.state.env.highlight then for i = 1, #self.state.env.highlight, 1 do vim.fn.matchadd("NeogitPopupBranchName", self.state.env.highlight[i], 100) end else - vim.fn.matchadd("NeogitPopupBranchName", git.repo.head.branch, 100) + vim.fn.matchadd("NeogitPopupBranchName", git.repo.state.head.branch, 100) end if self.state.env.bold then @@ -658,26 +389,14 @@ function M:show() vim.schedule(function() if buf:is_focused() then vim.cmd.resize(vim.fn.line("$") + 1) + buf:set_window_option("winfixheight", true) end end) end end, render = function() - return { - List { - separator = "", - items = items, - }, - } + return ui.Popup(self.state) end, - autocmds = { - ["WinLeave"] = function() - if self.buffer and self.buffer.kind == "floating" then - -- We pcall this because it's possible the window was closed by a command invocation, e.g. "cc" for commits - pcall(self.close, self) - end - end, - }, } end diff --git a/lua/neogit/lib/popup/ui.lua b/lua/neogit/lib/popup/ui.lua new file mode 100644 index 000000000..29f758bf6 --- /dev/null +++ b/lua/neogit/lib/popup/ui.lua @@ -0,0 +1,280 @@ +local M = {} + +local common = require("neogit.buffers.common") +local Ui = require("neogit.lib.ui") +local util = require("neogit.lib.util") + +local EmptyLine = common.EmptyLine +local List = common.List +local Grid = common.Grid +local col = Ui.col +local row = Ui.row +local text = Ui.text +local Component = Ui.Component + +local intersperse = util.intersperse +local filter_map = util.filter_map +local map = util.map + +-- Builds config component to be rendered +---@return table +local function construct_config_options(config, prefix, suffix) + local set = false + local options = filter_map(config.options, function(option) + if option.display == "" then + return + end + + if option.condition and not option.condition() then + return + end + + local highlight + if config.value == option.value then + set = true + highlight = "NeogitPopupConfigEnabled" + else + highlight = "NeogitPopupConfigDisabled" + end + + return text.highlight(highlight)(option.display) + end) + + local value = intersperse(options, text.highlight("NeogitPopupConfigDisabled")("|")) + table.insert(value, 1, text.highlight("NeogitPopupConfigDisabled")("[")) + table.insert(value, #value + 1, text.highlight("NeogitPopupConfigDisabled")("]")) + + if prefix then + table.insert( + value, + 1, + text.highlight(set and "NeogitPopupConfigEnabled" or "NeogitPopupConfigDisabled")(prefix) + ) + end + + if suffix then + table.insert( + value, + #value + 1, + text.highlight(set and "NeogitPopupConfigEnabled" or "NeogitPopupConfigDisabled")(suffix) + ) + end + + return value +end + +-- Determines the correct highlight group for a switch based on it's state. +---@return string +local function get_highlight_for_switch(switch) + if switch.enabled then + return "NeogitPopupSwitchEnabled" + end + + return "NeogitPopupSwitchDisabled" +end + +-- Determines the correct highlight group for an option based on it's state. +---@return string +local function get_highlight_for_option(option) + if option.value ~= nil and option.value ~= "" then + return "NeogitPopupOptionEnabled" + end + + return "NeogitPopupOptionDisabled" +end + +-- Determines the correct highlight group for a config based on it's type and state. +---@return string +local function get_highlight_for_config(config) + if config.value and config.value ~= "" then + return config.type or "NeogitPopupConfigEnabled" + end + + return "NeogitPopupConfigDisabled" +end + +local Switch = Component.new(function(switch) + local value + if switch.options then + value = row.id(switch.id)(construct_config_options(switch, switch.cli_prefix, switch.cli_suffix)) + else + value = row + .id(switch.id) + .highlight(get_highlight_for_switch(switch)) { text(switch.cli_prefix), text(switch.cli) } + end + + return row.tag("Switch").value(switch)({ + row.highlight("NeogitPopupSwitchKey") { + text(switch.key_prefix), + text(switch.key), + }, + text(" "), + text(switch.description), + text(" ("), + value, + text(")"), + }, { interactive = true }) +end) + +local Option = Component.new(function(option) + return row.tag("Option").value(option)({ + row.highlight("NeogitPopupOptionKey") { + text(option.key_prefix), + text(option.key), + }, + text(" "), + text(option.description), + text(" ("), + row.id(option.id).highlight(get_highlight_for_option(option)) { + text(option.cli_prefix), + text(option.cli), + text(option.separator), + text(option.value or ""), + }, + text(")"), + }, { interactive = true }) +end) + +local Section = Component.new(function(title, items) + return col { + text.highlight("NeogitPopupSectionTitle")(title), + col(items), + } +end) + +local Config = Component.new(function(props) + local c = {} + + if not props.state[1].heading then + table.insert(c, text.highlight("NeogitPopupSectionTitle")("Variables")) + end + + table.insert( + c, + col(map(props.state, function(config) + if config.heading then + return row.highlight("NeogitPopupSectionTitle") { text(config.heading) } + end + + local value + if config.options then + value = construct_config_options(config) + else + local value_text + if not config.value or config.value == "" then + value_text = "unset" + else + value_text = config.value + end + + value = { text.highlight(get_highlight_for_config(config))(value_text) } + end + + local key + if config.passive then + key = " " + elseif #config.key > 1 then + key = table.concat(vim.split(config.key, ""), " ") + else + key = config.key + end + + return row.tag("Config").value(config)({ + row.highlight("NeogitPopupConfigKey") { text(key) }, + text(" " .. config.name .. " "), + row.id(config.id) { unpack(value) }, + }, { interactive = true }) + end)) + ) + + return col(c) +end) + +local function render_action(action) + local items = {} + + -- selene: allow(empty_if) + if action.keys == nil then + -- Action group heading + elseif #action.keys == 0 then + table.insert(items, text.highlight("NeogitPopupActionDisabled")("_")) + else + for i, key in ipairs(action.keys) do + table.insert(items, text.highlight("NeogitPopupActionKey")(key)) + if i < #action.keys then + table.insert(items, text(",")) + end + end + end + + table.insert(items, text(" ")) + table.insert(items, text(action.description)) + + return items +end + +local Actions = Component.new(function(props) + return col { + Grid.padding_left(1) { + items = props.state, + gap = 3, + render_item = function(item) + if item.heading then + return row.highlight("NeogitPopupSectionTitle") { text(item.heading) } + elseif not item.callback then + return row.highlight("NeogitPopupActionDisabled")(render_action(item)) + else + return row(render_action(item)) + end + end, + }, + } +end) + +function M.items(state) + local items = {} + + if state.config[1] then + table.insert(items, Config { state = state.config }) + table.insert(items, EmptyLine()) + end + + if state.args[1] then + local section = {} + local name = "Arguments" + for _, item in ipairs(state.args) do + if item.type == "option" then + table.insert(section, Option(item)) + elseif item.type == "switch" then + table.insert(section, Switch(item)) + elseif item.type == "heading" then + if section[1] then -- If there are items in the section, flush to items table with current name + table.insert(items, Section(name, section)) + table.insert(items, EmptyLine()) + section = {} + end + + name = item.heading + end + end + + table.insert(items, Section(name, section)) + table.insert(items, EmptyLine()) + end + + if state.actions[1] then + table.insert(items, Actions { state = state.actions }) + end + + return items +end + +function M.Popup(state) + return { + List { + items = M.items(state), + }, + } +end + +return M diff --git a/lua/neogit/lib/record.lua b/lua/neogit/lib/record.lua index f50ade820..ac37e2f3f 100644 --- a/lua/neogit/lib/record.lua +++ b/lua/neogit/lib/record.lua @@ -1,8 +1,8 @@ local M = {} -local record_separator = { dec = "\30", hex = "%x1E" } -local field_separator = { dec = "\31", hex = "%x1F" } -local pair_separator = { dec = "\29", hex = "%x1D" } +local record_separator = { dec = "\30", hex_log = "%x1E", hex_ref = "%1E" } +local field_separator = { dec = "\31", hex_log = "%x1F", hex_ref = "%1F" } +local pair_separator = { dec = "\29", hex_log = "%x1D", hex_ref = "%1D" } -- Matches/captures each key/value pair of fields in a record -- 1. \31? - Optionally has a leading field separator (first field won't have this) @@ -44,14 +44,16 @@ function M.decode(lines) end ---@param tbl table Key/value pairs to format with delimiters +---@param type string Git log takes a different formatting string for escape literals than for-each-ref. ---@return string -function M.encode(tbl) +function M.encode(tbl, type) + local hex = "hex_" .. type local out = {} for k, v in pairs(tbl) do - table.insert(out, string.format("%s%s%s", k, pair_separator.hex, v)) + table.insert(out, string.format("%s%s%s", k, pair_separator[hex], v)) end - return table.concat(out, field_separator.hex) .. record_separator.hex + return table.concat(out, field_separator[hex]) .. record_separator[hex] end return M diff --git a/lua/neogit/lib/rpc.lua b/lua/neogit/lib/rpc.lua index c85ea8896..47d431436 100644 --- a/lua/neogit/lib/rpc.lua +++ b/lua/neogit/lib/rpc.lua @@ -1,14 +1,11 @@ -local fn = vim.fn - +---@class RPC +---@field address string +---@field ch string local RPC = {} --- @class RPC --- @field address --- @field ch --- ---- Creates a new rpc channel --- @param address --- @return RPC +---Creates a new rpc channel +---@param address string +---@return RPC function RPC.new(address) local instance = { address = address, @@ -28,11 +25,11 @@ function RPC.create_connection(address) end function RPC:connect() - self.ch = fn.sockconnect("pipe", self.address, { rpc = true }) + self.ch = vim.fn.sockconnect("pipe", self.address, { rpc = true }) end function RPC:disconnect() - fn.chanclose(self.ch) + vim.fn.chanclose(self.ch) self.ch = nil end diff --git a/lua/neogit/lib/signs.lua b/lua/neogit/lib/signs.lua index e665cef73..167f1ef05 100644 --- a/lua/neogit/lib/signs.lua +++ b/lua/neogit/lib/signs.lua @@ -1,35 +1,26 @@ local config = require("neogit.config") local M = {} -local signs = { - CommitViewDescription = { linehl = "NeogitHunkHeader" }, - CommitViewHeader = { linehl = "NeogitCommitViewHeader" }, - DiffAdd = { linehl = "NeogitDiffAdd" }, - DiffAddHighlight = { linehl = "NeogitDiffAddHighlight" }, - DiffContext = { linehl = "NeogitDiffContext" }, - DiffContextHighlight = { linehl = "NeogitDiffContextHighlight" }, - DiffDelete = { linehl = "NeogitDiffDelete" }, - DiffDeleteHighlight = { linehl = "NeogitDiffDeleteHighlight" }, - DiffHeader = { linehl = "NeogitDiffHeader" }, - HunkHeader = { linehl = "NeogitHunkHeader" }, - HunkHeaderHighlight = { linehl = "NeogitHunkHeaderHighlight" }, - LogViewCursorLine = { linehl = "NeogitCursorLine" }, - RebaseDone = { linehl = "NeogitRebaseDone" }, -} +local signs = { NeogitBlank = " " } + +function M.get(name) + local sign = signs[name] + if sign == "" then + return " " + else + return sign + end +end function M.setup() if not config.values.disable_signs then for key, val in pairs(config.values.signs) do if key == "hunk" or key == "item" or key == "section" then - vim.fn.sign_define("NeogitClosed:" .. key, { text = val[1] }) - vim.fn.sign_define("NeogitOpen:" .. key, { text = val[2] }) + signs["NeogitClosed" .. key] = val[1] + signs["NeogitOpen" .. key] = val[2] end end end - - for key, val in pairs(signs) do - vim.fn.sign_define("Neogit" .. key, val) - end end return M diff --git a/lua/neogit/lib/ui/component.lua b/lua/neogit/lib/ui/component.lua index e23b0d3d3..82a55d2cc 100644 --- a/lua/neogit/lib/ui/component.lua +++ b/lua/neogit/lib/ui/component.lua @@ -1,27 +1,44 @@ local util = require("neogit.lib.util") local default_component_options = { + foldable = false, folded = false, - hidden = false, } +---@class ComponentPosition +---@field row_start integer +---@field row_end integer +---@field col_start integer +---@field col_end integer + +---@class ComponentOptions +---@field line_hl string +---@field highlight string +---@field align_right integer|nil +---@field padding_left integer +---@field tag string +---@field foldable boolean +---@field folded boolean +---@field context boolean +---@field interactive boolean +---@field virtual_text string +---@field section string|nil +---@field item table|nil +---@field id string|nil + +---@class Component +---@field position ComponentPosition +---@field parent Component +---@field children Component[] +---@field tag string|nil +---@field options ComponentOptions +---@field index number|nil +---@field value string|nil +---@field id string|nil local Component = {} function Component:row_range_abs() - if self.position.row_end == nil then - return 0, 0 - end - local from = self.position.row_start - local len = self.position.row_end - from - if self.parent.tag ~= "_root" then - local p_from = self.parent:row_range_abs() - from = from + p_from - 1 - end - return from, from + len -end - -function Component:toggle_hidden() - self.options.hidden = not self.options.hidden + return self.position.row_start, self.position.row_end end function Component:get_padding_left(recurse) @@ -33,32 +50,6 @@ function Component:get_padding_left(recurse) return padding_left_text .. (self.parent and self.parent:get_padding_left() or "") end -function Component:is_hidden() - return self.options.hidden or (self.parent and self.parent:is_hidden()) -end - -function Component:is_under_cursor(cursor) - if self:is_hidden() then - return false - end - local row = cursor[1] - local col = cursor[2] - local from, to = self:row_range_abs() - local row_ok = from <= row and row <= to - local col_ok = self.position.col_end == -1 - or (self.position.col_start <= col and col <= self.position.col_end) - return row_ok and col_ok -end - -function Component:is_in_linewise_range(start, stop) - if self:is_hidden() then - return false - end - - local from, to = self:row_range_abs() - return from >= start and from <= stop and to >= start and to <= stop -end - function Component:get_width() if self.tag == "text" then local width = string.len(self.value) @@ -100,23 +91,68 @@ function Component:get_tag() end end -function Component:get_sign() - return self.options.sign or (self.parent and self.parent:get_sign() or nil) +function Component:get_line_highlight() + return self.options.line_hl or (self.parent and self.parent:get_line_highlight() or nil) end function Component:get_highlight() return self.options.highlight or (self.parent and self.parent:get_highlight() or nil) end +function Component:append(c) + table.insert(self.children, c) + return self +end + +---@param ui Ui +---@param depth integer +function Component:open_all_folds(ui, depth) + assert(ui, "Pass in self.buffer.ui") + + if self.options.foldable then + if self.options.on_open then + self.options.on_open(self, ui) + end + + self.options.folded = false + depth = depth - 1 + end + + if self.children and depth > 0 then + for _, child in ipairs(self.children) do + child:open_all_folds(ui, depth) + end + end +end + +---@param ui Ui +function Component:close_all_folds(ui) + assert(ui, "Pass in self.buffer.ui") + + if self.options.foldable then + self.options.folded = true + end + + if self.children then + for _, child in ipairs(self.children) do + child:close_all_folds(ui) + end + end +end + function Component.new(f) - local x = {} - setmetatable(x, { + local instance = {} + + local mt = { __call = function(tbl, ...) - local x = f(...) - local options = vim.tbl_extend("force", default_component_options, tbl, x.options or {}) - x.options = options - setmetatable(x, { __index = Component }) - return x + local this = f(...) + + local options = vim.tbl_extend("force", default_component_options, tbl, this.options or {}) + this.options = options + + setmetatable(this, { __index = Component }) + + return this end, __index = function(tbl, name) local value = rawget(Component, name) @@ -131,8 +167,11 @@ function Component.new(f) return value end, - }) - return x + } + + setmetatable(instance, mt) + + return instance end return Component diff --git a/lua/neogit/lib/ui/debug.lua b/lua/neogit/lib/ui/debug.lua new file mode 100644 index 000000000..4e5df63b9 --- /dev/null +++ b/lua/neogit/lib/ui/debug.lua @@ -0,0 +1,74 @@ +---@class Ui +local Ui = require("neogit.lib.ui") + +function Ui:debug(...) + Ui.visualize_tree { ... } +end + +--- Will only work if something has been rendered +function Ui:debug_layout() + Ui.visualize_tree(self.layout) +end + +function Ui.visualize_tree(components) + local tree = {} + Ui._visualize_tree(1, components, tree) + + vim.lsp.util.open_floating_preview(tree, "txt", { + relative = "editor", + anchor = "NW", + wrap = false, + width = vim.o.columns - 2, + height = vim.o.lines - 2, + }) +end + +function Ui._visualize_tree(indent, components, tree) + for _, c in ipairs(components) do + table.insert(tree, Ui._draw_component(indent, c)) + + if c.tag == "col" or c.tag == "row" then + Ui._visualize_tree(indent + 1, c.children, tree) + end + end +end + +function Ui.visualize_component(c, options) + Ui._print_component(0, c, options or {}) + + if c.tag == "col" or c.tag == "row" then + Ui._visualize_tree(1, c.children, options or {}) + end +end + +function Ui._draw_component(indent, c, _) + local output = string.rep(" ", indent) + if c.position then + local text = "" + if c.position.row_start == c.position.row_end then + text = c.position.row_start + else + text = c.position.row_start .. " - " .. c.position.row_end + end + + if c.position.col_end ~= -1 then + text = text .. " | " .. c.position.col_start .. " - " .. c.position.col_end + end + + output = output .. "[" .. text .. "]" + end + + output = output .. " " .. c:get_tag() + + if c.tag == "text" then + output = output .. " '" .. c.value .. "'" + end + + for k, v in pairs(c.options) do + if k ~= "tag" then + output = output .. " " .. k .. "=" .. tostring(v) + end + end + + return output +end diff --git a/lua/neogit/lib/ui/helpers.lua b/lua/neogit/lib/ui/helpers.lua new file mode 100644 index 000000000..84b000621 --- /dev/null +++ b/lua/neogit/lib/ui/helpers.lua @@ -0,0 +1,23 @@ +local M = {} + +---Closable must implement self:close() method +---@return function +function M.close_topmost(closable) + return function() + local commit_view = require("neogit.buffers.commit_view") + local popup = require("neogit.lib.popup") + local history = require("neogit.buffers.git_command_history") + + if popup.is_open() then + popup.instance:close() + elseif commit_view.is_open() then + commit_view.instance:close() + elseif history.is_open() then + history.instance:close() + else + closable:close() + end + end +end + +return M diff --git a/lua/neogit/lib/ui/init.lua b/lua/neogit/lib/ui/init.lua index e31746a23..bd3cd4ae3 100644 --- a/lua/neogit/lib/ui/init.lua +++ b/lua/neogit/lib/ui/init.lua @@ -1,98 +1,61 @@ local Component = require("neogit.lib.ui.component") local util = require("neogit.lib.util") - -local filter = util.filter +local Renderer = require("neogit.lib.ui.renderer") +local Collection = require("neogit.lib.collection") +local logger = require("neogit.logger") -- TODO: Add logging + +---@class Section +---@field items StatusItem[] + +---@class Selection +---@field sections Section[] +---@field first_line number +---@field last_line number +---@field section Section|nil +---@field item StatusItem|nil +---@field commit CommitLogEntry|nil +---@field commits CommitLogEntry[] +---@field items StatusItem[] +local Selection = {} +Selection.__index = Selection ---@class UiComponent ---@field tag string ---@field options table Component props or arguments ---@field children UiComponent[] +---@class FindOptions + ---@class Ui ----@field buf number +---@field buf Buffer ---@field layout table local Ui = {} +Ui.__index = Ui +---@param buf Buffer +---@return Ui function Ui.new(buf) - local this = { - buf = buf, - layout = {}, - } - setmetatable(this, { __index = Ui }) - return this -end - -function Ui._print_component(indent, c, _options) - local output = string.rep(" ", indent) - if c.options.hidden then - output = output .. "(H)" - elseif c.position then - local text = "" - if c.position.row_start == c.position.row_end then - text = c.position.row_start - else - text = c.position.row_start .. " - " .. c.position.row_end - end - - if c.position.col_end ~= -1 then - text = text .. " | " .. c.position.col_start .. " - " .. c.position.col_end - end - - output = output .. "[" .. text .. "]" - end - - output = output .. " " .. c:get_tag() - - if c.tag == "text" then - output = output .. " '" .. c.value .. "'" - end - - for k, v in pairs(c.options) do - if k ~= "tag" and k ~= "hidden" then - output = output .. " " .. k .. "=" .. tostring(v) - end - end - - print(output) -end - -function Ui._visualize_tree(indent, components, options) - for _, c in ipairs(components) do - Ui._print_component(indent, c, options) - if - (c.tag == "col" or c.tag == "row") - and not (options.collapse_hidden_components and c.options.hidden) - then - Ui._visualize_tree(indent + 1, c.children, options) - end - end + return setmetatable({ buf = buf, layout = {} }, Ui) end function Ui._find_component(components, f, options) for _, c in ipairs(components) do - if (options.include_hidden and c.options.hidden) or not c.options.hidden then - if c.tag == "col" or c.tag == "row" then - local res = Ui._find_component(c.children, f, options) + if c.tag == "col" or c.tag == "row" then + local res = Ui._find_component(c.children, f, options) - if res then - return res - end + if res then + return res end + end - if f(c) then - return c - end + if f(c) then + return c end end return nil end ----@class FindOptions ----@field include_hidden boolean - ---- Finds a ui component in the buffer ---- ---@param f fun(c: UiComponent): boolean ---@param options FindOptions|nil function Ui:find_component(f, options) @@ -117,279 +80,593 @@ function Ui:find_components(f, options) return result end -function Ui:get_component_under_cursor() - local cursor = vim.api.nvim_win_get_cursor(0) - return self:find_component(function(c) - return c:is_under_cursor(cursor) +---@param fn? fun(c: Component): boolean +---@return Component|nil +function Ui:get_component_under_cursor(fn) + fn = fn or function() + return true + end + + local line = vim.api.nvim_win_get_cursor(0)[1] + return self:get_component_on_line(line, fn) +end + +---@param line integer +---@param fn fun(c: Component): boolean +---@return Component|nil +function Ui:get_component_on_line(line, fn) + return self:_find_component_by_index(line, fn) +end + +---@param line integer +---@param f fun(c: Component): boolean +---@return Component|nil +function Ui:_find_component_by_index(line, f) + local node = self.node_index:find_by_line(line)[1] + while node do + if f(node) then + return node + end + + node = node.parent + end +end + +---@return Component|nil +function Ui:find_by_id(id) + return self.node_index:find_by_id(id) +end + +---@return Component|nil +function Ui:get_cursor_context(line) + local cursor = line or vim.api.nvim_win_get_cursor(0)[1] + return self:_find_component_by_index(cursor, function(node) + return node.options.context + end) +end + +---@return string|nil +function Ui:get_line_highlight(line) + local component = self:_find_component_by_index(line, function(node) + return node.options.line_hl ~= nil end) + + return component and component.options.line_hl end -function Ui:get_component_on_line(line) - return self:find_component(function(c) - return c:is_under_cursor { line, 0 } +---@return Component|nil +function Ui:get_interactive_component_under_cursor() + local cursor = vim.api.nvim_win_get_cursor(0) + + return self:_find_component_by_index(cursor[1], function(node) + return node.options.interactive end) end -function Ui:get_component_stack_under_cursor() +---@return Component|nil +function Ui:get_fold_under_cursor() local cursor = vim.api.nvim_win_get_cursor(0) - return self:find_components(function(c) - return c:is_under_cursor(cursor) + + return self:_find_component_by_index(cursor[1], function(node) + return node.options.foldable end) end -function Ui:get_component_stack_in_linewise_selection() +---@class StatusItem +---@field name string +---@field first number +---@field last number +---@field oid string|nil optional object id +---@field commit CommitLogEntry|nil optional object id +---@field folded boolean|nil +---@field hunks Hunk[]|nil + +---@class SelectedHunk: Hunk +---@field from number start offset from the first line of the hunk +---@field to number end offset from the first line of the hunk +---@field lines string[] +--- +---@param item StatusItem +---@param first_line number +---@param last_line number +---@param partial boolean +---@return SelectedHunk[] +function Ui:item_hunks(item, first_line, last_line, partial) + local hunks = {} + + -- TODO: Move this to lib.git.diff + -- local diff = require("neogit.lib.git").cli.diff.check.call_sync { hidden = true, ignore_error = true } + -- local conflict_markers = {} + -- if diff.code == 2 then + -- for _, out in ipairs(diff.stdout) do + -- local line = string.gsub(out, "^" .. item.name .. ":", "") + -- if line ~= out and string.match(out, "conflict") then + -- table.insert(conflict_markers, tonumber(string.match(line, "%d+"))) + -- end + -- end + -- end + + if not item.folded and item.diff.hunks then + for _, h in ipairs(item.diff.hunks) do + if h.first <= last_line and h.last >= first_line then + local from, to + + if partial then + local cursor_offset = first_line - h.first + local length = last_line - first_line + + from = h.diff_from + cursor_offset + to = from + length + else + from = h.diff_from + 1 + to = h.diff_to + end + + local hunk_lines = {} + for i = from, to do + table.insert(hunk_lines, item.diff.lines[i]) + end + + -- local conflict = false + -- for _, n in ipairs(conflict_markers) do + -- if from <= n and n <= to then + -- conflict = true + -- break + -- end + -- end + + local o = { + from = from, + to = to, + __index = h, + hunk = h, + lines = hunk_lines, + -- conflict = conflict, + } + + setmetatable(o, o) + + table.insert(hunks, o) + end + end + end + + return hunks +end + +function Ui:get_selection() + local visual_pos = vim.fn.line("v") + local cursor_pos = vim.fn.line(".") + + local first_line = math.min(visual_pos, cursor_pos) + local last_line = math.max(visual_pos, cursor_pos) + + local res = { + sections = {}, + first_line = first_line, + last_line = last_line, + item = nil, + commit = nil, + commits = {}, + items = {}, + } + + for _, section in ipairs(self.item_index) do + local items = {} + + if not section.first or section.first > last_line then + break + end + + if section.last >= first_line then + if section.first <= first_line and section.last >= last_line then + res.section = section + end + + local entire_section = section.first == first_line and first_line == last_line + + for _, item in pairs(section.items) do + if entire_section or item.first <= last_line and item.last >= first_line then + if not res.item and item.first <= first_line and item.last >= last_line then + res.item = item + + res.commit = item.commit + end + + if item.commit then + table.insert(res.commits, item.commit) + end + + table.insert(res.items, item) + table.insert(items, item) + end + end + + local section = { + section = section, + items = items, + __index = section, + } + + setmetatable(section, section) + table.insert(res.sections, section) + end + end + + return setmetatable(res, Selection) +end + +---@return string[] +function Ui:get_commits_in_selection() local range = { vim.fn.getpos("v")[2], vim.fn.getpos(".")[2] } table.sort(range) local start, stop = unpack(range) - return self:find_components(function(c) - return c:is_in_linewise_range(start, stop) - end) + local commits = {} + for i = start, stop do + local component = self:_find_component_by_index(i, function(node) + return node.options.oid + end) + + if component then + table.insert(commits, 1, component.options.oid) + end + end + + return util.deduplicate(commits) +end + +---@return string[] +function Ui:get_filepaths_in_selection() + local range = { vim.fn.getpos("v")[2], vim.fn.getpos(".")[2] } + table.sort(range) + local start, stop = unpack(range) + + local paths = {} + for i = start, stop do + local component = self:_find_component_by_index(i, function(node) + return node.options.item and node.options.item.escaped_path + end) + + if component then + table.insert(paths, 1, component.options.item.escaped_path) + end + end + + return util.deduplicate(paths) end -function Ui:get_component_stack_on_line(line) - return self:find_components(function(c) - return c:is_under_cursor { line, 0 } +---@return string|nil +function Ui:get_commit_under_cursor() + local cursor = vim.api.nvim_win_get_cursor(0) + local component = self:_find_component_by_index(cursor[1], function(node) + return node.options.oid ~= nil end) + + return component and component.options.oid end -function Ui:get_commits_in_selection() - local commits = util.filter_map(self:get_component_stack_in_linewise_selection(), function(c) - if c.options.oid then - return c.options.oid - end +---@return string|nil +function Ui:get_yankable_under_cursor() + local cursor = vim.api.nvim_win_get_cursor(0) + local component = self:_find_component_by_index(cursor[1], function(node) + return node.options.yankable ~= nil end) - -- Reversed so that the oldest commit is the first in the list - return util.reverse(commits) + return component and component.options.yankable end -function Ui:get_commit_under_cursor() - local stack = self:get_component_stack_under_cursor() - return stack[#stack].options.oid +---@return Section|nil +function Ui:first_section() + return self.item_index[1] end -function Ui.visualize_component(c, options) - Ui._print_component(0, c, options or {}) - if c.tag == "col" or c.tag == "row" then - Ui._visualize_tree(1, c.children, options or {}) - end +---@return Component|nil +function Ui:get_current_section(line) + line = line or vim.api.nvim_win_get_cursor(0)[1] + local component = self:_find_component_by_index(line, function(node) + return node.options.section ~= nil + end) + + return component end -function Ui.visualize_tree(components, options) - print("root") - Ui._visualize_tree(1, components, options or {}) +---@class CursorLocation +---@field first number +---@field last number +---@field section {index: number, name: string}|nil +---@field file {index: number, name: string}|nil +---@field hunk {index: number, name: string}|nil + +---Encode the cursor location into a table +---@param line number? +---@return CursorLocation +function Ui:get_cursor_location(line) + line = line or vim.api.nvim_win_get_cursor(0)[1] + local section_loc, section_offset, file_loc, hunk_loc, first, last + + for li, loc in ipairs(self.item_index) do + if line == loc.first then + section_loc = { index = li, name = loc.name } + first, last = loc.first, loc.last + + break + elseif loc.first and line >= loc.first and line <= loc.last then + section_loc = { index = li, name = loc.name } + + if #loc.items > 0 then + for fi, file in ipairs(loc.items) do + if line == file.first then + file_loc = { index = fi, name = file.name } + first, last = file.first, file.last + + break + elseif line >= file.first and line <= file.last then + file_loc = { index = fi, name = file.name } + + for hi, hunk in ipairs(file.diff.hunks) do + if line >= hunk.first and line <= hunk.last then + hunk_loc = { index = hi, name = hunk.hash } + first, last = hunk.first, hunk.last + + break + end + end + + break + end + end + else + section_offset = line - loc.first + end + + break + end + end + + return { + section = section_loc, + file = file_loc, + hunk = hunk_loc, + first = first, + last = last, + section_offset = section_offset, + } end -function Ui:_render(first_line, first_col, parent, components, flags) - local curr_line = first_line +---@param cursor CursorLocation +---@return number +function Ui:resolve_cursor_location(cursor) + if #self.item_index == 0 then + logger.debug("[UI] No items to resolve cursor location") + return 1 + end - if flags.in_row then - local col_start = first_col - local col_end - local highlights = {} - local text = {} + if not cursor.section then + logger.debug("[UI] No Cursor Section") + cursor.section = { index = 1, name = "" } + end - for i, c in ipairs(components) do - c.parent = parent - c.index = i + local section = Collection.new(self.item_index):find(function(s) + return s.name == cursor.section.name + end) - if not c.options.hidden then - c.position = {} - c.position.row_start = curr_line - first_line + 1 + if not section then + logger.debug("[UI] No Section Found '" .. cursor.section.name .. "'") - local highlight = c:get_highlight() + cursor.file = nil + cursor.hunk = nil + section = self.item_index[cursor.section.index] or self.item_index[#self.item_index] + end - if c.tag == "text" then - local padding_left = flags.in_nested_row and "" or c:get_padding_left(i == 1) - table.insert(text, 1, padding_left) + if not cursor.file or not section.items or #section.items == 0 then + if cursor.section_offset then + return section.first + cursor.section_offset + else + logger.debug("[UI] No file - using section.first") + return section.first + end + end - col_start = col_start + #padding_left - col_end = col_start + c:get_width() - c.position.col_start = col_start - c.position.col_end = col_end - 1 + local file = Collection.new(section.items):find(function(f) + return f.name == cursor.file.name + end) - if c.options.align_right then - table.insert(text, c.value) - table.insert(text, (" "):rep(c.options.align_right - #c.value)) - else - table.insert(text, c.value) - end + if not file then + logger.debug(("[UI] No file found %q"):format(cursor.file.name)) - if highlight then - table.insert(highlights, { - from = col_start, - to = col_end, - name = highlight, - }) - end + cursor.hunk = nil + file = section.items[cursor.file.index] or section.items[#section.items] + end - col_start = col_end - elseif c.tag == "row" then - flags.in_nested_row = true + if not cursor.hunk or not file.diff.hunks or #file.diff.hunks == 0 then + logger.debug("[UI] No hunk - using file.first") + return file.first + end - local padding_left = flags.in_nested_row and "" or c:get_padding_left(i == 1) - local res = self:_render(curr_line, col_start, c, c.children, flags) + local hunk = Collection.new(file.diff.hunks):find(function(h) + return h.hash == cursor.hunk.name + end) or file.diff.hunks[cursor.hunk.index] or file.diff.hunks[#file.diff.hunks] - flags.in_nested_row = false + logger.debug(("[UI] Using hunk.first %q"):format(cursor.hunk.name)) - if c.position.col_end then - c.position.col_end = c.position.col_end + #padding_left - end + return hunk.first +end - table.insert(text, padding_left) - table.insert(text, res.text) +---@return table|nil +function Ui:get_hunk_or_filename_under_cursor() + local cursor = vim.api.nvim_win_get_cursor(0) + local component = self:_find_component_by_index(cursor[1], function(node) + return node.options.hunk or node.options.filename + end) - for _, h in ipairs(res.highlights) do - h.to = h.to + #padding_left - table.insert(highlights, h) - end + return component and { + hunk = component.options.hunk, + filename = component.options.filename, + } +end - col_end = col_start + vim.fn.strdisplaywidth(res.text) - c.position.col_start = col_start - c.position.col_end = col_end - col_start = col_end - else - error("The row component does not support having a `" .. c.tag .. "` as child") - end +---@return table|nil +function Ui:get_item_under_cursor() + local cursor = vim.api.nvim_win_get_cursor(0) + local component = self:_find_component_by_index(cursor[1], function(node) + return node.options.item + end) - c.position.row_end = c.position.row_start - end - end + return component and component.options.item +end - if flags.in_nested_row then - return { - text = table.concat(text), - highlights = highlights, - } - end +---@param layout table +---@return table[] +local function filter_layout(layout) + return util.filter(layout, function(x) + return type(x) == "table" + end) +end - if not flags.hidden then - self.buf:buffered_set_line(table.concat(text)) +local function node_prefix(node, prefix) + local base = false + local key + if node.options.section then + key = node.options.section + elseif node.options.filename then + key = node.options.filename + elseif node.options.hunk then + base = true + key = node.options.hunk.hash + end - for _, h in ipairs(highlights) do - self.buf:buffered_add_highlight(curr_line - 1, h.from, h.to, h.name) - end + if key then + return ("%s--%s"):format(prefix, key), base + else + return nil, base + end +end + +local function folded_node_state(node, node_table, prefix) + if not node_table then + node_table = {} + end + + prefix = prefix or "" + + local key, base = node_prefix(node, prefix) + if key then + prefix = key + node_table[prefix] = { folded = node.options.folded } + end - curr_line = curr_line + 1 + if node.children and not base then + for _, child in ipairs(node.children) do + folded_node_state(child, node_table, prefix) end - else - for i, c in ipairs(components) do - c.parent = parent - c.index = i - - if not c.options.hidden then - c.position = {} - c.position.row_start = curr_line - first_line + 1 - c.position.col_start = 0 - c.position.col_end = -1 - local sign = c:get_sign() - local highlight = c:get_highlight() - - if c.tag == "text" then - if not flags.hidden then - self.buf:buffered_set_line(table.concat { c:get_padding_left(), c.value }) - - if highlight then - self.buf:buffered_add_highlight( - curr_line - 1, - c.position.col_start, - c.position.col_end, - highlight - ) - end + end - if sign then - self.buf:buffered_place_sign(curr_line, sign, "hl") - end + return node_table +end - curr_line = curr_line + 1 - end - elseif c.tag == "col" then - curr_line = curr_line + self:_render(curr_line, 0, c, c.children, flags) - elseif c.tag == "row" then - flags.in_row = true - curr_line = curr_line + self:_render(curr_line, 0, c, c.children, flags) - - if not flags.hidden and sign then - self.buf:buffered_place_sign(curr_line - 1, sign, "hl") - end +function Ui:_update_fold_state(node, attributes, prefix) + prefix = prefix or "" - if not flags.hidden and c.options.virtual_text then - local ns = self.buf:create_namespace("NeogitBufferVirtualText") - self.buf:buffered_set_extmark(ns, curr_line - 2, 0, { - hl_mode = "combine", - virt_text = c.options.virtual_text, - virt_text_pos = "right_align", - }) - end + local key, base = node_prefix(node, prefix) + if key then + prefix = key - flags.in_row = false - end + if attributes[prefix] then + node.options.folded = attributes[prefix].folded + end + end - c.position.row_end = curr_line - first_line - else - flags.hidden = true - - if c.tag == "col" then - self:_render(curr_line, 0, c, c.children, flags) - elseif c.tag == "row" then - flags.in_row = true - self:_render(curr_line, 0, c, c.children, flags) - flags.in_row = false - end + if node.children and not base then + for _, child in ipairs(node.children) do + self:_update_fold_state(child, attributes, prefix) + end + end +end - flags.hidden = false +function Ui:_update_on_open(node, attributes, prefix) + prefix = prefix or "" + + local key, base = node_prefix(node, prefix) + if key then + prefix = key + + -- TODO: If a hunk is closed, it will be re-opened on update because the on_open callback runs async :\ + if attributes[prefix] then + if node.options.on_open and not attributes[prefix].folded then + node.options.on_open(node, self, prefix) end end end - return curr_line - first_line + if node.children and not base then + for _, child in ipairs(node.children) do + self:_update_on_open(child, attributes, prefix) + end + end end function Ui:render(...) - self.layout = { ... } - self.layout = filter(self.layout, function(x) - return type(x) == "table" - end) + local layout = filter_layout { ... } + local root = Component.new(function() + return { tag = "_root", children = layout } + end)() + + if not vim.tbl_isempty(self.layout) then + self._node_fold_state = folded_node_state(self.layout) + end + + self.layout = root self:update() end --- This shouldn't be called often as it completely rewrites the whole buffer function Ui:update() + -- Copy over the old fold state _before_ buffer is rendered so the output of the fold buffer is correct + if self._node_fold_state then + self:_update_fold_state(self.layout, self._node_fold_state) + end + + local renderer = Renderer:new(self.layout, self.buf):render() + self.node_index = renderer:node_index() + self.item_index = renderer:item_index() + + local cursor_line = self.buf:cursor_line() self.buf:unlock() - local lines_used = self:_render( - 1, - 0, - Component.new(function() - return { - tag = "_root", - children = self.layout, - } - end)(), - self.layout, - {} - ) - self.buf:resize(lines_used) - self.buf:flush_buffers() - self.buf:lock() -end + self.buf:clear() + self.buf:clear_namespace("default") + self.buf:clear_namespace("ViewContext") + self.buf:resize(#renderer.buffer.line) + self.buf:set_lines(0, -1, false, renderer.buffer.line) + self.buf:set_highlights(renderer.buffer.highlight) + self.buf:set_extmarks(renderer.buffer.extmark) + self.buf:set_line_highlights(renderer.buffer.line_highlight) + self.buf:set_folds(renderer.buffer.fold) + + self.statuscolumn = {} + self.statuscolumn.foldmarkers = {} + + for i = 1, #renderer.buffer.line do + self.statuscolumn.foldmarkers[i] = false + end ---- Will only work if something has been rendered -function Ui:print_layout_tree(options) - Ui.visualize_tree(self.layout, options) -end + for _, fold in ipairs(renderer.buffer.fold) do + self.statuscolumn.foldmarkers[fold[1]] = fold[4] + end -function Ui:debug(...) - Ui.visualize_tree({ ... }, {}) + -- Run on_open callbacks for hunks once buffer is rendered + if self._node_fold_state then + self:_update_on_open(self.layout, self._node_fold_state) + self._node_fold_state = nil + end + + self.buf:lock() + self.buf:move_cursor(math.min(cursor_line, #renderer.buffer.line)) end Ui.col = Component.new(function(children, options) return { tag = "col", - children = filter(children, function(x) - return type(x) == "table" - end), + children = filter_layout(children), options = options, } end) @@ -397,9 +674,7 @@ end) Ui.row = Component.new(function(children, options) return { tag = "row", - children = filter(children, function(x) - return type(x) == "table" - end), + children = filter_layout(children), options = options, } end) @@ -417,9 +692,14 @@ Ui.text = Component.new(function(value, options, ...) tag = "text", value = value or "", options = type(options) == "table" and options or nil, + __index = { + render = function(self) + return self.value + end, + }, } end) -Ui.Component = require("neogit.lib.ui.component") +Ui.Component = Component return Ui diff --git a/lua/neogit/lib/ui/renderer.lua b/lua/neogit/lib/ui/renderer.lua new file mode 100644 index 000000000..e09e9eb84 --- /dev/null +++ b/lua/neogit/lib/ui/renderer.lua @@ -0,0 +1,327 @@ +---@source component.lua + +---@class RendererIndex +---@field index table +---@field items table +local RendererIndex = {} +RendererIndex.__index = RendererIndex + +---@param line number +---@return Component[] +function RendererIndex:find_by_line(line) + return self.index[line] or {} +end + +---@param id string +---@return Component +function RendererIndex:find_by_id(id) + return self.index[id] +end + +---@param node Component +function RendererIndex:add(node) + if not self.index[node.position.row_start] then + self.index[node.position.row_start] = {} + end + + table.insert(self.index[node.position.row_start], node) +end + +---@param node Component +---@param id? string +function RendererIndex:add_id(node, id) + id = id or node.options.id + assert(id, "id cannot be nil") + + if tonumber(id) then + error("Cannot use an integer ID for a component") + end + + self.index[id] = node +end + +---For tracking item locations within status buffer. Needed to make selections. +---@param name string +---@param first number +---@param last number +function RendererIndex:add_section(name, first, last) + self.items[#self.items].name = name + self.items[#self.items].first = first + self.items[#self.items].last = last + table.insert(self.items, { items = {} }) +end + +function RendererIndex:add_item(item, first, last) + self.items[#self.items].last = last + + item.first = first + item.last = last + table.insert(self.items[#self.items].items, item) +end + +function RendererIndex.new() + return setmetatable({ + index = {}, + items = { + { items = {} }, -- First section + }, + }, RendererIndex) +end + +---@class RendererBuffer +---@field line string[] +---@field highlight table[] +---@field line_highlight table[] +---@field extmark table[] +---@field fold table[] + +---@class RendererFlags +---@field in_row boolean +---@field in_nested_row boolean + +---@class Renderer +---@field buffer RendererBuffer +---@field flags RendererFlags +---@field namespace integer +---@field layout table +---@field current_column number +---@field index table +local Renderer = {} +Renderer.__index = Renderer + +---@param layout table +---@param buffer Buffer +---@return Renderer +function Renderer:new(layout, buffer) + local obj = { + namespace = buffer:create_namespace("VirtualText"), + layout = layout, + buffer = { + line = {}, + highlight = {}, + line_highlight = {}, + extmark = {}, + fold = {}, + }, + index = RendererIndex.new(), + flags = { + in_row = false, + in_nested_row = false, + }, + } + + setmetatable(obj, self) + + return obj +end + +---@return Renderer +function Renderer:render() + self:_render(self.layout, self.layout.children, 0) + + return self +end + +---@return RendererIndex +function Renderer:node_index() + return self.index +end + +---@return RendererIndex +function Renderer:item_index() + return self.index.items +end + +function Renderer:_build_child(child, parent, index) + if child.options.id then + self.index:add_id(child) + end + + if child.options.yankable then + self.index:add_id(child, child.options.yankable) + end + + child.parent = parent + child.index = index + child.position = { + row_start = #self.buffer.line + 1, + row_end = self.flags.in_row and #self.buffer.line + 1 or -1, + col_start = 0, + col_end = -1, + } +end + +---@param parent Component +---@param children Component[] +---@param column integer +function Renderer:_render(parent, children, column) + if self.flags.in_row then + local col_start = column + local col_end + local highlights = {} + local text = {} + + for index, child in ipairs(children) do + self:_build_child(child, parent, index) + col_start = self:_render_child_in_row(child, index, col_start, col_end, highlights, text) + end + + if self.flags.in_nested_row then + return { text = table.concat(text), highlights = highlights } + end + + table.insert(self.buffer.line, table.concat(text)) + + for _, h in ipairs(highlights) do + table.insert(self.buffer.highlight, { #self.buffer.line - 1, h.from, h.to, h.name }) + end + else + for index, child in ipairs(children) do + self:_build_child(child, parent, index) + self:_render_child(child) + end + end +end + +---@param child Component +function Renderer:_render_child(child) + if child.tag == "text" then + self:_render_text(child) + elseif child.tag == "col" then + self:_render_col(child) + elseif child.tag == "row" then + self:_render_row(child) + end + + child.position.row_end = #self.buffer.line + + if child.options.section then + self.index:add_section(child.options.section, child.position.row_start, child.position.row_end) + end + + if child.options.item then + child.options.item.folded = child.options.folded + self.index:add_item(child.options.item, child.position.row_start, child.position.row_end) + end + + local line_hl = child:get_line_highlight() + if line_hl then + table.insert(self.buffer.line_highlight, { #self.buffer.line - 1, line_hl }) + end + + if child.options.virtual_text then + table.insert(self.buffer.extmark, { + self.namespace, + #self.buffer.line - 1, + 0, + { + hl_mode = "combine", + virt_text = child.options.virtual_text, + virt_text_pos = "right_align", + }, + }) + end + + if child.options.foldable then + table.insert(self.buffer.fold, { + #self.buffer.line - (child.position.row_end - child.position.row_start), + #self.buffer.line, + not child.options.folded, + child.options.tag, + }) + end +end + +---@param child Component +function Renderer:_render_row(child) + self.flags.in_row = true + self:_render(child, child.children, 0) + self.flags.in_row = false +end + +---@param child Component +function Renderer:_render_col(child) + self:_render(child, child.children, 0) +end + +---@param child Component +function Renderer:_render_text(child) + local highlight = child:get_highlight() + if highlight then + table.insert(self.buffer.highlight, { + #self.buffer.line, + child.position.col_start, + child.position.col_end, + highlight, + }) + end + + local line_highlight = child:get_line_highlight() + if line_highlight then + table.insert(self.buffer.line_highlight, { #self.buffer.line, line_highlight }) + end + + table.insert(self.buffer.line, table.concat { child:get_padding_left(), child.value }) + self.index:add(child) +end + +---@param child Component +---@param i integer index of child in parent.children +function Renderer:_render_child_in_row(child, i, col_start, col_end, highlights, text) + if child.tag == "text" then + return self:_render_in_row_text(child, i, col_start, highlights, text) + elseif child.tag == "row" then + return self:_render_in_row_row(child, highlights, text, col_start, col_end) + else + error("The row component does not support having a `" .. child.tag .. "` as a child") + end +end + +---@param child Component +---@param index integer index of child in parent.children +function Renderer:_render_in_row_text(child, index, col_start, highlights, text) + local padding_left = self.flags.in_nested_row and "" or child:get_padding_left(index == 1) + table.insert(text, 1, padding_left) + + col_start = col_start + #padding_left + local col_end = col_start + child:get_width() + + child.position.col_start = col_start + child.position.col_end = col_end - 1 + + if child.options.align_right then + table.insert(text, child.value) + table.insert(text, (" "):rep(child.options.align_right - #child.value)) + else + table.insert(text, child.value) + end + + local highlight = child:get_highlight() + if highlight then + table.insert(highlights, { from = col_start, to = col_end, name = highlight }) + end + + self.index:add(child) + return col_end +end + +---@param child Component +function Renderer:_render_in_row_row(child, highlights, text, col_start, col_end) + self.flags.in_nested_row = true + local res = self:_render(child, child.children, col_start) + self.flags.in_nested_row = false + + table.insert(text, res.text) + + for _, h in ipairs(res.highlights) do + table.insert(highlights, h) + end + + col_end = col_start + vim.fn.strdisplaywidth(res.text) + child.position.col_start = col_start + child.position.col_end = col_end + + return col_end +end + +return Renderer diff --git a/lua/neogit/lib/util.lua b/lua/neogit/lib/util.lua index 8b686799a..949508a5b 100644 --- a/lua/neogit/lib/util.lua +++ b/lua/neogit/lib/util.lua @@ -388,10 +388,23 @@ function M.lists_equal(l1, l2) return true end +local special_chars = { "%%", "%(", "%)", "%.", "%+", "%-", "%*", "%?", "%[", "%^", "%$" } +function M.pattern_escape(str) + for _, char in ipairs(special_chars) do + str, _ = str:gsub(char, "%" .. char) + end + + return str +end + function M.pad_right(s, len) return s .. string.rep(" ", math.max(len - #s, 0)) end +function M.pad_left(s, len) + return string.rep(" ", math.max(len - #s, 0)) .. s +end + --- http://lua-users.org/wiki/StringInterpolation --- @param template string --- @param values table @@ -467,7 +480,7 @@ function M.memoize(f, opts) local timer = {} return function(...) - local key = vim.inspect { ... } + local key = vim.inspect { vim.loop.cwd(), ... } if cache[key] == nil then cache[key] = f(...) @@ -485,4 +498,43 @@ function M.memoize(f, opts) end end +--- Debounces a function on the trailing edge. +--- +--- @generic F: function +--- @param ms number Timeout in ms +--- @param fn F Function to debounce +--- @param hash? integer|fun(...): any Function that determines id from arguments to fn +--- @return F Debounced function. +function M.debounce_trailing(ms, fn, hash) + local running = {} --- @type table + + if type(hash) == "number" then + local hash_i = hash + hash = function(...) + return select(hash_i, ...) + end + end + + return function(...) + local id = hash and hash(...) or true + if running[id] == nil then + running[id] = assert(vim.loop.new_timer()) + end + + local timer = running[id] + local argv = { ... } + timer:start(ms, 0, function() + timer:stop() + running[id] = nil + fn(unpack(argv, 1, table.maxn(argv))) + end) + end +end + +---@param value any +---@return table +function M.tbl_wrap(value) + return type(value) == "table" and value or { value } +end + return M diff --git a/lua/neogit/lib/uv.lua b/lua/neogit/lib/uv.lua deleted file mode 100644 index 2a8dcb2d0..000000000 --- a/lua/neogit/lib/uv.lua +++ /dev/null @@ -1,39 +0,0 @@ -local a = require("plenary.async") - -local M = {} - -function M.read_file(path) - local err, fd = a.uv.fs_open(path, "r", 438) - if err then - return err - end - - local err, stat = a.uv.fs_fstat(fd) - if err then - return err - end - - local err, data = a.uv.fs_read(fd, stat.size, 0) - if err then - return err - end - - local err = a.uv.fs_close(fd) - if err then - return err - end - - return nil, data -end - -M.read_file_sync = function(path) - local output = {} - - for line in io.lines(path) do - table.insert(output, line) - end - - return output -end - -return M diff --git a/lua/neogit/logger.lua b/lua/neogit/logger.lua index a00dff1dd..e02626261 100644 --- a/lua/neogit/logger.lua +++ b/lua/neogit/logger.lua @@ -107,8 +107,10 @@ log.new = function(config, standalone) if config.use_file then local fp = io.open(outfile, "a") local str = string.format("[%-6s%s] %s: %s\n", nameupper, os.date(), lineinfo, msg) - fp:write(str) - fp:close() + if fp then + fp:write(str) + fp:close() + end end end diff --git a/lua/neogit/popups/bisect/actions.lua b/lua/neogit/popups/bisect/actions.lua new file mode 100644 index 000000000..2e1d91b1a --- /dev/null +++ b/lua/neogit/popups/bisect/actions.lua @@ -0,0 +1,138 @@ +local M = {} +local git = require("neogit.lib.git") +local notification = require("neogit.lib.notification") +local input = require("neogit.lib.input") +local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") +local util = require("neogit.lib.util") + +---@return table|nil +local function use_popup_revisions(popup) + local bad_revision = popup.state.env.commits[1] + local good_revision = popup.state.env.commits[#popup.state.env.commits] + + if git.log.is_ancestor(good_revision, bad_revision) then + return { bad_revision, good_revision } + elseif git.log.is_ancestor(bad_revision, good_revision) then + return { good_revision, bad_revision } + else + local message = ("The first revision selected (%s) has to be an ancestor of the last one (%s)"):format( + bad_revision, + good_revision + ) + + notification.warn(message) + end +end + +---@return table|nil +local function get_user_revisions(popup) + local refs = + util.merge(popup.state.env.commits, git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) + local bad_revision = FuzzyFinderBuffer.new(refs):open_async { + prompt_prefix = "Start bisect with bad revision", + } + + if not bad_revision then + return + end + + util.remove_item_from_table(refs, bad_revision) + local good_revision = FuzzyFinderBuffer.new(refs):open_async { + prompt_prefix = "Good revision", + } + + if not good_revision then + return + end + + if git.log.is_ancestor(good_revision, bad_revision) then + return { bad_revision, good_revision } + else + local message = ("The good revision (%s) has to be an ancestor of the bad one (%s)"):format( + good_revision, + bad_revision + ) + + notification.warn(message) + end +end + +---@param popup table +---@return table|nil +local function revisions(popup) + popup.state.env.commits = popup.state.env.commits or {} + local revisions + if #popup.state.env.commits > 1 then + revisions = use_popup_revisions(popup) + else + revisions = get_user_revisions(popup) + end + + if revisions then + return revisions + end +end + +function M.start(popup) + if git.status.is_dirty() then + notification.warn("Cannot bisect with uncommitted changes") + return + end + + local revisions = revisions(popup) + if revisions then + notification.info("Bisecting...") + local bad_revision, good_revision = unpack(revisions) + git.bisect.start(bad_revision, good_revision, popup:get_arguments()) + end +end + +function M.scripted(popup) + if git.status.is_dirty() then + notification.warn("Cannot bisect with uncommitted changes") + return + end + + local revisions = revisions(popup) + if revisions then + local command = input.get_user_input("Bisect shell command") + if command then + notification.info("Bisecting...") + + local bad_revision, good_revision = unpack(revisions) + git.bisect.start(bad_revision, good_revision, popup:get_arguments()) + git.bisect.run(command) + end + end +end + +function M.good() + git.bisect.good() +end + +function M.bad() + git.bisect.bad() +end + +function M.skip() + git.bisect.skip() +end + +function M.reset_with_permission() + if input.get_permission("End bisection?") then + git.bisect.reset() + end +end + +function M.reset() + git.bisect.reset() +end + +function M.run() + local command = input.get_user_input("Bisect shell command") + if command then + git.bisect.run(command) + end +end + +return M diff --git a/lua/neogit/popups/bisect/init.lua b/lua/neogit/popups/bisect/init.lua new file mode 100644 index 000000000..31d14f89d --- /dev/null +++ b/lua/neogit/popups/bisect/init.lua @@ -0,0 +1,34 @@ +local M = {} + +local popup = require("neogit.lib.popup") +local git = require("neogit.lib.git") +local actions = require("neogit.popups.bisect.actions") + +function M.create(env) + local in_progress = git.bisect.in_progress() + local finished = git.bisect.is_finished() + + local p = popup + .builder() + :name("NeogitBisectPopup") + :switch_if(not in_progress, "r", "no-checkout", "Don't checkout commits") + :switch_if(not in_progress, "p", "first-parent", "Follow only first parent of a merge") + :group_heading_if(not in_progress, "Bisect") + :group_heading_if(in_progress, "Actions") + :action_if(not in_progress, "B", "Start", actions.start) + :action_if(not in_progress, "S", "Scripted", actions.scripted) + :action_if(not finished and in_progress, "b", "Bad", actions.bad) + :action_if(not finished and in_progress, "g", "Good", actions.good) + :action_if(not finished and in_progress, "s", "Skip", actions.skip) + :action_if(not finished and in_progress, "r", "Reset", actions.reset_with_permission) + :action_if(finished and in_progress, "r", "Reset", actions.reset) + :action_if(not finished and in_progress, "S", "Run script", actions.run) + :env(env) + :build() + + p:show() + + return p +end + +return M diff --git a/lua/neogit/popups/branch/actions.lua b/lua/neogit/popups/branch/actions.lua index 49d65bb41..b44926f67 100644 --- a/lua/neogit/popups/branch/actions.lua +++ b/lua/neogit/popups/branch/actions.lua @@ -44,7 +44,7 @@ local function spin_off_branch(checkout) end end ----@param popup Popup +---@param popup PopupData ---@param prompt string ---@param checkout boolean ---@return string|nil @@ -54,8 +54,8 @@ local function create_branch(popup, prompt, checkout) local options = util.deduplicate(util.merge( { popup.state.env.commits[1] }, { git.branch.current() or "HEAD" }, - git.branch.get_all_branches(false), - git.tag.list(), + git.refs.list_branches(), + git.refs.list_tags(), git.refs.heads() )) @@ -87,7 +87,8 @@ M.spin_out_branch = operation("spin_out_branch", function() end) M.checkout_branch_revision = operation("checkout_branch_revision", function(popup) - local options = util.merge(popup.state.env.commits, git.branch.get_all_branches(false), git.tag.list()) + local options = + util.merge(popup.state.env.commits, git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) local selected_branch = FuzzyFinderBuffer.new(options):open_async() if not selected_branch then return @@ -98,8 +99,8 @@ M.checkout_branch_revision = operation("checkout_branch_revision", function(popu end) M.checkout_local_branch = operation("checkout_local_branch", function(popup) - local local_branches = git.branch.get_local_branches(true) - local remote_branches = util.filter_map(git.branch.get_remote_branches(), function(name) + local local_branches = git.refs.list_local_branches() + local remote_branches = util.filter_map(git.refs.list_remote_branches(), function(name) local branch_name = name:match([[%/(.*)$]]) -- Remove remote branches that have a local branch by the same name if branch_name and not vim.tbl_contains(local_branches, branch_name) then @@ -140,7 +141,7 @@ M.create_branch = operation("create_branch", function(popup) end) M.configure_branch = operation("configure_branch", function() - local branch_name = FuzzyFinderBuffer.new(git.branch.get_local_branches(true)):open_async() + local branch_name = FuzzyFinderBuffer.new(git.refs.list_local_branches()):open_async() if not branch_name then return end @@ -149,13 +150,7 @@ M.configure_branch = operation("configure_branch", function() end) M.rename_branch = operation("rename_branch", function() - local current_branch = git.branch.current() - local branches = git.branch.get_local_branches(false) - if current_branch then - table.insert(branches, 1, current_branch) - end - - local selected_branch = FuzzyFinderBuffer.new(branches):open_async() + local selected_branch = FuzzyFinderBuffer.new(git.refs.list_local_branches()):open_async() if not selected_branch then return end @@ -173,11 +168,7 @@ end) M.reset_branch = operation("reset_branch", function(popup) if git.status.is_dirty() then - local confirmation = input.get_confirmation( - "Uncommitted changes will be lost. Proceed?", - { values = { "&Yes", "&No" }, default = 2 } - ) - if not confirmation then + if not input.get_permission("Uncommitted changes will be lost. Proceed?") then return end end @@ -189,10 +180,11 @@ M.reset_branch = operation("reset_branch", function(popup) local options = util.deduplicate( util.merge( - popup.state.env.commits, + popup.state.env.commits or {}, relatives, - git.branch.get_all_branches(false), - git.tag.list(), + git.refs.list_branches(), + git.refs.list_tags(), + git.stash.list_refs(), git.refs.heads() ) ) @@ -214,7 +206,7 @@ M.reset_branch = operation("reset_branch", function(popup) end) M.delete_branch = operation("delete_branch", function() - local branches = git.branch.get_all_branches(true) + local branches = git.refs.list_branches() local selected_branch = FuzzyFinderBuffer.new(branches):open_async() if not selected_branch then return @@ -226,10 +218,7 @@ M.delete_branch = operation("delete_branch", function() if remote and branch_name - and input.get_confirmation( - string.format("Delete remote branch '%s/%s'?", remote, branch_name), - { values = { "&Yes", "&No" }, default = 2 } - ) + and input.get_permission(("Delete remote branch '%s/%s'?"):format(remote, branch_name)) then success = git.cli.push.remote(remote).delete.to(branch_name).call_sync().code == 0 elseif not remote and branch_name == git.branch.current() then @@ -279,7 +268,7 @@ M.open_pull_request = operation("open_pull_request", function() local url = git.remote.get_url(git.branch.upstream_remote())[1] for s, v in pairs(config.values.git_services) do - if url:match(s) then + if url:match(util.pattern_escape(s)) then template = v break end diff --git a/lua/neogit/popups/branch_config/actions.lua b/lua/neogit/popups/branch_config/actions.lua index f322be3c0..74f20a1a2 100644 --- a/lua/neogit/popups/branch_config/actions.lua +++ b/lua/neogit/popups/branch_config/actions.lua @@ -1,6 +1,5 @@ local a = require("plenary.async") local git = require("neogit.lib.git") -local util = require("neogit.lib.util") local client = require("neogit.client") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") @@ -41,12 +40,8 @@ function M.update_pull_rebase() end function M.merge_config(branch) - local local_branches = git.branch.get_local_branches() - local remote_branches = git.branch.get_remote_branches() - local branches = util.merge(local_branches, remote_branches) - - return a.void(function(popup, c) - local target = FuzzyFinderBuffer.new(branches):open_async { prompt_prefix = "upstream" } + local fn = function() + local target = FuzzyFinderBuffer.new(git.refs.list_branches()):open_async { prompt_prefix = "upstream" } if not target then return end @@ -64,13 +59,14 @@ function M.merge_config(branch) git.config.set("branch." .. branch .. ".merge", merge_value) git.config.set("branch." .. branch .. ".remote", remote_value) - c.value = merge_value - popup:repaint_config() - end) + return merge_value + end + + return a.wrap(fn, 2) end function M.description_config(branch) - return a.void(function(popup, c) + local fn = function() client.wrap(git.cli.branch.edit_description, { autocmd = "NeogitDescriptionComplete", msg = { @@ -78,9 +74,10 @@ function M.description_config(branch) }, }) - c.value = git.config.get("branch." .. branch .. ".description"):read() - popup:repaint_config() - end) + return git.config.get("branch." .. branch .. ".description"):read() + end + + return a.wrap(fn, 2) end return M diff --git a/lua/neogit/popups/cherry_pick/actions.lua b/lua/neogit/popups/cherry_pick/actions.lua index e7032abe2..28a7ea833 100644 --- a/lua/neogit/popups/cherry_pick/actions.lua +++ b/lua/neogit/popups/cherry_pick/actions.lua @@ -11,7 +11,10 @@ local function get_commits(popup) if #popup.state.env.commits > 0 then commits = popup.state.env.commits else - commits = CommitSelectViewBuffer.new(git.log.list { "--max-count=256" }):open_async() + commits = CommitSelectViewBuffer.new( + git.log.list { "--max-count=256" }, + "Select one or more commits to cherry pick with , or to abort" + ):open_async() end return commits or {} diff --git a/lua/neogit/popups/cherry_pick/init.lua b/lua/neogit/popups/cherry_pick/init.lua index b7a904ca6..fbef4d2a1 100644 --- a/lua/neogit/popups/cherry_pick/init.lua +++ b/lua/neogit/popups/cherry_pick/init.lua @@ -1,10 +1,11 @@ local popup = require("neogit.lib.popup") local actions = require("neogit.popups.cherry_pick.actions") +local git = require("neogit.lib.git") local M = {} function M.create(env) - local in_progress = require("neogit.lib.git.sequencer").pick_or_revert_in_progress() + local in_progress = git.sequencer.pick_or_revert_in_progress() -- TODO -- :switch("x", "x", "Reference cherry in commit message", { cli_prefix = "-" }) diff --git a/lua/neogit/popups/commit/actions.lua b/lua/neogit/popups/commit/actions.lua index 0c12b4647..9d188830e 100644 --- a/lua/neogit/popups/commit/actions.lua +++ b/lua/neogit/popups/commit/actions.lua @@ -5,18 +5,18 @@ local git = require("neogit.lib.git") local client = require("neogit.client") local input = require("neogit.lib.input") local notification = require("neogit.lib.notification") +local config = require("neogit.config") local a = require("plenary.async") local function confirm_modifications() if git.branch.upstream() - and #git.repo.upstream.unmerged.items < 1 - and not input.get_confirmation( + and #git.repo.state.upstream.unmerged.items < 1 + and not input.get_permission( string.format( "This commit has already been published to %s, do you really want to modify it?", git.branch.upstream() - ), - { values = { "&Yes", "&No" }, default = 2 } + ) ) then return false @@ -32,18 +32,14 @@ local function do_commit(popup, cmd) success = "Committed", }, interactive = true, + show_diff = config.values.commit_editor.show_staged_diff, }) end local function commit_special(popup, method, opts) if not git.status.anything_staged() then if git.status.anything_unstaged() then - local stage_all = input.get_confirmation( - "Nothing is staged. Commit all uncommitted changed?", - { values = { "&Yes", "&No" }, default = 2 } - ) - - if stage_all then + if input.get_permission("Nothing is staged. Commit all uncommitted changed?") then opts.all = true else return @@ -54,14 +50,9 @@ local function commit_special(popup, method, opts) end end - local commit - if popup.state.env.commit then - commit = popup.state.env.commit - else - commit = CommitSelectViewBuffer.new(git.log.list()):open_async()[1] - if not commit then - return - end + local commit = popup.state.env.commit or CommitSelectViewBuffer.new(git.log.list()):open_async()[1] + if not commit then + return end if opts.rebase and not git.log.is_ancestor(commit, "HEAD") then @@ -100,7 +91,7 @@ local function commit_special(popup, method, opts) if opts.rebase then a.util.scheduler() - git.rebase.instantly(commit .. "~1", { "--autosquash", "--autostash", "--keep-empty" }) + git.rebase.instantly(commit .. "~1", { "--keep-empty" }) end end @@ -160,4 +151,36 @@ function M.instant_squash(popup) commit_special(popup, "squash", { rebase = true, edit = false }) end +function M.absorb(popup) + if vim.fn.executable("git-absorb") == 0 then + notification.info("Absorb requires `https://github.com/tummychow/git-absorb` to be installed.") + return + end + + if not git.status.anything_staged() then + if git.status.anything_unstaged() then + if input.get_permission("Nothing is staged. Absorb all unstaged changed?") then + git.status.stage_modified() + else + return + end + else + notification.warn("There are no changes that could be absorbed") + return + end + end + + local commit = popup.state.env.commit + or CommitSelectViewBuffer.new( + git.log.list { "HEAD" }, + "Select a base commit for the absorb stack with , or to abort" + ) + :open_async()[1] + if not commit then + return + end + + git.cli.absorb.verbose.base(commit).and_rebase.call() +end + return M diff --git a/lua/neogit/popups/commit/init.lua b/lua/neogit/popups/commit/init.lua index 3262e9fdd..6b9223519 100644 --- a/lua/neogit/popups/commit/init.lua +++ b/lua/neogit/popups/commit/init.lua @@ -11,14 +11,14 @@ function M.create(env) :switch("e", "allow-empty", "Allow empty commit") :switch("v", "verbose", "Show diff of changes to be committed") :switch("h", "no-verify", "Disable hooks") - :switch("s", "signoff", "Add Signed-off-by line") - :switch("S", "no-gpg-sign", "Do not sign this commit") :switch("R", "reset-author", "Claim authorship and reset author date") - :option("A", "author", "", "Override the author") - :option("S", "gpg-sign", "", "Sign using gpg") - :option("C", "reuse-message", "", "Reuse commit message") + :option("A", "author", "", "Override the author", { key_prefix = "-" }) + :switch("s", "signoff", "Add Signed-off-by line") + :option("S", "gpg-sign", "", "Sign using gpg", { key_prefix = "-" }) + :option("C", "reuse-message", "", "Reuse commit message", { key_prefix = "-" }) :group_heading("Create") :action("c", "Commit", actions.commit) + :action("x", "Absorb", actions.absorb) :new_action_group("Edit HEAD") :action("e", "Extend", actions.extend) :action("w", "Reword", actions.reword) diff --git a/lua/neogit/popups/echo/actions.lua b/lua/neogit/popups/echo/actions.lua deleted file mode 100644 index a8284bc1c..000000000 --- a/lua/neogit/popups/echo/actions.lua +++ /dev/null @@ -1,9 +0,0 @@ ---- This popup is for unit testing purposes and is not associated to any git command. -local notification = require("neogit.lib.notification") -local M = {} - -function M.echo(value) - notification.info("Echo: " .. value) -end - -return M diff --git a/lua/neogit/popups/echo/init.lua b/lua/neogit/popups/echo/init.lua deleted file mode 100644 index 8ebce3f7d..000000000 --- a/lua/neogit/popups/echo/init.lua +++ /dev/null @@ -1,24 +0,0 @@ ---- This popup is for unit testing purposes and is not associated to any git command. - -local popup = require("neogit.lib.popup") -local actions = require("neogit.popups.echo.actions") - -local M = {} - -function M.create(...) - local args = { ... } - local p = popup.builder():name("NeogitEchoPopup") - for k, v in ipairs(args) do - p:action(tostring(k), tostring(v), function() - actions.echo(v) - end) - end - - local p = p:build() - - p:show() - - return p -end - -return M diff --git a/lua/neogit/popups/fetch/actions.lua b/lua/neogit/popups/fetch/actions.lua index f96cb7986..abd28228c 100644 --- a/lua/neogit/popups/fetch/actions.lua +++ b/lua/neogit/popups/fetch/actions.lua @@ -85,7 +85,7 @@ function M.fetch_another_branch(popup) return end - local branches = util.filter_map(git.branch.get_all_branches(true), function(branch) + local branches = util.filter_map(git.refs.list_branches(), function(branch) return branch:match("^" .. remote .. "/(.*)") end) diff --git a/lua/neogit/popups/help/actions.lua b/lua/neogit/popups/help/actions.lua index 3e00a5426..2800a1821 100644 --- a/lua/neogit/popups/help/actions.lua +++ b/lua/neogit/popups/help/actions.lua @@ -32,12 +32,10 @@ local function present(commands) return presenter end -M.popups = function() +M.popups = function(env) local popups = require("neogit.popups") - - local items = vim.list_extend({ + local items = { { - "CommandHistory", "History", function() @@ -45,7 +43,66 @@ M.popups = function() end, }, { "InitRepo", "Init", require("neogit.lib.git").init.init_repo }, - }, popups.mappings_table()) + -- { "HelpPopup", "Help", M.open("help") }, + { "DiffPopup", "Diff", popups.open("diff", function(p) + p(env.diff) + end) }, + { "PullPopup", "Pull", popups.open("pull", function(p) + p(env.pull) + end) }, + { "RebasePopup", "Rebase", popups.open("rebase", function(p) + p(env.rebase) + end) }, + { "MergePopup", "Merge", popups.open("merge", function(p) + p(env.merge) + end) }, + { "PushPopup", "Push", popups.open("push", function(p) + p(env.push) + end) }, + { "CommitPopup", "Commit", popups.open("commit", function(p) + p(env.commit) + end) }, + { "IgnorePopup", "Ignore", popups.open("ignore", function(p) + p(env.ignore) + end) }, + { "TagPopup", "Tag", popups.open("tag", function(p) + p(env.tag) + end) }, + { "LogPopup", "Log", popups.open("log", function(p) + p(env.log) + end) }, + { + "CherryPickPopup", + "Cherry Pick", + popups.open("cherry_pick", function(p) + p(env.cherry_pick) + end), + }, + { "BranchPopup", "Branch", popups.open("branch", function(p) + p(env.branch) + end) }, + { "BisectPopup", "Bisect", popups.open("bisect", function(p) + p(env.bisect) + end) }, + { "FetchPopup", "Fetch", popups.open("fetch", function(p) + p(env.fetch) + end) }, + { "ResetPopup", "Reset", popups.open("reset", function(p) + p(env.reset) + end) }, + { "RevertPopup", "Revert", popups.open("revert", function(p) + p(env.revert) + end) }, + { "RemotePopup", "Remote", popups.open("remote", function(p) + p(env.remote) + end) }, + { "WorktreePopup", "Worktree", popups.open("worktree", function(p) + p(env.worktree) + end) }, + { "StashPopup", "Stash", popups.open("stash", function(p) + p(env.stash) + end) }, + } return present(items) end @@ -58,6 +115,7 @@ M.actions = function() { "Unstage", "Unstage", NONE }, { "UnstageStaged", "Unstage-Staged", NONE }, { "Discard", "Discard", NONE }, + { "Untrack", "Untrack", NONE }, } end @@ -67,7 +125,10 @@ M.essential = function() "RefreshBuffer", "Refresh", function() - require("neogit.status").refresh(nil, "user_refresh") + local status = require("neogit.buffers.status") + if status.is_open() then + status.instance():dispatch_refresh(nil, "user_refresh") + end end, }, { "GoToFile", "Go to file", NONE }, diff --git a/lua/neogit/popups/help/init.lua b/lua/neogit/popups/help/init.lua index 9c38a5f0b..2b55046d7 100644 --- a/lua/neogit/popups/help/init.lua +++ b/lua/neogit/popups/help/init.lua @@ -4,10 +4,10 @@ local actions = require("neogit.popups.help.actions") local M = {} -- TODO: Better alignment for labels, keys -function M.create() +function M.create(env) local p = popup.builder():name("NeogitHelpPopup"):group_heading("Commands") - local popups = actions.popups() + local popups = actions.popups(env) for i, cmd in ipairs(popups) do p = p:action(cmd.keys, cmd.name, cmd.fn) diff --git a/lua/neogit/popups/ignore/actions.lua b/lua/neogit/popups/ignore/actions.lua index 2feb9f72d..9084e89bf 100644 --- a/lua/neogit/popups/ignore/actions.lua +++ b/lua/neogit/popups/ignore/actions.lua @@ -13,7 +13,9 @@ local function make_rules(popup, relative) return util.deduplicate(vim.tbl_map(function(v) if vim.startswith(v, relative) then - return "/" .. Path:new(v):make_relative(relative) + return Path:new(v):make_relative(relative) + else + return v end end, files)) end diff --git a/lua/neogit/popups/ignore/init.lua b/lua/neogit/popups/ignore/init.lua index f6799be56..7b624a461 100644 --- a/lua/neogit/popups/ignore/init.lua +++ b/lua/neogit/popups/ignore/init.lua @@ -1,5 +1,6 @@ local actions = require("neogit.popups.ignore.actions") local Path = require("plenary.path") +local git = require("neogit.lib.git") local popup = require("neogit.lib.popup") local M = {} @@ -7,7 +8,7 @@ local M = {} ---@class IgnoreEnv ---@field files string[] Absolute paths function M.create(env) - local excludesFile = require("neogit.lib.git.config").get_global("core.excludesfile") + local excludesFile = git.config.get_global("core.excludesfile") local p = popup .builder() diff --git a/lua/neogit/popups/init.lua b/lua/neogit/popups/init.lua index 93938a64f..276f20fb8 100644 --- a/lua/neogit/popups/init.lua +++ b/lua/neogit/popups/init.lua @@ -1,10 +1,12 @@ +---@class Popups +---@field open fun(name: string, f: nil|fun(create: fun(...): any)): fun(): any +---@field mapping_for fun(name: string):string|string[] local M = {} -local git = require("neogit.lib.git") -local util = require("neogit.lib.util") +---Creates a curried function which will open the popup with the given name when called ---@param name string ---@param f nil|fun(create: fun(...)): any ---- Creates a curried function which will open the popup with the given name when called +---@return fun(): any function M.open(name, f) f = f or function(c) c() @@ -24,9 +26,9 @@ function M.open(name, f) end end +---Returns the keymapping for a popup ---@param name string ---@return string|string[] ----Returns the keymapping for a popup function M.mapping_for(name) local mappings = require("neogit.config").get_reversed_popup_maps() @@ -37,131 +39,4 @@ function M.mapping_for(name) end end ---- Returns an array useful for creating mappings for the available popups ----@return table -function M.mappings_table() - ---@param commit CommitLogEntry|nil - ---@return string|nil - local function commit_oid(commit) - return commit and commit.oid - end - - ---@param commits CommitLogEntry[] - ---@return string[] - local function map_commits(commits) - return vim.tbl_map(function(v) - return v.oid - end, commits) - end - - return { - { "HelpPopup", "Help", M.open("help") }, - { - "DiffPopup", - "Diff", - M.open("diff", function(f) - local section, item = require("neogit.status").get_current_section_item() - - f { section = section, item = item } - end), - }, - { "PullPopup", "Pull", M.open("pull") }, - { - "RebasePopup", - "Rebase", - M.open("rebase", function(f) - f { commit = commit_oid(require("neogit.status").get_selection().commit) } - end), - }, - { "MergePopup", "Merge", M.open("merge") }, - { - "PushPopup", - "Push", - M.open("push", function(f) - f { commit = commit_oid(require("neogit.status").get_selection().commit) } - end), - }, - { - "CommitPopup", - "Commit", - M.open("commit", function(f) - f { commit = commit_oid(require("neogit.status").get_selection().commit) } - end), - }, - { - "IgnorePopup", - "Ignore", - { - "nv", - M.open("ignore", function(f) - f { - paths = util.filter_map(require("neogit.status").get_selection().items, function(v) - return v.absolute_path - end), - git_root = git.repo.git_root, - } - end), - }, - }, - { - "TagPopup", - "Tag", - M.open("tag", function(f) - f { commit = commit_oid(require("neogit.status").get_selection().commit) } - end), - }, - { "LogPopup", "Log", M.open("log") }, - { - "CherryPickPopup", - "Cherry Pick", - { - "nv", - M.open("cherry_pick", function(f) - f { commits = util.reverse(map_commits(require("neogit.status").get_selection().commits)) } - end), - }, - }, - { - "BranchPopup", - "Branch", - { - "nv", - M.open("branch", function(f) - f { commits = map_commits(require("neogit.status").get_selection().commits) } - end), - }, - }, - { "FetchPopup", "Fetch", M.open("fetch") }, - { - "ResetPopup", - "Reset", - { - "nv", - M.open("reset", function(f) - f { commit = commit_oid(require("neogit.status").get_selection().commit) } - end), - }, - }, - { - "RevertPopup", - "Revert", - { - "nv", - M.open("revert", function(f) - f { commits = util.reverse(map_commits(require("neogit.status").get_selection().commits)) } - end), - }, - }, - { "RemotePopup", "Remote", M.open("remote") }, - { "WorktreePopup", "Worktree", M.open("worktree") }, - { - "StashPopup", - "Stash", - M.open("stash", function(f) - f { name = require("neogit.status").status_buffer:get_current_line()[1]:match("^(stash@{%d+})") } - end), - }, - } -end - return M diff --git a/lua/neogit/popups/log/actions.lua b/lua/neogit/popups/log/actions.lua index 6a035c532..4d00da511 100644 --- a/lua/neogit/popups/log/actions.lua +++ b/lua/neogit/popups/log/actions.lua @@ -8,6 +8,7 @@ local ReflogViewBuffer = require("neogit.buffers.reflog_view") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") local operation = require("neogit.operations") +local a = require("plenary.async") --- Runs `git log` and parses the commits ---@param popup table Contains the argument list @@ -23,44 +24,76 @@ local function commits(popup, flags) ) end +---@param popup table +---@param flags table +---@return fun(offset: number): CommitLogEntry[] +local function fetch_more_commits(popup, flags) + return function(offset) + return commits(popup, util.merge(flags, { ("--skip=%s"):format(offset) })) + end +end + -- TODO: Handle when head is detached M.log_current = operation("log_current", function(popup) - LogViewBuffer.new(commits(popup, {}), popup:get_internal_arguments(), popup.state.env.files):open() + LogViewBuffer.new( + commits(popup, {}), + popup:get_internal_arguments(), + popup.state.env.files, + fetch_more_commits(popup, {}) + ) + :open() end) function M.log_head(popup) - LogViewBuffer.new(commits(popup, { "HEAD" }), popup:get_internal_arguments(), popup.state.env.files):open() + local flags = { "HEAD" } + LogViewBuffer.new( + commits(popup, flags), + popup:get_internal_arguments(), + popup.state.env.files, + fetch_more_commits(popup, flags) + ):open() end function M.log_local_branches(popup) + local flags = { git.branch.is_detached() and "" or "HEAD", "--branches" } LogViewBuffer.new( - commits(popup, { git.branch.is_detached() and "" or "HEAD", "--branches" }), + commits(popup, flags), popup:get_internal_arguments(), - popup.state.env.files + popup.state.env.files, + fetch_more_commits(popup, flags) ):open() end function M.log_other(popup) - local branch = FuzzyFinderBuffer.new(git.branch.get_all_branches()):open_async() + local branch = FuzzyFinderBuffer.new(git.refs.list_branches()):open_async() if branch then - LogViewBuffer.new(commits(popup, { branch }), popup:get_internal_arguments(), popup.state.env.files) - :open() + local flags = { branch } + LogViewBuffer.new( + commits(popup, flags), + popup:get_internal_arguments(), + popup.state.env.files, + fetch_more_commits(popup, flags) + ):open() end end function M.log_all_branches(popup) + local flags = { git.branch.is_detached() and "" or "HEAD", "--branches", "--remotes" } LogViewBuffer.new( - commits(popup, { git.branch.is_detached() and "" or "HEAD", "--branches", "--remotes" }), + commits(popup, flags), popup:get_internal_arguments(), - popup.state.env.files + popup.state.env.files, + fetch_more_commits(popup, flags) ):open() end function M.log_all_references(popup) + local flags = { git.branch.is_detached() and "" or "HEAD", "--all" } LogViewBuffer.new( - commits(popup, { git.branch.is_detached() and "" or "HEAD", "--all" }), + commits(popup, flags), popup:get_internal_arguments(), - popup.state.env.files + popup.state.env.files, + fetch_more_commits(popup, flags) ):open() end @@ -73,7 +106,7 @@ function M.reflog_head(popup) end function M.reflog_other(popup) - local branch = FuzzyFinderBuffer.new(git.branch.get_local_branches()):open_async() + local branch = FuzzyFinderBuffer.new(git.refs.list_local_branches()):open_async() if branch then ReflogViewBuffer.new(git.reflog.list(branch, popup:get_arguments())):open() end @@ -81,18 +114,11 @@ end -- TODO: Prefill the fuzzy finder with the filepath under cursor, if there is one ---comment ----@param popup Popup ----@param option table ----@param set function ----@return nil -function M.limit_to_files(popup, option, set) - local a = require("plenary.async") - - a.run(function() +function M.limit_to_files() + local fn = function(popup, option) if option.value ~= "" then popup.state.env.files = nil - set("") - return + return "" end local files = FuzzyFinderBuffer.new(git.files.all_tree()):open_async { @@ -102,8 +128,7 @@ function M.limit_to_files(popup, option, set) if not files or vim.tbl_isempty(files) then popup.state.env.files = nil - set("") - return + return "" end popup.state.env.files = files @@ -111,8 +136,10 @@ function M.limit_to_files(popup, option, set) return string.format([[ "%s"]], file) end) - set(table.concat(files, "")) - end) + return table.concat(files, "") + end + + return a.wrap(fn, 2) end return M diff --git a/lua/neogit/popups/log/init.lua b/lua/neogit/popups/log/init.lua index d2bbeda81..c579cce29 100644 --- a/lua/neogit/popups/log/init.lua +++ b/lua/neogit/popups/log/init.lua @@ -29,7 +29,7 @@ function M.create() :option("-", "", "", "Limit to files", { key_prefix = "-", separator = "", - fn = actions.limit_to_files, + fn = actions.limit_to_files(), setup = function(popup) local state = require("neogit.lib.state").get { "NeogitLogPopup", "" } if state then diff --git a/lua/neogit/popups/merge/actions.lua b/lua/neogit/popups/merge/actions.lua index d8452018d..272c50de1 100644 --- a/lua/neogit/popups/merge/actions.lua +++ b/lua/neogit/popups/merge/actions.lua @@ -1,4 +1,5 @@ local M = {} +local util = require("neogit.lib.util") local git = require("neogit.lib.git") local input = require("neogit.lib.input") @@ -6,7 +7,7 @@ local input = require("neogit.lib.input") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") function M.in_merge() - return git.repo.merge.head + return git.repo.state.merge.head end function M.commit() @@ -14,20 +15,62 @@ function M.commit() end function M.abort() - if not input.get_confirmation("Abort merge?", { values = { "&Yes", "&No" }, default = 2 }) then - return + if input.get_permission("Abort merge?") then + git.merge.abort() end - - git.merge.abort() end function M.merge(popup) - local branch = FuzzyFinderBuffer.new(git.branch.get_all_branches()):open_async() - if not branch then - return + local refs = util.merge({ popup.state.env.commit }, git.refs.list_branches(), git.refs.list_tags()) + + local ref = FuzzyFinderBuffer.new(refs):open_async() + if ref then + local args = popup:get_arguments() + table.insert(args, "--no-edit") + git.merge.merge(ref, args) end +end + +function M.squash(popup) + local refs = util.merge({ popup.state.env.commit }, git.refs.list_branches(), git.refs.list_tags()) + + local ref = FuzzyFinderBuffer.new(refs):open_async() + if ref then + local args = popup:get_arguments() + table.insert(args, "--squash") + git.merge.merge(ref, args) + end +end - git.merge.merge(branch, popup:get_arguments()) +function M.merge_edit(popup) + local refs = util.merge({ popup.state.env.commit }, git.refs.list_branches(), git.refs.list_tags()) + + local ref = FuzzyFinderBuffer.new(refs):open_async() + if ref then + local args = popup:get_arguments() + table.insert(args, "--edit") + util.remove_item_from_table(args, "--ff-only") + if not vim.tbl_contains(args, "--no-ff") then + table.insert(args, "--no-ff") + end + + git.merge.merge(ref, args) + end end +function M.merge_nocommit(popup) + local refs = util.merge({ popup.state.env.commit }, git.refs.list_branches(), git.refs.list_tags()) + + local ref = FuzzyFinderBuffer.new(refs):open_async() + if ref then + local args = popup:get_arguments() + table.insert(args, "--no-commit") + util.remove_item_from_table(args, "--ff-only") + if not vim.tbl_contains(args, "--no-ff") then + table.insert(args, "--no-ff") + end + + git.merge.merge(ref, args) + end +end return M diff --git a/lua/neogit/popups/merge/init.lua b/lua/neogit/popups/merge/init.lua index d1c389a85..8ea303136 100644 --- a/lua/neogit/popups/merge/init.lua +++ b/lua/neogit/popups/merge/init.lua @@ -3,7 +3,7 @@ local actions = require("neogit.popups.merge.actions") local M = {} -function M.create() +function M.create(env) local in_merge = actions.in_merge() local p = popup .builder() @@ -13,6 +13,14 @@ function M.create() :action_if(in_merge, "a", "Abort merge", actions.abort) :switch_if(not in_merge, "f", "ff-only", "Fast-forward only", { incompatible = { "no-ff" } }) :switch_if(not in_merge, "n", "no-ff", "No fast-forward", { incompatible = { "ff-only" } }) + :option_if(not in_merge, "s", "strategy", "", "Strategy", { + choices = { "resolve", "recursive", "octopus", "ours", "subtree" }, + key_prefix = "-", + }) + :option_if(not in_merge, "X", "strategy-option", "", "Strategy Option", { + choices = { "ours", "theirs", "patience" }, + key_prefix = "-", + }) :switch_if( not in_merge, "b", @@ -27,27 +35,23 @@ function M.create() "Ignore whitespace when comparing lines", { cli_prefix = "-" } ) - :option_if(not in_merge, "s", "strategy", "", "Strategy", { - choices = { "resolve", "recursive", "octopus", "ours", "subtree" }, - }) - :option_if(not in_merge, "X", "strategy-option", "", "Strategy Option", { - choices = { "ours", "theirs", "patience" }, - }) :option_if(not in_merge, "A", "Xdiff-algorithm", "", "Diff algorithm", { - cli_prefix = "-", choices = { "default", "minimal", "patience", "histogram" }, + cli_prefix = "-", + key_prefix = "-", }) - :option_if(not in_merge, "S", "gpg-sign", "", "Sign using gpg") + :option_if(not in_merge, "S", "gpg-sign", "", "Sign using gpg", { key_prefix = "-" }) :group_heading_if(not in_merge, "Actions") :action_if(not in_merge, "m", "Merge", actions.merge) - :action_if(not in_merge, "e", "Merge and edit message") -- https://github.com/magit/magit/blob/main/lisp/magit-merge.el#L105 - :action_if(not in_merge, "n", "Merge but don't commit") -- https://github.com/magit/magit/blob/main/lisp/magit-merge.el#L119 - :action_if(not in_merge, "A", "Absorb") -- https://github.com/magit/magit/blob/main/lisp/magit-merge.el#L158 + :action_if(not in_merge, "e", "Merge and edit message", actions.merge_edit) + :action_if(not in_merge, "n", "Merge but don't commit", actions.merge_nocommit) + :action_if(not in_merge, "a", "Absorb") -- https://github.com/magit/magit/blob/main/lisp/magit-merge.el#L158 :new_action_group_if(not in_merge, "") :action_if(not in_merge, "p", "Preview merge") -- https://github.com/magit/magit/blob/main/lisp/magit-merge.el#L225 - :action_if(not in_merge, "s", "Squash merge") -- -- https://github.com/magit/magit/blob/main/lisp/magit-merge.el#L217 :group_heading_if(not in_merge, "") + :action_if(not in_merge, "s", "Squash merge", actions.squash) :action_if(not in_merge, "i", "Dissolve") -- https://github.com/magit/magit/blob/main/lisp/magit-merge.el#L131 + :env(env) :build() p:show() diff --git a/lua/neogit/popups/pull/actions.lua b/lua/neogit/popups/pull/actions.lua index f7b7c7798..f87702545 100644 --- a/lua/neogit/popups/pull/actions.lua +++ b/lua/neogit/popups/pull/actions.lua @@ -38,17 +38,17 @@ function M.from_pushremote(popup) end if pushRemote then - pull_from(popup:get_arguments(), pushRemote, git.repo.head.branch) + pull_from(popup:get_arguments(), pushRemote, git.repo.state.head.branch) end end function M.from_upstream(popup) - local upstream = git.repo.upstream.ref + local upstream = git.repo.state.upstream.ref local set_upstream if not upstream then set_upstream = true - upstream = FuzzyFinderBuffer.new(git.branch.get_remote_branches()):open_async { + upstream = FuzzyFinderBuffer.new(git.refs.list_remote_branches()):open_async { prompt_prefix = "set upstream", } @@ -62,8 +62,7 @@ function M.from_upstream(popup) end function M.from_elsewhere(popup) - local target = FuzzyFinderBuffer.new(git.branch.get_all_branches(false)) - :open_async { prompt_prefix = "pull" } + local target = FuzzyFinderBuffer.new(git.refs.list_branches()):open_async { prompt_prefix = "pull" } if not target then return end diff --git a/lua/neogit/popups/push/actions.lua b/lua/neogit/popups/push/actions.lua index 819d51746..dea4347e6 100644 --- a/lua/neogit/popups/push/actions.lua +++ b/lua/neogit/popups/push/actions.lua @@ -14,6 +14,10 @@ local function push_to(args, remote, branch, opts) table.insert(args, "--set-upstream") end + if vim.tbl_contains(args, "--force-with-lease") then + table.insert(args, "--force-if-includes") + end + local name if branch then name = remote .. "/" .. branch @@ -68,7 +72,7 @@ function M.to_upstream(popup) end function M.to_elsewhere(popup) - local target = FuzzyFinderBuffer.new(git.branch.get_remote_branches()):open_async { + local target = FuzzyFinderBuffer.new(git.refs.list_remote_branches()):open_async { prompt_prefix = "push", } @@ -92,7 +96,7 @@ function M.push_other(popup) return end - local destinations = git.branch.get_remote_branches() + local destinations = git.refs.list_remote_branches() for _, remote in ipairs(git.remote.list()) do table.insert(destinations, 1, remote .. "/" .. source) end diff --git a/lua/neogit/popups/rebase/actions.lua b/lua/neogit/popups/rebase/actions.lua index 7970aa71c..f9baf6eba 100644 --- a/lua/neogit/popups/rebase/actions.lua +++ b/lua/neogit/popups/rebase/actions.lua @@ -2,13 +2,17 @@ local git = require("neogit.lib.git") local input = require("neogit.lib.input") local notification = require("neogit.lib.notification") local operation = require("neogit.operations") -local status = require("neogit.status") +local util = require("neogit.lib.util") local CommitSelectViewBuffer = require("neogit.buffers.commit_select_view") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") local M = {} +local function base_commit(popup, list, header) + return popup.state.env.commit or CommitSelectViewBuffer.new(list, header):open_async()[1] +end + function M.onto_base(popup) git.rebase.onto_branch(git.branch.base_branch(), popup:get_arguments()) end @@ -29,10 +33,10 @@ end function M.onto_upstream(popup) local upstream - if git.repo.upstream.ref then - upstream = string.format("refs/remotes/%s", git.repo.upstream.ref) + if git.repo.state.upstream.ref then + upstream = string.format("refs/remotes/%s", git.repo.state.upstream.ref) else - local target = FuzzyFinderBuffer.new(git.branch.get_remote_branches()):open_async() + local target = FuzzyFinderBuffer.new(git.refs.list_remote_branches()):open_async() if not target then return end @@ -44,20 +48,18 @@ function M.onto_upstream(popup) end function M.onto_elsewhere(popup) - local target = FuzzyFinderBuffer.new(git.branch.get_all_branches()):open_async() + local target = FuzzyFinderBuffer.new(git.refs.list_branches()):open_async() if target then git.rebase.onto_branch(target, popup:get_arguments()) end end function M.interactively(popup) - local commit - if popup.state.env.commit then - commit = popup.state.env.commit - else - commit = CommitSelectViewBuffer.new(git.log.list({}, {}, {}, true)):open_async()[1] - end - + local commit = base_commit( + popup, + git.log.list({}, {}, {}, true), + "Select a commit with to rebase it and all commits above it, or to abort" + ) if commit then if not git.log.is_ancestor(commit, "HEAD") then notification.warn("Commit isn't an ancestor of HEAD") @@ -96,58 +98,35 @@ function M.interactively(popup) end M.reword = operation("rebase_reword", function(popup) - local commit - if popup.state.env.commit then - commit = popup.state.env.commit - else - commit = CommitSelectViewBuffer.new(git.log.list()):open_async()[1] - if not commit then - return - end - end - - -- TODO: Support multiline input for longer commit messages - local old_message = git.log.message(commit) - local new_message = input.get_user_input("Message", { default = old_message }) - if not new_message then + local commit = base_commit( + popup, + git.log.list(), + "Select a commit to with to reword its message, or to abort" + ) + if not commit then return end - git.rebase.reword(commit, new_message) + git.rebase.reword(commit) end) M.modify = operation("rebase_modify", function(popup) - local commit - if popup.state.env.commit then - commit = popup.state.env.commit - else - commit = CommitSelectViewBuffer.new(git.log.list()):open_async()[1] - if not commit then - return - end + local commit = base_commit(popup, git.log.list(), "Select a commit to edit with , or to abort") + if commit then + git.rebase.modify(commit) end - git.rebase.modify(commit) - status.refresh(nil, "rebase_modify") end) M.drop = operation("rebase_drop", function(popup) - local commit - if popup.state.env.commit then - commit = popup.state.env.commit - else - commit = CommitSelectViewBuffer.new(git.log.list()):open_async()[1] - if not commit then - return - end + local commit = base_commit(popup, git.log.list(), "Select a commit to remove with , or to abort") + if commit then + git.rebase.drop(commit) end - git.rebase.drop(commit) - status.refresh(nil, "drop") end) function M.subset(popup) - local newbase = FuzzyFinderBuffer.new(git.branch.get_all_branches()) + local newbase = FuzzyFinderBuffer.new(git.refs.list_branches()) :open_async { prompt_prefix = "rebase subset onto" } - if not newbase then return end @@ -156,14 +135,16 @@ function M.subset(popup) if popup.state.env.commit and git.log.is_ancestor(popup.state.env.commit, "HEAD") then start = popup.state.env.commit else - start = CommitSelectViewBuffer.new(git.log.list { "HEAD" }):open_async()[1] + start = CommitSelectViewBuffer.new( + git.log.list { "HEAD" }, + "Select a commit with to rebase it and commits above it onto " .. newbase .. ", or to abort" + ) + :open_async()[1] end - if not start then - return + if start then + git.rebase.onto(start, newbase, popup:get_arguments()) end - - git.rebase.onto(start, newbase, popup:get_arguments()) end function M.continue() @@ -178,9 +159,26 @@ function M.edit() git.rebase.edit() end +function M.autosquash(popup) + local base + if popup.state.env.commit and git.log.is_ancestor(popup.state.env.commit, "HEAD") then + base = popup.state.env.commit + else + base = git.rebase.merge_base_HEAD() + end + + if base then + git.rebase.onto( + "HEAD", + base, + util.deduplicate(util.merge(popup:get_arguments(), { "--autosquash", "--keep-empty" })) + ) + end +end + -- TODO: Extract to rebase lib? function M.abort() - if input.get_confirmation("Abort rebase?", { values = { "&Yes", "&No" }, default = 2 }) then + if input.get_permission("Abort rebase?") then git.cli.rebase.abort.call_sync() end end diff --git a/lua/neogit/popups/rebase/init.lua b/lua/neogit/popups/rebase/init.lua index 6044c1359..19d6a45e6 100644 --- a/lua/neogit/popups/rebase/init.lua +++ b/lua/neogit/popups/rebase/init.lua @@ -6,7 +6,7 @@ local M = {} function M.create(env) local branch = git.branch.current() - local in_rebase = git.repo.rebase.head + local in_rebase = git.repo.state.rebase.head local base_branch = git.branch.base_branch() local show_base_branch = branch ~= base_branch and base_branch ~= nil @@ -26,7 +26,7 @@ function M.create(env) :switch_if(not in_rebase, "u", "update-refs", "Update branches") :switch_if(not in_rebase, "d", "committer-date-is-author-date", "Use author date as committer date") :switch_if(not in_rebase, "t", "ignore-date", "Use current time as author date") - :switch_if(not in_rebase, "a", "autosquash", "Autosquash fixup and squash commits") + :switch_if(not in_rebase, "a", "autosquash", "Autosquash") :switch_if(not in_rebase, "A", "autostash", "Autostash", { enabled = true }) :switch_if(not in_rebase, "i", "interactive", "Interactive") :switch_if(not in_rebase, "h", "no-verify", "Disable hooks") @@ -43,10 +43,10 @@ function M.create(env) :action_if(not in_rebase, "m", "to modify a commit", actions.modify) :action_if(not in_rebase, "w", "to reword a commit", actions.reword) :action_if(not in_rebase, "d", "to remove a commit", actions.drop) - :action_if(not in_rebase, "f", "to autosquash") + :action_if(not in_rebase, "f", "to autosquash", actions.autosquash) :env({ commit = env.commit, - highlight = { branch, git.repo.upstream.ref, base_branch }, + highlight = { branch, git.repo.state.upstream.ref, base_branch }, bold = { "@{upstream}", "pushRemote" }, }) :build() diff --git a/lua/neogit/popups/remote/actions.lua b/lua/neogit/popups/remote/actions.lua index 8717f29cc..7186e6ceb 100644 --- a/lua/neogit/popups/remote/actions.lua +++ b/lua/neogit/popups/remote/actions.lua @@ -46,10 +46,7 @@ M.add = operation("add_remote", function(popup) local success = git.remote.add(name, remote_url, popup:get_arguments()) if success then local set_default = ask_to_set_pushDefault() - and input.get_confirmation( - [[Set 'remote.pushDefault' to "]] .. name .. [["?]], - { values = { "&Yes", "&No" }, default = 2 } - ) + and input.get_permission([[Set 'remote.pushDefault' to "]] .. name .. [["?]]) if set_default then git.config.set("remote.pushDefault", name) diff --git a/lua/neogit/popups/reset/actions.lua b/lua/neogit/popups/reset/actions.lua index fa54fe94c..c3ffec77d 100644 --- a/lua/neogit/popups/reset/actions.lua +++ b/lua/neogit/popups/reset/actions.lua @@ -1,74 +1,88 @@ -local a = require("plenary.async") local git = require("neogit.lib.git") local util = require("neogit.lib.util") - -local CommitSelectViewBuffer = require("neogit.buffers.commit_select_view") +local notification = require("neogit.lib.notification") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") local M = {} -local function reset(type, popup) - local commit +---@param popup PopupData +---@param prompt string +---@return string|nil +local function target(popup, prompt) + local commit = {} if popup.state.env.commit then - commit = popup.state.env.commit - else - commit = CommitSelectViewBuffer.new(git.log.list()):open_async()[1] - if not commit then - return - end + commit = { popup.state.env.commit, popup.state.env.commit .. "^" } end - git.reset[type](commit) + local refs = util.merge(commit, git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) + return FuzzyFinderBuffer.new(refs):open_async { prompt_prefix = prompt } +end + +---@param type string +---@param popup PopupData +---@param prompt string +local function reset(type, popup, prompt) + local target = target(popup, prompt) + if target then + git.reset[type](target) + end end +---@param popup PopupData function M.mixed(popup) - reset("mixed", popup) + reset("mixed", popup, ("Reset %s to"):format(git.branch.current())) end +---@param popup PopupData function M.soft(popup) - reset("soft", popup) + reset("soft", popup, ("Soft reset %s to"):format(git.branch.current())) end +---@param popup PopupData function M.hard(popup) - reset("hard", popup) + reset("hard", popup, ("Hard reset %s to"):format(git.branch.current())) end +---@param popup PopupData function M.keep(popup) - reset("keep", popup) + reset("keep", popup, ("Reset %s to"):format(git.branch.current())) end +---@param popup PopupData function M.index(popup) - reset("index", popup) + reset("index", popup, "Reset index to") end --- https://github.com/magit/magit/blob/main/lisp/magit-reset.el#L87 --- function M.worktree() --- end +---@param popup PopupData +function M.worktree(popup) + local target = target(popup, "Reset worktree to") + if target then + git.index.with_temp_index(target, function(index) + git.cli["checkout-index"].all.force.env({ GIT_INDEX_FILE = index }).call() + notification.info(("Reset worktree to %s"):format(target)) + end) + end +end +---@param popup PopupData function M.a_file(popup) - local commit - if popup.state.env.commit then - commit = popup.state.env.commit - else - local commits = git.log.list(util.merge({ "--all" }, git.stash.list_refs())) - commit = CommitSelectViewBuffer.new(commits):open_async()[1] - if not commit then - return - end + local target = target(popup, "Checkout from revision") + if not target then + return end - local files = util.deduplicate(util.merge(git.files.all(), git.files.diff(commit))) + local files = util.deduplicate(util.merge(git.files.all(), git.files.diff(target))) if not files[1] then + notification.info(("No files differ between HEAD and %s"):format(target)) return end - a.util.scheduler() local files = FuzzyFinderBuffer.new(files):open_async { allow_multi = true } if not files[1] then return end - git.reset.file(commit, files) + git.reset.file(target, files) end return M diff --git a/lua/neogit/popups/reset/init.lua b/lua/neogit/popups/reset/init.lua index b192138ed..d723c3a3f 100644 --- a/lua/neogit/popups/reset/init.lua +++ b/lua/neogit/popups/reset/init.lua @@ -1,5 +1,6 @@ local popup = require("neogit.lib.popup") local actions = require("neogit.popups.reset.actions") +local branch_actions = require("neogit.popups.branch.actions") local M = {} @@ -8,14 +9,15 @@ function M.create(env) .builder() :name("NeogitResetPopup") :group_heading("Reset") + :action("f", "file", actions.a_file) + :action("b", "branch", branch_actions.reset_branch) + :new_action_group("Reset this") :action("m", "mixed (HEAD and index)", actions.mixed) :action("s", "soft (HEAD only)", actions.soft) :action("h", "hard (HEAD, index and files)", actions.hard) :action("k", "keep (HEAD and index, keeping uncommitted)", actions.keep) :action("i", "index (only)", actions.index) - :action("w", "worktree (only)") - :group_heading("") - :action("f", "a file", actions.a_file) + :action("w", "worktree (only)", actions.worktree) :env(env) :build() diff --git a/lua/neogit/popups/revert/actions.lua b/lua/neogit/popups/revert/actions.lua index 5c65b569a..bf0a2638a 100644 --- a/lua/neogit/popups/revert/actions.lua +++ b/lua/neogit/popups/revert/actions.lua @@ -12,7 +12,10 @@ local function get_commits(popup) if #popup.state.env.commits > 0 then commits = popup.state.env.commits else - commits = CommitSelectViewBuffer.new(git.log.list { "--max-count=256" }):open_async() + commits = CommitSelectViewBuffer.new( + git.log.list { "--max-count=256" }, + "Select one or more commits to revert with , or to abort" + ):open_async() end return commits or {} @@ -26,7 +29,7 @@ local function build_commit_message(commits) table.insert(message, string.format("%s '%s'", commit:sub(1, 7), git.log.message(commit))) end - return table.concat(message, "\n") .. "\04" + return table.concat(message, "\n") end function M.commits(popup) @@ -53,7 +56,6 @@ function M.commits(popup) client.wrap(commit_cmd, { autocmd = "NeogitRevertComplete", - refresh = "do_revert", msg = { success = "Reverted", }, diff --git a/lua/neogit/popups/revert/init.lua b/lua/neogit/popups/revert/init.lua index e52568935..092f16596 100644 --- a/lua/neogit/popups/revert/init.lua +++ b/lua/neogit/popups/revert/init.lua @@ -1,10 +1,11 @@ local actions = require("neogit.popups.revert.actions") +local git = require("neogit.lib.git") local popup = require("neogit.lib.popup") local M = {} function M.create(env) - local in_progress = require("neogit.lib.git.sequencer").pick_or_revert_in_progress() + local in_progress = git.sequencer.pick_or_revert_in_progress() -- TODO: enabled = true needs to check if incompatible switch is toggled in internal state, and not apply. -- if you enable 'no edit', and revert, next time you load the popup both will be enabled -- diff --git a/lua/neogit/popups/stash/actions.lua b/lua/neogit/popups/stash/actions.lua index e2fcf4471..7e3d7077e 100644 --- a/lua/neogit/popups/stash/actions.lua +++ b/lua/neogit/popups/stash/actions.lua @@ -1,5 +1,6 @@ local git = require("neogit.lib.git") local operation = require("neogit.operations") +local input = require("neogit.lib.input") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") @@ -22,10 +23,12 @@ function M.push(popup) git.stash.push(popup:get_arguments(), files) end -local function use(action, stash) - local name +local function use(action, stash, opts) + opts = opts or {} + local name, get_permission if stash and stash.name then + get_permission = true name = stash.name else name = FuzzyFinderBuffer.new(git.stash.list()):open_async() @@ -37,6 +40,14 @@ local function use(action, stash) end if name then + if + get_permission + and opts.confirm + and not input.get_permission(("%s%s '%s'?"):format(action:upper():sub(1, 1), action:sub(2, -1), name)) + then + return + end + git.stash[action](name) end end @@ -50,7 +61,7 @@ function M.apply(popup) end function M.drop(popup) - use("drop", popup.state.env.stash) + use("drop", popup.state.env.stash, { confirm = true }) end M.rename = operation("stash_rename", function(popup) diff --git a/lua/neogit/popups/tag/actions.lua b/lua/neogit/popups/tag/actions.lua index 319cb9fdc..363a8c374 100644 --- a/lua/neogit/popups/tag/actions.lua +++ b/lua/neogit/popups/tag/actions.lua @@ -112,7 +112,7 @@ function M.prune(_) elseif choice == "r" then l_tags = utils.filter(l_tags, function(tag) vim.cmd.redraw() - return input.get_confirmation("Delete local tag: " .. tag) + return input.get_permission("Delete local tag: " .. tag) end) else l_tags = {} @@ -131,7 +131,7 @@ function M.prune(_) elseif choice == "r" then r_tags = utils.filter(r_tags, function(tag) vim.cmd.redraw() - return input.get_confirmation("Delete remote tag: " .. tag) + return input.get_permission("Delete remote tag: " .. tag) end) else r_tags = {} diff --git a/lua/neogit/popups/worktree/actions.lua b/lua/neogit/popups/worktree/actions.lua index 623103dae..d0f43f108 100644 --- a/lua/neogit/popups/worktree/actions.lua +++ b/lua/neogit/popups/worktree/actions.lua @@ -3,7 +3,7 @@ local M = {} local git = require("neogit.lib.git") local input = require("neogit.lib.input") local util = require("neogit.lib.util") -local status = require("neogit.status") +local status = require("neogit.buffers.status") local notification = require("neogit.lib.notification") local operations = require("neogit.operations") @@ -32,11 +32,12 @@ local function get_path(prompt) end until not dir:exists() - return dir:absolute() + local path, _ = dir:absolute():gsub("%s", "_") + return path end M.checkout_worktree = operations("checkout_worktree", function() - local options = util.merge(git.branch.get_all_branches(), git.tag.list(), git.refs.heads()) + local options = util.merge(git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) local selected = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = "checkout" } if not selected then return @@ -49,7 +50,9 @@ M.checkout_worktree = operations("checkout_worktree", function() if git.worktree.add(selected, path) then notification.info("Added worktree") - status.chdir(path) + if status.is_open() then + status.instance():chdir(path) + end end end) @@ -59,7 +62,7 @@ M.create_worktree = operations("create_worktree", function() return end - local options = util.merge(git.branch.get_all_branches(), git.tag.list(), git.refs.heads()) + local options = util.merge(git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) local selected = FuzzyFinderBuffer.new(options) :open_async { prompt_prefix = "Create and checkout branch starting at" } if not selected then @@ -73,7 +76,9 @@ M.create_worktree = operations("create_worktree", function() if git.worktree.add(selected, path, { "-b", name }) then notification.info("Added worktree") - status.chdir(path) + if status.is_open() then + status.instance():chdir(path) + end end end) @@ -102,8 +107,8 @@ M.move = operations("move_worktree", function() if git.worktree.move(selected, path) then notification.info(("Moved worktree to %s"):format(path)) - if change_dir then - status.chdir(path) + if change_dir and status.is_open() then + status.instance():chdir(path) end end end) @@ -126,16 +131,16 @@ M.delete = operations("delete_worktree", function() local change_dir = selected == vim.fn.getcwd() local success = false - if input.get_confirmation("Remove worktree?") then - if change_dir then - status.chdir(git.worktree.main().path) + if input.get_permission("Remove worktree?") then + if change_dir and status.is_open() then + status.instance():chdir(git.worktree.main().path) end -- This might produce some error messages that need to get suppressed if git.worktree.remove(selected) then success = true else - if input.get_confirmation("Worktree has untracked or modified files. Remove anyways?") then + if input.get_permission("Worktree has untracked or modified files. Remove anyways?") then if git.worktree.remove(selected, { "--force" }) then success = true end @@ -159,8 +164,8 @@ M.visit = operations("visit_worktree", function() end local selected = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = "visit worktree" } - if selected then - status.chdir(selected) + if selected and status.is_open() then + status.instance():chdir(selected) end end) diff --git a/lua/neogit/process.lua b/lua/neogit/process.lua index 9d5732363..cd734cc95 100644 --- a/lua/neogit/process.lua +++ b/lua/neogit/process.lua @@ -1,13 +1,14 @@ local a = require("plenary.async") local notification = require("neogit.lib.notification") -local Buffer = require("neogit.lib.buffer") local config = require("neogit.config") local logger = require("neogit.logger") -- from: https://stackoverflow.com/questions/48948630/lua-ansi-escapes-pattern +local pattern_1 = "[\27\155][][()#;?%d]*[A-PRZcf-ntqry=><~]" +local pattern_2 = "[\r\n\04\08]" local function remove_escape_codes(s) - return s:gsub("[\27\155][][()#;?%d]*[A-PRZcf-ntqry=><~]", ""):gsub("[\r\n\04\08]", "") + return s:gsub(pattern_1, ""):gsub(pattern_2, "") end local command_mask = @@ -27,6 +28,7 @@ end ---@field job number|nil ---@field stdin number|nil ---@field pty boolean|nil +---@field buffer ProcessBuffer ---@field on_partial_line fun(process: Process, data: string, raw: string)|nil callback on complete lines ---@field on_error (fun(res: ProcessResult): boolean) Intercept the error externally, returning false prevents the error from being logged local Process = {} @@ -34,6 +36,7 @@ Process.__index = Process ---@type { number: Process } local processes = {} +setmetatable(processes, { __mode = "k" }) ---@class ProcessResult ---@field stdout string[] @@ -62,112 +65,25 @@ ProcessResult.__index = ProcessResult ---@param process Process ---@return Process function Process.new(process) + process.buffer = require("neogit.buffers.process"):new(process) return setmetatable(process, Process) end -local preview_buffer = nil - -local function create_preview_buffer() - local kind = config.values.preview_buffer.kind - - -- May be called multiple times due to scheduling - if preview_buffer then - if preview_buffer.buffer then - logger.trace("[PROCESS] Preview buffer already exists. Focusing the existing one") - preview_buffer.buffer:focus() - end - return - end - - local name = "NeogitConsole" - local cur = vim.fn.bufnr(name) - if cur and cur ~= -1 then - vim.api.nvim_buf_delete(cur, { force = true }) - end - - local buffer = Buffer.create { - name = name, - bufhidden = "hide", - filetype = "NeogitConsole", - kind = kind, - open = false, - mappings = { - n = { - ["q"] = function(buffer) - buffer:hide(true) - end, - [""] = function(buffer) - buffer:hide(true) - end, - }, - }, - autocmds = { - ["BufUnload"] = function() - preview_buffer = nil - end, - }, - } - - preview_buffer = { - buffer = buffer, - current_span = nil, - content = "", - } -end - -function Process.show_console() - create_preview_buffer() - - vim.api.nvim_chan_send(vim.api.nvim_open_term(preview_buffer.buffer.handle, {}), preview_buffer.content) - preview_buffer.buffer:show() - -- Scroll to the end of viewable text - vim.cmd.startinsert() - vim.defer_fn(vim.cmd.stopinsert, 50) - - -- vim.api.nvim_win_call(win, function() - -- vim.cmd.normal("G") - -- end) -end - ----@param process Process ----@param data string -local function append_log(process, data) - local function append() - if data == "" then - return - end - - if preview_buffer.current_span ~= process.job then - preview_buffer.content = preview_buffer.content - .. string.format("> %s\r\n", table.concat(process.cmd, " ")) - preview_buffer.current_span = process.job - end - - preview_buffer.content = preview_buffer.content .. data .. "\r\n" - end - - vim.schedule(function() - create_preview_buffer() - append() - end) -end - local hide_console = false function Process.hide_preview_buffers() hide_console = true + --- Stop all times from opening the buffer for _, v in pairs(processes) do v:stop_timer() end - - if preview_buffer then - preview_buffer.buffer:hide() - end end function Process:start_timer() if self.timer == nil then local timer = vim.loop.new_timer() + self.timer = timer + timer:start( config.values.console_timeout, 0, @@ -175,24 +91,26 @@ function Process:start_timer() if not self.timer then return end + self:stop_timer() + if not self.result or (self.result.code ~= 0) then local message = string.format( "Command %q running for more than: %.1f seconds", - table.concat(self.cmd, " "), + mask_command(table.concat(self.cmd, " ")), math.ceil((vim.loop.now() - self.start) / 100) / 10 ) - append_log(self, message) + self.buffer:append(message) + if config.values.auto_show_console then - Process.show_console() + self.buffer:show() else notification.warn(message .. "\n\nOpen the console for details") end end end) ) - self.timer = timer end end @@ -201,6 +119,7 @@ function Process:stop_timer() local timer = self.timer self.timer = nil timer:stop() + if not timer:is_closing() then timer:close() end @@ -275,7 +194,7 @@ function Process:spawn(cb) }, ProcessResult) assert(self.job == nil, "Process started twice") - -- An empty table is treated as an array + self.env = self.env or {} self.env.TERM = "xterm-256color" @@ -312,14 +231,14 @@ function Process:spawn(cb) table.insert(res.stdout_raw, raw) if self.verbose then table.insert(res.output, line) - append_log(self, raw) + self.buffer:append(raw) end end) local on_stderr, stderr_cleanup = handle_output(function() end, function(line, raw) table.insert(res.stderr, line) table.insert(res.output, line) - append_log(self, raw) + self.buffer:append(raw) end) local function on_exit(_, code) @@ -334,9 +253,9 @@ function Process:spawn(cb) stdout_cleanup() stderr_cleanup() - if not hide_console and code > 0 and self.on_error(res) then - append_log(self, string.format("Process exited with code: %d", code)) + self.buffer:append(string.format("Process exited with code: %d", code)) + if not self.buffer:is_visible() and code > 0 and self.on_error(res) then local output = {} local start = math.max(#res.output - 16, 1) for i = start, math.min(#res.output, start + 16) do @@ -364,7 +283,7 @@ function Process:spawn(cb) local job = vim.fn.jobstart(self.cmd, { cwd = self.cwd, env = self.env, - pty = not not self.pty, -- Fake a small standard terminal + pty = not not self.pty, width = 80, height = 24, on_stdout = on_stdout, @@ -373,7 +292,7 @@ function Process:spawn(cb) }) if job <= 0 then - error("Failed to start process: ", vim.inspect(self)) + error("Failed to start process: " .. vim.inspect(self)) if cb then cb(nil) end diff --git a/lua/neogit/status.lua b/lua/neogit/status.lua deleted file mode 100644 index 780c46e1c..000000000 --- a/lua/neogit/status.lua +++ /dev/null @@ -1,1562 +0,0 @@ -local Buffer = require("neogit.lib.buffer") -local GitCommandHistory = require("neogit.buffers.git_command_history") -local CommitView = require("neogit.buffers.commit_view") -local git = require("neogit.lib.git") -local notification = require("neogit.lib.notification") -local config = require("neogit.config") -local a = require("plenary.async") -local logger = require("neogit.logger") -local Collection = require("neogit.lib.collection") -local F = require("neogit.lib.functional") -local LineBuffer = require("neogit.lib.line_buffer") -local fs = require("neogit.lib.fs") -local input = require("neogit.lib.input") -local util = require("neogit.lib.util") -local watcher = require("neogit.watcher") -local operation = require("neogit.operations") - -local api = vim.api -local fn = vim.fn - -local M = {} - -M.disabled = false - -M.prev_autochdir = nil -M.status_buffer = nil -M.commit_view = nil -M.cursor_location = nil - ----@class Section ----@field first number ----@field last number ----@field items StatusItem[] ----@field name string ----@field ignore_sign boolean If true will skip drawing the section icons ----@field folded boolean|nil - ----@type Section[] ----Sections in order by first lines -M.locations = {} - -M.outdated = {} - ----@class StatusItem ----@field name string ----@field first number ----@field last number ----@field oid string|nil optional object id ----@field commit CommitLogEntry|nil optional object id ----@field folded boolean|nil ----@field hunks Hunk[]|nil - -local head_start = "@" -local add_start = "+" -local del_start = "-" - -local function get_section_idx_for_line(linenr) - for i, l in pairs(M.locations) do - if l.first <= linenr and linenr <= l.last then - return i - end - end - return nil -end - -local function get_section_item_idx_for_line(linenr) - local section_idx = get_section_idx_for_line(linenr) - local section = M.locations[section_idx] - - if section == nil then - return nil, nil - end - - for i, item in pairs(section.items) do - if item.first <= linenr and linenr <= item.last then - return section_idx, i - end - end - - return section_idx, nil -end - ----@return Section|nil, StatusItem|nil -local function get_section_item_for_line(linenr) - local section_idx, item_idx = get_section_item_idx_for_line(linenr) - local section = M.locations[section_idx] - - if section == nil then - return nil, nil - end - if item_idx == nil then - return section, nil - end - - return section, section.items[item_idx] -end - ----@return Section|nil, StatusItem|nil -local function get_current_section_item() - return get_section_item_for_line(vim.fn.line(".")) -end - -local mode_to_text = { - M = "Modified", - N = "New file", - A = "Added", - D = "Deleted", - C = "Copied", - U = "Updated", - UU = "Both Modified", - R = "Renamed", -} - -local max_len = #"Modified by us" - -local function draw_sign_for_item(item, name) - if item.folded then - M.status_buffer:place_sign(item.first, "NeogitClosed:" .. name, "fold_markers") - else - M.status_buffer:place_sign(item.first, "NeogitOpen:" .. name, "fold_markers") - end -end - -local function draw_signs() - if config.values.disable_signs then - return - end - for _, l in ipairs(M.locations) do - if not l.ignore_sign then - draw_sign_for_item(l, "section") - if not l.folded then - Collection.new(l.items):filter(F.dot("hunks")):each(function(f) - draw_sign_for_item(f, "item") - if not f.folded then - Collection.new(f.hunks):each(function(h) - draw_sign_for_item(h, "hunk") - end) - end - end) - end - end - end -end - -local function format_mode(mode) - if not mode then - return "" - end - local res = mode_to_text[mode] - if res then - return res - end - - local res = mode_to_text[mode:sub(1, 1)] - if res then - return res .. " by us" - end - - return mode -end - -local function draw_buffer() - M.status_buffer:clear_sign_group("hl") - M.status_buffer:clear_sign_group("fold_markers") - - local output = LineBuffer.new() - if not config.values.disable_hint then - local reversed_status_map = config.get_reversed_status_maps() - local reversed_popup_map = config.get_reversed_popup_maps() - - local function hint_label(map_name, hint) - local keys = reversed_status_map[map_name] or reversed_popup_map[map_name] - if keys and #keys > 0 then - return string.format("[%s] %s", table.concat(keys, " "), hint) - else - return string.format("[] %s", hint) - end - end - - local hints = { - hint_label("Toggle", "toggle diff"), - hint_label("Stage", "stage"), - hint_label("Unstage", "unstage"), - hint_label("Discard", "discard"), - hint_label("CommitPopup", "commit"), - hint_label("HelpPopup", "help"), - } - - output:append("Hint: " .. table.concat(hints, " | ")) - output:append("") - end - - local new_locations = {} - local locations_lookup = Collection.new(M.locations):key_by("name") - - output:append( - string.format( - "Head: %s%s %s", - (git.repo.head.abbrev and git.repo.head.abbrev .. " ") or "", - git.repo.head.branch, - git.repo.head.commit_message or "(no commits)" - ) - ) - - table.insert(new_locations, { - name = "head_branch_header", - first = #output, - last = #output, - items = {}, - ignore_sign = true, - commit = { oid = git.repo.head.oid }, - }) - - if not git.branch.is_detached() then - if git.repo.upstream.ref then - output:append( - string.format( - "Merge: %s%s %s", - (git.repo.upstream.abbrev and git.repo.upstream.abbrev .. " ") or "", - git.repo.upstream.ref, - git.repo.upstream.commit_message or "(no commits)" - ) - ) - - table.insert(new_locations, { - name = "upstream_header", - first = #output, - last = #output, - items = {}, - ignore_sign = true, - commit = { oid = git.repo.upstream.oid }, - }) - end - - if git.branch.pushRemote_ref() and git.repo.pushRemote.abbrev then - output:append( - string.format( - "Push: %s%s %s", - (git.repo.pushRemote.abbrev and git.repo.pushRemote.abbrev .. " ") or "", - git.branch.pushRemote_ref(), - git.repo.pushRemote.commit_message or "(does not exist)" - ) - ) - - table.insert(new_locations, { - name = "push_branch_header", - first = #output, - last = #output, - items = {}, - ignore_sign = true, - ref = git.branch.pushRemote_ref(), - }) - end - end - - if git.repo.head.tag.name then - output:append(string.format("Tag: %s (%s)", git.repo.head.tag.name, git.repo.head.tag.distance)) - table.insert(new_locations, { - name = "tag_header", - first = #output, - last = #output, - items = {}, - ignore_sign = true, - commit = { oid = git.rev_parse.oid(git.repo.head.tag.name) }, - }) - end - - output:append("") - - local function render_section(header, key, data) - local section_config = config.values.sections[key] - if section_config.hidden then - return - end - - data = data or git.repo[key] - if #data.items == 0 then - return - end - - if data.current then - output:append(string.format("%s (%d/%d)", header, data.current, #data.items)) - else - output:append(string.format("%s (%d)", header, #data.items)) - end - - local location = locations_lookup[key] - or { - name = key, - folded = section_config.folded, - items = {}, - } - location.first = #output - - if not location.folded then - local items_lookup = Collection.new(location.items):key_by("name") - location.items = {} - - for _, f in ipairs(data.items) do - local label = util.pad_right(format_mode(f.mode), max_len) - if label and vim.o.columns < 120 then - label = vim.trim(label) - end - - if f.mode and f.original_name then - output:append(string.format("%s %s -> %s", label, f.original_name, f.name)) - elseif f.mode then - output:append(string.format("%s %s", label, f.name)) - else - output:append(f.name) - end - - if f.done then - M.status_buffer:place_sign(#output, "NeogitRebaseDone", "hl") - end - - local file = items_lookup[f.name] or { folded = true } - file.first = #output - - if not file.folded and f.has_diff then - local hunks_lookup = Collection.new(file.hunks or {}):key_by("hash") - - local hunks = {} - for _, h in ipairs(f.diff.hunks) do - local current_hunk = hunks_lookup[h.hash] or { folded = false } - - output:append(f.diff.lines[h.diff_from]) - current_hunk.first = #output - - if not current_hunk.folded then - for i = h.diff_from + 1, h.diff_to do - output:append(f.diff.lines[i]) - end - end - - current_hunk.last = #output - table.insert(hunks, setmetatable(current_hunk, { __index = h })) - end - - file.hunks = hunks - elseif f.has_diff then - file.hunks = file.hunks or {} - end - - file.last = #output - table.insert(location.items, setmetatable(file, { __index = f })) - end - end - - location.last = #output - - if not location.folded then - output:append("") - end - - table.insert(new_locations, location) - end - - if git.repo.rebase.head then - render_section("Rebasing: " .. git.repo.rebase.head, "rebase") - elseif git.repo.sequencer.head == "REVERT_HEAD" then - render_section("Reverting", "sequencer") - elseif git.repo.sequencer.head == "CHERRY_PICK_HEAD" then - render_section("Picking", "sequencer") - end - - render_section("Untracked files", "untracked") - render_section("Unstaged changes", "unstaged") - render_section("Staged changes", "staged") - render_section("Stashes", "stashes") - - local pushRemote = git.branch.pushRemote_ref() - local upstream = git.branch.upstream() - - if pushRemote and upstream ~= pushRemote then - render_section( - string.format("Unpulled from %s", pushRemote), - "unpulled_pushRemote", - git.repo.pushRemote.unpulled - ) - render_section( - string.format("Unpushed to %s", pushRemote), - "unmerged_pushRemote", - git.repo.pushRemote.unmerged - ) - end - - if upstream then - render_section( - string.format("Unpulled from %s", upstream), - "unpulled_upstream", - git.repo.upstream.unpulled - ) - render_section( - string.format("Unmerged into %s", upstream), - "unmerged_upstream", - git.repo.upstream.unmerged - ) - end - - render_section("Recent commits", "recent") - - M.status_buffer:replace_content_with(output) - M.locations = new_locations -end - ---- Find the smallest section the cursor is contained within. --- --- The first 3 values are tables in the shape of {number, string}, where the number is --- the relative offset of the found item and the string is it's identifier. --- The remaining 2 numbers are the first and last line of the found section. ----@param linenr number|nil ----@return table, table, table, number, number -local function save_cursor_location(linenr) - local line = linenr or vim.api.nvim_win_get_cursor(0)[1] - local section_loc, file_loc, hunk_loc, first, last - - for li, loc in ipairs(M.locations) do - if line == loc.first then - section_loc = { li, loc.name } - first, last = loc.first, loc.last - - break - elseif line >= loc.first and line <= loc.last then - section_loc = { li, loc.name } - - for fi, file in ipairs(loc.items) do - if line == file.first then - file_loc = { fi, file.name } - first, last = file.first, file.last - - break - elseif line >= file.first and line <= file.last then - file_loc = { fi, file.name } - - for hi, hunk in ipairs(file.hunks) do - if line >= hunk.first and line <= hunk.last then - hunk_loc = { hi, hunk.hash } - first, last = hunk.first, hunk.last - - break - end - end - - break - end - end - - break - end - end - - return section_loc, file_loc, hunk_loc, first, last -end - -local function restore_cursor_location(section_loc, file_loc, hunk_loc) - if #M.locations == 0 then - return vim.api.nvim_win_set_cursor(0, { 1, 0 }) - end - - if not section_loc then - -- Skip the headers and put the cursor on the first foldable region - local idx = 1 - for i, location in ipairs(M.locations) do - if not location.ignore_sign then - idx = i - break - end - end - section_loc = { idx, "" } - end - - local section = Collection.new(M.locations):find(function(s) - return s.name == section_loc[2] - end) - - if not section then - file_loc, hunk_loc = nil, nil - section = M.locations[section_loc[1]] or M.locations[#M.locations] - end - - if not file_loc or not section.items or #section.items == 0 then - return vim.api.nvim_win_set_cursor(0, { section.first, 0 }) - end - - local file = Collection.new(section.items):find(function(f) - return f.name == file_loc[2] - end) - - if not file then - hunk_loc = nil - file = section.items[file_loc[1]] or section.items[#section.items] - end - - if not hunk_loc or not file.hunks or #file.hunks == 0 then - return vim.api.nvim_win_set_cursor(0, { file.first, 0 }) - end - - local hunk = Collection.new(file.hunks):find(function(h) - return h.hash == hunk_loc[2] - end) or file.hunks[hunk_loc[1]] or file.hunks[#file.hunks] - - return vim.api.nvim_win_set_cursor(0, { hunk.first, 0 }) -end - -local function refresh_status_buffer() - if M.status_buffer == nil then - return - end - - M.status_buffer:unlock() - - logger.debug("[STATUS BUFFER]: Redrawing") - - draw_buffer() - draw_signs() - - logger.debug("[STATUS BUFFER]: Finished Redrawing") - - M.status_buffer:lock() - - vim.cmd("redraw") -end - -local refresh_lock = a.control.Semaphore.new(1) - -function M.is_refresh_locked() - return refresh_lock.permits == 0 -end - -local function get_refresh_lock(reason) - local permit = refresh_lock:acquire() - logger.debug(("[STATUS BUFFER]: Acquired refresh lock:"):format(reason or "unknown")) - - vim.defer_fn(function() - if M.is_refresh_locked() then - permit:forget() - logger.debug( - ("[STATUS BUFFER]: Refresh lock for %s expired after 10 seconds"):format(reason or "unknown") - ) - end - end, 10000) - - return permit -end - -local function refresh(partial, reason) - local permit = get_refresh_lock(reason) - local callback = function() - local s, f, h = save_cursor_location() - refresh_status_buffer() - - if M.status_buffer ~= nil and M.status_buffer:is_focused() then - pcall(restore_cursor_location, s, f, h) - end - - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitStatusRefreshed", modeline = false }) - - permit:forget() - logger.info("[STATUS BUFFER]: Refresh lock is now free") - end - - git.repo:refresh { source = reason, callback = callback, partial = partial } -end - -local dispatch_refresh = a.void(function(partial, reason) - reason = reason or "unknown" - if M.is_refresh_locked() then - logger.debug("[STATUS] Refresh lock is active. Skipping refresh from " .. reason) - else - refresh(partial, reason) - end -end) - -local refresh_manually = a.void(function(fname) - if not fname or fname == "" then - return - end - - local path = fs.relpath_from_repository(fname) - if not path then - return - end - if refresh_lock.permits > 0 then - refresh({ update_diffs = { "*:" .. path } }, "manually") - end -end) - ---- Compatibility endpoint to refresh data from an autocommand. --- `fname` should be `` in this case. This function will take care of --- resolving the file name to the path relative to the repository root and --- refresh that file's cache data. -local function refresh_viml_compat(fname) - logger.info("[STATUS BUFFER]: refresh_viml_compat") - if not config.values.auto_refresh then - return - end - if #vim.fs.find(".git/", { upward = true }) == 0 then -- not a git repository - return - end - - refresh_manually(fname) -end - -local function current_line_is_hunk() - local _, _, h = save_cursor_location() - return h ~= nil -end - -local function toggle() - local selection = M.get_selection() - if selection.section == nil then - return - end - - local item = selection.item - - local hunks = item and M.get_item_hunks(item, selection.first_line, selection.last_line, false) - if item and hunks and #hunks > 0 then - for _, hunk in ipairs(hunks) do - hunk.hunk.folded = not hunk.hunk.folded - end - - vim.api.nvim_win_set_cursor(0, { hunks[1].first, 0 }) - elseif item then - item.folded = not item.folded - elseif selection.section ~= nil then - selection.section.folded = not selection.section.folded - end - - refresh_status_buffer() -end - -local reset = function() - git.repo:reset() - M.locations = {} - if not config.values.auto_refresh then - return - end - refresh(nil, "reset") -end - -local dispatch_reset = a.void(reset) - -local closing = false -local function close(skip_close) - if closing then - return - end - closing = true - - if skip_close == nil then - skip_close = false - end - - M.cursor_location = { save_cursor_location() } - - if not skip_close then - M.status_buffer:close() - end - - if M.watcher then - M.watcher:stop() - end - notification.delete_all() - M.status_buffer = nil - vim.o.autochdir = M.prev_autochdir - if M.old_cwd then - vim.cmd.lcd(M.old_cwd) - end - - closing = false -end - ----@class Selection ----@field sections SectionSelection[] ----@field first_line number ----@field last_line number ----Current items under the cursor ----@field section Section|nil ----@field item StatusItem|nil ----@field commit CommitLogEntry|nil ---- ----@field commits CommitLogEntry[] ----@field items StatusItem[] -local Selection = {} -Selection.__index = Selection - ----@class SectionSelection: Section ----@field section Section ----@field name string ----@field items StatusItem[] - ----@return string[], string[] - -function Selection:format() - local lines = {} - - table.insert(lines, string.format("%d,%d:", self.first_line, self.last_line)) - - for _, sec in ipairs(self.sections) do - table.insert(lines, string.format("%s:", sec.name)) - for _, item in ipairs(sec.items) do - table.insert(lines, string.format(" %s%s:", item == self.item and "*" or "", item.name)) - for _, hunk in ipairs(M.get_item_hunks(item, self.first_line, self.last_line, true)) do - table.insert(lines, string.format(" %d,%d:", hunk.from, hunk.to)) - for _, line in ipairs(hunk.lines) do - table.insert(lines, string.format(" %s", line)) - end - end - end - end - - return table.concat(lines, "\n") -end - ----@class SelectedHunk: Hunk ----@field from number start offset from the first line of the hunk ----@field to number end offset from the first line of the hunk ----@field conflict boolean true if this hunk contains conflict markers ----@field lines string[] - ----@param item StatusItem ----@param first_line number ----@param last_line number ----@param partial boolean ----@return SelectedHunk[] -function M.get_item_hunks(item, first_line, last_line, partial) - local hunks = {} - - local diff = git.cli.diff.check.call_sync { hidden = true, ignore_error = true } - local conflict_markers = {} - if diff.code == 2 then - for _, out in ipairs(diff.stdout) do - local line = string.gsub(out, "^" .. item.name .. ":", "") - if line ~= out and string.match(out, "conflict") then - table.insert(conflict_markers, tonumber(string.match(line, "%d+"))) - end - end - end - - if not item.folded and item.hunks then - for _, h in ipairs(item.hunks) do - if h.first <= last_line and h.last >= first_line then - local from, to - - if partial then - local cursor_offset = first_line - h.first - local length = last_line - first_line - - from = h.diff_from + cursor_offset - to = from + length - else - from = h.diff_from + 1 - to = h.diff_to - end - - local hunk_lines = {} - for i = from, to do - table.insert(hunk_lines, item.diff.lines[i]) - end - - local conflict = false - for _, n in ipairs(conflict_markers) do - if from <= n and n <= to then - conflict = true - break - end - end - - local o = { - from = from, - to = to, - __index = h, - hunk = h, - conflict = conflict, - lines = hunk_lines, - } - - setmetatable(o, o) - - table.insert(hunks, o) - end - end - end - - return hunks -end - ----@param selection Selection -function M.selection_hunks(selection) - local res = {} - for _, item in ipairs(selection.items) do - local lines = {} - local hunks = {} - - for _, h in ipairs(selection.item.hunks) do - if h.first <= selection.last_line and h.last >= selection.first_line then - table.insert(hunks, h) - for i = h.diff_from, h.diff_to do - table.insert(lines, item.diff.lines[i]) - end - break - end - end - - table.insert(res, { - item = item, - hunks = hunks, - lines = lines, - }) - end - - return res -end - ----Returns the selected items grouped by spanned sections ----@return Selection -function M.get_selection() - local visual_pos = vim.fn.getpos("v")[2] - local cursor_pos = vim.fn.getpos(".")[2] - - local first_line = math.min(visual_pos, cursor_pos) - local last_line = math.max(visual_pos, cursor_pos) - - local res = { - sections = {}, - first_line = first_line, - last_line = last_line, - item = nil, - commit = nil, - commits = {}, - items = {}, - } - - for _, section in ipairs(M.locations) do - local items = {} - - if section.first > last_line then - break - end - - if section.last >= first_line then - if section.first <= first_line and section.last >= last_line then - res.section = section - end - - local entire_section = section.first == first_line and first_line == last_line - - for _, item in pairs(section.items) do - if entire_section or item.first <= last_line and item.last >= first_line then - if not res.item and item.first <= first_line and item.last >= last_line then - res.item = item - - res.commit = item.commit - end - - if item.commit then - table.insert(res.commits, item.commit) - end - - table.insert(res.items, item) - table.insert(items, item) - end - end - - local section = { - section = section, - items = items, - __index = section, - } - - setmetatable(section, section) - table.insert(res.sections, section) - end - end - - return setmetatable(res, Selection) -end - -local stage = operation("stage", function() - local selection = M.get_selection() - local mode = vim.api.nvim_get_mode() - - local files = {} - - for _, section in ipairs(selection.sections) do - for _, item in ipairs(section.items) do - local hunks = M.get_item_hunks(item, selection.first_line, selection.last_line, mode.mode == "V") - - if section.name == "unstaged" then - if #hunks > 0 then - for _, hunk in ipairs(hunks) do - -- Apply works for both tracked and untracked - local patch = git.index.generate_patch(item, hunk, hunk.from, hunk.to) - git.index.apply(patch, { cached = true }) - end - else - git.status.stage { item.name } - end - elseif section.name == "untracked" then - if #hunks > 0 then - for _, hunk in ipairs(hunks) do - -- Apply works for both tracked and untracked - git.index.apply(git.index.generate_patch(item, hunk, hunk.from, hunk.to), { cached = true }) - end - else - table.insert(files, item.name) - end - else - logger.fmt_debug("[STATUS]: Not staging item in %s", section.name) - end - end - end - - --- Add all collected files - if #files > 0 then - git.index.add(files) - end - - refresh({ - update_diffs = vim.tbl_map(function(v) - return "*:" .. v.name - end, selection.items), - }, "stage_finish") -end) - -local unstage = operation("unstage", function() - local selection = M.get_selection() - local mode = vim.api.nvim_get_mode() - - local files = {} - - for _, section in ipairs(selection.sections) do - for _, item in ipairs(section.items) do - if section.name == "staged" then - local hunks = M.get_item_hunks(item, selection.first_line, selection.last_line, mode.mode == "V") - - if #hunks > 0 then - for _, hunk in ipairs(hunks) do - logger.fmt_debug( - "[STATUS]: Unstaging hunk %d %d of %d %d, index_from %d", - hunk.from, - hunk.to, - hunk.diff_from, - hunk.diff_to, - hunk.index_from - ) - -- Apply works for both tracked and untracked - git.index.apply( - git.index.generate_patch(item, hunk, hunk.from, hunk.to, true), - { cached = true, reverse = true } - ) - end - else - table.insert(files, item.name) - end - end - end - end - - if #files > 0 then - git.status.unstage(files) - end - - refresh({ - update_diffs = vim.tbl_map(function(v) - return "*:" .. v.name - end, selection.items), - }, "unstage_finish") -end) - -local function discard_message(files, hunk_count) - if vim.api.nvim_get_mode() == "V" then - return "Discard selection?" - elseif hunk_count > 0 then - return string.format("Discard %d hunks?", hunk_count) - elseif #files > 1 then - return string.format("Discard %d files?", #files) - else - return string.format("Discard %q?", files[1]) - end -end - -local discard = operation("discard", function() - local selection = M.get_selection() - local mode = vim.api.nvim_get_mode() - - git.index.update() - - local t = {} - - local hunk_count = 0 - local file_count = 0 - local files = {} - - for _, section in ipairs(selection.sections) do - local section_name = section.name - - file_count = file_count + #section.items - for _, item in ipairs(section.items) do - table.insert(files, item.name) - local hunks = M.get_item_hunks(item, selection.first_line, selection.last_line, mode.mode == "V") - - if #hunks > 0 then - logger.fmt_debug("Discarding %d hunks from %q", #hunks, item.name) - - hunk_count = hunk_count + #hunks - - for _, hunk in ipairs(hunks) do - table.insert(t, function() - local patch = git.index.generate_patch(item, hunk, hunk.from, hunk.to, true) - logger.fmt_debug("Patch: %s", patch) - - if section_name == "staged" then - --- Apply both to the worktree and the staging area - git.index.apply(patch, { index = true, reverse = true }) - else - git.index.apply(patch, { reverse = true }) - end - end) - end - else - logger.fmt_debug("Discarding in section %s %s", section_name, item.name) - if item.mode ~= nil and item.mode:match("^[UA][AU]") then - local choices = { "&ours", "&theirs", "&abort" } - local choice = - input.get_choice("Discard conflict by taking...", { values = choices, default = #choices }) - if choice == "o" then - git.cli.checkout.ours.file(item.absolute_path).call_sync() - elseif choice == "t" then - git.cli.checkout.theirs.file(item.absolute_path).call_sync() - else - return - end - git.status.stage { item.name } - M.refresh(false, "Resolved conflict") - return - end - table.insert(t, function() - if section_name == "untracked" then - a.util.scheduler() - vim.fn.delete(vim.fn.fnameescape(item.absolute_path)) - elseif section_name == "unstaged" then - git.index.checkout { item.name } - elseif section_name == "staged" then - git.index.reset { item.name } - git.index.checkout { item.name } - end - end) - end - end - end - - if - not input.get_confirmation( - discard_message(files, hunk_count), - { values = { "&Yes", "&No" }, default = 2 } - ) - then - return - end - - for i, v in ipairs(t) do - logger.fmt_debug("Discard job %d", i) - v() - end - - refresh(nil, "discard") - - a.util.scheduler() - vim.cmd("checktime") -end) - -local set_folds = function(to) - Collection.new(M.locations):each(function(l) - l.folded = to[1] - Collection.new(l.items):each(function(f) - f.folded = to[2] - if f.hunks then - Collection.new(f.hunks):each(function(h) - h.folded = to[3] - end) - end - end) - end) - refresh(nil, "set_folds") -end - ---- Handles the GoToFile action on sections that contain a hunk ----@param item File ----@see section_has_hunks -local function handle_section_item(item) - if not item.absolute_path then - notification.error("Cannot open file. No path found.") - return - end - - local row, col - local cursor_row, cursor_col = unpack(vim.api.nvim_win_get_cursor(0)) - local hunk = M.get_item_hunks(item, cursor_row, cursor_row, false)[1] - if hunk then - local line_offset = cursor_row - hunk.first - row = hunk.disk_from + line_offset - 1 - for i = 1, line_offset do - if string.sub(hunk.lines[i], 1, 1) == "-" then - row = row - 1 - end - end - -- adjust for diff sign column - col = math.max(0, cursor_col - 1) - end - - notification.delete_all() - M.status_buffer:close() - - if not vim.o.hidden and vim.bo.buftype == "" and not vim.bo.readonly and vim.fn.bufname() ~= "" then - vim.cmd("update") - end - - local path = vim.fn.fnameescape(vim.fn.fnamemodify(item.absolute_path, ":~:.")) - vim.cmd(string.format("edit %s", path)) - - if row and col then - vim.api.nvim_win_set_cursor(0, { row, col }) - end -end - ---- Returns the section header ref the user selected ----@param section Section ----@return string|nil -local function get_header_ref(section) - if section.name == "head_branch_header" then - return git.repo.head.branch - end - if section.name == "upstream_header" and git.repo.upstream.branch then - return git.repo.upstream.branch - end - if section.name == "tag_header" and git.repo.head.tag.name then - return git.repo.head.tag.name - end - if section.name == "push_branch_header" and git.repo.pushRemote.abbrev then - return git.repo.pushRemote.abbrev - end - return nil -end - ---- Determines if a given section is a status header section ----@param section Section ----@return boolean -local function is_section_header(section) - return vim.tbl_contains( - { "head_branch_header", "upstream_header", "tag_header", "push_branch_header" }, - section.name - ) -end - ---- Determines if a given section contains hunks/diffs ----@param section Section ----@return boolean -local function section_has_hunks(section) - return vim.tbl_contains({ "unstaged", "staged", "untracked" }, section.name) -end - ---- Determines if a given section has a list of commits under it ----@param section Section ----@return boolean -local function section_has_commits(section) - return vim.tbl_contains({ - "unmerged_pushRemote", - "unpulled_pushRemote", - "unmerged_upstream", - "unpulled_upstream", - "recent", - "stashes", - }, section.name) -end - ---- These needs to be a function to avoid a circular dependency ---- between this module and the popup modules -local cmd_func_map = function() - local mappings = { - ["Close"] = M.close, - ["InitRepo"] = a.void(git.init.init_repo), - ["Depth1"] = a.void(function() - set_folds { true, true, false } - end), - ["Depth2"] = a.void(function() - set_folds { false, true, false } - end), - ["Depth3"] = a.void(function() - set_folds { false, false, true } - end), - ["Depth4"] = a.void(function() - set_folds { false, false, false } - end), - ["Toggle"] = toggle, - ["Discard"] = { "nv", a.void(discard) }, - ["Stage"] = { "nv", a.void(stage) }, - ["StageUnstaged"] = a.void(function() - git.status.stage_modified() - refresh({ update_diffs = true }, "StageUnstaged") - end), - ["StageAll"] = a.void(function() - git.status.stage_all() - refresh { update_diffs = true } - end), - ["Unstage"] = { "nv", a.void(unstage) }, - ["UnstageStaged"] = a.void(function() - git.status.unstage_all() - refresh({ update_diffs = true }, "UnstageStaged") - end), - ["CommandHistory"] = function() - GitCommandHistory:new():show() - end, - ["Console"] = function() - local process = require("neogit.process") - process.show_console() - end, - ["TabOpen"] = function() - local _, item = get_current_section_item() - if item then - vim.cmd("tabedit " .. item.name) - end - end, - ["VSplitOpen"] = function() - local _, item = get_current_section_item() - if item then - vim.cmd("vsplit " .. item.name) - end - end, - ["SplitOpen"] = function() - local _, item = get_current_section_item() - if item then - vim.cmd("split " .. item.name) - end - end, - ["YankSelected"] = function() - local yank - - local selection = require("neogit.status").get_selection() - if selection.item then - yank = selection.item.oid or selection.item.name - elseif selection.commit then - yank = selection.commit.oid - elseif selection.section and selection.section.ref then - yank = selection.section.ref - elseif selection.section and selection.section.commit then - yank = selection.section.commit.oid - end - - if yank then - if yank:match("^stash@{%d+}") then - yank = git.rev_parse.oid(yank:match("^(stash@{%d+})")) - end - - yank = string.format("'%s'", yank) - vim.cmd.let("@+=" .. yank) - vim.cmd.echo(yank) - else - vim.cmd("echo ''") - end - end, - ["GoToPreviousHunkHeader"] = function() - local section, item = get_current_section_item() - if not section then - return - end - - local selection = M.get_selection() - local on_hunk = item and current_line_is_hunk() - - if item and not on_hunk then - local _, prev_item = get_section_item_for_line(vim.fn.line(".") - 1) - if prev_item then - vim.api.nvim_win_set_cursor(0, { prev_item.hunks[#prev_item.hunks].first, 0 }) - end - elseif on_hunk then - local hunks = M.get_item_hunks(selection.item, 0, selection.first_line - 1, false) - local hunk = hunks[#hunks] - - if hunk then - vim.api.nvim_win_set_cursor(0, { hunk.first, 0 }) - vim.cmd("normal! zt") - else - local _, prev_item = get_section_item_for_line(vim.fn.line(".") - 2) - if prev_item then - vim.api.nvim_win_set_cursor(0, { prev_item.hunks[#prev_item.hunks].first, 0 }) - end - end - end - end, - ["GoToNextHunkHeader"] = function() - local section, item = get_current_section_item() - if not section then - return - end - - local on_hunk = item and current_line_is_hunk() - - if item and not on_hunk then - vim.api.nvim_win_set_cursor(0, { vim.fn.line(".") + 1, 0 }) - elseif on_hunk then - local selection = M.get_selection() - local hunks = - M.get_item_hunks(selection.item, selection.last_line + 1, selection.last_line + 1, false) - - local hunk = hunks[1] - - assert(hunk, "Hunk is nil") - assert(item, "Item is nil") - - if hunk.last == item.last then - local _, next_item = get_section_item_for_line(hunk.last + 1) - if next_item then - vim.api.nvim_win_set_cursor(0, { next_item.first + 1, 0 }) - end - else - vim.api.nvim_win_set_cursor(0, { hunk.last + 1, 0 }) - end - vim.cmd("normal! zt") - end - end, - ["GoToFile"] = a.void(function() - a.util.scheduler() - local section, item = get_current_section_item() - if not section then - return - end - if item then - if section_has_hunks(section) then - handle_section_item(item) - else - if section_has_commits(section) then - if M.commit_view and M.commit_view.is_open then - M.commit_view:close() - end - M.commit_view = CommitView.new(item.name:match("(.-):? ")) - M.commit_view:open() - end - end - else - if is_section_header(section) then - local ref = get_header_ref(section) - if not ref then - return - end - if M.commit_view and M.commit_view.is_open then - M.commit_view:close() - end - M.commit_view = CommitView.new(ref) - M.commit_view:open() - end - end - end), - - ["RefreshBuffer"] = function() - notification.info("Refreshing Status") - dispatch_refresh(nil, "manual") - end, - } - - local popups = require("neogit.popups") - --- Load the popups from the centralized popup file - for _, v in ipairs(popups.mappings_table()) do - --- { name, display_name, mapping } - if mappings[v[1]] then - error("Neogit: Mapping '" .. v[1] .. "' is already in use!") - end - - mappings[v[1]] = v[3] - end - - return mappings -end - --- Sets decoration provider for buffer ----@param buffer Buffer ----@return nil -local function set_decoration_provider(buffer) - local decor_ns = api.nvim_create_namespace("NeogitStatusDecor") - local context_ns = api.nvim_create_namespace("NeogitStatusContext") - - local function on_start() - return buffer:exists() and buffer:is_focused() - end - - local function on_win() - buffer:clear_namespace(decor_ns) - buffer:clear_namespace(context_ns) - - -- first and last lines of current context based on cursor position, if available - local _, _, _, first, last = save_cursor_location() - local cursor_line = vim.fn.line(".") - - for line = fn.line("w0"), fn.line("w$") do - local text = buffer:get_line(line)[1] - if text then - local highlight - local start = string.sub(text, 1, 1) - local _, _, hunk, _, _ = save_cursor_location(line) - - if start == head_start then - highlight = "NeogitHunkHeader" - elseif line == cursor_line then - highlight = "NeogitCursorLine" - elseif start == add_start then - highlight = "NeogitDiffAdd" - elseif start == del_start then - highlight = "NeogitDiffDelete" - elseif hunk then - highlight = "NeogitDiffContext" - end - - if highlight then - buffer:set_extmark(decor_ns, line - 1, 0, { line_hl_group = highlight, priority = 9 }) - end - - if - not config.values.disable_context_highlighting - and first - and last - and line >= first - and line <= last - and highlight ~= "NeogitCursorLine" - then - buffer:set_extmark( - context_ns, - line - 1, - 0, - { line_hl_group = (highlight or "NeogitDiffContext") .. "Highlight", priority = 10 } - ) - end - end - end - end - - buffer:set_decorations(decor_ns, { on_start = on_start, on_win = on_win }) -end - ---- Creates a new status buffer -function M.create(kind, cwd) - kind = kind or config.values.kind - - if M.status_buffer then - logger.debug("Status buffer already exists. Focusing the existing one") - M.status_buffer:focus() - return - end - - logger.debug("[STATUS BUFFER]: Creating...") - - Buffer.create { - name = "NeogitStatus", - filetype = "NeogitStatus", - kind = kind, - disable_line_numbers = config.values.disable_line_numbers, - disable_relative_line_numbers = config.values.disable_relative_line_numbers, - ---@param buffer Buffer - initialize = function(buffer, win) - logger.debug("[STATUS BUFFER]: Initializing...") - - M.status_buffer = buffer - - M.prev_autochdir = vim.o.autochdir - - -- Breaks when initializing a new repo in CWD - if cwd and win then - M.old_cwd = vim.fn.getcwd(win) - - vim.api.nvim_win_call(win, function() - vim.cmd.lcd(cwd) - end) - end - - vim.o.autochdir = false - - local mappings = buffer.mmanager.mappings - local func_map = cmd_func_map() - local keys = vim.tbl_extend("error", config.values.mappings.status, config.values.mappings.popup) - - for key, val in pairs(keys) do - if val and val ~= "" then - local func = func_map[val] - - if func ~= nil then - if type(func) == "function" then - mappings.n[key] = func - elseif type(func) == "table" then - for _, mode in pairs(vim.split(func[1], "")) do - mappings[mode][key] = func[2] - end - end - elseif type(val) == "function" then -- For user mappings that are either function values... - mappings.n[key] = val - elseif type(val) == "string" then -- ...or VIM command strings - mappings.n[key] = function() - vim.cmd(val) - end - end - end - end - - set_decoration_provider(buffer) - - logger.debug("[STATUS BUFFER]: Dispatching initial render") - refresh(nil, "Buffer.create") - end, - after = function() - vim.cmd([[setlocal nowrap]]) - M.watcher = watcher.new(git.repo:git_path():absolute()) - - if M.cursor_location then - vim.wait(2000, function() - return not M.is_refresh_locked() - end) - - local ok, _ = pcall(restore_cursor_location, unpack(M.cursor_location)) - if ok then - M.cursor_location = nil - end - end - end, - } -end - -M.toggle = toggle -M.reset = reset -M.dispatch_reset = dispatch_reset -M.refresh = refresh -M.dispatch_refresh = dispatch_refresh -M.refresh_viml_compat = refresh_viml_compat -M.refresh_manually = refresh_manually -M.get_current_section_item = get_current_section_item -M.close = close - -function M.enable() - M.disabled = false -end - -function M.disable() - M.disabled = true -end - -function M.get_status() - return M.status -end - -function M.chdir(dir) - local destination = require("plenary.path").new(dir) - vim.wait(5000, function() - return destination:exists() - end) - - logger.debug("[STATUS] Changing Dir: " .. dir) - M.old_cwd = dir - vim.cmd.cd(dir) - vim.loop.chdir(dir) - reset() -end - -return M diff --git a/lua/neogit/watcher.lua b/lua/neogit/watcher.lua index d34093d7b..8301f5d97 100644 --- a/lua/neogit/watcher.lua +++ b/lua/neogit/watcher.lua @@ -1,89 +1,75 @@ -local uv = vim.loop +-- Adapted from https://github.com/lewis6991/gitsigns.nvim/blob/main/lua/gitsigns/watcher.lua#L103 -local config = require("neogit.config") local logger = require("neogit.logger") +local Path = require("plenary.path") -local paused = false - -local fs_event_handler = function(err, filename, events) - if paused then - return - end - - if err then - logger.error(string.format("[WATCHER] Git dir update error: %s", err)) - return - end +---@class Watcher +---@field git_root string +---@field status_buffer StatusBuffer +---@field running boolean +---@field fs_event_handler uv_fs_event_t +local Watcher = {} +Watcher.__index = Watcher - local info = string.format( - "[WATCHER] Git dir update: '%s' %s", - filename, - vim.inspect(events, { indent = "", newline = " " }) - ) +function Watcher.new(status_buffer, root) + local instance = { + status_buffer = status_buffer, + git_root = Path.new(root):joinpath(".git"):absolute(), + running = false, + fs_event_handler = assert(vim.loop.new_fs_event()), + } - -- stylua: ignore - if - filename == nil or - filename:match("%.lock$") or - filename:match("COMMIT_EDITMSG") or - filename:match("~$") or - filename:match("%d%d%d%d") - then - logger.debug(string.format("%s (ignoring)", info)) - return - end + setmetatable(instance, Watcher) - logger.debug(info) - require("neogit.status").dispatch_refresh(nil, "watcher") + return instance end --- Adapted from https://github.com/lewis6991/gitsigns.nvim/blob/main/lua/gitsigns/watcher.lua#L103 ---- @param gitdir string ---- @return uv_fs_event_t -local function start(gitdir) - local w = assert(uv.new_fs_event()) - w:start(gitdir, {}, fs_event_handler) +function Watcher:start() + if not self.running then + self.running = true - return w + logger.debug("[WATCHER] Watching git dir: " .. self.git_root) + self.fs_event_handler:start(self.git_root, {}, self:fs_event_callback()) + end end ----@class Watcher ----@field gitdir string ----@field fs_event_handler uv_fs_event_t|nil -local Watcher = {} -Watcher.__index = Watcher - function Watcher:stop() - if self.fs_event_handler then - logger.debug("[WATCHER] Stopped watching git dir: " .. self.gitdir) + if self.running then + self.running = false + + logger.debug("[WATCHER] Stopped watching git dir: " .. self.git_root) self.fs_event_handler:stop() end end -function Watcher:create(gitdir) - self.gitdir = gitdir - self.paused = false - - if config.values.filewatcher.enabled then - logger.debug("[WATCHER] Watching git dir: " .. gitdir) - self.fs_event_handler = start(gitdir) +function Watcher:fs_event_callback() + return function(err, filename, events) + if err then + logger.error(string.format("[WATCHER] Git dir update error: %s", err)) + return + end + + local info = string.format( + "[WATCHER] Git dir update: '%s' %s", + filename, + vim.inspect(events, { indent = "", newline = " " }) + ) + + -- stylua: ignore + if + filename == nil or + filename:match("%.lock$") or + filename:match("COMMIT_EDITMSG") or + filename:match("~$") or + filename:match("%d%d%d%d") + then + logger.debug(string.format("%s (ignoring)", info)) + return + end + + logger.debug(info) + self.status_buffer:dispatch_refresh(nil, "watcher") end - - return self -end - -function Watcher.new(...) - return Watcher:create(...) -end - -function Watcher.pause() - logger.debug("[WATCHER] Paused") - paused = true -end - -function Watcher.resume() - logger.debug("[WATCHER] Resumed") - paused = false end return Watcher diff --git a/syntax/NeogitCommitView.vim b/syntax/NeogitCommitView.vim deleted file mode 100644 index bf962335b..000000000 --- a/syntax/NeogitCommitView.vim +++ /dev/null @@ -1,8 +0,0 @@ -if exists("b:current_syntax") - finish -endif - -syn match NeogitDiffAdd /.*/ contained -syn match NeogitDiffDelete /.*/ contained - -let b:current_syntax = 1 diff --git a/syntax/NeogitStatus.vim b/syntax/NeogitStatus.vim deleted file mode 100644 index d14279313..000000000 --- a/syntax/NeogitStatus.vim +++ /dev/null @@ -1,65 +0,0 @@ -if exists("b:current_syntax") - finish -endif - -" Support the rebase todo highlights -source $VIMRUNTIME/syntax/gitrebase.vim - -" Added for Reverting section when sequencer/todo doesn't exist -syn match gitrebasePick "\v^work=>" nextgroup=gitrebaseCommit skipwhite -syn match gitrebaseBreak "\v^onto=>" nextgroup=gitrebaseCommit skipwhite - -" Labels to the left of files -syn match NeogitChangeModified /\v^Modified( by us|)/ -syn match NeogitChangeAdded /\v^Added( by us|)/ -syn match NeogitChangeDeleted /\v^Deleted( by us|)/ -syn match NeogitChangeRenamed /\v^Renamed( by us|)/ -syn match NeogitChangeUpdated /\v^Updated( by us|)/ -syn match NeogitChangeCopied /\v^Copied( by us|)/ -syn match NeogitChangeBothModified /^Both Modified/ -syn match NeogitChangeNewFile /^New file/ - -syn match NeogitCommitMessage /.*/ contained -syn match NeogitBranch /\S\+/ contained nextgroup=NeogitObjectId,NeogitCommitMessage -syn match NeogitRemote /\S\+/ contained nextgroup=NeogitObjectId,NeogitCommitMessage -syn match NeogitDiffAdd /.*/ contained -syn match NeogitDiffDelete /.*/ contained -syn match NeogitUnmergedInto /Unmerged into/ contained -syn match NeogitUnpushedTo /Unpushed to/ contained -syn match NeogitUnpulledFrom /Unpulled from/ contained -syn match NeogitTagName /\S\+ / contained nextgroup=NeogitTagDistance -syn match NeogitTagDistance /[0-9]/ contained - -syn match NeogitStash /stash@{[0-9]*}\ze/ -syn match NeogitObjectId "\v<\x{7,}>" contains=@NoSpell - -let b:sections = [ - \ "Untracked files", - \ "Unstaged changes", - \ "Unmerged changes", - \ "Unpulled changes", - \ "Recent commits", - \ "Staged changes", - \ "Stashes", - \ "Rebasing", - \ "Reverting", - \ "Picking" - \ ] - -for section in b:sections - let id = join(split(section, " "), "") - execute 'syn match Neogit' . id . ' /^' . section . '/ contained' - execute 'syn region Neogit' . id . 'Region start=/^' . section . '\ze.*/ end=/./ contains=Neogit' . id -endfor - -syn region NeogitHeadRegion start=/^Head: \zs/ end=/$/ contains=NeogitObjectId,NeogitBranch -syn region NeogitPushRegion start=/^Push: \zs/ end=/$/ contains=NeogitObjectId,NeogitRemote -syn region NeogitMergeRegion start=/^Merge: \zs/ end=/$/ contains=NeogitObjectId,NeogitRemote -syn region NeogitUnmergedIntoRegion start=/^Unmerged into .*/ end=/$/ contains=NeogitRemote,NeogitUnmergedInto -syn region NeogitUnpushedToRegion start=/^Unpushed to .*/ end=/$/ contains=NeogitRemote,NeogitUnpushedTo -syn region NeogitUnpulledFromRegion start=/^Unpulled from .*/ end=/$/ contains=NeogitRemote,NeogitUnpulledFrom -syn region NeogitDiffAddRegion start=/^+.*$/ end=/$/ contains=NeogitDiffAdd -syn region NeogitDiffDeleteRegion start=/^-.*$/ end=/$/ contains=NeogitDiffDelete -syn region NeogitTagRegion start=/^Tag: \zs/ end=/$/ contains=NeogitTagName,NeogitTagDistance - -let b:current_syntax = 1 diff --git a/tests/specs/neogit/config_spec.lua b/tests/specs/neogit/config_spec.lua index a999976b3..3a51eb951 100644 --- a/tests/specs/neogit/config_spec.lua +++ b/tests/specs/neogit/config_spec.lua @@ -51,11 +51,6 @@ describe("Neogit config", function() assert.True(vim.tbl_count(require("neogit.config").validate_config()) ~= 0) end) - it("should return invalid when auto_refresh isn't a boolean", function() - config.values.auto_refresh = "not a boolean" - assert.True(vim.tbl_count(require("neogit.config").validate_config()) ~= 0) - end) - it("should return invalid when sort_branches isn't a string", function() config.values.sort_branches = false assert.True(vim.tbl_count(require("neogit.config").validate_config()) ~= 0) diff --git a/tests/specs/neogit/lib/git/branch_spec.lua b/tests/specs/neogit/lib/git/branch_spec.lua index f0a79aa5a..1f9166e9f 100644 --- a/tests/specs/neogit/lib/git/branch_spec.lua +++ b/tests/specs/neogit/lib/git/branch_spec.lua @@ -1,5 +1,5 @@ local gb = require("neogit.lib.git.branch") -local status = require("neogit.status") +local neogit = require("neogit") local plenary_async = require("plenary.async") local git_harness = require("tests.util.git_harness") local neogit_util = require("neogit.lib.util") @@ -10,7 +10,7 @@ describe("lib.git.branch", function() describe("#exists", function() before_each(function() git_harness.prepare_repository() - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) end) it("returns true when branch exists", function() @@ -25,7 +25,7 @@ describe("lib.git.branch", function() describe("#is_unmerged", function() before_each(function() git_harness.prepare_repository() - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) end) it("returns true when feature branch has commits base branch doesn't", function() @@ -73,7 +73,7 @@ describe("lib.git.branch", function() describe("#delete", function() before_each(function() git_harness.prepare_repository() - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) end) describe("when branch is unmerged", function() @@ -117,7 +117,7 @@ describe("lib.git.branch", function() describe("recent branches", function() before_each(function() git_harness.prepare_repository() - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) end) it( @@ -173,7 +173,7 @@ describe("lib.git.branch", function() before_each(function() git_harness.prepare_repository() - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) setup_local_git_branches() end) diff --git a/tests/specs/neogit/lib/git/log_spec.lua b/tests/specs/neogit/lib/git/log_spec.lua index 08892cf92..40de3975e 100644 --- a/tests/specs/neogit/lib/git/log_spec.lua +++ b/tests/specs/neogit/lib/git/log_spec.lua @@ -1,4 +1,4 @@ -local status = require("neogit.status") +local neogit = require("neogit") local plenary_async = require("plenary.async") local git_harness = require("tests.util.git_harness") local util = require("tests.util.util") @@ -9,7 +9,7 @@ local subject = require("neogit.lib.git.log") describe("lib.git.log", function() before_each(function() git_harness.prepare_repository() - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) end) describe("#is_ancestor", function() diff --git a/tests/specs/neogit/lib/git/remote_spec.lua b/tests/specs/neogit/lib/git/remote_spec.lua index b9b753013..b37749848 100644 --- a/tests/specs/neogit/lib/git/remote_spec.lua +++ b/tests/specs/neogit/lib/git/remote_spec.lua @@ -195,5 +195,19 @@ describe("lib.git.remote", function() user = "git", }) end) + + it("can parse 'https://github.com/my-org/myrepo' - no .git suffix", function() + local url = "https://github.com/my-org/myrepo" + + assert.are.same(git.remote.parse(url), { + host = "github.com", + owner = "my-org", + protocol = "https", + repo = "myrepo", + repository = "myrepo", + path = "my-org", + url = "https://github.com/my-org/myrepo", + }) + end) end) end) diff --git a/tests/specs/neogit/lib/git/status_spec.lua b/tests/specs/neogit/lib/git/status_spec.lua index 9953caeac..0b155903d 100644 --- a/tests/specs/neogit/lib/git/status_spec.lua +++ b/tests/specs/neogit/lib/git/status_spec.lua @@ -1,4 +1,4 @@ -local status = require("neogit.status") +local neogit = require("neogit") local plenary_async = require("plenary.async") local git_harness = require("tests.util.git_harness") local util = require("tests.util.util") @@ -8,20 +8,20 @@ local subject = require("neogit.lib.git.status") describe("lib.git.status", function() before_each(function() git_harness.prepare_repository() - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) end) describe("#anything_staged", function() it("returns true when there are staged items", function() util.system("git add --all") - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) assert.True(subject.anything_staged()) end) it("returns false when there are no staged items", function() util.system("git reset") - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) assert.False(subject.anything_staged()) end) @@ -30,14 +30,14 @@ describe("lib.git.status", function() describe("#anything_unstaged", function() it("returns true when there are unstaged items", function() util.system("git reset") - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) assert.True(subject.anything_unstaged()) end) it("returns false when there are no unstaged items", function() util.system("git add --all") - plenary_async.util.block_on(status.reset) + plenary_async.util.block_on(neogit.reset) assert.False(subject.anything_unstaged()) end) diff --git a/tests/specs/neogit/popups/branch_spec.lua b/tests/specs/neogit/popups/branch_spec.lua index 89b3e23b3..475a96478 100644 --- a/tests/specs/neogit/popups/branch_spec.lua +++ b/tests/specs/neogit/popups/branch_spec.lua @@ -10,7 +10,7 @@ local get_git_rev = harness.get_git_rev local util = require("tests.util.util") local FuzzyFinderBuffer = require("tests.mocks.fuzzy_finder") -local status = require("neogit.status") +local neogit = require("neogit") local input = require("tests.mocks.input") local function act(normal_cmd) @@ -189,7 +189,7 @@ describe("branch popup", function() input.confirmed = true local remote = harness.prepare_repository() - async.util.block_on(status.reset) + async.util.block_on(neogit.reset) util.system("git remote add upstream " .. remote) util.system([[ git stash --include-untracked @@ -268,7 +268,7 @@ describe("branch popup", function() git add . git commit -m 'some feature' ]]) - async.util.block_on(status.reset) + async.util.block_on(neogit.reset) local input_branch = "spin-out-branch" input.values = { input_branch } @@ -300,7 +300,7 @@ describe("branch popup", function() touch wip.js git add . ]]) - async.util.block_on(status.reset) + async.util.block_on(neogit.reset) local input_branch = "spin-out-branch" input.values = { input_branch } @@ -332,7 +332,7 @@ describe("branch popup", function() git add . git commit -m 'some feature' ]]) - async.util.block_on(status.reset) + async.util.block_on(neogit.reset) local input_branch = "spin-off-branch" input.values = { input_branch } diff --git a/tests/specs/neogit/popups/log_spec.lua b/tests/specs/neogit/popups/log_spec.lua index 904b04c46..81d6f94f2 100644 --- a/tests/specs/neogit/popups/log_spec.lua +++ b/tests/specs/neogit/popups/log_spec.lua @@ -5,7 +5,6 @@ local harness = require("tests.util.git_harness") local util = require("tests.util.util") local in_prepared_repo = harness.in_prepared_repo -local status = require("neogit.status") local state = require("neogit.lib.state") local input = require("tests.mocks.input") @@ -14,6 +13,10 @@ local function act(normal_cmd) vim.fn.feedkeys("", "x") -- flush typeahead end +local function actual() + return vim.api.nvim_buf_get_lines(0, 0, -1, true) +end + describe("log popup", function() before_each(function() -- Reset all switches. @@ -75,8 +78,19 @@ describe("log popup", function() in_prepared_repo(function() act("l-n1l") operations.wait("log_current") - vim.fn.feedkeys("G", "x") - eq("e2c2a1c * master origin/second-branch b.txt", vim.api.nvim_get_current_line()) + + local expected = { + "e2c2a1c * master origin/second-branch b.txt", + " * Author: Florian Proksch ", + " * AuthorDate: Tue, Feb 9 20:33:33 2021 +0100", + " * Commit: Florian Proksch ", + " * CommitDate: Tue, Feb 9 20:33:33 2021 +0100", + " *", + " * b.txt", + " * ", + } + + eq(expected, actual()) end) ) @@ -91,8 +105,8 @@ describe("log popup", function() ]]) act("l-APersonl") operations.wait("log_current") - vim.fn.feedkeys("G", "x") - assert.is_not.Nil(string.find(vim.api.nvim_get_current_line(), "Empty commit", 1, true)) + + assert.is_not.Nil(string.find(actual()[1], "Empty commit", 1, true)) end) ) @@ -101,8 +115,19 @@ describe("log popup", function() in_prepared_repo(function() act("l-Fa.txtl") operations.wait("log_current") - vim.fn.feedkeys("G", "x") - eq("d86fa0e * a.txt", vim.api.nvim_get_current_line()) + + local expected = { + "d86fa0e * a.txt", + " * Author: Florian Proksch ", + " * AuthorDate: Sat, Feb 6 08:08:32 2021 +0100", + " * Commit: Florian Proksch ", + " * CommitDate: Sat, Feb 6 21:20:33 2021 +0100", + " *", + " * a.txt", + " * ", + } + + eq(expected, actual()) end) ) @@ -111,8 +136,19 @@ describe("log popup", function() in_prepared_repo(function() act("l-sFeb 8 2021l") operations.wait("log_current") - vim.fn.feedkeys("G", "x") - eq("e2c2a1c * master origin/second-branch b.txt", vim.api.nvim_get_current_line()) + + local expected = { + "e2c2a1c * master origin/second-branch b.txt", + " * Author: Florian Proksch ", + " * AuthorDate: Tue, Feb 9 20:33:33 2021 +0100", + " * Commit: Florian Proksch ", + " * CommitDate: Tue, Feb 9 20:33:33 2021 +0100", + " *", + " * b.txt", + " * ", + } + + eq(expected, actual()) end) ) @@ -121,8 +157,19 @@ describe("log popup", function() in_prepared_repo(function() act("l-uFeb 7 2021l") operations.wait("log_current") - vim.fn.feedkeys("G", "x") - eq("d86fa0e * a.txt", vim.api.nvim_get_current_line()) + + local expected = { + "d86fa0e * a.txt", + " * Author: Florian Proksch ", + " * AuthorDate: Sat, Feb 6 08:08:32 2021 +0100", + " * Commit: Florian Proksch ", + " * CommitDate: Sat, Feb 6 21:20:33 2021 +0100", + " *", + " * a.txt", + " * ", + } + + eq(expected, actual()) end) ) @@ -132,8 +179,20 @@ describe("log popup", function() input.values = { "text file" } act("l-Gl") operations.wait("log_current") - vim.fn.feedkeys("G", "x") - eq("d86fa0e * a.txt", vim.api.nvim_get_current_line()) + + local expected = { + " ...", + "d86fa0e * a.txt", + " * Author: Florian Proksch ", + " * AuthorDate: Sat, Feb 6 08:08:32 2021 +0100", + " * Commit: Florian Proksch ", + " * CommitDate: Sat, Feb 6 21:20:33 2021 +0100", + " *", + " * a.txt", + " * ", + } + + eq(expected, actual()) end) ) @@ -143,8 +202,19 @@ describe("log popup", function() input.values = { "test file" } act("l-Sl") operations.wait("log_current") - vim.fn.feedkeys("G", "x") - eq("e2c2a1c * master origin/second-branch b.txt", vim.api.nvim_get_current_line()) + + local expected = { + "e2c2a1c * master origin/second-branch b.txt", + " * Author: Florian Proksch ", + " * AuthorDate: Tue, Feb 9 20:33:33 2021 +0100", + " * Commit: Florian Proksch ", + " * CommitDate: Tue, Feb 9 20:33:33 2021 +0100", + " *", + " * b.txt", + " * ", + } + + eq(expected, actual()) end) ) diff --git a/tests/specs/neogit/popups/rebase_spec.lua b/tests/specs/neogit/popups/rebase_spec.lua index b86e27228..d9dbc22ac 100644 --- a/tests/specs/neogit/popups/rebase_spec.lua +++ b/tests/specs/neogit/popups/rebase_spec.lua @@ -20,7 +20,7 @@ describe("rebase popup", function() CommitSelectViewBufferMock.clear() end) - function test_reword(commit_to_reword, new_commit_message, selected) + local function test_reword(commit_to_reword, new_commit_message, selected) local original_branch = git.branch.current() if selected == false then CommitSelectViewBufferMock.add(git.rev_parse.oid(commit_to_reword)) @@ -32,7 +32,7 @@ describe("rebase popup", function() assert.are.same(new_commit_message, git.log.message(commit_to_reword)) end - function test_modify(commit_to_modify, selected) + local function test_modify(commit_to_modify, selected) local new_head = git.rev_parse.oid(commit_to_modify) if selected == false then CommitSelectViewBufferMock.add(git.rev_parse.oid(commit_to_modify)) @@ -42,7 +42,7 @@ describe("rebase popup", function() assert.are.same(new_head, git.rev_parse.oid("HEAD")) end - function test_drop(commit_to_drop, selected) + local function test_drop(commit_to_drop, selected) local dropped_commit = git.rev_parse.oid(commit_to_drop) if selected == false then CommitSelectViewBufferMock.add(git.rev_parse.oid(commit_to_drop)) diff --git a/tests/specs/neogit/popups/remote_spec.lua b/tests/specs/neogit/popups/remote_spec.lua index 9c36972a0..c9fcac0fa 100644 --- a/tests/specs/neogit/popups/remote_spec.lua +++ b/tests/specs/neogit/popups/remote_spec.lua @@ -5,7 +5,7 @@ local operations = require("neogit.operations") local harness = require("tests.util.git_harness") local in_prepared_repo = harness.in_prepared_repo -local status = require("neogit.status") +local neogit = require("neogit") local input = require("tests.mocks.input") local lib = require("neogit.lib") @@ -20,7 +20,7 @@ describe("remote popup", function() in_prepared_repo(function() local remote_a = harness.prepare_repository() local remote_b = harness.prepare_repository() - async.util.block_on(status.reset) + async.util.block_on(neogit.reset) input.values = { "foo", remote_a } act("Ma") diff --git a/tests/specs/neogit/status_spec.lua b/tests/specs/neogit/status_spec.lua index 53dba1ed2..4974e0b73 100644 --- a/tests/specs/neogit/status_spec.lua +++ b/tests/specs/neogit/status_spec.lua @@ -1,6 +1,6 @@ local a = require("plenary.async") local eq = assert.are.same -local status = require("neogit.status") +local neogit = require("neogit") local operations = require("neogit.operations") local util = require("tests.util.util") local harness = require("tests.util.git_harness") @@ -37,8 +37,8 @@ describe("status buffer", function() harness.exec { "git", "add", "testfile" } harness.exec { "git", "add", "renamed-testfile" } - a.util.block_on(status.reset) - a.util.block_on(status.refresh) + a.util.block_on(neogit.reset) + a.util.block_on(neogit.refresh) local lines = vim.api.nvim_buf_get_lines(0, 0, -1, true) assert.True(vim.tbl_contains(lines, "Renamed testfile -> renamed-testfile")) @@ -51,8 +51,8 @@ describe("status buffer", function() "Handles non-english filenames correctly", in_prepared_repo(function() harness.exec { "touch", "你好.md" } - a.util.block_on(status.reset) - a.util.block_on(status.refresh) + a.util.block_on(neogit.reset) + a.util.block_on(neogit.refresh) find("你好%.md") act("s") diff --git a/tests/util/git_harness.lua b/tests/util/git_harness.lua index 4554b9f7d..1077f6dd3 100644 --- a/tests/util/git_harness.lua +++ b/tests/util/git_harness.lua @@ -1,4 +1,4 @@ -local status = require("neogit.status") +local neogit = require("neogit") local a = require("plenary.async") local M = {} local util = require("tests.util.util") @@ -54,13 +54,14 @@ end function M.in_prepared_repo(cb) return function() local dir = M.prepare_repository() - require("neogit").setup() + require("neogit").setup {} + local status = require("neogit.buffers.status") vim.cmd("Neogit") - a.util.block_on(status.reset) + a.util.block_on(neogit.reset) vim.wait(1000, function() - return not status.is_refresh_locked() + return not status.instance and status.instance:_is_refresh_locked() end, 100) a.util.block_on(function() @@ -69,7 +70,11 @@ function M.in_prepared_repo(cb) error(err) end - a.util.block_on(status.close) + a.util.block_on(function() + if status.instance then + status.instance:close() + end + end) end) end end