diff --git a/CLAUDE.md b/CLAUDE.md index 792e68b..a08a093 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,10 @@ Symlink-based deployment. Editing `~/.` directly edits the repo file. `git - `dotfiles/.bashrc` — main shell config - `dotfiles/.vimrc`, `.gitconfig`, `.tmux.conf`, `.psqlrc`, `.pspgconf` - `dotfiles/bin/td` — time-tracking CLI -- `dotfiles/nvim/td.lua`, `td_mappings.lua` — nvim lua modules +- `dotfiles/nvim/mappings.lua` — all nvim keybindings +- `dotfiles/nvim/td.lua`, `td_mappings.lua` — nvim time-tracking integration +- `dotfiles/nvim/rename_term.lua` — vault-wide find-and-replace with confirmation popup (`rn`) +- `dotfiles/nvim/telescope_tasks.lua` — vault-wide task pickers (`tl` open tasks, `tL` priority tasks) **Gitignored** (machine-specific, auto-bootstrapped from examples): - `dotfiles/.bashrc_local` — secrets and credentials diff --git a/dotfiles/.bashrc b/dotfiles/.bashrc index 1edb294..2a8fc4d 100644 --- a/dotfiles/.bashrc +++ b/dotfiles/.bashrc @@ -129,14 +129,14 @@ xmspa() { } alias gs='git status -s' -alias ga='git status --untracked-files=all -s | fzf -m | awk "{print \$2}" | xargs git add ' -alias gx='git status --untracked-files=all -s | fzf -m | awk "{print \$2}" | xargs git checkout ' +alias ga='git status --untracked-files=all -s | fzf -m | while IFS= read -r line; do git add "${line:3}"; done' +alias gx='git status --untracked-files=all -s | fzf -m | while IFS= read -r line; do git checkout "${line:3}"; done' alias td='rg "\- \[[^(x|~)]\]"' alias tdp='rg "\- \[[^(x|~)]\].*(🔼|⏫)"' alias tdtp='rg "\- \[[^(x|~)]\].*⏫"' # alias tdo='rg "\- \[[^x]\]" | fzf | xargs nvim' -alias tdo='rg "\- \[[^(x|~)]\]" --line-number | fzf | awk -F: "{print \$1, \"+\"\$2}" | xargs -r nvim' -alias tdop='rg "\- \[[^(x|~)]\].*(🔼|⏫)" --line-number | fzf | awk -F: "{print \$1, \"+\"\$2}" | xargs -r nvim' +alias tdo='rg "\- \[[^(x|~)]\]" --line-number | fzf | { IFS=: read -r file line rest && nvim "$file" "+$line"; }' +alias tdop='rg "\- \[[^(x|~)]\].*(🔼|⏫)" --line-number | fzf | { IFS=: read -r file line rest && nvim "$file" "+$line"; }' # time-track markdown todos — see `command td --help`. Log at $TD_LOG (default ./time.csv) # `command td` bypasses the `td` alias (which is rg for finding todos). diff --git a/dotfiles/nvim/mappings.lua b/dotfiles/nvim/mappings.lua new file mode 100644 index 0000000..c063bb3 --- /dev/null +++ b/dotfiles/nvim/mappings.lua @@ -0,0 +1,78 @@ +require "nvchad.mappings" + +-- add yours here + +local map = vim.keymap.set + +map("n", ";", ":", { desc = "CMD enter command mode" }) +map("i", "jk", "") + +-- map({ "n", "i", "v" }, "", " w ") + +-- open tasks / priority tasks vault-wide (telescope_tasks.lua) +map('n', 'tl', function() require('telescope_tasks').open_tasks() end, { desc = "telescope: open tasks" }) +map('n', 'tL', function() require('telescope_tasks').priority_tasks() end, { desc = "telescope: priority tasks" }) +-- Add a keybinding for calling ObsidianTag +vim.api.nvim_set_keymap('n', 'tt', ':ObsidianTag', { noremap = true, silent = true }) +-- Add a keybinding for calling ObsidianBacklinks +vim.api.nvim_set_keymap('n', 'lb', ':ObsidianBacklinks', { noremap = true, silent = true }) +-- Add a keybinding for calling ObsidianLink +vim.api.nvim_set_keymap('v', 'fl', ':ObsidianLink', { noremap = true, silent = true }) +-- priority task +vim.api.nvim_set_keymap("n", "p1", "A 🔼", { noremap = true, silent = true }) +vim.api.nvim_set_keymap("n", "p2", "A ⏫", { noremap = true, silent = true }) + +-- Live grep all files (including gitignored and hidden files) +vim.keymap.set("n", "fW", function() + require('telescope.builtin').live_grep({ + additional_args = function() + return { "--hidden", "--no-ignore" } + end + }) +end, { desc = "telescope live grep all files" }) + +-- make leader-e toggle the tree view as opposed to just setting focus +vim.api.nvim_set_keymap('n', 'e', ':NvimTreeToggle', {noremap = true, silent = true}) + +-- move the whole page without moving the cursor +vim.api.nvim_set_keymap('n', 'J', '', { noremap = true }) +vim.keymap.set('n', 'j', 'J', { remap = false, desc = "Join lines" }) +vim.api.nvim_set_keymap('n', 'K', '', { noremap = true }) + +-- Resize windows +vim.api.nvim_set_keymap('n', '', '5+', { silent = true }) +vim.api.nvim_set_keymap('n', '', '5-', { silent = true }) +vim.api.nvim_set_keymap('n', '', '10>', { silent = true }) +vim.api.nvim_set_keymap('n', '', '10<', { silent = true }) + +-- wrap selected text in single quotes +vim.keymap.set('x', "'", function() + local text = vim.fn.getreg('"') -- Get the visually selected text + vim.cmd("normal! c'" .. text .. "'") +end, { desc = "Wrap selected text in single quotes" }) + +-- wrap selected text in double quotes +vim.keymap.set('x', '"', function() + local text = vim.fn.getreg('"') -- Get the visually selected text + vim.cmd('normal! c"' .. text .. '"') +end, { desc = "Wrap selected text in double quotes" }) + +-- Gitsigns blame current line +vim.keymap.set("n", "gb", ":Gitsigns blame_line", { noremap = true, silent = true, desc = "Git blame line" }) + +-- Format current buffer (uses conform; for markdown this snaps tables via mdformat-gfm) +vim.keymap.set({ "n", "v" }, "fm", function() + require("conform").format({ async = true, lsp_fallback = true }) +end, { desc = "Format buffer / selection" }) + +-- Disable alt+h and alt+v terminal toggles +vim.keymap.del({ "n", "t" }, "") +vim.keymap.del({ "n", "t" }, "") + +-- Vault-wide find-and-replace with confirmation popup +vim.keymap.set("n", "rn", function() + require("rename_term").run() +end, { desc = "Rename term across vault (find → replace)" }) + +-- td: time-track markdown todos — all in ~/setup_env/dotfiles/nvim/{td,td_mappings}.lua +require("td_mappings") diff --git a/dotfiles/nvim/rename_term.lua b/dotfiles/nvim/rename_term.lua new file mode 100644 index 0000000..ff7ba4b --- /dev/null +++ b/dotfiles/nvim/rename_term.lua @@ -0,0 +1,169 @@ +-- rename_term.lua +-- Vault-wide find-and-replace with a confirmation popup. +-- Usage: require("rename_term").run() +-- Keybinding: rn + +local M = {} + +local function get_vault_root() + -- prefer obsidian vault root, fall back to cwd + local ok, obs = pcall(require, "obsidian") + if ok and obs.get_client then + local client = obs.get_client() + if client and client.dir then + return tostring(client.dir) + end + end + return vim.fn.getcwd() +end + +local function show_popup(lines, on_confirm) + local width = math.min(80, vim.o.columns - 4) + local height = math.min(#lines + 4, vim.o.lines - 6) + local row = math.floor((vim.o.lines - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe") + + local display = { "Affected files — press y to apply, n/q to cancel:", "" } + for _, l in ipairs(lines) do + table.insert(display, " " .. l) + end + table.insert(display, "") + table.insert(display, string.format(" %d file(s) will be modified.", #lines)) + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, display) + vim.api.nvim_buf_set_option(buf, "modifiable", false) + + local win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + }) + + local function close() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end + + local opts = { buffer = buf, nowait = true, silent = true } + + vim.keymap.set("n", "y", function() + close() + on_confirm() + end, opts) + + vim.keymap.set("n", "n", close, opts) + vim.keymap.set("n", "q", close, opts) + vim.keymap.set("n", "", close, opts) +end + +function M.run(search, replace) + local vault = get_vault_root() + + -- prompt if not passed as args + if not search or search == "" then + search = vim.fn.input("Search: ") + if search == "" then + print("rename_term: search term is empty, aborting.") + return + end + end + if not replace or replace == "" then + replace = vim.fn.input("Replace: ") + -- allow replacing with empty string intentionally + end + + -- find affected files (content) + local raw = vim.fn.systemlist( + string.format("grep -rl --include='*.md' -F %q %q", search, vault) + ) + + -- find affected file names + local named = vim.fn.systemlist( + string.format("find %q -name %q", vault, "*" .. search .. "*") + ) + + -- deduplicate + local seen = {} + local content_files = {} + for _, f in ipairs(raw) do + if not seen[f] then + seen[f] = true + table.insert(content_files, f) + end + end + + if #content_files == 0 and #named == 0 then + vim.notify('rename_term: no matches for "' .. search .. '"', vim.log.levels.WARN) + return + end + + -- build display list + local display = {} + for _, f in ipairs(content_files) do + table.insert(display, vim.fn.fnamemodify(f, ":~:.") .. " [content]") + end + for _, f in ipairs(named) do + table.insert(display, vim.fn.fnamemodify(f, ":~:.") .. " [filename]") + end + + show_popup(display, function() + local errors = {} + + -- 1. replace content in all matched files + for _, f in ipairs(content_files) do + -- escape for sed: & / \ need escaping + local function sed_escape(s) + return s:gsub("([&/\\])", "\\%1") + end + local cmd = string.format( + "sed -i 's/%s/%s/g' %q", + sed_escape(search), sed_escape(replace), f + ) + local result = vim.fn.system(cmd) + if vim.v.shell_error ~= 0 then + table.insert(errors, "content: " .. f .. " — " .. result) + end + end + + -- 2. rename files whose names contain the search term + for _, f in ipairs(named) do + local dir = vim.fn.fnamemodify(f, ":h") + local base = vim.fn.fnamemodify(f, ":t") + local newbase = base:gsub(vim.pesc(search), replace) + local newpath = dir .. "/" .. newbase + if newbase ~= base then + local ok, err = os.rename(f, newpath) + if not ok then + table.insert(errors, "rename: " .. f .. " — " .. (err or "unknown")) + end + end + end + + -- reload any open buffers whose files were touched + for _, f in ipairs(content_files) do + local bufnr = vim.fn.bufnr(f) + if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then + vim.api.nvim_buf_call(bufnr, function() vim.cmd("edit!") end) + end + end + + if #errors > 0 then + vim.notify("rename_term errors:\n" .. table.concat(errors, "\n"), vim.log.levels.ERROR) + else + vim.notify(string.format( + 'rename_term: replaced "%s" → "%s" in %d file(s), renamed %d file(s).', + search, replace, #content_files, #named + ), vim.log.levels.INFO) + end + end) +end + +return M diff --git a/dotfiles/nvim/telescope_tasks.lua b/dotfiles/nvim/telescope_tasks.lua new file mode 100644 index 0000000..0e1a5d5 --- /dev/null +++ b/dotfiles/nvim/telescope_tasks.lua @@ -0,0 +1,38 @@ +local telescope = require('telescope.builtin') + +local M = {} + +local function vault_root() + local path = vim.fn.expand('%:p:h') + local home = vim.fn.expand('~') + while path ~= home and path ~= '/' do + if vim.fn.filereadable(path .. '/time.csv') == 1 + or vim.fn.isdirectory(path .. '/.obsidian') == 1 then + return path + end + path = vim.fn.fnamemodify(path, ':h') + end + return vim.fn.getcwd() +end + +-- all open tasks vault-wide (equivalent to tdo shell alias) +function M.open_tasks() + telescope.grep_string({ + prompt_title = "Open Tasks", + search = "\\- \\[[^x~]\\]", + use_regex = true, + cwd = vault_root(), + }) +end + +-- priority open tasks vault-wide (equivalent to tdop shell alias) +function M.priority_tasks() + telescope.grep_string({ + prompt_title = "Priority Tasks", + search = "\\- \\[[^x~]\\].*(🔼|⏫)", + use_regex = true, + cwd = vault_root(), + }) +end + +return M diff --git a/install_nvchad.sh b/install_nvchad.sh index 7c80d5d..3675c93 100755 --- a/install_nvchad.sh +++ b/install_nvchad.sh @@ -82,6 +82,20 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then exit 0 fi +# Overlay personal lua modules from setup_env (symlinks override cloned files) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NVIM_DOTFILES="$SCRIPT_DIR/dotfiles/nvim" +if [ -d "$NVIM_DOTFILES" ]; then + echo "Symlinking personal nvim modules from $NVIM_DOTFILES ..." + for mod in "$NVIM_DOTFILES"/*.lua; do + [ -f "$mod" ] || continue + target="$HOME/.config/nvim/lua/$(basename "$mod")" + [ -L "$target" ] && rm "$target" + ln -s "$mod" "$target" + echo " linked: $(basename "$mod")" + done +fi + echo "" echo "Starting installation..." echo ""