Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ config key and above the built-in default — `editor` (`$EDITOR`) and `update_c
(`$HERDR_FILE_VIEWER_NO_UPDATE_CHECK`) — giving those two a `config > env > default` chain. Every
other key (`markdown`, `diff`, `syntax`, `open`, `reveal`, `hide_dotfiles`, `show_ignored`,
`compact_dirs`, `changed_file_view`, `confirm_discard`, `scroll_lines`, `tree_width`,
`tree_position`, `tree_max_cols`, `preview_max_lines`, `preview_max_kib`) has no
`tree_position`, `open_direction`, `tree_max_cols`, `preview_max_lines`, `preview_max_kib`) has no
applicable environment variable; for those it's `config > default` only.

## Keys
Expand All @@ -75,6 +75,7 @@ scroll_lines = 3 # mouse-wheel step (content/search/help), a 1 to 10
tree_width = 30 # tree column's share of the viewer pane, percent 20-80 (content takes the rest)
tree_max_cols = 30 # HARD CAP in columns; the SMALLER of this and tree_width% wins (raise both to widen)
tree_position = "left" # which side the directory tree sits on: "left" (default) or "right"
open_direction = "right" # which way the viewer PANE splits off your work pane: "right" (default) or "down"

preview_max_lines = 10000 # show at most this many lines before a truncated preview (100–100000)
preview_max_kib = 1024 # ...or this size before truncating, in KiB (1024 = 1 MB; 64–65536)
Expand Down Expand Up @@ -104,6 +105,12 @@ the `left` (default) or `right`. All three set the **startup** split inside the
(not the herdr pane, which the host decides); you can still resize live with the grow/shrink keys or
by dragging the divider, and an explicit resize lifts the cap.

`open_direction` is the one placement key that acts **outside** the viewer's own pane: it decides
which way the launchers split the viewer off your work pane when it opens — `"right"` (the default,
the viewer appears beside your work) or `"down"` (below it, which suits a tall/narrow layout).
Values are trimmed and case-insensitive; an unrecognized value falls back to `"right"`, and it
only affects the split-pane launcher (`open-file-viewer`) — the tab variant has no split to aim.

`preview_max_lines` and `preview_max_kib` cap how much of a file the content pane shows: a file is
displayed in full until it exceeds **either** cap, then the pane shows a truncated preview with a
`⚠ Truncated preview` notice (the same bound also applies to a large diff). Truncation fires on
Expand Down
18 changes: 17 additions & 1 deletion scripts/open-file-viewer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,25 @@ function Get-ConfigDir {
return ''
}

# The `open_direction` config key, read through the binary's `--open-direction` probe (the shell
# cannot parse TOML). Anything unexpected — binary missing, probe failure, a future value —
# degrades to `right`, today's layout. HERDR_PLUGIN_CONFIG_DIR is passed for the same reason
# Open-Pane passes it: without it the probe cannot find config.toml on Windows and would always
# answer the default.
function Get-OpenDirection {
try {
$cfg = Get-ConfigDir
if ($cfg) { $env:HERDR_PLUGIN_CONFIG_DIR = $cfg }
$d = (& $ViewerBin --open-direction 2>$null | Out-String).Trim()
if ($d -eq 'down') { return 'down' }
} catch {}
return 'right'
}

function Open-Pane {
$cwd = Get-UserCwd
$splitArgs = @('pane', 'split', '--direction', 'right', '--cwd', $cwd, '--focus')
$direction = Get-OpenDirection
$splitArgs = @('pane', 'split', '--direction', $direction, '--cwd', $cwd, '--focus')
$cfg = Get-ConfigDir
if ($cfg) { $splitArgs += @('--env', "HERDR_PLUGIN_CONFIG_DIR=$cfg") }
$out = (& $HerdrBin @splitArgs | Out-String)
Expand Down
12 changes: 11 additions & 1 deletion scripts/open-file-viewer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,21 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
viewer_bin="$script_dir/../target/release/herdr-file-viewer"

open_pane() {
# `open_direction` config key, read through the binary's `--open-direction` probe (the shell
# cannot parse TOML). The value is already in `--direction` vocabulary; anything unexpected —
# binary missing, probe failure, a future value — degrades to `right`, today's layout, rather
# than handing herdr a direction it would reject.
direction="right"
if [ -x "$viewer_bin" ]; then
case "$("$viewer_bin" --open-direction 2>/dev/null)" in
down) direction="down" ;;
esac
fi
exec "$herdr_bin" plugin pane open \
--plugin herdr-file-viewer \
--entrypoint file-viewer \
--placement split \
--direction right \
--direction "$direction" \
--focus
}

Expand Down
99 changes: 99 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,33 @@ impl TreePosition {
}
}

/// Which way the viewer pane is **split off the invoking pane** when it opens (`open_direction`
/// config key): `Right` (the default — the viewer appears beside the work, today's layout) or
/// `Down` (the viewer appears below it). The vocabulary is exactly what herdr's
/// `plugin pane open --direction` accepts, so the config can never ask for a placement herdr
/// cannot perform. Like [`TreePosition`], the config value is a lenient `Option<String>` resolved
/// into this by [`resolve`] (case-insensitive, trimmed); this enum is never deserialized directly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OpenDirection {
/// Split right of the invoking pane (the default, today's layout).
#[default]
Right,
/// Split below the invoking pane.
Down,
}

impl OpenDirection {
/// The lowercase label: shown in the read-only Settings overlay, and the literal value the
/// launcher passes to `herdr plugin pane open --direction` (kept identical on purpose —
/// `--open-direction` prints this and the shell never re-maps it).
pub fn label(self) -> &'static str {
match self {
OpenDirection::Right => "right",
OpenDirection::Down => "down",
}
}
}

/// A `[keys]` entry's value: the key(s) an intent binds to, written **either** as a single string
/// (`refresh = "g"`) **or** as a TOML array of strings (`nav_up = ["w", "Up"]`). `#[serde(untagged)]`
/// tries the variants in order, so `One(String)` must come first: a bare string deserializes to
Expand Down Expand Up @@ -156,6 +183,11 @@ pub struct Config {
/// (`"left"` / `"right"`, case-insensitive, trimmed) resolved into a [`TreePosition`] by
/// [`resolve`]; `None` or an unrecognized value falls back to [`TreePosition::Left`].
pub tree_position: Option<String>,
/// The **open direction**: which way the viewer pane splits off the invoking pane when it
/// opens (`"right"` / `"down"`, case-insensitive, trimmed), resolved into an
/// [`OpenDirection`] by [`resolve`]. `None` or an unrecognized value falls back to
/// [`OpenDirection::Right`] — the beside-the-work split the viewer has always used.
pub open_direction: Option<String>,
/// The **tree column cap**: the maximum tree width in character columns (see
/// [`DEFAULT_TREE_MAX_COLS`]). `None` falls back to that default; the resolver clamps any present
/// value into `MIN_TREE_MAX_COLS..=MAX_TREE_MAX_COLS`. Held as `u32` (like `tree_width`) so an
Expand Down Expand Up @@ -316,6 +348,10 @@ pub struct EffectiveSettings {
/// The effective **tree position**: the config `tree_position` mapped to `Left`/`Right`, else
/// [`TreePosition::Left`]. Config-or-default (no env var).
pub tree_position: TreePosition,
/// The effective **open direction**: the config `open_direction` mapped to `Right`/`Down`,
/// else [`OpenDirection::Right`]. Consumed by the launcher scripts via `--open-direction`,
/// not by the running TUI. Config-or-default (no env var).
pub open_direction: OpenDirection,
/// The effective **tree column cap**: the config `tree_max_cols` clamped to
/// `MIN_TREE_MAX_COLS..=MAX_TREE_MAX_COLS` when present, else [`DEFAULT_TREE_MAX_COLS`]. The tree
/// is drawn at `min(tree_width% of the pane, tree_max_cols)`. Config-or-default (no env var).
Expand Down Expand Up @@ -437,6 +473,20 @@ pub fn resolve(config: &Config, get_env: impl Fn(&str) -> Option<String>) -> Eff
_ => TreePosition::Left,
};

// Config > default; no env var. Lenient string match (trimmed, case-insensitive): only `down`
// selects the non-default split; anything else — absent, unrecognized, differently-typed —
// keeps the default `Right`, so a fat-fingered value loses the customization without ever
// producing a `--direction` herdr would reject (AC-5..7).
let open_direction = match config
.open_direction
.as_deref()
.map(|s| s.trim().to_ascii_lowercase())
.as_deref()
{
Some("down") => OpenDirection::Down,
_ => OpenDirection::Right,
};

// Config > default; no env var. Clamp to `MIN_TREE_MAX_COLS..=MAX_TREE_MAX_COLS` so the cap can
// never shrink the tree to an unreadable sliver, and a huge value just becomes the effective
// "no cap" (it never bites on a real terminal). A non-representable value degraded the whole
Expand Down Expand Up @@ -478,6 +528,7 @@ pub fn resolve(config: &Config, get_env: impl Fn(&str) -> Option<String>) -> Eff
scroll_lines,
tree_width,
tree_position,
open_direction,
tree_max_cols,
preview_max_lines,
preview_max_kib,
Expand Down Expand Up @@ -1354,6 +1405,54 @@ mod tests {
}
}

