Skip to content
Merged
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ ratatui = "0.30.2"
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
image = { version = "0.25.6", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.151"
serde_norway = "0.9.42"
toml = "0.9.8"
toml_edit = "0.25.15"
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ wiki-reader fixtures/worked-example
# q to quit, ? for help
```

### In herdr

[`integrations/herdr/`](integrations/herdr/README.md) is a herdr plugin: bind a key to its
`wiki-reader.open` action to open the reader in a new split pane in the focused pane's
directory. (Images need an ordinary pane, which that action uses; herdr 0.9.x plugin panes
such as overlays and popups show text only.)

## Checks

```bash
Expand Down
1 change: 1 addition & 0 deletions crates/wiki-reader-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ thiserror = { workspace = true }
ignore = { workspace = true }
globset = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_norway = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
Expand Down
155 changes: 154 additions & 1 deletion crates/wiki-reader-core/src/herdr.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,80 @@
//! Reading herdr's own settings (read-only).
//! Reading herdr's settings and plugin launch context (read-only).
//!
//! herdr does not tell child programs which theme it uses (no environment variable or socket
//! call), but the choice is in its `config.toml`, so wiki-reader reads the theme name from there
//! when its own `theme = "herdr"` is in effect.

use std::path::{Path, PathBuf};

/// What the launcher needs from herdr's plugin context (`HERDR_PLUGIN_CONTEXT_JSON`).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PluginContext {
/// The pane that had focus when the plugin ran, if known.
pub focused_pane_id: Option<String>,
/// Collection cwd: the focused pane's cwd first, then the workspace's.
pub cwd: Option<PathBuf>,
}

/// Parse the plugin context. `None` for text that is not a JSON object; absent or empty
/// fields are left unset. This only parses: callers check that the cwd is a directory.
#[must_use]
pub fn parse_context(text: &str) -> Option<PluginContext> {
let context: serde_json::Value = serde_json::from_str(text).ok()?;
let object = context.as_object()?;
let string = |key: &str| {
object
.get(key)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
};
Some(PluginContext {
focused_pane_id: string("focused_pane_id").map(str::to_owned),
cwd: string("focused_pane_cwd")
.or_else(|| string("workspace_cwd"))
.map(PathBuf::from),
})
}

/// Collection cwd from Herdr's plugin context: focused pane first, then workspace.
///
/// Missing, malformed or empty fields return `None`.
#[must_use]
pub fn parse_context_cwd(text: &str) -> Option<PathBuf> {
parse_context(text)?.cwd
}

/// The new pane's id from a `herdr pane split` JSON response (`.result.pane.pane_id`).
#[must_use]
pub fn parse_split_pane_id(text: &str) -> Option<String> {
let response: serde_json::Value = serde_json::from_str(text).ok()?;
let id = response
.get("result")?
.get("pane")?
.get("pane_id")?
.as_str()?
.trim();
(!id.is_empty()).then(|| id.to_owned())
}

/// Whether this process was started by a herdr plugin pane entrypoint of any placement:
/// inside herdr (`HERDR_ENV=1`) with `HERDR_PLUGIN_ENTRYPOINT_ID` set.
///
/// herdr 0.9.x starts those panes without pixel metrics and never answers the terminal's
/// cell-size query, so graphics cannot be sized there. Ordinary shell panes answer.
#[must_use]
pub fn is_plugin_pane(herdr_env: Option<&str>, entrypoint_id: Option<&str>) -> bool {
herdr_env == Some("1") && entrypoint_id.is_some_and(|id| !id.trim().is_empty())
}

/// [`is_plugin_pane`] for this process's environment.
#[must_use]
pub fn running_in_plugin_pane() -> bool {
let env = std::env::var("HERDR_ENV").ok();
let entrypoint = std::env::var("HERDR_PLUGIN_ENTRYPOINT_ID").ok();
is_plugin_pane(env.as_deref(), entrypoint.as_deref())
}

