[SPACE-LUA] Addon with missing Git commands (history, diff, restore)

git

Git History Module

:bullseye: Description

This module adds full Git integration into SilverBullet / Space-Lua.

It provides:

  • :nine_o_clock: Commit history browser for the current .md file
  • :receipt: View file contents at any commit
  • :magnifying_glass_tilted_left: Compare (diff) between two commits, rendered in Markdown
  • :recycling_symbol: Restore any commit into the active editor buffer
  • :pushpin: View Git status as clean, emoji-enhanced Markdown
  • :page_facing_up: Virtual pages to navigate Git history like normal documents

warning Warning
Depends on:
git CLI
utilities.debug helper


:gear: How It Works

:check_mark: Virtual Pages

The module registers custom pages:

Virtual Page Pattern Purpose
git:<path_hash> Displays file content at a specific commit
diff:<ref1>,<ref2> Shows a diff between two commits
git status Shows Git status in Markdown

The user can navigate to these pages just like regular documents.


:check_mark: Commands

The module adds SilverBullet commands:

Command Action
Git: History Pick a commit and view its content
Git: Diff Pick 2 commits → view diff
Git: Status Display git status
Git: Restore Load an old commit into the editor

Everything is interactive using editor.filterBox() and editor.navigate().


:check_mark: Markdown Rendering

The module converts raw Git output to readable Markdown:

  • :green_circle: additions
  • :red_circle: deletions
  • :blue_square: context lines
  • :new_button: untracked files
  • :yellow_circle: unstaged modifications
  • :orange_circle: staged modifications
  • :repeat_button: renamed files

:puzzle_piece: Code

-- ###########################################################################
-- ## Git History Module (complete, restored)
-- ## Depends on: Utilities.md (utilities.debug), and environment helpers:
-- ##   string.trim, string.split, string.startsWith, shell.run, editor.*, virtualPage.*, command.*
-- ###########################################################################

-- ===========================================================================
-- == Configuration
-- ===========================================================================
local LOG_ENABLE = false

local function log(...)
  if LOG_ENABLE and utilities and utilities.debug then
     -- utilities.debug(table.concat({...}, " "))
  end
end

local source = config.get("history.source")
if source == nil then
  editor.flashNotification("'marp.source' configuration not set", "error")
end

local current_panel_id = "rhs"
local is_panel_visible = false

-- ===========================================================================
-- == Shell Helpers
-- ===========================================================================
---
-- Executes a shell command and returns its stdout.
-- Throws an error on non-zero exit code.
-- @param cmd string
-- @param args table
-- @return string stdout
local function run_shell_command(cmd, args)
  log("Running command:", cmd, table.concat(args or {}, " "))
  local result = shell.run(cmd, args)
  if not result then
    error("shell.run returned nil for: " .. cmd)
  end
  if result.code ~= 0 then
    error("Command failed: " .. cmd .. " " .. table.concat(args or {}, " ") .. "\n" .. (result.stderr or ""))
  end
  return result.stdout or ""
end

-- ===========================================================================
-- == Git Status & Properties
-- ===========================================================================
---
-- Returns true if current working dir is a git repository.
local function is_git_repo()
  local result = run_shell_command("git", { "rev-parse", "--is-inside-work-tree" })
  return string.trim(result) == "true"
end

---
-- Returns true if a file is tracked by git.
-- @param file_path string
local function is_git_tracked(file_path)
  local stdout = run_shell_command("git", { "status", "--porcelain", "--", file_path })
  return not string.startsWith(string.trim(stdout), "??")
end

---
-- Returns true if a file has uncommitted changes.
-- @param file_path string
local function has_uncommitted_changes(file_path)
  local stdout = run_shell_command("git", { "status", "--porcelain", "--", file_path })
  return string.trim(stdout) ~= ""
end

-- ===========================================================================
-- == Git Log & File Access
-- ===========================================================================
---
-- Format git unix timestamp to human string.
-- @param ts number (seconds)
local function format_git_timestamp(ts)
  return os.date("%Y-%m-%d at %H:%M:%S", ts)
end

