aboutsummaryrefslogtreecommitdiff
path: root/.config/yazi/plugins/git.yazi
diff options
context:
space:
mode:
authorawy <awy@awy.one>2025-09-14 19:06:01 +0300
committerawy <awy@awy.one>2025-09-14 19:06:01 +0300
commit0f82b66de5668deacc7a29aeb12399d98213ce29 (patch)
tree8917ddb7ead7ab638e97efe3e1d619318cd095f4 /.config/yazi/plugins/git.yazi
parentec7330d8212b4881e86d5e06f17d5dfd0633b922 (diff)
downloadhyprdots-0f82b66de5668deacc7a29aeb12399d98213ce29.tar.gz
Diffstat (limited to '.config/yazi/plugins/git.yazi')
-rw-r--r--.config/yazi/plugins/git.yazi/main.lua261
1 files changed, 261 insertions, 0 deletions
diff --git a/.config/yazi/plugins/git.yazi/main.lua b/.config/yazi/plugins/git.yazi/main.lua
new file mode 100644
index 0000000..e6b3a36
--- /dev/null
+++ b/.config/yazi/plugins/git.yazi/main.lua
@@ -0,0 +1,261 @@
+--- @since 25.5.31
+
+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 = {
+ 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 },
+}
+
+---@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.unknown) > 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.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
+ -- TODO: remove this
+ if ui.render then
+ ui.render()
+ else
+ ya.render()
+ end
+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
+
+ -- TODO: remove this
+ if ui.render then
+ ui.render()
+ else
+ ya.render()
+ end
+ 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.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
+
+---@type UnstableFetcher
+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))
+ :arg({ "--no-optional-locks", "-c", "core.quotePath=", "status", "--porcelain", "-unormal", "--no-renames", "--ignored=matching" })
+ :arg(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 }