feat: public dotfiles tier — no credential, no identity, one installer
Fresh history. This is the repo a throwaway VM clones anonymously: it brings a
machine to a working baseline and carries nothing that makes it mine.
56 files. 50 land in $HOME, 3 are chezmoi metadata, 2 are repo documentation,
1 is the manifest, and a 15-file test harness stays behind in .tests/.
What did not travel, and why:
encrypted_private_bws-token.age a real credential; age is dropped entirely
.chezmoidata/bws.toml env-var -> secret-id map; belongs with the
tier that can use it
SECRETS.md documentation of the rules, not config
finish-setup.sh.tmpl superseded by dotup
nvim/init.lua.backup dead file
dot_claude/**, dot_codex/**, 120 files of agent config, private tier
dot_pi/**
De-identified rather than dropped:
.gitconfig [user], the GitHub ssh rewrite and both Gitea host rewrites are
identity, not configuration. They move behind an [include] of
~/.config/git/config.local, which the private tier writes. Git
treats a missing include as a no-op, so a public-only machine
reads the file and stops.
.zshrc the two gitea aliases carried a personal domain and a LAN IP.
They move behind a guarded source of ~/.config/zsh/local.zsh,
the sibling of the secrets.zsh seam phase 2 established.
nvim a commented-out LM Studio endpoint naming a LAN address.
ghostty a stale auto-generated header naming an absolute home directory.
Newly captured, never tracked before: ~/.zshenv, ~/.config/gh/config.yml. The
former sourced ~/.cargo/env unguarded, so every zsh on a machine without rustup
printed an error -- the same shape as the unguarded oh-my-zsh source phase 2
fixed. It is guarded now.
.chezmoiexternal.toml grows from one entry to six. oh-my-zsh, powerlevel10k,
zsh-autosuggestions, zsh-ai and tpm were hand-installed and declared nowhere,
which is why `chezmoi init --apply` on a clean box produced a .zshrc that broke
the shell it configures. The theme and both plugins nest under
.oh-my-zsh/custom/, which is what $ZSH_CUSTOM resolves to.
dotup gains an install engine. It resolves each selected package to a channel
(apt, brew, npm, uv, snap, deb, flatpak, tarball, script, builtin) through one
function every consumer reads, probes apt-cache before batching so a name apt
does not know moves to brew instead of failing all thirty, and retries
individually if a batch still fails -- which earned its keep on the first real
container run, where mermaid-cli's puppeteer dependency failed and the other
twelve npm packages installed anyway. --unattended computes safe defaults fresh
from the manifest rather than inheriting a state file, and refuses private and
invasive rows outright even when a stale state file ticks them.
The manifest gains @spec, a second directive kind alongside @needs, carrying the
argument a channel needs but a package name cannot supply -- the scoped npm
name, the flatpak app id, the .deb source. The TSV stays five columns wide.
Three bugs the container runs found, all fixed here:
* `apt install nodejs` gives you node WITHOUT npm on Ubuntu, so all thirteen
npm packages failed on a fresh box. The manifest asks apt for both names.
* A tool installed a moment ago is not on this process's PATH -- uv lands in
~/.local/bin, npm -g honours the ~/.npmrc prefix, linuxbrew is outside a
non-login PATH. Resolved by looking in the places we just wrote to, never by
exporting a modified PATH.
* `A || { B && C; }` is one || list, so when `command -v sudo` failed the list
failed and `set -e` killed dotup at load. On a non-root machine with no
sudo it died before printing anything. There is a regression test.
.zshenv and .p10k.zsh are marked private_. Both are shell code the login shell
executes and both applied at 664, group-writable. Third occurrence of the class
of bug phase 1 found on .pi/agent/auth.json and phase 2 found on .zshrc; the
first one found on purpose rather than by accident.
Verification: 81 assertions, 81/81 on this box and in ubuntu:24.04, ubuntu:22.04
and debian:12. The installer is driven against a directory of fake package
managers that record what they were asked to do and install nothing, so the
engine is exercised end to end without a package landing on the test machine.
`gitleaks detect` over the full history and the working tree: no leaks found,
with no allowlist and no .gitleaks.toml.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,861 @@
|
||||
-- Markdown rendering with inline mermaid via md-render.nvim.
|
||||
--
|
||||
-- Why not snacks.image / diagram.nvim / image.nvim?
|
||||
-- Those all try to overlay the rendered image on top of the source fenced
|
||||
-- code block. snacks issue #1384 ("mermaid blocks text behind it") was
|
||||
-- closed as "not planned" — overlap-in-source is a fundamental limitation
|
||||
-- of the current kitty-graphics-protocol design in nvim.
|
||||
--
|
||||
-- md-render takes the opposite approach: render mode is a *separate buffer*.
|
||||
-- Normal mode -> auto-swap to render (full diagrams, no source text in the way).
|
||||
-- Insert mode -> swap back to source so we can edit. The hop is per-buffer.
|
||||
--
|
||||
-- Ghostty itself is verified by the plugin; the SSH transport shim below is
|
||||
-- local glue so the terminal never has to read remote-only image paths.
|
||||
local function use_direct_kitty_bytes()
|
||||
return true
|
||||
end
|
||||
|
||||
local function image_debug(message)
|
||||
if vim.env.MD_RENDER_IMAGE_DEBUG ~= "1" then return end
|
||||
local path = vim.fn.stdpath("cache") .. "/md-render-image-debug.log"
|
||||
local line = os.date("%Y-%m-%d %H:%M:%S") .. " " .. message .. "\n"
|
||||
vim.fn.writefile({ line }, path, "a")
|
||||
end
|
||||
|
||||
return {
|
||||
{
|
||||
"delphinus/md-render.nvim",
|
||||
version = "*",
|
||||
ft = { "markdown" },
|
||||
dependencies = {
|
||||
{ "nvim-tree/nvim-web-devicons", version = "*" },
|
||||
},
|
||||
init = function()
|
||||
-- Ghostty does not normally forward TERM_PROGRAM /
|
||||
-- GHOSTTY_RESOURCES_DIR into the remote shell. Neovim 0.12 therefore
|
||||
-- cannot prove Kitty graphics support from the remote side on its own.
|
||||
-- Herdr panes expose HERDR_ENV=1, but the underlying display path is
|
||||
-- still Ghostty-compatible when kitty_graphics is enabled.
|
||||
--
|
||||
-- Keep the terminal identity as Ghostty. md-render uses that identity to
|
||||
-- avoid Ghostty's flaky stored-image re-placement path during redraws.
|
||||
if use_direct_kitty_bytes() and not vim.env.TERM_PROGRAM then
|
||||
vim.env.TERM_PROGRAM = "ghostty"
|
||||
end
|
||||
|
||||
-- In auto mode the render view is a separate, nomodifiable buffer, so
|
||||
-- normal-mode editing (dd/x/ciw/p/…) does nothing — you'd have to toggle
|
||||
-- to source first. md-render already solves this for insert-entry keys
|
||||
-- (i/I/a/A/o/O): pressing one hops to the source at the mapped line and
|
||||
-- replays the key (see AUTO_INSERT_KEYS in the plugin). It intentionally
|
||||
-- leaves operator/normal-mode keys alone.
|
||||
--
|
||||
-- We extend that same hop to editing keys, scoped buffer-locally to the
|
||||
-- render buffer (ft=md-render) only — the real source buffer is never
|
||||
-- touched. Press dd/x/p/etc. while viewing the render and it transparently
|
||||
-- drops to source on the right line and performs the edit. (Pure-motion
|
||||
-- keys h/j/k/w/G/… are deliberately NOT mapped, so you can still navigate
|
||||
-- the render freely.) Yank keys stay in the read-only render buffer so
|
||||
-- copying text does not dismiss rendered diagrams. Visual-entry keys hop
|
||||
-- to source so the resulting selection can be changed safely.
|
||||
-- NOTE: the plugin sets the render buffer's filetype under
|
||||
-- `eventignore=all` (preview.lua, so markdown ftplugins/treesitter don't
|
||||
-- clobber the pre-rendered content), so a `FileType md-render` autocmd
|
||||
-- never fires. Hook `BufEnter` instead — it fires when `toggle` swaps the
|
||||
-- render buffer into the window — and guard on filetype + a buffer flag so
|
||||
-- the maps install exactly once per render buffer.
|
||||
local HOP_KEYS = {
|
||||
"d", "D", "c", "C", "x", "X", "s", "S", "r", "R",
|
||||
"p", "P", "J", ">", "<", "=", "u", "v", "V", ".",
|
||||
}
|
||||
local hop_group = vim.api.nvim_create_augroup("MdRenderEditHop", { clear = true })
|
||||
vim.api.nvim_create_autocmd({ "BufEnter", "BufWinEnter" }, {
|
||||
group = hop_group,
|
||||
desc = "md-render: editing keys hop to source then replay",
|
||||
callback = function(ev)
|
||||
if vim.bo[ev.buf].filetype ~= "md-render" then return end
|
||||
if vim.b[ev.buf].md_render_hop_installed then return end
|
||||
vim.b[ev.buf].md_render_hop_installed = true
|
||||
|
||||
for _, key in ipairs(HOP_KEYS) do
|
||||
vim.keymap.set("n", key, function()
|
||||
-- Preserve a leading count and explicit register (e.g. 3dd, "ayy)
|
||||
-- so the replayed operator behaves like the keystroke you typed.
|
||||
local count = vim.v.count
|
||||
local reg = vim.v.register
|
||||
local prefix = (reg and reg ~= '"') and ('"' .. reg) or ""
|
||||
if count > 0 then prefix = prefix .. tostring(count) end
|
||||
|
||||
-- toggle() is synchronous: on return the window already shows the
|
||||
-- source buffer with the cursor on the mapped line. Feed the key
|
||||
-- *synchronously* (no vim.schedule) so it enters the typeahead
|
||||
-- before any follow-up keystroke. That keeps multi-key operators
|
||||
-- in order — `d` becomes operator-pending and your next key (e.g.
|
||||
-- the second `d`, or `w`, `ip`) completes it natively in source.
|
||||
-- (The plugin schedules its insert-entry hop because those are
|
||||
-- single keys that immediately switch to Insert mode; operators
|
||||
-- need the ordering guarantee a sync feed provides.)
|
||||
require("md-render").preview.toggle()
|
||||
if key == "v" or key == "V" then
|
||||
-- Enter Visual mode before the user's next queued key arrives.
|
||||
-- feedkeys() would put v/V behind that key, turning `Vd` into
|
||||
-- `dV`; :normal! establishes the selection synchronously.
|
||||
local visual_count = count > 0 and tostring(count) or ""
|
||||
vim.cmd("normal! " .. visual_count .. key)
|
||||
else
|
||||
vim.api.nvim_feedkeys(prefix .. key, "n", false)
|
||||
end
|
||||
end, {
|
||||
buffer = ev.buf,
|
||||
noremap = true,
|
||||
silent = true,
|
||||
desc = "md-render: hop to source then " .. key,
|
||||
})
|
||||
end
|
||||
end,
|
||||
})
|
||||
end,
|
||||
keys = {
|
||||
{
|
||||
"<leader>mp",
|
||||
function() require("md-render").preview.show({ max_width = 140 }) end,
|
||||
ft = "markdown",
|
||||
desc = "Markdown preview (wide float toggle)",
|
||||
},
|
||||
{
|
||||
"<leader>mt",
|
||||
function() require("md-render").preview.show_tab({ max_width = 140 }) end,
|
||||
ft = "markdown",
|
||||
desc = "Markdown preview (wide tab toggle)",
|
||||
},
|
||||
{
|
||||
"<leader>ms",
|
||||
function() require("md-render").preview.split({ max_width = 140 }) end,
|
||||
ft = "markdown",
|
||||
desc = "Markdown source/render split",
|
||||
},
|
||||
{
|
||||
"<leader>mr",
|
||||
function() require("md-render").preview.toggle({ max_width = 140 }) end,
|
||||
ft = "markdown",
|
||||
desc = "Markdown render toggle in-place",
|
||||
},
|
||||
{ "<leader>md", "<Plug>(md-render-demo)", desc = "Markdown render demo" },
|
||||
},
|
||||
config = function()
|
||||
local function patch_mermaid_rendering()
|
||||
local img = require("md-render.image")
|
||||
if img._thembones_mermaid_patch then return end
|
||||
img._thembones_mermaid_patch = true
|
||||
|
||||
local MERMAID_SCALE = "4"
|
||||
local MERMAID_ROWS = 45
|
||||
local original_calc_display_size = img.calc_display_size
|
||||
|
||||
img.calc_display_size = function(img_w, img_h, max_cols, max_rows)
|
||||
if max_rows == 25 then
|
||||
max_rows = MERMAID_ROWS
|
||||
end
|
||||
return original_calc_display_size(img_w, img_h, max_cols, max_rows)
|
||||
end
|
||||
|
||||
local function mermaid_cache_dir()
|
||||
local dir = vim.fn.stdpath("cache") .. "/md-render/mermaid"
|
||||
vim.fn.mkdir(dir, "p")
|
||||
return dir
|
||||
end
|
||||
|
||||
local function mermaid_theme_args()
|
||||
local bg = vim.o.background
|
||||
local hl = vim.api.nvim_get_hl(0, { name = "NormalFloat", link = false })
|
||||
if not hl.bg then
|
||||
hl = vim.api.nvim_get_hl(0, { name = "Normal", link = false })
|
||||
end
|
||||
local bg_color = hl.bg
|
||||
if bg == "dark" then
|
||||
return "dark", bg_color and string.format("#%06x", bg_color) or "#1e1e2e"
|
||||
end
|
||||
return "default", bg_color and string.format("#%06x", bg_color) or "#ffffff"
|
||||
end
|
||||
|
||||
local function mermaid_cache_path(source)
|
||||
local theme, bg_hex = mermaid_theme_args()
|
||||
local key = table.concat({ source, theme, bg_hex, "scale=" .. MERMAID_SCALE }, "|")
|
||||
local hash = vim.fn.sha256(key):sub(1, 16)
|
||||
return mermaid_cache_dir() .. "/" .. hash .. ".png"
|
||||
end
|
||||
|
||||
local function mmdc_cmd(input_path, output_path)
|
||||
local cmd
|
||||
if vim.fn.executable("mmdc") == 1 then
|
||||
cmd = { "mmdc" }
|
||||
elseif vim.fn.executable("npx") == 1 then
|
||||
cmd = { "npx", "-y", "@mermaid-js/mermaid-cli" }
|
||||
else
|
||||
return nil
|
||||
end
|
||||
|
||||
local theme, bg_hex = mermaid_theme_args()
|
||||
vim.list_extend(cmd, {
|
||||
"-i", input_path,
|
||||
"-o", output_path,
|
||||
"-t", theme,
|
||||
"-b", bg_hex,
|
||||
"-s", MERMAID_SCALE,
|
||||
})
|
||||
return cmd
|
||||
end
|
||||
|
||||
img.get_mermaid_cached = function(source)
|
||||
local path = mermaid_cache_path(source)
|
||||
if vim.fn.filereadable(path) == 1 then
|
||||
return path
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
img.render_mermaid = function(source)
|
||||
local cache_path = mermaid_cache_path(source)
|
||||
if vim.fn.filereadable(cache_path) == 1 then
|
||||
return cache_path
|
||||
end
|
||||
|
||||
local tmp_input = vim.fn.tempname() .. ".mmd"
|
||||
local f = io.open(tmp_input, "w")
|
||||
if not f then return nil end
|
||||
f:write(source)
|
||||
f:close()
|
||||
|
||||
local cmd = mmdc_cmd(tmp_input, cache_path)
|
||||
if not cmd then
|
||||
os.remove(tmp_input)
|
||||
return nil
|
||||
end
|
||||
|
||||
vim.system(cmd, { text = true, timeout = 30000 }):wait()
|
||||
os.remove(tmp_input)
|
||||
if vim.fn.filereadable(cache_path) == 1 then
|
||||
return cache_path
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Callbacks per in-flight cache path, so concurrent requests for the
|
||||
-- same diagram share one mmdc process instead of racing on the output.
|
||||
local mermaid_inflight = {}
|
||||
|
||||
img.render_mermaid_async = function(source, callback)
|
||||
local cache_path = mermaid_cache_path(source)
|
||||
if vim.fn.filereadable(cache_path) == 1 then
|
||||
callback(cache_path)
|
||||
return
|
||||
end
|
||||
|
||||
if mermaid_inflight[cache_path] then
|
||||
table.insert(mermaid_inflight[cache_path], callback)
|
||||
return
|
||||
end
|
||||
mermaid_inflight[cache_path] = { callback }
|
||||
|
||||
local function finish(result)
|
||||
local callbacks = mermaid_inflight[cache_path]
|
||||
mermaid_inflight[cache_path] = nil
|
||||
for _, cb in ipairs(callbacks) do
|
||||
cb(result)
|
||||
end
|
||||
end
|
||||
|
||||
local tmp_input = vim.fn.tempname() .. ".mmd"
|
||||
local f = io.open(tmp_input, "w")
|
||||
if not f then
|
||||
finish(nil)
|
||||
return
|
||||
end
|
||||
f:write(source)
|
||||
f:close()
|
||||
|
||||
local cmd = mmdc_cmd(tmp_input, cache_path)
|
||||
if not cmd then
|
||||
os.remove(tmp_input)
|
||||
finish(nil)
|
||||
return
|
||||
end
|
||||
|
||||
vim.system(cmd, { text = true, timeout = 30000 }, function()
|
||||
vim.schedule(function()
|
||||
os.remove(tmp_input)
|
||||
if vim.fn.filereadable(cache_path) == 1 then
|
||||
finish(cache_path)
|
||||
else
|
||||
finish(nil)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
patch_mermaid_rendering()
|
||||
|
||||
-- md-render only processes placements near the viewport on first paint,
|
||||
-- and its WinScrolled retry loop skips placements without a `path`.
|
||||
-- Uncached mermaid placements carry only `mermaid_source`, so a diagram
|
||||
-- that starts off-screen never renders at all, no matter how far you
|
||||
-- scroll. Eagerly kick off rendering for every mermaid placement when
|
||||
-- images are set up: the plugin's own rebuild-on-complete then swaps in
|
||||
-- a normal `path` placement, which its retry loop handles.
|
||||
local function patch_offscreen_mermaid()
|
||||
local du = require("md-render.display_utils")
|
||||
if du._thembones_offscreen_mermaid_patch then return end
|
||||
du._thembones_offscreen_mermaid_patch = true
|
||||
local original_setup_images = du.setup_images
|
||||
du.setup_images = function(win, content, ns, opts)
|
||||
local state = original_setup_images(win, content, ns, opts)
|
||||
if state and state.placements and state.process_placement then
|
||||
vim.schedule(function()
|
||||
for _, placement in ipairs(state.placements) do
|
||||
if placement.mermaid_source then
|
||||
local ok, err = pcall(state.process_placement, placement)
|
||||
if not ok then
|
||||
image_debug("offscreen mermaid eager render err=" .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
return state
|
||||
end
|
||||
end
|
||||
|
||||
patch_offscreen_mermaid()
|
||||
|
||||
-- md-render's Ghostty path intentionally re-transmits images on every
|
||||
-- placement so redraws stay visible. Its stock transport uses `t=f`,
|
||||
-- which means "the terminal reads this file path". Over SSH that path is
|
||||
-- remote-only, so Ghostty cannot load it. In Herdr/Ghostty, file-path
|
||||
-- transfer can reserve the image area without painting the pixels, so
|
||||
-- avoid that path there too. Keep Ghostty's placement model, but switch
|
||||
-- transport to direct PNG bytes (`t=d`).
|
||||
if use_direct_kitty_bytes() then
|
||||
image_debug("direct-byte override enabled")
|
||||
local img = require("md-render.image")
|
||||
local tty = require("md-render.tty")
|
||||
local original_begin_batch = img.begin_batch
|
||||
local original_flush_batch = img.flush_batch
|
||||
local original_clear_all = img.clear_all
|
||||
local original_delete_image = img.delete_image
|
||||
local original_delete_images = img.delete_images
|
||||
local original_put_image = img.put_image
|
||||
local original_get_tty_path = tty.get_tty_path
|
||||
|
||||
local CHUNK = 4096
|
||||
local next_image_id = 800000
|
||||
local direct_images = {}
|
||||
local batch_depth = 0
|
||||
local after_flush = {}
|
||||
|
||||
tty.get_tty_path = function()
|
||||
if vim.env.SSH_TTY and vim.fn.filereadable(vim.env.SSH_TTY) == 1 then
|
||||
return vim.env.SSH_TTY
|
||||
end
|
||||
return original_get_tty_path()
|
||||
end
|
||||
|
||||
local function queue_terminal(data)
|
||||
image_debug("queue_terminal bytes=" .. tostring(#data) .. " batch_depth=" .. tostring(batch_depth))
|
||||
if batch_depth > 0 then
|
||||
table.insert(after_flush, data)
|
||||
else
|
||||
vim.api.nvim_ui_send(data)
|
||||
end
|
||||
end
|
||||
|
||||
local function flush_direct_queue()
|
||||
if batch_depth == 0 and #after_flush > 0 then
|
||||
image_debug("flush_direct_queue chunks=" .. tostring(#after_flush))
|
||||
for _, data in ipairs(after_flush) do
|
||||
vim.api.nvim_ui_send(data)
|
||||
end
|
||||
after_flush = {}
|
||||
end
|
||||
end
|
||||
|
||||
img.begin_batch = function(...)
|
||||
batch_depth = batch_depth + 1
|
||||
return original_begin_batch(...)
|
||||
end
|
||||
|
||||
img.flush_batch = function(...)
|
||||
local result = original_flush_batch(...)
|
||||
batch_depth = math.max(0, batch_depth - 1)
|
||||
flush_direct_queue()
|
||||
return result
|
||||
end
|
||||
|
||||
local function next_direct_id()
|
||||
next_image_id = next_image_id + 1
|
||||
return next_image_id
|
||||
end
|
||||
|
||||
local function read_png_base64(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
return vim.base64.encode(data)
|
||||
end
|
||||
|
||||
local function send_chunked(header, payload)
|
||||
image_debug("send_chunked header=" .. header .. " payload_len=" .. tostring(#payload))
|
||||
local i = 1
|
||||
local first = true
|
||||
while i <= #payload do
|
||||
local piece = payload:sub(i, i + CHUNK - 1)
|
||||
i = i + CHUNK
|
||||
local more = i <= #payload and 1 or 0
|
||||
local hdr
|
||||
if first then
|
||||
hdr = string.format("%s,m=%d", header, more)
|
||||
first = false
|
||||
else
|
||||
hdr = string.format("m=%d,q=2", more)
|
||||
end
|
||||
queue_terminal("\27_G" .. hdr .. ";" .. piece .. "\27\\")
|
||||
end
|
||||
end
|
||||
|
||||
local function crop_params_for(win, row, col, display_cols, display_rows, img_w, img_h)
|
||||
if not vim.api.nvim_win_is_valid(win) then return nil end
|
||||
|
||||
local win_pos = vim.api.nvim_win_get_position(win)
|
||||
local wininfo = vim.fn.getwininfo(win)[1]
|
||||
if not wininfo then return nil end
|
||||
|
||||
local win_height = wininfo.height
|
||||
local topline = wininfo.topline - 1
|
||||
local leftcol = wininfo.leftcol or 0
|
||||
local textoff = wininfo.textoff or 0
|
||||
|
||||
local img_end_row = row + display_rows - 1
|
||||
if img_end_row < topline or row >= topline + win_height then return nil end
|
||||
|
||||
local visual_row = row - topline
|
||||
local border_left_width = 0
|
||||
local border_top_height = 0
|
||||
local ok_cfg, win_cfg = pcall(vim.api.nvim_win_get_config, win)
|
||||
if ok_cfg and win_cfg.border then
|
||||
local border = win_cfg.border
|
||||
if type(border) == "table" then
|
||||
local left = border[8]
|
||||
if type(left) == "table" then left = left[1] end
|
||||
if left and left ~= "" then
|
||||
border_left_width = vim.api.nvim_strwidth(left)
|
||||
end
|
||||
local top = border[2]
|
||||
if type(top) == "table" then top = top[1] end
|
||||
if top and top ~= "" then
|
||||
border_top_height = 1
|
||||
end
|
||||
elseif border ~= "none" and border ~= "" then
|
||||
border_left_width = vim.api.nvim_strwidth("|")
|
||||
border_top_height = 1
|
||||
end
|
||||
end
|
||||
|
||||
local visual_col = col - leftcol
|
||||
local visible_text_cols = vim.api.nvim_win_get_width(win) - textoff
|
||||
local img_end_col = col + display_cols - 1
|
||||
if img_end_col < leftcol or col >= leftcol + visible_text_cols then return nil end
|
||||
|
||||
local screen_col = win_pos[2] + visual_col + border_left_width + textoff + 1
|
||||
local src_x, src_y, src_w, src_h
|
||||
|
||||
if visual_col < 0 then
|
||||
local hidden_cols = -visual_col
|
||||
if img_w then
|
||||
src_x = math.floor(img_w * hidden_cols / display_cols)
|
||||
src_w = img_w - src_x
|
||||
end
|
||||
display_cols = display_cols - hidden_cols
|
||||
visual_col = 0
|
||||
screen_col = win_pos[2] + border_left_width + textoff + 1
|
||||
end
|
||||
|
||||
if visual_row < 0 then
|
||||
local hidden_rows = -visual_row
|
||||
if img_h then
|
||||
src_y = math.floor(img_h * hidden_rows / display_rows)
|
||||
src_h = img_h - src_y
|
||||
end
|
||||
display_rows = display_rows - hidden_rows
|
||||
visual_row = 0
|
||||
end
|
||||
|
||||
local winbar_height = 0
|
||||
local ok_wb, wb = pcall(function() return vim.wo[win].winbar end)
|
||||
if ok_wb and wb and wb ~= "" then
|
||||
winbar_height = 1
|
||||
end
|
||||
local screen_row = wininfo.winrow + visual_row + border_top_height + winbar_height
|
||||
|
||||
local visible_rows = win_height - visual_row
|
||||
if visible_rows <= 0 then return nil end
|
||||
if display_rows > visible_rows and img_h then
|
||||
local remaining_h = src_h or img_h
|
||||
src_h = math.floor(remaining_h * visible_rows / display_rows)
|
||||
display_rows = visible_rows
|
||||
end
|
||||
|
||||
local visible_cols = visible_text_cols - visual_col
|
||||
if visible_cols <= 0 then return nil end
|
||||
if display_cols > visible_cols and img_w then
|
||||
local remaining_w = src_w or img_w
|
||||
src_w = math.floor(remaining_w * visible_cols / display_cols)
|
||||
display_cols = visible_cols
|
||||
end
|
||||
|
||||
local crop_params = ""
|
||||
if (src_x or src_y or src_w or src_h) and img_w and img_h then
|
||||
crop_params = string.format(
|
||||
",x=%d,y=%d,w=%d,h=%d",
|
||||
src_x or 0,
|
||||
src_y or 0,
|
||||
src_w or img_w,
|
||||
src_h or img_h
|
||||
)
|
||||
end
|
||||
|
||||
return {
|
||||
screen_col = screen_col,
|
||||
screen_row = screen_row,
|
||||
display_cols = display_cols,
|
||||
display_rows = display_rows,
|
||||
crop_params = crop_params,
|
||||
}
|
||||
end
|
||||
|
||||
local function transmit_direct(path, callback)
|
||||
image_debug("transmit_direct path=" .. tostring(path))
|
||||
local payload = read_png_base64(path)
|
||||
if not payload then
|
||||
image_debug("transmit_direct no payload")
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
|
||||
local id = next_direct_id()
|
||||
local img_w, img_h = img.image_dimensions(path)
|
||||
image_debug("transmit_direct id=" .. tostring(id) .. " dims=" .. tostring(img_w) .. "x" .. tostring(img_h))
|
||||
direct_images[id] = {
|
||||
path = path,
|
||||
payload = payload,
|
||||
width = img_w,
|
||||
height = img_h,
|
||||
}
|
||||
|
||||
send_chunked(
|
||||
string.format("a=t,f=100,t=d,i=%d,q=2", id),
|
||||
payload
|
||||
)
|
||||
callback(id, img_w, img_h)
|
||||
end
|
||||
|
||||
-- Herdr re-encodes every visible placement as base64 RGBA of the FULL
|
||||
-- source image (bytes ≈ w*h*4*4/3, ~16x the PNG size) when forwarding
|
||||
-- frames to its client, and drops ALL graphics for any frame whose
|
||||
-- total exceeds 32MiB ("dropping oversized graphics payload" in
|
||||
-- herdr-server.log). A scale-4 mermaid PNG alone is ~60MB on that
|
||||
-- wire, so nothing paints. Cap source pixels inside herdr panes; the
|
||||
-- render view is ≤45 rows so the downscale is visually lossless.
|
||||
local HERDR_MAX_PIXELS = 1500000
|
||||
|
||||
local function herdr_scaled_path(png_path, max_dim)
|
||||
local mtime = vim.fn.getftime(png_path)
|
||||
local key = png_path .. "|" .. tostring(mtime) .. "|" .. tostring(max_dim)
|
||||
local dir = vim.fn.stdpath("cache") .. "/md-render/herdr-scaled"
|
||||
vim.fn.mkdir(dir, "p")
|
||||
return dir .. "/" .. vim.fn.sha256(key):sub(1, 16) .. ".png"
|
||||
end
|
||||
|
||||
-- Downscale tool detection, same priority as md-render's own
|
||||
-- find_convert_tool(): sips (macOS) → ffmpeg → magick (IM7) →
|
||||
-- convert (IM6, Linux distro ImageMagick installs).
|
||||
local _downscale_tool = nil
|
||||
local _downscale_checked = false
|
||||
local function find_downscale_tool()
|
||||
if _downscale_checked then return _downscale_tool end
|
||||
_downscale_checked = true
|
||||
if vim.fn.has("mac") == 1 and vim.fn.executable("sips") == 1 then
|
||||
_downscale_tool = "sips"
|
||||
elseif vim.fn.executable("ffmpeg") == 1 then
|
||||
_downscale_tool = "ffmpeg"
|
||||
elseif vim.fn.executable("magick") == 1 then
|
||||
_downscale_tool = "magick"
|
||||
elseif vim.fn.executable("convert") == 1 then
|
||||
_downscale_tool = "convert"
|
||||
end
|
||||
return _downscale_tool
|
||||
end
|
||||
|
||||
local function build_downscale_cmd(tool, src, dst, max_dim)
|
||||
local dim = tostring(max_dim)
|
||||
if tool == "sips" then
|
||||
return { "sips", "-Z", dim, src, "--out", dst }
|
||||
elseif tool == "ffmpeg" then
|
||||
return {
|
||||
"ffmpeg", "-y", "-i", src,
|
||||
"-vframes", "1",
|
||||
"-vf", "scale='min(" .. dim .. ",iw)':'min(" .. dim .. ",ih)':force_original_aspect_ratio=decrease",
|
||||
dst,
|
||||
}
|
||||
else -- magick / convert share the same argument shape
|
||||
return { tool, src, "-resize", dim .. "x" .. dim .. ">", dst }
|
||||
end
|
||||
end
|
||||
|
||||
local function downscale_for_herdr(png_path, callback)
|
||||
if vim.env.HERDR_ENV ~= "1" then
|
||||
callback(png_path)
|
||||
return
|
||||
end
|
||||
local w, h = img.image_dimensions(png_path)
|
||||
if not w or not h or w * h <= HERDR_MAX_PIXELS then
|
||||
callback(png_path)
|
||||
return
|
||||
end
|
||||
local scale = math.sqrt(HERDR_MAX_PIXELS / (w * h))
|
||||
local max_dim = math.floor(math.max(w, h) * scale)
|
||||
local out = herdr_scaled_path(png_path, max_dim)
|
||||
if vim.fn.filereadable(out) == 1 then
|
||||
image_debug("downscale_for_herdr cached " .. out)
|
||||
callback(out)
|
||||
return
|
||||
end
|
||||
-- If no tool is available or it fails, fall back to the original
|
||||
-- (which herdr will drop, same as before this patch).
|
||||
local tool = find_downscale_tool()
|
||||
if not tool then
|
||||
image_debug("downscale_for_herdr no downscale tool")
|
||||
callback(png_path)
|
||||
return
|
||||
end
|
||||
vim.system(
|
||||
build_downscale_cmd(tool, png_path, out, max_dim),
|
||||
{ text = true, timeout = 15000 },
|
||||
function()
|
||||
vim.schedule(function()
|
||||
if vim.fn.filereadable(out) == 1 then
|
||||
image_debug("downscale_for_herdr " .. tool .. " " .. w .. "x" .. h .. " -> max_dim=" .. max_dim)
|
||||
callback(out)
|
||||
else
|
||||
image_debug("downscale_for_herdr " .. tool .. " failed")
|
||||
callback(png_path)
|
||||
end
|
||||
end)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
img.transmit_image_async = function(path, callback)
|
||||
image_debug("transmit_image_async path=" .. tostring(path))
|
||||
if not img.supports_kitty() then
|
||||
image_debug("transmit_image_async no kitty support")
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
img.ensure_png_async(path, function(png_path)
|
||||
if not png_path then
|
||||
image_debug("transmit_image_async no png_path")
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
image_debug("transmit_image_async png_path=" .. tostring(png_path))
|
||||
downscale_for_herdr(png_path, function(final_path)
|
||||
transmit_direct(final_path, callback)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
img.put_image = function(image_id, win, row, col, display_cols, display_rows, anim_path, img_w, img_h)
|
||||
image_debug(
|
||||
"put_image id=" .. tostring(image_id)
|
||||
.. " row=" .. tostring(row)
|
||||
.. " col=" .. tostring(col)
|
||||
.. " cols=" .. tostring(display_cols)
|
||||
.. " rows=" .. tostring(display_rows)
|
||||
.. " anim=" .. tostring(anim_path)
|
||||
)
|
||||
local direct = direct_images[image_id]
|
||||
if not direct or anim_path then
|
||||
image_debug("put_image fallback direct=" .. tostring(direct ~= nil) .. " anim=" .. tostring(anim_path))
|
||||
return original_put_image(image_id, win, row, col, display_cols, display_rows, anim_path, img_w, img_h)
|
||||
end
|
||||
|
||||
if not img.supports_kitty() then return end
|
||||
local placement = crop_params_for(
|
||||
win,
|
||||
row,
|
||||
col,
|
||||
display_cols,
|
||||
display_rows,
|
||||
img_w or direct.width,
|
||||
img_h or direct.height
|
||||
)
|
||||
if not placement then
|
||||
image_debug("put_image no placement")
|
||||
return
|
||||
end
|
||||
image_debug(
|
||||
"put_image placement screen="
|
||||
.. tostring(placement.screen_row)
|
||||
.. ","
|
||||
.. tostring(placement.screen_col)
|
||||
.. " cells="
|
||||
.. tostring(placement.display_cols)
|
||||
.. "x"
|
||||
.. tostring(placement.display_rows)
|
||||
.. " crop="
|
||||
.. tostring(placement.crop_params)
|
||||
)
|
||||
|
||||
queue_terminal("\27[s")
|
||||
queue_terminal(string.format("\27[%d;%dH", placement.screen_row, placement.screen_col))
|
||||
send_chunked(
|
||||
string.format(
|
||||
"a=T,f=100,t=d,i=%d,c=%d,r=%d%s,C=1,q=2",
|
||||
image_id,
|
||||
placement.display_cols,
|
||||
placement.display_rows,
|
||||
placement.crop_params
|
||||
),
|
||||
direct.payload
|
||||
)
|
||||
queue_terminal("\27[u")
|
||||
end
|
||||
|
||||
img.clear_all = function(...)
|
||||
direct_images = {}
|
||||
return original_clear_all(...)
|
||||
end
|
||||
|
||||
img.delete_image = function(image_id, ...)
|
||||
direct_images[image_id] = nil
|
||||
return original_delete_image(image_id, ...)
|
||||
end
|
||||
|
||||
img.delete_images = function(image_ids, ...)
|
||||
for _, image_id in ipairs(image_ids or {}) do
|
||||
direct_images[image_id] = nil
|
||||
end
|
||||
return original_delete_images(image_ids, ...)
|
||||
end
|
||||
end
|
||||
|
||||
local function mermaid_sources(buf)
|
||||
local sources = {}
|
||||
local in_mermaid = false
|
||||
local block = {}
|
||||
|
||||
for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
|
||||
if not in_mermaid then
|
||||
if line:match("^%s*```%s*mermaid%s*$") then
|
||||
in_mermaid = true
|
||||
block = {}
|
||||
end
|
||||
elseif line:match("^%s*```%s*$") then
|
||||
if #block > 0 then
|
||||
table.insert(sources, table.concat(block, "\n"))
|
||||
end
|
||||
in_mermaid = false
|
||||
block = {}
|
||||
else
|
||||
table.insert(block, line)
|
||||
end
|
||||
end
|
||||
|
||||
return sources
|
||||
end
|
||||
|
||||
local function prime_mermaid_cache(buf)
|
||||
if not vim.api.nvim_buf_is_valid(buf) then return end
|
||||
|
||||
local ok, image = pcall(require, "md-render.image")
|
||||
if not ok or not image.has_mmdc() then return end
|
||||
|
||||
for _, source in ipairs(mermaid_sources(buf)) do
|
||||
if not image.get_mermaid_cached(source) then
|
||||
image.render_mermaid(source)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function ensure_md_render_auto(buf)
|
||||
if not vim.api.nvim_buf_is_valid(buf) then return end
|
||||
if vim.bo[buf].filetype ~= "markdown" then return end
|
||||
|
||||
if not vim.b[buf].md_render_auto then
|
||||
local cache_group = vim.api.nvim_create_augroup("MdRenderMermaidCache" .. buf, { clear = true })
|
||||
vim.api.nvim_create_autocmd({ "InsertLeave", "BufWritePost" }, {
|
||||
group = cache_group,
|
||||
buffer = buf,
|
||||
callback = function()
|
||||
prime_mermaid_cache(buf)
|
||||
end,
|
||||
desc = "Prime md-render Mermaid cache before redraw",
|
||||
})
|
||||
end
|
||||
|
||||
prime_mermaid_cache(buf)
|
||||
if vim.b[buf].md_render_auto then return end
|
||||
|
||||
local ok, md = pcall(require, "md-render")
|
||||
if ok then
|
||||
local win = vim.api.nvim_get_current_win()
|
||||
local ok_state, state = pcall(vim.api.nvim_win_get_var, win, "md_render_state")
|
||||
if ok_state and type(state) == "table" and state.source_buf ~= buf then
|
||||
pcall(vim.api.nvim_win_del_var, win, "md_render_state")
|
||||
end
|
||||
|
||||
pcall(md.preview.auto_on, { max_width = 140 })
|
||||
end
|
||||
end
|
||||
|
||||
local group = vim.api.nvim_create_augroup("MdRenderMarkdownAuto", { clear = true })
|
||||
vim.api.nvim_create_autocmd("FileType", {
|
||||
group = group,
|
||||
pattern = { "markdown", "*.md", "*.markdown" },
|
||||
callback = function(ev)
|
||||
-- Defer so the FileType pass that loads md-render completes first.
|
||||
vim.schedule(function()
|
||||
if vim.api.nvim_get_current_buf() == ev.buf then
|
||||
ensure_md_render_auto(ev.buf)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
desc = "Enable md-render auto mode for markdown buffers",
|
||||
})
|
||||
|
||||
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
||||
vim.schedule(function()
|
||||
ensure_md_render_auto(buf)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
},
|
||||
|
||||
-- Disable the previous attempts so they don't fight md-render for the same
|
||||
-- terminal cells (the "1 frame then gone" / "behind the text" symptoms).
|
||||
{ "3rd/image.nvim", enabled = false },
|
||||
{ "3rd/diagram.nvim", enabled = false },
|
||||
{
|
||||
"folke/snacks.nvim",
|
||||
opts = function(_, opts)
|
||||
opts.image = vim.tbl_deep_extend("force", opts.image or {}, {
|
||||
enabled = false,
|
||||
})
|
||||
return opts
|
||||
end,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user