---
-- Get the newest commit hash for a file.
-- @param file_path string
local function get_newest_version(file_path)
  local stdout = run_shell_command("git", { "log", "-1", "--format=%h", "--", file_path })
  return string.trim(stdout)
end

---
-- Get the contents of a file at a specific commit.
-- @param file_path string (path relative to repo)
-- @param hash string commit hash
local function get_file_contents(file_path, hash)
  log("get_file_contents:", file_path, "at", hash)
  return run_shell_command("git", { "show", hash .. ":" .. file_path })
end

---
-- Get commit history for a given file.
-- Returns a table of entries suitable for editor.filterBox.
-- Each entry: { name, description, ref, type, prefix, timestamp }
-- @param file_path string
local function get_history(file_path)
  log("get_history:", file_path)
  local stdout = run_shell_command("git", { "log", "--format=%h %ct %s", "--", file_path })
  stdout = string.trim(stdout)
  if stdout == "" then
    return {}
  end

  local lines = string.split(stdout, "\n")
  local commits = {}

  for _, line in ipairs(lines) do
    if line and line ~= "" then
      local parts = string.split(line, " ", 3)
      if #parts == 3 then
        local hash = parts[1]
        local ts = tonumber(parts[2]) or 0
        local msg = parts[3] or ""
        table.insert(commits, {
          name        = hash,
          description = msg .. " - " .. format_git_timestamp(ts),
          ref         = string.gsub(file_path, "%.md$", "") .. "_" .. hash,
          type        = "commits",
          prefix      = "⚡",
          timestamp   = ts
        })
      end
    end
  end

  table.sort(commits, function(a, b)
    return (a.timestamp or 0) > (b.timestamp or 0)
  end)

  return commits
end

-- ===========================================================================
-- == Git Status Renderer
-- ===========================================================================
---
---
-- Get and render git status.
local function get_git_status()
  local raw = run_shell_command("git", { "status", "--porcelain" })
  return gitstatus_render(string.trim(raw))
end

-- ===========================================================================
-- == Diff Tools
-- ===========================================================================

---
-- Compute diff between two commits for a given file path.
-- @param hash_old string
-- @param hash_new string
-- @param file_path string (path to file in repo)
local function get_diff_between_commits(hash_old, hash_new, file_path)
  log("get_diff_between_commits:", hash_old, hash_new, file_path)
  local raw = run_shell_command("git", { "diff", "--no-color", hash_old, hash_new, "--", file_path })
  raw = string.trim(raw)
  if raw == "" then
    return "### 🟢 No Differences Found"
  end
  local path=string.gsub(file_path,".md","")
  local old="[[git:"..path.."_"..hash_old.."|".. hash_old .."]]"
  local new="[[git:"..path.."_"..hash_new.."|".. hash_new .."]]"
  return  "### 🔍[["..path.. "]] : diff between " .. old .. " and " .. new .."\n" .."${gitdiff_render(\"".. encoding.base64Encode(raw).."\")}"
end

-- ===========================================================================
-- == Helpers for virtual refs
-- ===========================================================================
---
-- Parse a virtual ref "path_hash" and fetch content.
-- returns { path=..., hash=..., content=... }
local function get_content(ref)
  local data = string.split(ref, "_")
  if #data > 1 then
    local path = data[1]
    local hash = data[2]
    local ok, content = pcall(get_file_contents, path .. ".md", hash)
    if not ok then
      log("get_content error for", ref, content)
      return { path = path, hash = hash, content = nil }
    end
    return { path = path, hash = hash, content = content }
  end
  return { path = nil, hash = nil, content = nil }
end

-- ===========================================================================
-- == Virtual Pages
-- ===========================================================================
virtualPage.define {
  pattern = "git:(.+)",
  run = function(ref)
    local result = get_content(ref).content
    if result == nil then
      editor.flashNotification("Path " .. ref .. " corrupted", "error")
    end
    return result
  end
}

virtualPage.define {
  pattern = "git status",
  run = function()
    return get_git_status()
  end
}

