diff --git a/Documentation.md b/Documentation.md index af445c5..1520ab5 100644 --- a/Documentation.md +++ b/Documentation.md @@ -429,6 +429,29 @@ Activate a named profile. Emits `bread.profile.activated` over IPC. #### `bread.exec(cmd)` Run a shell command. Fire-and-forget (async, does not block Lua). +#### `bread.exec_capture(cmd, opts) -> ok, stdout` +Run a shell command and return its result: `ok` is whether it exited zero, +`stdout` is its captured standard output. Unlike `bread.exec`, this blocks +the calling Lua callback until the command exits (or the timeout below +elapses), so it's only appropriate for fast, local commands — e.g. +`git -C rev-parse --abbrev-ref HEAD`, not anything that hits the +network or waits on user input. + +```lua +local ok, branch = bread.exec_capture("git -C " .. dir .. " rev-parse --abbrev-ref HEAD") +if ok then + branch = branch:gsub("%s+$", "") -- trailing newline +end +``` + +Options: + +| Key | Type | Default | +|-----|------|---------| +| `timeout_ms` | number | `2000` | + +On timeout or spawn failure, returns `false, ""`. + ### Notifications #### `bread.notify(message, opts)` @@ -494,9 +517,20 @@ Read a file. Returns `nil` if the file does not exist. `~` is expanded. #### `bread.fs.exists(path) -> bool` Returns true if the path exists. `~` is expanded. +#### `bread.fs.readlink(path) -> string | nil` +Read a symlink's target. Returns `nil` if the path doesn't exist or isn't a +symlink. Distinct from `bread.fs.read`, which opens and reads file +*contents* — for something like `/proc//cwd`, the payload is the link +target itself, not a file to read. + #### `bread.fs.expand(path) -> string` Expand `~` to the home directory. +#### `bread.json.decode(str) -> table | nil` +Parse a JSON string into a Lua table. Returns `nil` on malformed input. +Pairs naturally with `bread.exec_capture` for consuming JSON output from a +CLI (e.g. `kitty @ ls`). + ### Hyprland The `bread.hyprland` namespace provides compositor bindings. diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 0f8a049..edbf437 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -582,6 +582,43 @@ impl LuaEngine { })?; bread.set("exec", exec_fn)?; + // `bread.exec` is deliberately fire-and-forget (spawn_blocking, no + // result). This is the capturing counterpart for the common "run a + // fast local command and read its stdout back into Lua" case (e.g. + // `git -C rev-parse --abbrev-ref HEAD`). It blocks the calling + // Lua callback for real, so it's only appropriate for quick + // commands — hence the timeout. The subprocess itself runs on a + // plain std::thread (not spawn_blocking) so the Lua thread can wait + // on a channel with a deadline; `Command::output()` drains stdout + // internally as it reads, so a chatty command can't deadlock this by + // filling a pipe buffer while nobody's reading it. On timeout the + // spawned thread and its child are left to finish/exit on their own + // rather than force-killed — acceptable for the fast-command case + // this exists for, not worth the extra complexity for a rare hang. + let exec_capture_fn = + self.lua + .create_function(|_lua, (cmd, opts): (String, Option)| { + let timeout_ms: u64 = opts + .as_ref() + .and_then(|o| o.get("timeout_ms").ok()) + .unwrap_or(2000); + + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = std::process::Command::new("sh").arg("-c").arg(&cmd).output(); + let _ = tx.send(result); + }); + + match rx.recv_timeout(std::time::Duration::from_millis(timeout_ms)) { + Ok(Ok(output)) => Ok(( + output.status.success(), + String::from_utf8_lossy(&output.stdout).to_string(), + )), + Ok(Err(_)) | Err(_) => Ok((false, String::new())), + } + })?; + bread.set("exec_capture", exec_capture_fn)?; + let notify_path = self.notifications_config.notify_send_path.clone(); let default_urgency = self.notifications_config.default_urgency.clone(); let default_timeout = self.notifications_config.default_timeout_ms; @@ -1078,6 +1115,19 @@ impl LuaEngine { .create_function(|_lua, path: String| Ok(lua_expand_path(&path).exists()))?; fs_tbl.set("exists", exists_fn)?; + // Distinct from `read`: `/proc//cwd` and friends are symlinks + // whose *target path* is the payload, not a file to open and read — + // `std::fs::read_to_string` on one of those fails with "Is a + // directory" (or reads the wrong thing for a symlink-to-file). + let readlink_fn = self.lua.create_function(|_lua, path: String| { + let expanded = lua_expand_path(&path); + match std::fs::read_link(&expanded) { + Ok(target) => Ok(Some(target.to_string_lossy().to_string())), + Err(_) => Ok(None), + } + })?; + fs_tbl.set("readlink", readlink_fn)?; + let expand_fn = self.lua.create_function(|_lua, path: String| { Ok(lua_expand_path(&path).to_string_lossy().to_string()) })?; @@ -1085,6 +1135,21 @@ impl LuaEngine { bread.set("fs", fs_tbl)?; + // bread.json — for parsing output from things like `kitty @ ls` or + // any other JSON-emitting CLI invoked via bread.exec_capture. Uses + // the same null-handling as every other JSON entry point into Lua + // (json_to_lua, not a bare to_value) so `nil` behaves as Lua nil, + // not a sentinel. + let json_tbl = self.lua.create_table()?; + let decode_fn = self.lua.create_function(|lua, s: String| { + match serde_json::from_str::(&s) { + Ok(v) => json_to_lua(lua, &v).map(Some).or(Ok(None)), + Err(_) => Ok(None), + } + })?; + json_tbl.set("decode", decode_fn)?; + bread.set("json", json_tbl)?; + // bread.bluetooth — BlueZ control let bluetooth_tbl = self.lua.create_table()?; diff --git a/examples/modules/README.md b/examples/modules/README.md index 05eaa67..0dc9439 100644 --- a/examples/modules/README.md +++ b/examples/modules/README.md @@ -26,6 +26,7 @@ bread reload | `bluetooth-toggle-widget.lua` | One-click Bluetooth power toggle in breadbar's tray, via `bread.widget` + a click handler. | none | | `focus-mode-widget.lua` | Click-to-toggle "Focus" profile that mutes audio; a widget as an action launcher, not just a readout, and stays in sync with profile changes triggered elsewhere. | none (needs `wpctl`) | | `workflow-status-widget.lua` | Surfaces `bread.workflow.list()` in breadbar's tray — shows whichever workflow (e.g. `dock-workflow.lua`, below) is currently running or failed, hidden otherwise. | none | +| `git-branch-widget.lua` | Shows the repo + branch of whichever git repo the focused kitty tab is sitting in; yellow when dirty. Entirely self-contained in Lua — no adapter behind it. | kitty remote control (see the module's header comment) | Each module is the standard skeleton — `bread.module{...}`, an `on_load` that registers subscriptions, `return M` — so they double as references for writing diff --git a/examples/modules/git-branch-widget.lua b/examples/modules/git-branch-widget.lua new file mode 100644 index 0000000..8e971ba --- /dev/null +++ b/examples/modules/git-branch-widget.lua @@ -0,0 +1,154 @@ +-- git-branch-widget — shows " " for whichever git repo the +-- currently focused terminal's *active tab* is sitting in, yellow when the +-- worktree is dirty. Hides entirely when focus isn't on a terminal, or the +-- focused tab isn't inside a git repo. +-- +-- This is a plain Lua module doing its own OS-level legwork end to end — +-- no dedicated Rust adapter behind it. It combines four general-purpose +-- primitives that all already exist (or were added alongside this module +-- as small, non-kitty-specific additions): bread.hyprland.active_window() +-- for the focused window's class + pid, bread.fs.exists to probe for a +-- listening socket, bread.exec_capture to run `kitty @ ls` and read its +-- output, and bread.json.decode to parse it. +-- +-- Why kitty remote control instead of /proc: a kitty *window* can host +-- several *tabs*, each a separate child shell process, and the kernel has +-- no notion of "which pty is currently displayed" — that's purely internal +-- kitty state. Walking /proc can find the window's child processes but +-- can't tell which one you're actually looking at. Kitty's own `kitty @ ls` +-- tracks focus precisely at the OS-window/tab/window level, so asking it +-- directly is the only way to get this exactly right for multi-tab windows. +-- +-- Prerequisite — add to ~/.config/kitty/kitty.conf: +-- allow_remote_control socket-only +-- listen_on unix:/tmp/kitty-bread-{kitty_pid} +-- `{kitty_pid}` makes the socket path unique per kitty process, so this +-- works whether or not you run kitty in single-instance mode, and however +-- many separate kitty processes you have open — this module derives the +-- exact socket to ask from the focused window's own pid. Kitty only picks +-- up `listen_on` on (re)start, not a config reload, so existing kitty +-- windows need to be restarted once after adding this. +-- +-- Drop-in: copy into ~/.config/bread/modules/. Needs `git` and the kitty +-- remote-control config above. Assumes the terminal's WM_CLASS is "kitty" +-- (edit TERMINAL_CLASS below for another terminal, if it has an equivalent +-- remote-control/introspection story). + +local M = bread.module({ name = "git-branch-widget", version = "1.0.0" }) + +local TERMINAL_CLASS = "kitty" + +local function shell_quote(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +-- The exact cwd of the focused tab in the focused kitty window, or nil if +-- focus isn't on kitty, that kitty process hasn't been restarted since the +-- listen_on config was added, or nothing came back focused (shouldn't +-- happen for a window Hyprland itself says is focused, but `kitty @ ls` +-- reflects kitty's own state, not Hyprland's, so treat it as fallible). +local function focused_tab_cwd() + local win = bread.hyprland.active_window() + if type(win) ~= "table" or win.class ~= TERMINAL_CLASS or not win.pid then + return nil + end + + local socket_path = "/tmp/kitty-bread-" .. win.pid + if not bread.fs.exists(socket_path) then + return nil + end + + local ok, output = bread.exec_capture("kitty @ --to unix:" .. socket_path .. " ls") + if not ok then + return nil + end + + local os_windows = bread.json.decode(output) + if type(os_windows) ~= "table" then + return nil + end + + for _, osw in ipairs(os_windows) do + if osw.is_focused then + for _, tab in ipairs(osw.tabs or {}) do + if tab.is_focused then + for _, w in ipairs(tab.windows or {}) do + if w.is_focused then + return w.cwd + end + end + end + end + end + end + + return nil +end + +local function git_info(cwd) + local quoted = shell_quote(cwd) + + local ok, toplevel = bread.exec_capture("git -C " .. quoted .. " rev-parse --show-toplevel") + if not ok then + return nil + end + toplevel = toplevel:gsub("%s+$", "") + local repo = toplevel:match("([^/]+)/?$") or toplevel + + local repo_quoted = shell_quote(toplevel) + local branch_ok, branch = bread.exec_capture("git -C " .. repo_quoted .. " rev-parse --abbrev-ref HEAD") + if not branch_ok then + return nil + end + branch = branch:gsub("%s+$", "") + + local _, status = bread.exec_capture("git -C " .. repo_quoted .. " status --porcelain") + local dirty = status:match("%S") ~= nil + + return { repo = repo, branch = branch, dirty = dirty } +end + +local function widget_root(info) + if not info then + return { type = "label", text = "" } + end + return { + type = "label", + text = info.repo .. " " .. info.branch, + style = { color = info.dirty and "yellow" or "dim" }, + } +end + +local function update() + local cwd = focused_tab_cwd() + local info = cwd and git_info(cwd) or nil + + bread.widget.update("branch", { + visible = info ~= nil, + tooltip = info and ("git: " .. info.repo .. "@" .. info.branch .. (info.dirty and " (dirty)" or "")) or "", + root = widget_root(info), + }) +end + +function M.on_load() + local cwd = focused_tab_cwd() + local info = cwd and git_info(cwd) or nil + + bread.widget.register({ + id = "branch", + placement = "right_of_clock", + visible = info ~= nil, + tooltip = info and ("git: " .. info.repo .. "@" .. info.branch) or "", + root = widget_root(info), + }) + + -- Event-driven for instant updates on focus change, plus a poll to + -- catch a branch switch inside the same still-focused tab (e.g. `git + -- checkout` run without ever changing window focus), which produces no + -- focus event at all. + bread.on("bread.window.focused", update) + bread.on("bread.window.focus.changed", update) + bread.every(2000, update) +end + +return M