#[test]
fn resolve_open_direction_config_value_wins() {
// AC-5: "down" -> Down, "right" -> Right (config > default).
for (value, want) in [
("down", OpenDirection::Down),
("right", OpenDirection::Right),
] {
let cfg = Config {
open_direction: Some(value.to_string()),
..Default::default()
};
assert_eq!(resolve(&cfg, |_| None).open_direction, want);
}
}

#[test]
fn resolve_open_direction_defaults_when_absent() {
// AC-6: omitted -> the default split (Right, today's layout).
assert_eq!(
resolve(&Config::default(), |_| None).open_direction,
OpenDirection::Right
);
}

#[test]
fn resolve_open_direction_lenient_and_case_insensitive() {
// AC-7: an unrecognized value degrades to the default Right without panicking — herdr
// would reject an invented direction, so leniency here is what keeps the pane opening at
// all — and a valid value is matched case-insensitively after trimming.
for (value, want) in [
("sideways", OpenDirection::Right),
("", OpenDirection::Right),
(" DOWN ", OpenDirection::Down),
("Right", OpenDirection::Right),
("DoWn", OpenDirection::Down),
] {
let cfg = Config {
open_direction: Some(value.to_string()),
..Default::default()
};
assert_eq!(
resolve(&cfg, |_| None).open_direction,
want,
"{value:?} must resolve to {want:?}"
);
}
}