/// herdr's config file: `$XDG_CONFIG_HOME/herdr/config.toml`, else `~/.config/herdr/config.toml`.
#[must_use]
pub fn config_path() -> Option<PathBuf> {
Expand Down Expand Up @@ -46,6 +115,90 @@ pub fn parse_theme_name(text: &str) -> Option<String> {
mod tests {
use super::*;

#[test]
fn plugin_pane_is_herdr_with_an_entrypoint_id() {
assert!(is_plugin_pane(Some("1"), Some("reader-popup")));
assert!(is_plugin_pane(Some("1"), Some("overlay")));
assert!(!is_plugin_pane(Some("1"), None), "an ordinary herdr pane");
assert!(!is_plugin_pane(Some("1"), Some(" ")));
assert!(!is_plugin_pane(None, Some("overlay")), "outside herdr");
assert!(!is_plugin_pane(Some("0"), Some("overlay")));
}

#[test]
fn context_reads_the_focused_pane_and_cwd() {
let context = parse_context(
r#"{"focused_pane_id":" w1:p2 ","focused_pane_cwd":"/a b","workspace_cwd":"/w","x":1}"#,
)
.unwrap();
assert_eq!(context.focused_pane_id.as_deref(), Some("w1:p2"));
assert_eq!(context.cwd, Some(PathBuf::from("/a b")));
let partial = parse_context(r#"{"workspace_cwd":"/w"}"#).unwrap();
assert_eq!(partial.focused_pane_id, None);
assert_eq!(partial.cwd, Some(PathBuf::from("/w")));
assert_eq!(parse_context("{}"), Some(PluginContext::default()));
for text in ["", "not json", "null", "[]", "42"] {
assert_eq!(parse_context(text), None, "{text}");
}
}

#[test]
fn split_response_yields_the_new_pane_id() {
assert_eq!(
parse_split_pane_id(r#"{"id":"cli:pane:split","result":{"pane":{"pane_id":"w30:pZ"},"type":"pane_info"}}"#)
.as_deref(),
Some("w30:pZ")
);
for text in [
"",
"{}",
r#"{"result":{}}"#,
r#"{"result":{"pane":{"pane_id":""}}}"#,
r#"{"result":{"pane":{"pane_id":7}}}"#,
r#"{"error":{"code":"x"}}"#,
] {
assert_eq!(parse_split_pane_id(text), None, "{text}");
}
}

#[test]
fn context_prefers_focused_pane_and_preserves_path() {
assert_eq!(
parse_context_cwd(
r#"{"focused_pane_cwd":"/a collection/with spaces", "workspace_cwd":"/workspace", "extra":true}"#
),
Some(PathBuf::from("/a collection/with spaces"))
);
}

#[test]
fn context_falls_back_to_workspace() {
for focused in ["null", "42", "\"\"", "\" \""] {
let text = format!(r#"{{"focused_pane_cwd":{focused},"workspace_cwd":"/workspace"}}"#);
assert_eq!(parse_context_cwd(&text), Some(PathBuf::from("/workspace")));
}
assert_eq!(
parse_context_cwd(r#"{"workspace_cwd":"/workspace"}"#),
Some(PathBuf::from("/workspace"))
);
}

#[test]
fn context_rejects_malformed_or_missing_paths() {
for text in [
"",
"not json",
"{",
"null",
"[]",
"{}",
r#"{"workspace_cwd":false}"#,
r#"{"workspace_cwd":" "}"#,
] {
assert_eq!(parse_context_cwd(text), None, "{text}");
}
}

#[test]
fn reads_the_theme_name() {
let text = "onboarding = false\n[ui]\nagent_panel_sort = \"spaces\"\n[theme]\nname = \"vesper\"\nauto_switch = false\n";
Expand Down
5 changes: 5 additions & 0 deletions crates/wiki-reader-core/src/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub enum ImageReject {
NoRoot,
/// The terminal has no usable graphics protocol (the image tier is an upgrade).
NoGraphics,
/// No graphics inside a herdr plugin pane, which reports no cell size (herdr 0.9.x).
NoGraphicsHerdrPlugin,
}

impl fmt::Display for ImageReject {
Expand All @@ -50,6 +52,9 @@ impl fmt::Display for ImageReject {
Self::Unreadable => "unreadable image",
Self::NoRoot => "no collection root",
Self::NoGraphics => "no graphics protocol",
Self::NoGraphicsHerdrPlugin => {
"no graphics protocol; herdr plugin panes report no cell size, open the reader in a normal pane"
}
})
}
}
Expand Down
86 changes: 86 additions & 0 deletions crates/wiki-reader-core/tests/herdr_plugin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! Contract checks for the shipped plugin; never links to a live Herdr session.

fn manifest() -> toml::Table {
toml::from_str(include_str!(
"../../../integrations/herdr/herdr-plugin.toml"
))
.unwrap()
}

fn argv(value: &toml::Value) -> Vec<&str> {
value["command"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect()
}

#[test]
fn manifest_offers_text_tier_plugin_panes_and_a_split_launcher() {
let manifest = manifest();
assert_eq!(manifest["id"].as_str(), Some("wiki-reader"));
assert_eq!(manifest["min_herdr_version"].as_str(), Some("0.9.0"));
assert_eq!(manifest["platforms"].as_array().unwrap().len(), 2);
let panes = manifest["panes"].as_array().unwrap();
assert_eq!(panes.len(), 2);
assert_eq!(panes[0]["id"].as_str(), Some("reader-overlay"));
assert_eq!(panes[0]["placement"].as_str(), Some("overlay"));
let popup = &panes[1];
assert_eq!(popup["id"].as_str(), Some("reader-popup"));
assert_eq!(popup["placement"].as_str(), Some("popup"));
assert_eq!(popup["width"].as_str(), Some("80%"));
assert_eq!(popup["height"].as_str(), Some("80%"));
for pane in panes {
assert_eq!(argv(pane), ["wiki-reader", "--herdr-context"]);
}
let actions = manifest["actions"].as_array().unwrap();
let ids: Vec<_> = actions.iter().map(|a| a["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["open", "open-overlay", "open-popup"]);
assert_eq!(
argv(&actions[0]),
["wiki-reader", "--herdr-split"],
"the default action opens an ordinary pane, which can draw images"
);
}

#[cfg(unix)]
#[test]
fn plugin_pane_actions_use_inherited_binary_and_propagate_failure() {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;

let manifest = manifest();
let actions = manifest["actions"].as_array().unwrap();
assert_eq!(actions.len(), 3);
for (action, id, entrypoint) in [
(&actions[1], "open-overlay", "reader-overlay"),
(&actions[2], "open-popup", "reader-popup"),
] {
assert_eq!(action["id"].as_str(), Some(id));
let argv = argv(action);
let dir = tempfile::tempdir().unwrap();
// Spaces and shell metacharacters must stay part of the binary path.
let binary = dir.path().join("herdr binary; not a shell command");
std::fs::write(&binary, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap();
let output = Command::new(argv[0])
.args(&argv[1..])
.env("HERDR_BIN_PATH", &binary)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!("plugin\npane\nopen\n--plugin\nwiki-reader\n--entrypoint\n{entrypoint}\n")
);
let status = Command::new(argv[0])
.args(&argv[1..])
.env("HERDR_BIN_PATH", dir.path().join("missing"))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert!(!status.success());
}
}
Loading
Loading