diff --git a/CLAUDE.md b/CLAUDE.md index 8655b91..8ac03e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,9 @@ 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/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 @@ -69,7 +71,11 @@ Symlink-based deployment. Editing `~/.` directly edits the repo file. `git The nvim config lives in a separate repo at `git@gitea.hptrow.me:pt/nvchad.git` (`customize` branch), deployed to `~/.config/nvim`. `install_nvchad.sh` manages it. -`dotfiles/nvim/td.lua` and `td_mappings.lua` are deployed as symlinks into `~/.config/nvim/lua/` by `deploy_nvim`. The nvim repo's `mappings.lua` loads them via `require "td_mappings"`. The nvim repo does not track these files — setup_env owns them. +**Ownership boundary — important:** +- **nvchad repo owns `mappings.lua`** — this is the keymap entry point. It lives in `~/.config/nvim/lua/mappings.lua` and is tracked by the nvchad git repo. Edit it there, push from `~/.config/nvim`. +- **setup_env owns feature modules** — `td.lua`, `td_mappings.lua`, `rename_term.lua`, `telescope_tasks.lua`. These are deployed as symlinks into `~/.config/nvim/lua/` by `deploy_nvim`. The nvchad repo does NOT track them. `mappings.lua` loads them via `require`. + +Do NOT put `mappings.lua` in `dotfiles/nvim/`. It will conflict with the nvchad repo's tracked copy on every `git pull` during sync, producing `.backup` files on all machines. **Dependency**: `deploy_nvim` must run after `install_nvchad.sh` has created `~/.config/nvim/lua/`. On a fresh machine this is handled automatically by `setup_env.sh` ordering. On existing machines `sync.sh` handles it. diff --git a/dotfiles/.bashrc b/dotfiles/.bashrc index 1edb294..de2ab41 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). @@ -144,6 +144,7 @@ tstart() { command td start "$@"; } tstop() { command td stop; } treport() { command td report "$@"; } tweek() { command td week "$@"; } +tweekmd() { command td weekmd "$@"; } alias gr='git reset HEAD' alias gc='git commit -v' @@ -178,6 +179,50 @@ xnspa() { -f "$file" | vd - } +xndb2() { + local file candidates + + candidates=$( + lsof +D ~/.local/state/nvim/swap/ 2>/dev/null | grep -v 'lsof:' | + grep -o "/swap/.*" | + sed 's|^/swap/||' | + tr "%" "/" | + sed -E 's/\.sw[op]$//' | + grep '\.db2\.sql$' + ) + + if [[ -z "$candidates" ]]; then + echo "xndb2: no open .db2.sql nvim buffers found" >&2 + return 1 + fi + + file=$(echo "$candidates" | fzf) || return + + [[ -z "$file" ]] && return + + echo "xndb2: running $file" >&2 + + [[ -f ~/.jrqrc ]] && source ~/.jrqrc + + if [[ -z "$JR_URL_DB2" || -z "$JR_USER_DB2" || -z "$JR_PASS_DB2" ]]; then + echo "xndb2: DB2 credentials not set in ~/.jrqrc" >&2 + return 1 + fi + + local tmp + tmp=$(mktemp /tmp/xndb2_XXXXXX.csv) + + /opt/jrunner/bin/jrunner \ + -scu "$JR_URL_DB2" \ + -scn "$JR_USER_DB2" \ + -scp "$JR_PASS_DB2" \ + -sq "$file" \ + -f csv > "$tmp" + + vd "$tmp" + rm -f "$tmp" +} + # Add an "alert" alias for long running commands. Use like so: # sleep 10; alert diff --git a/dotfiles/bin/td b/dotfiles/bin/td index cddb1be..a307227 100755 --- a/dotfiles/bin/td +++ b/dotfiles/bin/td @@ -212,6 +212,57 @@ def cmd_week(args): print() print(f"Totals: completed {n_done} created {n_new} time {_fmt_dur(total_secs)}") +def _lookup_line(file, tid): + if not file or not os.path.exists(file): + return "" + try: + with open(file) as f: + for line in f: + if f"^{tid}" in line: + return line.rstrip() + except OSError: + pass + return "" + +def cmd_weekmd(args): + since = args.since or _default_since() + events = list(_scan_git(since)) + totals = _tid_totals() + if not events: + print(f"no task events since {since}"); return + by_day = defaultdict(list) + for ts, kind, tid, file, desc in events: + by_day[ts[:10]].append((ts, kind, tid, file, desc)) + marker = {"done": "[x]", "new": "[ ]", "done+new": "[+]", "reopen": "[o]"} + n_done = n_new = total_secs = 0 + counted_tids = set() + rows = [] + for day in sorted(by_day): + dt = datetime.fromisoformat(day) + day_label = dt.strftime("%a %Y-%m-%d") + first = True + for ts, kind, tid, file, desc in by_day[day]: + t = totals.get(tid, 0) + raw = _lookup_line(file, tid) or desc + line = re.sub(r"^\s*-\s*\[.\]\s*", "", raw) + line = re.sub(r"\s*\^\S+.*$", "", line).strip() + rows.append((day_label if first else "", marker.get(kind, "?"), tid, _fmt_dur(t) if t else "", file, line)) + first = False + if kind in ("done", "done+new"): n_done += 1 + if kind in ("new", "done+new"): n_new += 1 + if tid not in counted_tids: + counted_tids.add(tid); total_secs += t + print(f"# Week since {since}\n") + print("| Day | | TID | Time | File | Task |") + print("|-----|---|-----|------|------|------|") + for day_label, mk, tid, dur, file, line in rows: + dl = day_label.replace("|", "\\|") + fn = file.replace("|", "\\|") + ln = line.replace("|", "\\|") + print(f"| {dl} | {mk} | {tid} | {dur} | {fn} | {ln} |") + print() + print(f"**Totals:** completed {n_done} · created {n_new} · time {_fmt_dur(total_secs)}") + def main(): p = argparse.ArgumentParser(prog="td", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sp = p.add_subparsers(dest="cmd", required=True) @@ -221,6 +272,7 @@ def main(): sp.add_parser("current").set_defaults(func=cmd_current) sp.add_parser("tidgen").set_defaults(func=cmd_tidgen) s = sp.add_parser("week"); s.add_argument("--since", default=None); s.set_defaults(func=cmd_week) + s = sp.add_parser("weekmd"); s.add_argument("--since", default=None); s.set_defaults(func=cmd_weekmd) args = p.parse_args() args.func(args) 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