virtualPage.define {
  pattern = "diff:(.+)",
  run = function(ref)
    local data = string.split(ref, ",")
    if #data > 1 then
      local from = get_content(data[1])
      local to = get_content(data[2])
      if not from or not to or not from.hash or not to.hash or not from.path then
        editor.flashNotification("Path " .. ref .. " corrupted", "error")
        return nil
      end
      return get_diff_between_commits(from.hash, to.hash, from.path .. ".md")
    end
    editor.flashNotification("Path " .. ref .. " corrupted", "error")
    return nil
  end
}

-- ===========================================================================
-- == Commands
-- ===========================================================================
---
-- Browse commit history and open a commit.
command.define {
  name = "Git: History",
  run = function()
    local file_path = editor.getCurrentPage() .. ".md"
    local history = get_history(file_path)
    if not history or #history == 0 then
      editor.flashNotification("No git history for " .. file_path, "info")
      return
    end
    local selected = editor.filterBox("📜 Git History", history, "🔍 Select a commit", "Type to search...")
    if selected and selected.ref then
      editor.navigate("git:" .. selected.ref)
    end
  end
}

---
-- Select two commits and show their diff.
command.define {
  name = "Git: Diff",
  run = function()
    local file_path = editor.getCurrentPage() .. ".md"
    local history = get_history(file_path)
    if not history or #history == 0 then
      editor.flashNotification("No git history for " .. file_path, "info")
      return
    end
    local from = editor.filterBox("📜 Git History", history, "🔍 Select 1st commit")
    local to = editor.filterBox("📜 Git History", history, "🔍 Select 2nd commit")
    if from and to and from.ref and to.ref then
      editor.navigate("diff:" .. from.ref .. "," .. to.ref)
    end
  end
}

---
-- Restore a chosen commit to the current buffer.
command.define {
  name = "Git: Restore",
  run = function()
    local file_path = editor.getCurrentPage() .. ".md"
    local history = get_history(file_path)
    if not history or #history == 0 then
      editor.flashNotification("No git history for " .. file_path, "info")
      return
    end
    local selected = editor.filterBox("♻️ Restore", history, "Select commit to restore")
    if not selected or not selected.ref then
      return
    end
    local data = get_content(selected.ref)
    if not data or not data.content then
      editor.flashNotification("Could not restore this commit", "error")
      return
    end
    editor.setText(data.content)
    editor.flashNotification("Commit restored: " .. (data.hash or "unknown"), "success")
  end
}

---
-- Show git status in a virtual page.
command.define {
  name = "Git: Status",
  run = function()
    editor.navigate("git status")
  end
}

Renderers

-- Render `git status --porcelain` into readable Markdown.
-- Uses emojis to indicate status.
function gitstatus_render(raw)
  if not raw or raw == "" then
    return "### 🟢 Clean Working Tree\nNo changes."
  end

  local md = { "### 📌 Git Status\n" }

  for line in raw:gmatch("[^\r\n]+") do
    local code = line:sub(1, 2)
    local path = string.trim(line:sub(3) or "")

    if code == "??" then
      table.insert(md, "🆕 Untracked: " .. path)
    elseif code == " M" then
      table.insert(md, "🟡 Modified (unstaged): " .. path)
    elseif code == "M " then
      table.insert(md, "🟠 Modified (staged): " .. path)
    elseif code == " D" then
      table.insert(md, "🔴 Deleted (unstaged): " .. path)
    elseif code == "D " then
      table.insert(md, "🛑 Deleted (staged): " .. path)
    elseif code == "A " or code == " A" then
      table.insert(md, "🟢 Added: " .. path)
    elseif code == "R " or code == " R" then
      table.insert(md, "🔁 Renamed: " .. path)
    elseif code == "C " or code == " C" then
      table.insert(md, "📄 Copied: " .. path)
    else
      table.insert(md, "🟦 " .. code .. " " .. path)
    end
  end

  return table.concat(md, "\n")
end