#[test]
fn resolve_tree_max_cols_config_value_wins() {
// A valid config value (in range) is the effective tree column cap (config > default).
Expand Down
10 changes: 10 additions & 0 deletions src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ pub fn settings_text(
scroll_lines = {scroll_lines}\n\
tree_width = {tree_width}\n\
tree_position = {tree_position}\n\
open_direction = {open_direction}\n\
tree_max_cols = {tree_max_cols}\n\
preview_max_lines = {preview_max_lines}\n\
preview_max_kib = {preview_max_kib}",
Expand All @@ -300,6 +301,7 @@ pub fn settings_text(
scroll_lines = eff.scroll_lines,
tree_width = eff.tree_width,
tree_position = eff.tree_position.label(),
open_direction = eff.open_direction.label(),
tree_max_cols = eff.tree_max_cols,
preview_max_lines = eff.preview_max_lines,
preview_max_kib = eff.preview_max_kib,
Expand Down Expand Up @@ -808,6 +810,7 @@ mod tests {
scroll_lines: 7,
tree_width: 25,
tree_position: crate::config::TreePosition::Right,
open_direction: crate::config::OpenDirection::Down,
tree_max_cols: 50,
preview_max_lines: 8000,
preview_max_kib: 2048,
Expand Down Expand Up @@ -847,6 +850,7 @@ mod tests {
"scroll_lines",
"tree_width",
"tree_position",
"open_direction",
"tree_max_cols",
"preview_max_lines",
"preview_max_kib",
Expand All @@ -873,6 +877,11 @@ mod tests {
.any(|l| l.trim_start().starts_with("tree_position") && l.contains("right")),
"settings_text must show the effective tree_position (right):\n{text}"
);
assert!(
text.lines()
.any(|l| l.trim_start().starts_with("open_direction") && l.contains("down")),
"settings_text must show the effective open_direction (down):\n{text}"
);
assert!(
text.lines()
.any(|l| l.trim_start().starts_with("tree_max_cols") && l.contains("50")),
Expand Down Expand Up @@ -998,6 +1007,7 @@ mod tests {
),
&format!("tree_width = {}", crate::config::DEFAULT_TREE_WIDTH),
"tree_position = left",
"open_direction = right",
&format!(
"tree_max_cols = {}",
crate::config::DEFAULT_TREE_MAX_COLS
Expand Down
10 changes: 10 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ fn main() -> std::io::Result<()> {
println!("{}", herdr_file_viewer::launch::launch_decision_tab(&json));
Ok(())
}
CliAction::OpenDirection => {
// The launcher scripts cannot parse TOML; this prints the one resolved value they
// need, already in `plugin pane open --direction` vocabulary. Config loading is the
// same defensive path the TUI uses, so a malformed config degrades to `right` here
// exactly as it degrades to defaults there.
let (config, _) = herdr_file_viewer::config::load_config_from_env();
let eff = herdr_file_viewer::config::resolve(&config, |k| std::env::var(k).ok());
println!("{}", eff.open_direction.label());
Ok(())
}
CliAction::Run { open } => herdr_file_viewer::run(open),
}
}
28 changes: 28 additions & 0 deletions src/open_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ pub enum CliAction {
LaunchDecision,
/// Print a tab-launcher decision from stdin JSON, then exit.
LaunchDecisionTab,
/// Print the effective `open_direction` label (`right` / `down`), then exit — the launcher
/// script's way of reading the config it cannot parse itself.
OpenDirection,
/// Start the TUI; `open` is the raw `--open` value when present (env is layered in `app::run`).
Run { open: Option<String> },
}
Expand All @@ -61,6 +64,8 @@ pub enum CliAction {
/// - unknown flags are ignored (herdr may append args we do not control)
/// - a bare `--open` with no value is ignored (start with no open target)
/// - `--launch-decision` / `--launch-decision-tab` win over a normal run (and over `--open`)
/// - `--open-direction` wins over a normal run too, but yields to the launch-decision flags
/// (probe modes never start the TUI; the decision probes stay authoritative among probes)
///
/// `--open` values must not look like flags (`-…`); a following `-x` is left for the next
/// iteration so it can be ignored as unknown rather than treated as a path.
Expand All @@ -72,6 +77,7 @@ where
let mut open_flag: Option<String> = None;
let mut launch_tab = false;
let mut launch = false;
let mut open_direction = false;
let mut args = args.into_iter().peekable();
while let Some(arg) = args.next() {
let arg = arg.as_ref();
Expand All @@ -84,6 +90,9 @@ where
launch = true;
launch_tab = true;
}
"--open-direction" => {
open_direction = true;
}
"--open" => {
let take = args
.peek()
Expand Down Expand Up @@ -113,6 +122,8 @@ where
} else {
CliAction::LaunchDecision
}
} else if open_direction {
CliAction::OpenDirection
} else {
CliAction::Run { open: open_flag }
}
Expand Down Expand Up @@ -487,6 +498,23 @@ mod tests {
);
}

#[test]
fn parse_args_open_direction() {
assert_eq!(parse_args(["--open-direction"]), CliAction::OpenDirection);
}

#[test]
fn parse_args_open_direction_wins_over_open_but_not_launch_decision() {
assert_eq!(
parse_args(["--open", "src/a.rs", "--open-direction"]),
CliAction::OpenDirection
);
assert_eq!(
parse_args(["--open-direction", "--launch-decision"]),
CliAction::LaunchDecision
);
}

#[test]
fn parse_args_open_then_flag_not_eaten_as_path() {
// `--open --nope` must not treat `--nope` as the path.
Expand Down
1 change: 1 addition & 0 deletions tests/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10504,6 +10504,7 @@ fn open_help_orders_optional_sections_after_whats_new_and_keeps_independent_scro
scroll_lines: 3,
tree_width: 30,
tree_position: herdr_file_viewer::config::TreePosition::Left,
open_direction: herdr_file_viewer::config::OpenDirection::Right,
tree_max_cols: 45,
preview_max_lines: 5000,
preview_max_kib: 1024,
Expand Down