diff --git a/backends/codex/code-close-diff.sh b/backends/codex/code-close-diff.sh index 41e2f6f..55c74fc 100755 --- a/backends/codex/code-close-diff.sh +++ b/backends/codex/code-close-diff.sh @@ -27,9 +27,9 @@ log() { :; } # shellcheck source=/dev/null source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true # shellcheck source=/dev/null -source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true +source "$BIN_DIR/nvim-call.sh" 2>/dev/null || true if [[ -n "${NVIM_SOCKET:-}" ]]; then - _CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}') + _CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then diff --git a/backends/codex/code-preview-diff.sh b/backends/codex/code-preview-diff.sh index 4691ff3..30b0be5 100755 --- a/backends/codex/code-preview-diff.sh +++ b/backends/codex/code-preview-diff.sh @@ -44,9 +44,9 @@ log() { :; } # shellcheck source=/dev/null source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true # shellcheck source=/dev/null -source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true +source "$BIN_DIR/nvim-call.sh" 2>/dev/null || true if [[ -n "${NVIM_SOCKET:-}" ]]; then - _CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}') + _CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then diff --git a/backends/copilot/code-close-diff.sh b/backends/copilot/code-close-diff.sh index 66dc30b..987fc6b 100755 --- a/backends/copilot/code-close-diff.sh +++ b/backends/copilot/code-close-diff.sh @@ -25,9 +25,9 @@ log() { :; } # shellcheck source=/dev/null source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true # shellcheck source=/dev/null -source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true +source "$BIN_DIR/nvim-call.sh" 2>/dev/null || true if [[ -n "${NVIM_SOCKET:-}" ]]; then - _CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}') + _CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then diff --git a/backends/copilot/code-preview-diff.sh b/backends/copilot/code-preview-diff.sh index fdf59b3..091caa8 100755 --- a/backends/copilot/code-preview-diff.sh +++ b/backends/copilot/code-preview-diff.sh @@ -37,11 +37,11 @@ log() { :; } # shellcheck source=/dev/null source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true # shellcheck source=/dev/null -source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true +source "$BIN_DIR/nvim-call.sh" 2>/dev/null || true _NVIM_SERVERNAME="" _NVIM_CWD="" if [[ -n "${NVIM_SOCKET:-}" ]]; then - _CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or '',servername=vim.v.servername,cwd=vim.fn.getcwd()})\")" 2>/dev/null || echo '{}') + _CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) _NVIM_SERVERNAME=$(echo "$_CTX" | jq -r '.servername // ""' 2>/dev/null) diff --git a/bin/core-post-tool.sh b/bin/core-post-tool.sh index 8e4f16e..46aef81 100755 --- a/bin/core-post-tool.sh +++ b/bin/core-post-tool.sh @@ -22,12 +22,12 @@ TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)" # Discover Neovim socket (prefer instance whose cwd matches project) and load RPC helpers source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null -source "$SCRIPT_DIR/nvim-send.sh" +source "$SCRIPT_DIR/nvim-call.sh" # Set up logging — query debug config from nvim log_post() { :; } if [[ -n "${NVIM_SOCKET:-}" ]]; then - _POST_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}') + _POST_CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" _POST_DEBUG=$(echo "$_POST_CTX" | jq -r '.debug // false') _POST_LOG_FILE=$(echo "$_POST_CTX" | jq -r '.log_file // ""') if [[ "$_POST_DEBUG" == "true" && -n "$_POST_LOG_FILE" ]]; then @@ -42,8 +42,9 @@ log_post "tool=$TOOL_NAME" # doesn't clobber `modified` markers from concurrent Edit/Write/ApplyPatch # operations whose post-hook hasn't fired yet. if [[ "$TOOL_NAME" == "Bash" ]]; then - nvim_send "require('code-preview.changes').clear_by_statuses({'deleted','bash_modified','bash_created'})" || true - nvim_send "vim.defer_fn(function() pcall(function() require('code-preview.neo_tree').refresh() end) end, 200)" || true + nvim_call code-preview.changes clear_by_statuses \ + '[["deleted","bash_modified","bash_created"]]' >/dev/null || true + nvim_call code-preview.neo_tree refresh_deferred '[200]' >/dev/null || true exit 0 fi @@ -71,9 +72,9 @@ if [[ "$TOOL_NAME" == "ApplyPatch" ]]; then if [[ "$fpath" != /* && -n "$CWD_POST" ]]; then fpath="$CWD_POST/$fpath" fi - fpath_esc="$(escape_lua "$fpath")" log_post "closing diff for patch file=$fpath" - nvim_send "require('code-preview.diff').close_for_file('$fpath_esc')" || true + nvim_call code-preview.diff close_for_file \ + "$(jq -nc --arg f "$fpath" '[$f]')" >/dev/null || true done < <(extract_patch_paths "$PATCH_TEXT") fi rm -f "${TMPDIR:-/tmp}"/claude-diff-original* "${TMPDIR:-/tmp}"/claude-diff-proposed* "${TMPDIR:-/tmp}"/claude-patch-* @@ -82,13 +83,13 @@ fi # Extract file path early — needed for tagged is_open() check FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)" -FILE_PATH_ESC="$(escape_lua "${FILE_PATH:-}")" # Tell Lua to handle this file's close — tolerates out-of-order post-hooks # (OpenCode may fire them in a different order than pre-hooks). if [[ -n "$FILE_PATH" ]]; then log_post "closing diff for file=$FILE_PATH" - nvim_send "require('code-preview.diff').close_for_file('$FILE_PATH_ESC')" || true + nvim_call code-preview.diff close_for_file \ + "$(jq -nc --arg f "$FILE_PATH" '[$f]')" >/dev/null || true # neo_tree.refresh() is handled inside close_for_file() via vim.schedule() fi diff --git a/bin/core-pre-tool.sh b/bin/core-pre-tool.sh index 6e1d2bc..44cf7a8 100755 --- a/bin/core-pre-tool.sh +++ b/bin/core-pre-tool.sh @@ -26,7 +26,7 @@ CWD="$(echo "$INPUT" | jq -r '.cwd')" # Discover Neovim socket (prefer instance whose cwd matches project) and load RPC helpers source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true -source "$SCRIPT_DIR/nvim-send.sh" +source "$SCRIPT_DIR/nvim-call.sh" HAS_NVIM=true if [[ -z "${NVIM_SOCKET:-}" ]]; then @@ -36,7 +36,7 @@ fi # Set up logging early so all code paths can use it log_pre() { :; } if [[ "$HAS_NVIM" == "true" ]]; then - _PRE_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}') + _PRE_CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" _PRE_DEBUG=$(echo "$_PRE_CTX" | jq -r '.debug // false') _PRE_LOG_FILE=$(echo "$_PRE_CTX" | jq -r '.log_file // ""') if [[ "$_PRE_DEBUG" == "true" && -n "$_PRE_LOG_FILE" ]]; then @@ -113,12 +113,24 @@ case "$TOOL_NAME" in | grep -vE '^-' \ | while read -r p; do if [[ -z "$p" ]]; then continue; fi - # Resolve relative paths against CWD - if [[ "$p" != /* ]]; then - echo "$CWD/$p" - else - echo "$p" - fi + # Strip outer single/double quotes — agents wrap + # paths with shell-special chars (apostrophes, + # spaces) in quotes, and that quoting survives + # into tool_input.command literally. + p="${p#\"}"; p="${p%\"}" + p="${p#\'}"; p="${p%\'}" + # Strip trailing CR (Windows-style payloads). + p="${p%$'\r'}" + if [[ -z "$p" ]]; then continue; fi + # Resolve relative paths against CWD; absolute + # paths and `~/`-prefixed paths pass through. + # `'~/'*` is quoted so bash doesn't tilde-expand + # the pattern at match time. + case "$p" in + /*) echo "$p" ;; + '~/'*) echo "${HOME}/${p#'~/'}" ;; + *) echo "$CWD/$p" ;; + esac done fi } @@ -131,19 +143,24 @@ case "$TOOL_NAME" in done < <(detect_rm_paths "$subcmd") done < <(echo "$COMMAND" | sed 's/[;&|]\{1,2\}/\n/g') - RM_PATHS="$(echo "$RM_PATHS" | xargs)" + # Trim leading/trailing whitespace without invoking xargs — xargs does + # shell-like quote processing on its input and would discard everything + # if any path contained an unbalanced quote (e.g. an apostrophe in + # `it's-mine.txt`). + RM_PATHS="${RM_PATHS#"${RM_PATHS%%[![:space:]]*}"}" + RM_PATHS="${RM_PATHS%"${RM_PATHS##*[![:space:]]}"}" # Mark each rm-detected path as deleted in neo-tree if [[ -n "$RM_PATHS" && "$HAS_NVIM" == "true" ]]; then for path in $RM_PATHS; do - PATH_ESC="$(escape_lua "$path")" - nvim_send "require('code-preview.changes').set('$PATH_ESC', 'deleted')" || true + nvim_call code-preview.changes set \ + "$(jq -nc --arg p "$path" '[$p, "deleted"]')" >/dev/null || true done - nvim_send "pcall(function() require('code-preview.neo_tree').refresh() end)" || true + nvim_call code-preview.neo_tree refresh '[]' >/dev/null || true # Reveal the first deleted file in the tree FIRST_PATH="$(echo "$RM_PATHS" | awk '{print $1}')" - FIRST_ESC="$(escape_lua "$FIRST_PATH")" - nvim_send "vim.defer_fn(function() pcall(function() require('code-preview.neo_tree').reveal('$FIRST_ESC') end) end, 300)" || true + nvim_call code-preview.neo_tree reveal_deferred \ + "$(jq -nc --arg p "$FIRST_PATH" --argjson d 300 '[$p, $d]')" >/dev/null || true fi # ── Tier 1 shell-write detection ──────────────────────────────── @@ -244,7 +261,9 @@ case "$TOOL_NAME" in *) WRITE_PATHS="$WRITE_PATHS $raw" ;; esac done < <(detect_write_paths "$COMMAND") - WRITE_PATHS="$(echo "$WRITE_PATHS" | xargs)" + # Trim without xargs (see RM_PATHS comment above re: apostrophes). + WRITE_PATHS="${WRITE_PATHS#"${WRITE_PATHS%%[![:space:]]*}"}" + WRITE_PATHS="${WRITE_PATHS%"${WRITE_PATHS##*[![:space:]]}"}" # Note: this branch always runs for Bash (no early-exit on read-only # commands). The detector forks several subshells per invocation; if @@ -260,17 +279,17 @@ case "$TOOL_NAME" in else STATUS="bash_created" fi - PATH_ESC="$(escape_lua "$path")" - nvim_send "require('code-preview.changes').set('$PATH_ESC', '$STATUS')" || true + nvim_call code-preview.changes set \ + "$(jq -nc --arg p "$path" --arg s "$STATUS" '[$p, $s]')" >/dev/null || true done - nvim_send "pcall(function() require('code-preview.neo_tree').refresh() end)" || true + nvim_call code-preview.neo_tree refresh '[]' >/dev/null || true # Reveal precedence: rm wins. If the rm branch already queued a # reveal, skip ours so we don't double-fire two defer_fn reveals on # a command that both rm's and writes (e.g. `rm a && echo x > b`). if [[ -z "$RM_PATHS" ]]; then FIRST_PATH="$(echo "$WRITE_PATHS" | awk '{print $1}')" - FIRST_ESC="$(escape_lua "$FIRST_PATH")" - nvim_send "vim.defer_fn(function() pcall(function() require('code-preview.neo_tree').reveal('$FIRST_ESC') end) end, 300)" || true + nvim_call code-preview.neo_tree reveal_deferred \ + "$(jq -nc --arg p "$FIRST_PATH" --argjson d 300 '[$p, $d]')" >/dev/null || true fi fi @@ -318,12 +337,8 @@ case "$TOOL_NAME" in log_pre "ApplyPatch: file=$REL_PATH action=$ACTION" if [[ "$HAS_NVIM" == "true" ]]; then - display_esc="$(escape_lua "$REL_PATH")" - orig_esc="$(escape_lua "$PATCH_ORIG")" - prop_esc="$(escape_lua "$PATCH_PROP")" - fpath_esc="$(escape_lua "$PATCH_FILE_PATH")" - - HOOK_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"require('code-preview').hook_context('${fpath_esc}')\")" 2>/dev/null || echo '{}') + HOOK_CTX="$(nvim_call code-preview hook_context \ + "$(jq -nc --arg fp "$PATCH_FILE_PATH" '[$fp]')" || echo '{}')" VISIBLE_ONLY=$(echo "$HOOK_CTX" | jq -r '.visible_only // false') FILE_VISIBLE=$(echo "$HOOK_CTX" | jq -r '.file_visible // false') @@ -335,8 +350,9 @@ case "$TOOL_NAME" in if [[ "$SHOULD_SHOW" == "1" ]]; then log_pre "ApplyPatch: sending diff for $REL_PATH to nvim (action=$ACTION)" - action_esc="$(escape_lua "$ACTION")" - nvim_send "require('code-preview.diff').show_diff('$orig_esc', '$prop_esc', '$display_esc', '$fpath_esc', '$action_esc')" || true + nvim_call code-preview.diff show_diff \ + "$(jq -nc --arg o "$PATCH_ORIG" --arg p "$PATCH_PROP" --arg d "$REL_PATH" --arg f "$PATCH_FILE_PATH" --arg a "$ACTION" \ + '[$o, $p, $d, $f, $a]')" >/dev/null || true fi else log_pre "ApplyPatch: no nvim connection, skipping diff for $REL_PATH" @@ -357,15 +373,11 @@ esac DISPLAY_NAME="${FILE_PATH#"$CWD/"}" if [[ "$HAS_NVIM" == "true" ]]; then - ORIG_ESC="$(escape_lua "$ORIG_FILE")" - PROP_ESC="$(escape_lua "$PROP_FILE")" - DISPLAY_ESC="$(escape_lua "$DISPLAY_NAME")" - FILE_PATH_ESC="$(escape_lua "$FILE_PATH")" - # Query config + file visibility from nvim in a single RPC call. # Neo-tree indicator/reveal is now driven from lua/code-preview/diff.lua # (inside show_diff), so we only need visibility + permission fields here. - HOOK_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"require('code-preview').hook_context('${FILE_PATH_ESC}')\")" 2>/dev/null || echo '{}') + HOOK_CTX="$(nvim_call code-preview hook_context \ + "$(jq -nc --arg fp "$FILE_PATH" '[$fp]')" || echo '{}')" VISIBLE_ONLY=$(echo "$HOOK_CTX" | jq -r '.visible_only // false') FILE_VISIBLE=$(echo "$HOOK_CTX" | jq -r '.file_visible // false') DEFER_PERMISSIONS=$(echo "$HOOK_CTX" | jq -r 'if .defer_claude_permissions == true then "true" else "false" end') @@ -382,7 +394,9 @@ if [[ "$HAS_NVIM" == "true" ]]; then if [[ "$SHOULD_SHOW" == "1" ]]; then log_pre "sending diff to nvim (layout via config)" - nvim_send "require('code-preview.diff').show_diff('$ORIG_ESC', '$PROP_ESC', '$DISPLAY_ESC', '$FILE_PATH_ESC')" || true + nvim_call code-preview.diff show_diff \ + "$(jq -nc --arg o "$ORIG_FILE" --arg p "$PROP_FILE" --arg d "$DISPLAY_NAME" --arg f "$FILE_PATH" \ + '[$o, $p, $d, $f]')" >/dev/null || true fi fi diff --git a/bin/nvim-call.sh b/bin/nvim-call.sh new file mode 100755 index 0000000..7a3db82 --- /dev/null +++ b/bin/nvim-call.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# nvim-call.sh — Structured RPC into the running Neovim. +# +# Replaces nvim-send.sh's string-interpolated "build Lua source in bash" +# pattern. Args travel as a JSON array via a temp file; the receiving Lua +# decodes them with vim.json.decode and calls the target function. No user +# data ever enters a Lua source string, so escape_lua and its quoting +# footguns are gone. +# +# Usage: +# source bin/nvim-call.sh +# ARGS=$(jq -nc --arg p "$path" --arg s "deleted" '[$p, $s]') +# nvim_call code-preview.changes set "$ARGS" +# +# # Capturing a return value (function returns a string or table): +# CTX=$(nvim_call code-preview.log state '[]') +# echo "$CTX" | jq -r '.debug' +# +# Depends on nvim-socket.sh being sourced first (NVIM_SOCKET must be set). + +# nvim_call MOD FN JSON_ARGS +# Returns: +# 0 on success +# 1 if no nvim socket is available +# 2 if the dispatch itself failed (bad JSON, missing module/function, etc.) +# Stdout is the function's return value: strings pass through, tables are +# JSON-encoded, nil becomes empty string. Dispatch failures are also logged +# inside nvim via log.error (vim.notify), so a silent rc=2 here is visible +# to the developer through the editor. +nvim_call() { + local mod="$1" fn="$2" args="${3:-[]}" + if [[ -z "${NVIM_SOCKET:-}" ]]; then + return 1 + fi + local tmp + tmp="$(mktemp "${TMPDIR:-/tmp}/code-preview-args.XXXXXX")" + printf '%s' "$args" > "$tmp" + # Only mod/fn/tmp — all controlled by us — get interpolated into Lua source. + # User data flows through $tmp as JSON. + nvim --server "$NVIM_SOCKET" --remote-expr \ + "luaeval(\"require('code-preview.rpc').dispatch('$mod', '$fn', '$tmp')\")" 2>/dev/null + local rc=$? + rm -f "$tmp" + if [[ $rc -ne 0 ]]; then + return 2 + fi + return 0 +} diff --git a/bin/nvim-send.sh b/bin/nvim-send.sh deleted file mode 100755 index 447d727..0000000 --- a/bin/nvim-send.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# nvim-send.sh — Send a Lua command to Neovim via RPC. -# -# Usage: -# source bin/nvim-send.sh -# nvim_send "require('code-preview.diff').show_diff('a', 'b', 'c')" -# -# Depends on nvim-socket.sh being sourced first (NVIM_SOCKET must be set). - -# Escape a string for use inside a Lua single-quoted string literal -escape_lua() { - echo "$1" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g" -} - -# Send a Lua command to Neovim via --remote-expr (synchronous). -# Writes to a temp file and uses execute('luafile ...') so that: -# - There are no command-line length limits -# - No keystrokes are simulated (safe inside terminal buffers) -# - The call blocks until Neovim finishes executing the Lua -# Returns 0 if sent, 1 if no socket available -nvim_send() { - local lua_cmd="$1" - if [[ -z "${NVIM_SOCKET:-}" ]]; then - return 1 - fi - local tmp_lua - tmp_lua="$(mktemp /tmp/code-preview-nvim-cmd.XXXXXX)" - printf '%s' "$lua_cmd" > "$tmp_lua" - nvim --server "$NVIM_SOCKET" --remote-expr "execute('luafile $tmp_lua')" >/dev/null 2>&1 - local rc=$? - rm -f "$tmp_lua" - return $rc -} diff --git a/lua/code-preview/health.lua b/lua/code-preview/health.lua index 1d7a28d..fbad548 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -42,6 +42,14 @@ function M.check() end end + -- RPC dispatcher (loaded by every hook RPC; readability check only since + -- it's a Lua module on the rtp, not a standalone script). + if pcall(require, "code-preview.rpc") then + ok("code-preview.rpc dispatcher loadable") + else + error("code-preview.rpc dispatcher failed to load — hook RPC will not work") + end + -- ── Claude Code backend ─────────────────────────────────────── start("Claude Code backend") @@ -79,7 +87,7 @@ function M.check() -- Shared scripts for _, script in ipairs({ "nvim-socket.sh", - "nvim-send.sh", + "nvim-call.sh", "apply-edit.lua", "apply-multi-edit.lua", }) do diff --git a/lua/code-preview/log.lua b/lua/code-preview/log.lua index 439f38c..2d588d9 100644 --- a/lua/code-preview/log.lua +++ b/lua/code-preview/log.lua @@ -67,4 +67,23 @@ function M.get_log_path() return log_file_path end +--- Bundled state for hook-script logging setup. Returns the fields all +--- bash hooks need in one RPC call: debug flag, log file path, this +--- nvim's servername, and its cwd. Backends use the latter two when +--- they want to log which nvim instance the diff is being routed to. +--- +--- Consumers (all in bin/ and backends/): core-pre-tool.sh, core-post-tool.sh +--- use debug + log_file. copilot/code-preview-diff.sh additionally reads +--- servername + cwd. Renaming any field is a breaking change for those +--- scripts — grep for "log state" before touching this shape. +--- @return { debug: boolean, log_file: string, servername: string, cwd: string } +function M.state() + return { + debug = enabled, + log_file = log_file_path or "", + servername = vim.v.servername or "", + cwd = vim.fn.getcwd(), + } +end + return M diff --git a/lua/code-preview/neo_tree.lua b/lua/code-preview/neo_tree.lua index 693d355..3a331cf 100644 --- a/lua/code-preview/neo_tree.lua +++ b/lua/code-preview/neo_tree.lua @@ -357,6 +357,23 @@ function M.refresh() end) end +--- Schedule a M.refresh() call after `ms` milliseconds. Used by +--- core-post-tool.sh to give the file system a moment to settle after +--- Bash tool changes before redrawing the tree. +function M.refresh_deferred(ms) + vim.defer_fn(function() pcall(M.refresh) end, ms or 200) +end + +--- Schedule a M.reveal() call after `ms` milliseconds. Useful for hook +--- scripts that want the tree to settle (e.g. after a refresh) before the +--- viewport jumps. Wrapped in pcall so a torn-down nvim during the delay +--- can't propagate an error. +function M.reveal_deferred(filepath, ms, dir) + vim.defer_fn(function() + pcall(M.reveal, filepath, dir) + end, ms or 300) +end + function M.reveal(filepath, dir) if not has_neo_tree then return diff --git a/lua/code-preview/rpc.lua b/lua/code-preview/rpc.lua new file mode 100644 index 0000000..3df7d07 --- /dev/null +++ b/lua/code-preview/rpc.lua @@ -0,0 +1,66 @@ +-- rpc.lua — Dispatch entry point for hook scripts that call into +-- code-preview from bash via `nvim --server ... --remote-expr`. +-- +-- The bash side (bin/nvim-call.sh) writes JSON arguments to a temp file and +-- invokes: +-- +-- luaeval("require('code-preview.rpc').dispatch(MOD, FN, FILE)") +-- +-- Only the module name, function name, and temp-file path — all controlled +-- by us — are interpolated into the Lua string. User-controlled data +-- (file paths, file contents, etc.) flows through JSON, decoded here. This +-- replaces the older pattern of building Lua source via `escape_lua` in bash, +-- which was the source of every quoting bug in the hook layer. + +local M = {} + +--- @param mod string module name, e.g. "code-preview.diff" +--- @param fn string function name on that module, e.g. "show_diff" +--- @param args_file string path to a JSON file containing an array of args +--- @return string the function's return value, JSON-encoded if it's a table, +--- passed through if it's already a string, "" otherwise. +--- +--- Errors are caught and surfaced via log.error (which calls vim.notify), then +--- re-raised so the bash --remote-expr call exits non-zero. The bash side +--- swallows the rc by design — hooks must never block the agent — but the +--- vim.notify ensures the developer sees the failure instead of a silent +--- no-op preview. +function M.dispatch(mod, fn, args_file) + local ok, result = pcall(function() + local f, err = io.open(args_file, "r") + if not f then + error("open " .. args_file .. ": " .. tostring(err)) + end + local raw = f:read("*a") + f:close() + + local args = vim.json.decode(raw) + -- vim.islist (0.10+) / vim.tbl_islist (older) — JSON arrays only. + -- A bare object like {"k":"v"} would unpack to zero args and silently + -- call the target with no arguments. + local islist = vim.islist or vim.tbl_islist + if type(args) ~= "table" or not islist(args) then + error("args file must contain a JSON array, got " .. type(args)) + end + + local module = require(mod) + local target = module[fn] + if type(target) ~= "function" then + error(mod .. "." .. fn .. " is not a function") + end + + return target(unpack(args)) + end) + + if not ok then + local msg = string.format("rpc.dispatch(%s, %s): %s", mod, fn, tostring(result)) + require("code-preview.log").error(msg) + error(msg) + end + + if result == nil then return "" end + if type(result) == "string" then return result end + return vim.json.encode(result) +end + +return M