From ca4cac86f7ac70654373d7d3ac9da0d7b42b255a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Dom=C3=ADnguez?= Date: Sat, 10 May 2025 19:02:09 -0400 Subject: [PATCH] actually, it's quite useful so adding it back --- .config/yazi/plugins/git.yazi/LICENSE | 21 +++ .config/yazi/plugins/git.yazi/README.md | 78 ++++++++ .config/yazi/plugins/git.yazi/main.lua | 228 ++++++++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 .config/yazi/plugins/git.yazi/LICENSE create mode 100644 .config/yazi/plugins/git.yazi/README.md create mode 100644 .config/yazi/plugins/git.yazi/main.lua diff --git a/.config/yazi/plugins/git.yazi/LICENSE b/.config/yazi/plugins/git.yazi/LICENSE new file mode 100644 index 0000000..fb5b1d6 --- /dev/null +++ b/.config/yazi/plugins/git.yazi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 yazi-rs + +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. diff --git a/.config/yazi/plugins/git.yazi/README.md b/.config/yazi/plugins/git.yazi/README.md new file mode 100644 index 0000000..4c5b07c --- /dev/null +++ b/.config/yazi/plugins/git.yazi/README.md @@ -0,0 +1,78 @@ +# git.yazi + +> [!NOTE] +> Yazi v25.2.26 or later is required for this plugin to work. + +Show the status of Git file changes as linemode in the file list. + +https://github.com/user-attachments/assets/34976be9-a871-4ffe-9d5a-c4cdd0bf4576 + +## Installation + +```sh +ya pack -a yazi-rs/plugins:git +``` + +## Setup + +Add the following to your `~/.config/yazi/init.lua`: + +```lua +require("git"):setup() +``` + +And register it as fetchers in your `~/.config/yazi/yazi.toml`: + +```toml +[[plugin.prepend_fetchers]] +id = "git" +name = "*" +run = "git" + +[[plugin.prepend_fetchers]] +id = "git" +name = "*/" +run = "git" +``` + +## Advanced + +You can customize the [Style](https://yazi-rs.github.io/docs/plugins/layout#style) of the status sign with: + +- `th.git.modified` +- `th.git.added` +- `th.git.untracked` +- `th.git.ignored` +- `th.git.deleted` +- `th.git.updated` + +For example: + +```lua +-- ~/.config/yazi/init.lua +th.git = th.git or {} +th.git.modified = ui.Style():fg("blue") +th.git.deleted = ui.Style():fg("red"):bold() +``` + +You can also customize the text of the status sign with: + +- `th.git.modified_sign` +- `th.git.added_sign` +- `th.git.untracked_sign` +- `th.git.ignored_sign` +- `th.git.deleted_sign` +- `th.git.updated_sign` + +For example: + +```lua +-- ~/.config/yazi/init.lua +th.git = th.git or {} +th.git.modified_sign = "M" +th.git.deleted_sign = "D" +``` + +## License + +This plugin is MIT-licensed. For more information check the [LICENSE](LICENSE) file. diff --git a/.config/yazi/plugins/git.yazi/main.lua b/.config/yazi/plugins/git.yazi/main.lua new file mode 100644 index 0000000..d8f365a --- /dev/null +++ b/.config/yazi/plugins/git.yazi/main.lua @@ -0,0 +1,228 @@ +--- @since 25.4.4 + +local WINDOWS = ya.target_family() == "windows" + +-- The code of supported git status, +-- also used to determine which status to show for directories when they contain different statuses +-- see `bubble_up` +local CODES = { + excluded = 100, -- ignored directory + ignored = 6, -- ignored file + untracked = 5, + modified = 4, + added = 3, + deleted = 2, + updated = 1, + unknown = 0, +} + +local PATTERNS = { + { "!$", CODES.ignored }, + { "?$", CODES.untracked }, + { "[MT]", CODES.modified }, + { "[AC]", CODES.added }, + { "D", CODES.deleted }, + { "U", CODES.updated }, + { "[AD][AD]", CODES.updated }, +} + +local function match(line) + local signs = line:sub(1, 2) + for _, p in ipairs(PATTERNS) do + local path, pattern, code = nil, p[1], p[2] + if signs:find(pattern) then + path = line:sub(4, 4) == '"' and line:sub(5, -2) or line:sub(4) + path = WINDOWS and path:gsub("/", "\\") or path + end + if not path then + elseif path:find("[/\\]$") then + -- Mark the ignored directory as `excluded`, so we can process it further within `propagate_down` + return code == CODES.ignored and CODES.excluded or code, path:sub(1, -2) + else + return code, path + end + end +end + +local function root(cwd) + local is_worktree = function(url) + local file, head = io.open(tostring(url)), nil + if file then + head = file:read(8) + file:close() + end + return head == "gitdir: " + end + + repeat + local next = cwd:join(".git") + local cha = fs.cha(next) + if cha and (cha.is_dir or is_worktree(next)) then + return tostring(cwd) + end + cwd = cwd.parent + until not cwd +end + +local function bubble_up(changed) + local new, empty = {}, Url("") + for path, code in pairs(changed) do + if code ~= CODES.ignored then + local url = Url(path).parent + while url and url ~= empty do + local s = tostring(url) + new[s] = (new[s] or CODES.unknown) > code and new[s] or code + url = url.parent + end + end + end + return new +end + +local function propagate_down(excluded, cwd, repo) + local new, rel = {}, cwd:strip_prefix(repo) + for _, path in ipairs(excluded) do + if rel:starts_with(path) then + -- If `cwd` is a subdirectory of an excluded directory, also mark it as `excluded` + new[tostring(cwd)] = CODES.excluded + elseif cwd == repo:join(path).parent then + -- If `path` is a direct subdirectory of `cwd`, mark it as `ignored` + new[path] = CODES.ignored + else + -- Skipping, we only care about `cwd` itself and its direct subdirectories for maximum performance + end + end + return new +end + +local add = ya.sync(function(st, cwd, repo, changed) + st.dirs[cwd] = repo + st.repos[repo] = st.repos[repo] or {} + for path, code in pairs(changed) do + if code == CODES.unknown then + st.repos[repo][path] = nil + elseif code == CODES.excluded then + -- Mark the directory with a special value `excluded` so that it can be distinguished during UI rendering + st.dirs[path] = CODES.excluded + else + st.repos[repo][path] = code + end + end + ya.render() +end) + +local remove = ya.sync(function(st, cwd) + local repo = st.dirs[cwd] + if not repo then + return + end + + ya.render() + st.dirs[cwd] = nil + if not st.repos[repo] then + return + end + + for _, r in pairs(st.dirs) do + if r == repo then + return + end + end + st.repos[repo] = nil +end) + +local function setup(st, opts) + st.dirs = {} -- Mapping between a directory and its corresponding repository + st.repos = {} -- Mapping between a repository and the status of each of its files + + opts = opts or {} + opts.order = opts.order or 1500 + + local t = th.git or {} + local styles = { + [CODES.ignored] = t.ignored and ui.Style(t.ignored) or ui.Style():fg("darkgray"), + [CODES.untracked] = t.untracked and ui.Style(t.untracked) or ui.Style():fg("magenta"), + [CODES.modified] = t.modified and ui.Style(t.modified) or ui.Style():fg("yellow"), + [CODES.added] = t.added and ui.Style(t.added) or ui.Style():fg("green"), + [CODES.deleted] = t.deleted and ui.Style(t.deleted) or ui.Style():fg("red"), + [CODES.updated] = t.updated and ui.Style(t.updated) or ui.Style():fg("yellow"), + } + local signs = { + [CODES.ignored] = t.ignored_sign or "", + [CODES.untracked] = t.untracked_sign or "?", + [CODES.modified] = t.modified_sign or "", + [CODES.added] = t.added_sign or "", + [CODES.deleted] = t.deleted_sign or "", + [CODES.updated] = t.updated_sign or "", + } + + Linemode:children_add(function(self) + local url = self._file.url + local repo = st.dirs[tostring(url.base)] + local code + if repo then + code = repo == CODES.excluded and CODES.ignored or st.repos[repo][tostring(url):sub(#repo + 2)] + end + + if not code or signs[code] == "" then + return "" + elseif self._file.is_hovered then + return ui.Line { " ", signs[code] } + else + return ui.Line { " ", ui.Span(signs[code]):style(styles[code]) } + end + end, opts.order) +end + +local function fetch(_, job) + local cwd = job.files[1].url.base + local repo = root(cwd) + if not repo then + remove(tostring(cwd)) + return true + end + + local paths = {} + for _, file in ipairs(job.files) do + paths[#paths + 1] = tostring(file.url) + end + + -- stylua: ignore + local output, err = Command("git") + :cwd(tostring(cwd)) + :args({ "--no-optional-locks", "-c", "core.quotePath=", "status", "--porcelain", "-unormal", "--no-renames", "--ignored=matching" }) + :args(paths) + :stdout(Command.PIPED) + :output() + if not output then + return true, Err("Cannot spawn `git` command, error: %s", err) + end + + local changed, excluded = {}, {} + for line in output.stdout:gmatch("[^\r\n]+") do + local code, path = match(line) + if code == CODES.excluded then + excluded[#excluded + 1] = path + else + changed[path] = code + end + end + + if job.files[1].cha.is_dir then + ya.dict_merge(changed, bubble_up(changed)) + end + ya.dict_merge(changed, propagate_down(excluded, cwd, Url(repo))) + + -- Reset the status of any files that don't appear in the output of `git status` to `unknown`, + -- so that cleaning up outdated statuses from `st.repos` + for _, path in ipairs(paths) do + local s = path:sub(#repo + 2) + changed[s] = changed[s] or CODES.unknown + end + + add(tostring(cwd), repo, changed) + + return false +end + +return { setup = setup, fetch = fetch }