diff options
Diffstat (limited to 'config/yazi/plugins/git.yazi')
| -rw-r--r-- | config/yazi/plugins/git.yazi/LICENSE | 21 | ||||
| -rw-r--r-- | config/yazi/plugins/git.yazi/README.md | 89 | ||||
| -rw-r--r-- | config/yazi/plugins/git.yazi/main.lua | 259 | ||||
| -rw-r--r-- | config/yazi/plugins/git.yazi/types.lua | 12 |
4 files changed, 381 insertions, 0 deletions
diff --git a/config/yazi/plugins/git.yazi/LICENSE b/config/yazi/plugins/git.yazi/LICENSE new file mode 100644 index 00000000..fb5b1d62 --- /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 00000000..042bac6c --- /dev/null +++ b/config/yazi/plugins/git.yazi/README.md @@ -0,0 +1,89 @@ +# git.yazi + +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 pkg add yazi-rs/plugins:git +``` + +## Setup + +Add the following to your `~/.config/yazi/init.lua`: + +```lua +require("git"):setup { + -- Order of status signs showing in the linemode + order = 1500, +} +``` + +And register it as fetchers in your `~/.config/yazi/yazi.toml`: + +```toml +[[plugin.prepend_fetchers]] +id = "git" # Remove if Yazi > v26.1.22 +url = "*" +run = "git" +group = "git" + +[[plugin.prepend_fetchers]] +id = "git" # Remove if Yazi > v26.1.22 +url = "*/" +run = "git" +group = "git" +``` + +## Advanced + +> [!NOTE] +> The following configuration must be put before `require("git"):setup()` + +You can customize the [Style](https://yazi-rs.github.io/docs/plugins/layout#style) of the status sign with: + +- `[git].unknown` - status cannot/not yet determined +- `[git].modified` - modified file +- `[git].added` - added file +- `[git].untracked` - untracked file +- `[git].ignored` - ignored file +- `[git].deleted` - deleted file +- `[git].updated` - updated file +- `[git].clean` - clean file + +For example: + +```toml +# theme.toml / flavor.toml +[git] +modified = { fg = "blue" } +deleted = { fg = "red", bold = true } +``` + +You can also customize the text of the status sign with: + +- `[git].unknown_sign` - status cannot/not yet determined +- `[git].modified_sign` - modified file +- `[git].added_sign` - added file +- `[git].untracked_sign` - untracked file +- `[git].ignored_sign` - ignored file +- `[git].deleted_sign` - deleted file +- `[git].updated_sign` - updated file +- `[git].clean_sign` - clean file + +For example: + +```toml +# theme.toml / flavor.toml +[git] +unknown_sign = " " +modified_sign = "M" +deleted_sign = "D" +clean_sign = "✔" +``` + +## 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 00000000..19f5c445 --- /dev/null +++ b/config/yazi/plugins/git.yazi/main.lua @@ -0,0 +1,259 @@ +--- @since 26.5.6 + +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` +---@enum CODES +local CODES = { + unknown = 100, -- status cannot/not yet determined + excluded = 99, -- ignored directory + ignored = 6, -- ignored file + untracked = 5, + modified = 4, + added = 3, + deleted = 2, + updated = 1, + clean = 0, +} + +local PATTERNS = { + { "!$", CODES.ignored }, + { "?$", CODES.untracked }, + { "[MT]", CODES.modified }, + { "[AC]", CODES.added }, + { "D", CODES.deleted }, + { "U", CODES.updated }, + { "[AD][AD]", CODES.updated }, +} + +---@param line string +---@return CODES, string +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 + ---@diagnostic disable-next-line: missing-return + end +end + +---@param cwd Url +---@return string? +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 + +---@param changed Changes +---@return Changes +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.clean) > code and new[s] or code + url = url.parent + end + end + end + return new +end + +---@param excluded string[] +---@param cwd Url +---@param repo Url +---@return Changes +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 + +---@param cwd string +---@param repo string +---@param changed Changes +local add = ya.sync(function(st, cwd, repo, changed) + ---@cast st State + + st.dirs[cwd] = repo + st.repos[repo] = st.repos[repo] or {} + for path, code in pairs(changed) do + if code == CODES.clean 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 + ui.render() +end) + +---@param cwd string +local remove = ya.sync(function(st, cwd) + ---@cast st State + + local repo = st.dirs[cwd] + if not repo then + return + end + + ui.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) + +---@param st State +---@param opts Options +local function setup(st, opts) + st.dirs = {} + st.repos = {} + + opts = opts or {} + opts.order = opts.order or 1500 + + local t = th.git or {} + local styles = { + [CODES.unknown] = t.unknown or ui.Style(), + [CODES.ignored] = t.ignored or ui.Style():fg("darkgray"), + [CODES.untracked] = t.untracked or ui.Style():fg("magenta"), + [CODES.modified] = t.modified or ui.Style():fg("yellow"), + [CODES.added] = t.added or ui.Style():fg("green"), + [CODES.deleted] = t.deleted or ui.Style():fg("red"), + [CODES.updated] = t.updated or ui.Style():fg("yellow"), + [CODES.clean] = t.clean or ui.Style(), + } + local signs = { + [CODES.unknown] = t.unknown_sign or "", + [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 " ", + [CODES.clean] = t.clean_sign or "", + } + + Linemode:children_add(function(self) + if not self._file.in_current then + return "" + end + + local url = self._file.url + local repo = st.dirs[tostring(url.base or url.parent)] + local code = CODES.unknown + if repo then + code = repo == CODES.excluded and CODES.ignored or st.repos[repo][tostring(url):sub(#repo + 2)] or CODES.clean + end + + if 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 + +---@type UnstableFetcher +local function fetch(_, job) + local cwd = job.files[1].url.base or job.files[1].url.parent + 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)) + :arg({ "--no-optional-locks", "-c", "core.quotePath=", "status", "--porcelain", "-unormal", "--no-renames", "--ignored=matching" }) + :arg(paths) + :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 `clean`, + -- 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.clean + end + + add(tostring(cwd), repo, changed) + + return false +end + +return { setup = setup, fetch = fetch } diff --git a/config/yazi/plugins/git.yazi/types.lua b/config/yazi/plugins/git.yazi/types.lua new file mode 100644 index 00000000..99368495 --- /dev/null +++ b/config/yazi/plugins/git.yazi/types.lua @@ -0,0 +1,12 @@ +---@class State +---@field dirs table<string, string|CODES> Mapping between a directory and its corresponding repository +---@field repos table<string, Changes> Mapping between a repository and the status of each of its files + +---@class Options +---@field order number The order in which the status is displayed +---@field renamed boolean Whether to include renamed files in the status (or treat them as modified) + +-- TODO: move this to `types.yazi` once it's get stable +---@alias UnstableFetcher fun(self: unknown, job: { files: File[] }): boolean, Error? + +---@alias Changes table<string, CODES> |
