-- mod-version:3 -- version: 0.3.0 -- Git panel for Lite XL: uncommitted changes, staging, commits, history. -- Self-contained, needs only the `git` CLI in PATH. -- Toggle with Ctrl+Shift+G, the git button in the treeview toolbar or the -- branch name in the status bar. When the treeview plugin is present the -- panel replaces the folder list in the left pane; otherwise it opens as -- its own pane. -- In the panel: click a file to open it, click a commit to view its patch. -- Keys (panel focused): s stage · u unstage · a stage all · c commit · r refresh local core = require "core" local common = require "core.common" local config = require "core.config" local style = require "core.style" local keymap = require "core.keymap" local command = require "core.command" local syntax = require "core.syntax" local View = require "core.view" local Doc = require "core.doc" local StatusView = require "core.statusview" config.plugins.gitpanel = common.merge({ history_length = 40, }, config.plugins.gitpanel) -------------------------------------------------------------------------------- -- Async git helpers (run inside core.add_thread coroutines) -------------------------------------------------------------------------------- -- Run a command, reading incrementally so large outputs (git show) cannot -- deadlock on the pipe buffer. Must be called from a coroutine. local function exec(args) local proc = process.start(args) if not proc then return "" end local chunks = {} while proc:running() do local chunk = proc:read_stdout(65536) if chunk and #chunk > 0 then chunks[#chunks + 1] = chunk else coroutine.yield(0.05) end end while true do local chunk = proc:read_stdout(65536) if chunk and #chunk > 0 then chunks[#chunks + 1] = chunk else break end end return table.concat(chunks) end local state = { branch = nil, changes = {}, history = {}, clean = false, loading = false } local function parse_numstat(out, map) for line in (out .. "\n"):gmatch("([^\n]*)\n") do local ins, del, path = line:match("^(%d+)%s+(%d+)%s+(.+)$") if path then path = path:match("^.- ->%s*(.+)$") or path -- renames: keep new path map[path] = { n = tonumber(ins) or 0, d = tonumber(del) or 0 } end end end local function refresh(callback) if state.loading then return end state.loading = true core.add_thread(function() local branch, changes, history -- pcall so a failed git run (spawn error, ...) can never leave -- state.loading stuck and silently kill every later refresh local ok, err = pcall(function() branch, changes, history = nil, {}, {} if system.get_file_info(core.project_dir .. PATHSEP .. ".git") then local work, staged = {}, {} parse_numstat(exec { "git", "diff", "--numstat" }, work) parse_numstat(exec { "git", "diff", "--cached", "--numstat" }, staged) local status = exec { "git", "status", "--porcelain=v1", "-b" } local first = true for line in (status .. "\n"):gmatch("([^\n]*)\n") do if first and line:match("^## ") then branch = line:sub(4):match("^(.-)%.%.%.") or line:sub(4):match("^(%S+)") first = false elseif line ~= "" then local xy, path = line:match("^(..) (.+)$") if xy then path = path:match("^(.-)%s+->%s+(.+)$") or path local x, y = xy:sub(1, 1), xy:sub(2, 2) local nums = (x ~= " ") and staged[path] or work[path] changes[#changes + 1] = { xy = xy, st = xy == "??" and "??" or (x ~= " " and x or y), path = path, staged = x ~= " " and x ~= "?", untracked = xy == "??", plus = nums and nums.n or nil, minus = nums and nums.d or nil, } end end end local log = exec { "git", "log", "-n", tostring(config.plugins.gitpanel.history_length), "--pretty=format:%h %s" } for line in (log .. "\n"):gmatch("([^\n]*)\n") do local hash, subject = line:match("^(%S+) (.*)$") if hash then history[#history + 1] = { hash = hash, subject = subject } end end end end) state.loading = false if ok then state.branch, state.changes, state.history = branch, changes, history state.clean = branch ~= nil and #changes == 0 else core.error("git-panel: refresh failed: %s", tostring(err)) end if callback and ok then callback() end end) end -------------------------------------------------------------------------------- -- Panel view -------------------------------------------------------------------------------- local function row_height() return style.font:get_height() + common.round(4 * SCALE) end local function status_color(st) if st == "M" or st == "T" or st == "R" or st == "C" then return style.gitstatus_modification or style.warn end if st == "A" or st == "??" then return style.gitstatus_addition or style.good end if st == "D" or st == "U" then return style.gitstatus_deletion or style.error end return style.text end local GitPanelView = View:extend() local show_commit -- forward declaration: called from on_mouse_pressed below -- Set once the treeview plugin is available (it loads after us); when nil -- the panel falls back to opening as its own split pane. local treeview = nil function GitPanelView:new() GitPanelView.super.new(self) self.hover = nil -- { section = "changes"|"history", idx } self.mouse = { x = 0, y = 0 } self.changes_offset = 0 self.history_offset = 0 self.changes_rows = {} self.history_rows = {} self.changes_visible = 1 self.history_visible = 1 self.mid_y = 0 end function GitPanelView:get_name() return "Git" end -- While embedded in the treeview's pane, the pane width follows our size.x -- (the node is locked along x). Animate toward the treeview's target so -- divider drags resize us, and the folder view inherits the final width. function GitPanelView:set_target_size(axis, value) if axis == "x" and treeview then treeview:set_target_size(axis, value) return true end end function GitPanelView:update() if treeview then self:move_towards(self.size, "x", treeview.target_size) end GitPanelView.super.update(self) end -- The panel is split in two independently scrolling sections: the changes list -- on the top half and the history pinned to the bottom half. function GitPanelView:build_changes_rows() local rows = {} for _, e in ipairs(state.changes) do rows[#rows + 1] = { type = "file", entry = e } end if #rows == 0 then rows[#rows + 1] = { type = "empty", label = state.loading and "refreshing..." or (state.branch and "working tree clean" or "not a git repository"), } end return rows end function GitPanelView:build_history_rows() local rows = {} for _, c in ipairs(state.history) do rows[#rows + 1] = { type = "commit", entry = c } end if #rows == 0 then rows[#rows + 1] = { type = "empty", label = state.loading and "refreshing..." or "no commits yet", } end return rows end local function draw_row_hover(x, y, w, rh, hovered) if hovered then local c = { table.unpack(style.treeview_hover_bg or style.line_highlight) } c[4] = 120 renderer.draw_rect(x, y, w, rh, c) end end local function draw_file_row(x, y, w, rh, e, pad) common.draw_text(style.font, status_color(e.st), e.st, nil, x + pad, y, 0, rh) local tx = x + pad + style.font:get_width(e.st) + common.round(6 * SCALE) common.draw_text(style.font, style.treeview_text or style.text, e.path, nil, tx, y, 0, rh) if e.plus or e.minus then local label = string.format("+%d -%d", e.plus or 0, e.minus or 0) common.draw_text(style.font, style.dim, label, nil, x + w - style.font:get_width(label) - pad, y, 0, rh) end end local function draw_commit_row(x, y, rh, e, pad) common.draw_text(style.font, style.dim, e.hash, nil, x + pad, y, 0, rh) local tx = x + pad + style.font:get_width("0000000") + common.round(6 * SCALE) common.draw_text(style.font, style.treeview_text or style.text, e.subject, nil, tx, y, 0, rh) end function GitPanelView:draw() self:draw_background(style.background2) local x, y = self.position.x, self.position.y local w, h = self.size.x, self.size.y local rh = row_height() local pad = common.round(5 * SCALE) local font_h = style.font:get_height() -- history is pinned to the bottom half; snap the divider to a row boundary local footer_h = font_h + common.round(4 * SCALE) local half_rows = math.max(1, math.floor((h - footer_h) / 2 / rh)) local mid = y + half_rows * rh self.mid_y = mid self.changes_rows = self:build_changes_rows() self.history_rows = self:build_history_rows() self.changes_visible = math.max(1, half_rows - 2) -- branch + section headers self.history_visible = math.max(1, half_rows - 1) -- section header self.changes_offset = common.clamp(self.changes_offset, 0, math.max(0, #self.changes_rows - self.changes_visible)) self.history_offset = common.clamp(self.history_offset, 0, math.max(0, #self.history_rows - self.history_visible)) if state.branch then common.draw_text(style.font, style.accent, "branch " .. state.branch, nil, x + pad, y, 0, rh) else common.draw_text(style.font, style.dim, state.loading and "loading..." or "not a git repository", nil, x + pad, y, 0, rh) end common.draw_text(style.font, style.accent, string.format("changes (%d)", #state.changes), nil, x + pad, y + rh, 0, rh) local cy = y + 2 * rh core.push_clip_rect(x, cy, w, mid - cy) for i = self.changes_offset + 1, math.min(#self.changes_rows, self.changes_offset + self.changes_visible) do local row = self.changes_rows[i] local ry = cy + (i - 1 - self.changes_offset) * rh draw_row_hover(x, ry, w, rh, self.hover and self.hover.section == "changes" and self.hover.idx == i) if row.type == "file" then draw_file_row(x, ry, w, rh, row.entry, pad) else common.draw_text(style.font, style.dim, row.label, nil, x + pad, ry, 0, rh) end end core.pop_clip_rect() renderer.draw_rect(x, mid, w, SCALE, style.divider or style.dim) common.draw_text(style.font, style.accent, string.format("history (%d)", #state.history), nil, x + pad, mid, 0, rh) local hy = mid + rh core.push_clip_rect(x, hy, w, y + h - footer_h - hy) for i = self.history_offset + 1, math.min(#self.history_rows, self.history_offset + self.history_visible) do local row = self.history_rows[i] local ry = hy + (i - 1 - self.history_offset) * rh draw_row_hover(x, ry, w, rh, self.hover and self.hover.section == "history" and self.hover.idx == i) if row.type == "commit" then draw_commit_row(x, ry, rh, row.entry, pad) else common.draw_text(style.font, style.dim, row.label, nil, x + pad, ry, 0, rh) end end core.pop_clip_rect() local hint = "s stage - u unstage - a all - c commit - r refresh" common.draw_text(style.font, style.dim, hint, nil, x + pad, y + h - font_h - common.round(2 * SCALE), 0, font_h) end function GitPanelView:on_mouse_moved(px, py, ...) GitPanelView.super.on_mouse_moved(self, px, py, ...) self.mouse.x, self.mouse.y = px, py self.hover = nil local rh = row_height() local top_start = self.position.y + 2 * rh local hist_start = self.mid_y + rh if py >= top_start and py < self.mid_y then local idx = math.floor((py - top_start) / rh) + self.changes_offset + 1 if self.changes_rows[idx] then self.hover = { section = "changes", idx = idx } end elseif py >= hist_start then local idx = math.floor((py - hist_start) / rh) + self.history_offset + 1 if self.history_rows[idx] then self.hover = { section = "history", idx = idx } end end end function GitPanelView:on_mouse_pressed(button, px, py_, clicks) GitPanelView.super.on_mouse_pressed(self, button, px, py_, clicks) local hov = self.hover if hov then local row = hov.section == "changes" and self.changes_rows[hov.idx] or self.history_rows[hov.idx] if row and row.type == "file" and row.entry.st ~= "D" then -- deleted files can't be opened core.root_view:open_doc(core.open_doc(core.project_dir .. PATHSEP .. row.entry.path)) elseif row and row.type == "commit" then show_commit(row.entry.hash, row.entry.subject) end end return true end function GitPanelView:on_mouse_left() GitPanelView.super.on_mouse_left(self) self.hover = nil end function GitPanelView:on_mouse_wheel(dy) GitPanelView.super.on_mouse_wheel(self, dy) local in_history = self.mouse.y >= self.mid_y local rows_n = in_history and #self.history_rows or #self.changes_rows local visible = in_history and self.history_visible or self.changes_visible local offset = in_history and self.history_offset or self.changes_offset offset = common.clamp(offset - dy * 3, 0, math.max(0, rows_n - visible)) if in_history then self.history_offset = offset else self.changes_offset = offset end end -------------------------------------------------------------------------------- -- Actions -------------------------------------------------------------------------------- local panel = nil -- the open GitPanelView instance, if any local function open_scratch(name, text, syn) local doc = Doc() doc:reset() local lines = {} for line in (text .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = line .. "\n" end if #lines == 0 then lines[1] = "\n" end doc.lines = lines doc:set_filename(name, nil) doc:reset_syntax() if syn then doc.syntax = syn end -- else it stays whatever reset_syntax picked core.root_view:open_doc(doc) end -- Commit diffs are highlighted with the official language_diff plugin when it -- is installed (it registers its syntax under the name "Diff"); otherwise this -- fallback covers `git show` output with the same colors. style.syntax.diff_add = style.syntax.diff_add or { common.color "#72b886" } style.syntax.diff_del = style.syntax.diff_del or { common.color "#f36161" } local diff_syntax_fallback = syntax.add({ name = "Git Diff (gitpanel)", patterns = { { pattern = "^commit %x+", type = "function" }, { pattern = "^[%a%-]+: ", type = "keyword" }, { pattern = "^diff %-%-git .*", type = "function" }, { pattern = "^index .*", type = "comment" }, { pattern = "^new .*", type = "comment" }, { pattern = "^deleted .*", type = "comment" }, { pattern = "^similarity index .*", type = "comment" }, { pattern = "^rename .*", type = "comment" }, { pattern = "^@@ .-@@", type = "number" }, { pattern = "^%-%-%- .*", type = "keyword" }, { pattern = "^%+%+%+ .*", type = "keyword" }, { pattern = "^%-.*", type = "diff_del" }, { pattern = "^%+.*", type = "diff_add" }, { pattern = "^ %d+ files? changed.*", type = "function" }, }, symbols = {}, }) local function get_diff_syntax() for _, s in ipairs(syntax.items) do if s.name == "Diff" then return s end end return diff_syntax_fallback end function show_commit(hash, subject) core.add_thread(function() open_scratch("[git] " .. hash .. " " .. (subject or ""), exec { "git", "show", "--no-color", hash }, get_diff_syntax()) end) end local function hovered_file_entry() if not (panel and panel.hover and panel.hover.section == "changes") then return nil end local row = panel.changes_rows[panel.hover.idx] return row and row.type == "file" and row.entry or nil end local function run_on_entry(args_for) local e = hovered_file_entry() if not e or e.untracked and args_for == "unstage" then return end local args if args_for == "stage" then args = { "git", "add", "--", e.path } else args = { "git", "reset", "-q", "HEAD", "--", e.path } end core.add_thread(function() exec(args) refresh() end) end local function panel_active() return panel ~= nil and core.active_view == panel end -------------------------------------------------------------------------------- -- Treeview integration: the panel replaces the folder list in the left pane, -- toggled from the toolbar's git button. The treeview plugin loads after us, -- so require it lazily. The treeview's leaf node is locked (no tab bar is -- drawn there), which makes it a clean container to swap views in. -------------------------------------------------------------------------------- local function treeview_ready() if treeview then return true end if config.plugins.treeview == false then return false end local ok, tv = pcall(require, "plugins.treeview") if ok and tv and tv.node then treeview = tv return true end return false end local function treeview_leaf() return treeview.node:get_node_for_view(treeview) end local function pane_active() if not (treeview and panel) then return false end local leaf = treeview_leaf() return leaf ~= nil and leaf.active_view == panel end local function pane_attach() if not panel then panel = GitPanelView() end local leaf = treeview_leaf() if not leaf:get_view_idx(panel) then panel.size.x = treeview.size.x -- else the locked pane would collapse on us local locked = leaf.locked leaf.locked = nil -- add_view() refuses locked nodes leaf:add_view(panel) -- makes the panel the node's active view and focuses it leaf.locked = locked else leaf:set_active_view(panel) end refresh() end local function pane_detach() local leaf = treeview_leaf() local focused = core.active_view == panel leaf.active_view = treeview -- route the pane back without stealing focus leaf:remove_view(core.root_view.root_node, panel) panel:on_mouse_left() if focused then core.set_active_view(treeview) end end local function open_panel() if panel and core.root_view:get_node_for_view(panel) then refresh() core.set_active_view(panel) return end panel = GitPanelView() refresh() local node = core.root_view:get_active_node() node:split("left", panel) core.set_active_view(panel) end local function close_panel() if not panel then return end local node = core.root_view:get_node_for_view(panel) if node then node:close_view(core.root_view.node, panel) end panel = nil end local function toggle_panel() if treeview_ready() then if pane_active() then pane_detach() else pane_attach() end else if panel_active() then close_panel() else open_panel() end end end command.add(nil, { ["git-panel:toggle"] = function() toggle_panel() end, }) command.add(panel_active, { ["git-panel:stage"] = function() if hovered_file_entry() then run_on_entry("stage") end end, ["git-panel:unstage"] = function() if hovered_file_entry() then run_on_entry("unstage") end end, ["git-panel:stage-all"] = function() core.add_thread(function() exec { "git", "add", "-A" } refresh() end) end, ["git-panel:refresh"] = function() refresh() end, ["git-panel:commit"] = function() local staged = 0 for _, e in ipairs(state.changes) do if e.staged then staged = staged + 1 end end if staged == 0 then core.error("git-panel: nothing staged, press s on changed files first") return end core.command_view:enter("Commit message", function(text) text = text:match("^%s*(.-)%s*$") if text == "" then core.error("git-panel: empty commit message") return end core.add_thread(function() exec { "git", "commit", "-m", text } refresh(function() local left = 0 for _, e in ipairs(state.changes) do if e.staged then left = left + 1 end end if left == 0 then core.log("git-panel: committed: " .. text) else core.error("git-panel: commit failed, staged files remain") end end) end) end) end, }) keymap.add { ["ctrl+shift+g"] = "git-panel:toggle", } keymap.add { ["s"] = "git-panel:stage", ["u"] = "git-panel:unstage", ["a"] = "git-panel:stage-all", ["c"] = "git-panel:commit", ["r"] = "git-panel:refresh", } -------------------------------------------------------------------------------- -- Toolbar button: a git-branch icon next to the treeview toolbar buttons, -- highlighted while the panel replaces the folder list. treeview loads after -- us (plugins load alphabetically), so wait for it in a thread. -------------------------------------------------------------------------------- -- VS Code-style git-branch icon (vscode-codicons, MIT), rasterized offline to -- opaque rect runs on a 34x34 grid; soft = partial-coverage edge cells local GIT_ICON_GRID = 34 local GIT_ICON = { {8,0,3,1},{6,1,7,1},{5,2,3,1},{11,2,3,1},{5,3,2,1},{12,3,2,1},{5,4,1,1}, {13,4,2,3},{4,5,2,1},{5,6,1,1},{5,7,2,1},{12,7,2,1},{21,7,6,1},{6,8,2,1}, {11,8,3,1},{20,8,3,1},{26,8,2,1},{7,9,6,1},{20,9,2,1},{27,9,2,1},{9,10,2,10}, {19,10,2,3},{28,10,1,1},{28,11,2,1},{28,12,1,1},{20,13,2,1},{27,13,2,1}, {20,14,3,1},{26,14,3,1},{21,15,7,1},{23,16,3,1},{23,17,2,1},{22,18,3,1}, {12,19,12,1},{9,20,14,1},{9,21,3,1},{9,22,2,2},{7,24,6,1},{6,25,2,1}, {11,25,3,1},{5,26,2,1},{12,26,2,1},{5,27,1,1},{13,27,2,3},{4,28,2,1}, {5,29,1,1},{5,30,2,1},{12,30,2,1},{5,31,3,1},{11,31,3,1},{6,32,7,1},{8,33,3,1} } local GIT_ICON_SOFT = { {7,0,5,1},{5,1,9,1},{5,2,4,1},{10,2,4,1},{4,3,3,4},{12,3,3,5},{22,6,5,1}, {4,7,4,1},{21,7,7,1},{5,8,9,1},{20,8,9,1},{6,9,7,1},{19,9,3,5},{26,9,4,1}, {7,10,5,1},{27,10,3,4},{8,11,3,8},{20,14,4,1},{25,14,4,1},{20,15,9,1}, {22,16,5,1},{23,17,3,1},{21,18,4,1},{8,19,16,1},{8,20,15,1},{8,21,13,1}, {8,22,4,1},{7,23,5,1},{6,24,7,1},{5,25,9,1},{4,26,4,1},{12,26,3,5}, {4,27,3,4},{5,31,4,1},{10,31,4,1},{5,32,9,1},{7,33,5,1} } local function draw_git_icon(x, y, h, color) local s = h / GIT_ICON_GRID local soft = { table.unpack(color) } soft[4] = (soft[4] or 255) * 0.5 local eps = s / 6 -- hide seams between adjacent runs for _, r in ipairs(GIT_ICON_SOFT) do renderer.draw_rect(x + r[1] * s, y + r[2] * s, r[3] * s + eps, r[4] * s + eps, soft) end for _, r in ipairs(GIT_ICON) do renderer.draw_rect(x + r[1] * s, y + r[2] * s, r[3] * s + eps, r[4] * s + eps, color) end end core.add_thread(function() for _ = 1, 20 do if treeview_ready() then break end coroutine.yield(1) end if not treeview then return end local toolbar = treeview.toolbar if not toolbar then return end -- toolbarview disabled: keybinding still works -- blank symbol: the icon itself is painted by hand below local git_button = { symbol = " ", command = "git-panel:toggle" } table.insert(toolbar.toolbar_commands, git_button) -- widen the pane if the default is too narrow for the extra button treeview:set_target_size("x", math.max(treeview.target_size or 0, toolbar:get_min_width())) -- dim by default, lit on hover, accent while the panel is showing local toolbar_draw = toolbar.draw function toolbar.draw(self) toolbar_draw(self) for item, x, y, w, h in self:each_item() do if item == git_button then local color = pane_active() and style.accent or (self.hovered_item == item and style.text or style.dim) draw_git_icon(x + (w - h) / 2, y, h, color) end end end end) -------------------------------------------------------------------------------- -- Status bar "git button": make gitstatus's branch item open the panel. -- gitstatus loads after us, so poll until its item exists. -------------------------------------------------------------------------------- core.add_thread(function() for _ = 1, 20 do local ok, item = pcall(core.status_view.get_item, core.status_view, "status:git") if ok and item then item.command = "git-panel:toggle" item.tooltip = "git: click to open panel" return end coroutine.yield(1) end -- gitstatus absent: add our own fallback branch button core.status_view:add_item({ name = "git-panel:branch", alignment = StatusView.Item.RIGHT, position = -2, get_item = function() if not state.branch then return {} end return { style.accent, "branch: ", style.text, state.branch } end, on_click = function() command.perform("git-panel:toggle") end, tooltip = "git: click to open panel", separator = core.status_view.separator2, }) end)