Will change this commit message to mean something later
All checks were successful
dev release / build (push) Successful in 56s

This commit is contained in:
Breadway 2026-07-23 11:16:33 +08:00
parent f9a8c4f915
commit 8a794c03bf
4 changed files with 254 additions and 0 deletions

View file

@ -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 <dir> 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/<pid>/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.

View file

@ -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 <dir> 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<Table>)| {
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/<pid>/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::<JsonValue>(&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()?;

View file

@ -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

View file

@ -0,0 +1,154 @@
-- git-branch-widget — shows "<repo> <branch>" 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