-- Render raw git diff.
function gitdiff_render(base64Text)
  local diffText= encoding.utf8Decode(encoding.base64Decode(base64Text))
  local lines = string.split(diffText, "\n")  
  local html = '<pre class="git-diff">'  
    
  for _, line in ipairs(lines) do  
    if string.startsWith(line, "---") or string.startsWith(line, "+++") then  
      html = html .. '<div class="diff-header">' .. line .. '</div>'  
    elseif string.startsWith(line, "@@") then  
      html = html .. '<div class="diff-hunk">' .. line .. '</div>'  
    elseif string.startsWith(line, "-") then  
      html = html .. '<div class="diff-delete">' .. line .. '</div>'  
    elseif string.startsWith(line, "+") then  
      html = html .. '<div class="diff-add">' .. line .. '</div>'  
    else  
      html = html .. '<div class="diff-context">' .. line .. '</div>'  
    end  
  end  
  html = html..'</pre>'  
  return widget.htmlBlock(html)
end

CSS

.git-diff {  
  font-family: monospace;  
  margin: 0;  
}  
.diff-header { color: #888; font-weight: bold; }  
.diff-hunk { color: #0066cc; }  
.diff-delete { background-color: #ffdddd; color: #cc0000; }  
.diff-add { background-color: #ddffdd; color: #00cc00; }  
.diff-context { color: inherit; }

2 Likes

I have not published this script in my library because I have not migrated to new library manager and I think that it will be interesting to integrate those features into a lua git plugin like @zef or @ChenZhu-Xie to have a full-featured git plugins.

What do you think?

PD: I’m not totally satisfied by renders but it’s currently correct.

This is very cool looking!

Yeah, we can take two paths here, either we try to create the ultimate Git library, or we all contribute our own parts.

I’m more tempted to each have our own, allowing us freedom what to add and when, but ideally not overlapping too much in functionality. I will be archiving the GitHub - silverbulletmd/silverbullet-libraries: Awesome SilverBullet libraries repo I think, and publish some libraries (like the basic Git one) under my own name here: GitHub - zefhemel/silverbullet-libraries: My collection of libraries and plugs

You can do the same, and we can create a curated list of them in a repository (or you in your own repo).

2 Likes
virtualPage.define {
  pattern = "diff:(.+)",
  run = function(ref)
    local data = string.split(ref, ",")
    if #data > 1 then
      local from = get_content(data[1])
      local to = get_content(data[2])
      if not from or not to or not from.hash or not to.hash or not from.path then
        editor.flashNotification("Path " .. ref .. " corrupted", "error")
        return nil
      end
      return get_diff_between_commits(from.hash, to.hash, from.path .. ".md")
    end
    editor.flashNotification("Path " .. ref .. " corrupted", "error")
    return nil
  end
}

local function diff_render(diffText)
   local lines = string.split(diffText, "\n")  
  local html = '<pre class="git-diff">'  
    
  for _, line in ipairs(lines) do  
    if string.startsWith(line, "---") or string.startsWith(line, "+++") then  
      html = html .. '<div class="diff-header">' .. line .. '</div>'  
    elseif string.startsWith(line, "@@") then  
      html = html .. '<div class="diff-hunk">' .. line .. '</div>'  
    elseif string.startsWith(line, "-") then  
      html = html .. '<div class="diff-delete">' .. line .. '</div>'  
    elseif string.startsWith(line, "+") then  
      html = html .. '<div class="diff-add">' .. line .. '</div>'  
    else  
      html = html .. '<div class="diff-context">' .. line .. '</div>'  
    end  
  end  
    
  html = html..'</pre>'  
  return widget.htmlBlock(html)
end

if get_diff_between_commits return a widget, SB returns a strange error ("t.doc || "").split is not a function. Is it a bug?
widget is working calling it directly
virtualPage supports widget? @zef

The run function has to return a plain markdown string. In this markdown you can include ${...} again.

1 Like

git diff renderer works fine ! Thanks @zef

1 Like

To install:
Navigate to your Library Manager inside Silverbullet
Add my repository: silverbullet-libraries/Repository/Malys.md at main · malys/silverbullet-libraries · GitHub
Add any script my repository

1 Like

This does look great, but unfortunately I can’t install it. I am able to add your repo without problems, but all Libs I tried to install fail. with error:
“Lua error: Could not fetch: github:malys/silverbullet-libraries/GitHistory.md”
(or similar, depending in filename).

I tried with most recent SB Version (SilverBullet 2.2.1-52-g8c34b35e). And e.g. I can install Libs from Mr.Red without problem. So I assume, there might be an error in your github structure? Not sure…

Nevermind. Just found out, Xiè Chén-Zhú posted the solution in the “md Table render” thread :slight_smile:

GIT PLUG all functioning on linux machine:

but on windows machine, most git cmds failed for

Failed to navigate: Cannot read properties of undefined (reading 'lastModified')

I think this is a rather common error, although I haven’t located it yet -_-||

Could you investigate and propose me a patch to integrate?

Of course, I will further investigate where the problem lies.

the issue seems to originate here:

-- Browse commit history and open a commit.
command.define {
  name = "Git: History",
  run = function()
    local file_path = editor.getCurrentPage() .. ".md"
    local history = get_history(file_path)
    if not history or #history == 0 then
      editor.flashNotification("No git history for " .. file_path, "info")
      return
    end
    local selected = editor.filterBox("📜 Git History", history, "🔍 Select a commit", "Type to search...")
    if selected and selected.ref then
      -- editor.flashNotification(selected.ref)
      editor.navigate("git:" .. selected.ref) -- <=========
    end
  end
}

that is here:

${editor.navigate("git:Library/Malys/GitHistory_930bab41")}

thus here:

virtualPage.define {
  pattern = "git:(.+)",
  run = function(ref)
    -- print("dddddddddd") <============  however I cannot even get this
    -- editor.flashNotification("dddddddddd") -- <=========  nothing happened
    local result = get_content(ref).content
    if result == nil then
      editor.flashNotification("Path " .. ref .. " corrupted", "error")
    end
    return result
  end
}

Strange thing is that I can get neither print from console, nor flashNotification from editor, when I open git:Library/Malys/GitHistory_930bab41 as a VirtualPage…

the console:

[Service Worker] No local copy of git:Library/Malys/GitHistory_930bab41.md proxying to server
injected.js:1  
GET http://127.0.0.1:3000/.fs/git%3ALibrary/Malys/GitHistory_930bab41.md 500 (Internal Server Error)
client.js?v=cache-1762962938998:29 
[Client] Now navigating to Library/Malys/GitHistory

My guess is it’s the file path, so simply: :gsub("/", "\\") enough? But somehow, it didn’t work -_-||.

Emm, better we communicate through DMs?

I found the problem.

not your plugin’s problem,

but SilverBullet’s problem:

virtual pages whose name contains / cannot be properly parsed on windows machine:

like the virtual page tag:meta/api from the tag #meta/api below

time to raise a github issue -_-||


[UPDATE]

ON SilverBullet 2.3.0-0-g21ddcb4, problem related to virtual pages containing / has been fixed, which means pages that contains folder structure is now able to be git: historyed.

however, git: history acts on pages that contains _ will raise problem: Path CONFIG/Paste_as/Cursor_Anchor_79320637 corrupted both on linux and windows.

here’s the fixed code:

-- ###########################################################################
-- ## Git History Module (Fixed for paths with underscores)
-- ## Depends on: Utilities.md (utilities.debug), and environment helpers:
-- ##   string.trim, string.split, string.startsWith, shell.run, editor.*, virtualPage.*, command.*
-- ###########################################################################

-- ===========================================================================
-- == Configuration
-- ===========================================================================
local LOG_ENABLE = false

local function log(...)
  if LOG_ENABLE and utilities and utilities.debug then
     if type(utilities.debug) == "function" then 
       utilities.debug(table.concat({...}, " "))
     end  
  end
end

-- ===========================================================================
-- == Shell Helpers
-- ===========================================================================
---
-- Executes a shell command and returns its stdout.
-- Throws an error on non-zero exit code.
-- @param cmd string
-- @param args table
-- @return string stdout
local function run_shell_command(cmd, args)
  log("Running command:", cmd, table.concat(args or {}, " "))
  local result = shell.run(cmd, args)
  if not result then
    error("shell.run returned nil for: " .. cmd)
  end
  if result.code ~= 0 then
    error("Command failed: " .. cmd .. " " .. table.concat(args or {}, " ") .. "\n" .. (result.stderr or ""))
  end
  return result.stdout or ""
end

-- ===========================================================================
-- == Git Status & Properties
-- ===========================================================================
---
-- Returns true if current working dir is a git repository.
local function is_git_repo()
  local result = run_shell_command("git", { "rev-parse", "--is-inside-work-tree" })
  return string.trim(result) == "true"
end

---
-- Returns true if a file is tracked by git.
-- @param file_path string
local function is_git_tracked(file_path)
  local stdout = run_shell_command("git", { "status", "--porcelain", "--", file_path })
  return not string.startsWith(string.trim(stdout), "??")
end

---
-- Returns true if a file has uncommitted changes.
-- @param file_path string
local function has_uncommitted_changes(file_path)
  local stdout = run_shell_command("git", { "status", "--porcelain", "--", file_path })
  return string.trim(stdout) ~= ""
end

-- ===========================================================================
-- == Git Log & File Access
-- ===========================================================================
---
-- Format git unix timestamp to human string.
-- @param ts number (seconds)
local function format_git_timestamp(ts)
  return os.date("%Y-%m-%d at %H:%M:%S", ts)
end

---
-- Get the newest commit hash for a file.
-- @param file_path string
local function get_newest_version(file_path)
  local stdout = run_shell_command("git", { "log", "-1", "--format=%h", "--", file_path })
  return string.trim(stdout)
end

---
-- Get the contents of a file at a specific commit.
-- @param file_path string (path relative to repo)
-- @param hash string commit hash
local function get_file_contents(file_path, hash)
  log("get_file_contents:", file_path, "at", hash)
  return run_shell_command("git", { "show", hash .. ":" .. file_path })
end

---
-- Get commit history for a given file.
-- Returns a table of entries suitable for editor.filterBox.
-- Each entry: { name, description, ref, type, prefix, timestamp }
-- @param file_path string
local function get_history(file_path)
  log("get_history:", file_path)
  local stdout = run_shell_command("git", { "log", "--format=%h %ct %s", "--", file_path })
  stdout = string.trim(stdout)
  if stdout == "" then
    return {}
  end

  local lines = string.split(stdout, "\n")
  local commits = {}

  for _, line in ipairs(lines) do
    if line and line ~= "" then
      local parts = string.split(line, " ", 3)
      if #parts == 3 then
        local hash = parts[1]
        local ts = tonumber(parts[2]) or 0
        local msg = parts[3] or ""
        table.insert(commits, {
          name        = hash,
          description = msg .. " - " .. format_git_timestamp(ts),
          -- Note: The ref format is "path_hash". We rely on get_content to parse this correctly.
          ref         = string.gsub(file_path, "%.md$", "") .. "_" .. hash,
          type        = "commits",
          prefix      = "⚡",
          timestamp   = ts
        })
      end
    end
  end

  table.sort(commits, function(a, b)
    return (a.timestamp or 0) > (b.timestamp or 0)
  end)

  return commits
end

-- ===========================================================================
-- == Git Status Renderer
-- ===========================================================================
---
---
-- Get and render git status.
local function get_git_status()
  local raw = run_shell_command("git", { "status", "--porcelain" })
  return gitstatus_render(string.trim(raw))
end

-- ===========================================================================
-- == Diff Tools
-- ===========================================================================

---
-- Compute diff between two commits for a given file path.
-- @param hash_old string
-- @param hash_new string
-- @param file_path string (path to file in repo)
local function get_diff_between_commits(hash_old, hash_new, file_path)
  log("get_diff_between_commits:", hash_old, hash_new, file_path)
  local raw = run_shell_command("git", { "diff", "--no-color", hash_old, hash_new, "--", file_path })
  raw = string.trim(raw)
  if raw == "" then
    return "### 🟢 No Differences Found"
  end
  local path=string.gsub(file_path,".md","")
  local old="[[git:"..path.."_"..hash_old.."|".. hash_old .."]]"
  local new="[[git:"..path.."_"..hash_new.."|".. hash_new .."]]"
  return  "### 🔍[["..path.. "]] : diff between " .. old .. " and " .. new .."\n" .."${gitdiff_render(\"".. encoding.base64Encode(raw).."\")}"
end

-- ===========================================================================
-- == Helpers for virtual refs
-- ===========================================================================
---
-- Parse a virtual ref "path_hash" and fetch content.
-- returns { path=..., hash=..., content=... }
local function get_content(ref)
  -- FIX: Use string.match instead of string.split.
  -- Pattern "^(.*)_(.*)$":
  --   ^(.*) : Greedily matches everything from the start (swallowing underscores in filenames).
  --   _     : Matches the LAST underscore (because the first capture was greedy).
  --   (.*)$ : Matches everything after the last underscore (the hash).
  local path, hash = string.match(ref, "^(.*)_(.*)$")
  
  if path and hash then
    local ok, content = pcall(get_file_contents, path .. ".md", hash)
    if not ok then
      log("get_content error for", ref, content)
      return { path = path, hash = hash, content = nil }
    end
    return { path = path, hash = hash, content = content }
  end
  
  -- Fallback for safety, though likely not needed with the regex above
  return { path = nil, hash = nil, content = nil }
end

-- ===========================================================================
-- == Virtual Pages
-- ===========================================================================
virtualPage.define {
  pattern = "git:(.+)",
  run = function(ref)
    local result = get_content(ref).content
    if result == nil then
      editor.flashNotification("Path " .. ref .. " corrupted", "error")
    end
    return result
  end
}

virtualPage.define {
  pattern = "git status",
  run = function()
    return get_git_status()
  end
}

virtualPage.define {
  pattern = "diff:(.+)",
  run = function(ref)
    -- Note: This splits by comma to separate the two commit refs.
    -- If your FILENAME contains a comma, this will still break.
    -- But the underscore issue is fixed by get_content.
    local data = string.split(ref, ",")
    if #data > 1 then
      local from = get_content(data[1])
      local to = get_content(data[2])
      if not from or not to or not from.hash or not to.hash or not from.path then
        editor.flashNotification("Path " .. ref .. " corrupted", "error")
        return nil
      end
      return get_diff_between_commits(from.hash, to.hash, from.path .. ".md")
    end
    editor.flashNotification("Path " .. ref .. " corrupted", "error")
    return nil
  end
}

-- ===========================================================================
-- == Commands
-- ===========================================================================
---
-- Browse commit history and open a commit.
command.define {
  name = "Git: History",
  run = function()
    local file_path = editor.getCurrentPage() .. ".md"
    local history = get_history(file_path)
    if not history or #history == 0 then
      editor.flashNotification("No git history for " .. file_path, "info")
      return
    end
    local selected = editor.filterBox("📜 Git History", history, "🔍 Select a commit", "Type to search...")
    if selected and selected.ref then
      editor.navigate("git:" .. selected.ref)
    end
  end
}

---
-- Select two commits and show their diff.
command.define {
  name = "Git: Diff",
  run = function()
    local file_path = editor.getCurrentPage() .. ".md"
    local history = get_history(file_path)
    if not history or #history == 0 then
      editor.flashNotification("No git history for " .. file_path, "info")
      return
    end
    local from = editor.filterBox("📜 Git History", history, "🔍 Select 1st commit")
    local to = editor.filterBox("📜 Git History", history, "🔍 Select 2nd commit")
    if from and to and from.ref and to.ref then
      editor.navigate("diff:" .. from.ref .. "," .. to.ref)
    end
  end
}

---
-- Restore a chosen commit to the current buffer.
command.define {
  name = "Git: Restore",
  run = function()
    local file_path = editor.getCurrentPage() .. ".md"
    local history = get_history(file_path)
    if not history or #history == 0 then
      editor.flashNotification("No git history for " .. file_path, "info")
      return
    end
    local selected = editor.filterBox("♻️ Restore", history, "Select commit to restore")
    if not selected or not selected.ref then
      return
    end
    local data = get_content(selected.ref)
    if not data or not data.content then
      editor.flashNotification("Could not restore this commit", "error")
      return
    end
    editor.setText(data.content)
    editor.flashNotification("Commit restored: " .. (data.hash or "unknown"), "success")
  end
}

---
-- Show git status in a virtual page.
command.define {
  name = "Git: Status",
  run = function()
    editor.navigate("git status")
  end
}

1 Like