feat: configure new terminal cwd
This commit is contained in:
parent
0fec119967
commit
3de6ea7999
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Added `terminal.new_cwd` to choose whether new panes, tabs, and workspaces follow the source pane/workspace, start in `$HOME`, use Herdr's process directory, or use a fixed path.
|
||||
|
||||
## [0.6.1] - 2026-05-22
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ You can also open the global menu in Herdr and choose `reload config`.
|
|||
|
||||
Reload applies most UI settings without restarting panes. Startup-only settings still need a restart.
|
||||
|
||||
## Default shell
|
||||
## Terminal defaults
|
||||
|
||||
Set the executable Herdr uses for newly created interactive panes:
|
||||
|
||||
|
|
@ -56,6 +56,15 @@ default_shell = "nu"
|
|||
|
||||
When unset or empty, Herdr uses `$SHELL`, then `/bin/sh`. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Command panes still run through `/bin/sh -c`; detached custom command keybindings use Herdr's existing `/bin/sh -lc` path.
|
||||
|
||||
Set the working directory policy for new panes, tabs, and workspaces:
|
||||
|
||||
```toml
|
||||
[terminal]
|
||||
new_cwd = "follow"
|
||||
```
|
||||
|
||||
`new_cwd = "follow"` keeps the default behavior and inherits the source pane or workspace. Use `"home"` to always start in `$HOME`, `"current"` to use Herdr's process directory, or a fixed path such as `"~/Projects"`. Explicit `--cwd` values from the CLI or socket API still take precedence.
|
||||
|
||||
## Worktrees
|
||||
|
||||
Set the root directory Herdr uses for Git worktree checkouts created from the sidebar:
|
||||
|
|
|
|||
|
|
@ -21,14 +21,15 @@ impl App {
|
|||
};
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| {
|
||||
self.state.workspaces.get(ws_idx).and_then(|ws| {
|
||||
let follow_cwd = self.state.workspaces.get(ws_idx).and_then(|ws| {
|
||||
let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?;
|
||||
ws.tabs.get(tab_idx)?.cwd_for_pane(
|
||||
target_pane_id,
|
||||
&self.state.terminals,
|
||||
&self.terminal_runtimes,
|
||||
)
|
||||
})
|
||||
});
|
||||
Some(self.resolve_new_terminal_cwd(follow_cwd))
|
||||
});
|
||||
let default_shell = self.state.default_shell.clone();
|
||||
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
|
||||
|
|
|
|||
|
|
@ -63,15 +63,13 @@ impl App {
|
|||
} else {
|
||||
return encode_error(id, "workspace_not_found", "no active workspace");
|
||||
};
|
||||
let cwd = cwd
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
self.state
|
||||
.focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
|
||||
.and_then(|rt| rt.cwd())
|
||||
})
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let cwd = cwd.map(PathBuf::from).unwrap_or_else(|| {
|
||||
let follow_cwd = self
|
||||
.state
|
||||
.focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
|
||||
.and_then(|rt| rt.cwd());
|
||||
self.resolve_new_terminal_cwd(follow_cwd)
|
||||
});
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
let default_shell = self.state.default_shell.clone();
|
||||
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
|
||||
|
|
|
|||
|
|
@ -45,11 +45,12 @@ impl App {
|
|||
id: String,
|
||||
params: WorkspaceCreateParams,
|
||||
) -> String {
|
||||
let cwd = params
|
||||
.cwd
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let cwd = params.cwd.map(PathBuf::from).unwrap_or_else(|| {
|
||||
let follow_cwd = self
|
||||
.workspace_creation_source()
|
||||
.and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx));
|
||||
self.resolve_new_terminal_cwd(follow_cwd)
|
||||
});
|
||||
match self.create_workspace_with_options(cwd, params.focus) {
|
||||
Ok(index) => {
|
||||
if let Some(label) = params.label {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,44 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use tracing::error;
|
||||
|
||||
use super::{
|
||||
api_helpers::{pane_agent_status, tab_attention_priority},
|
||||
App, Mode,
|
||||
};
|
||||
use crate::workspace::Workspace;
|
||||
use crate::{config::NewTerminalCwdConfig, workspace::Workspace};
|
||||
|
||||
pub(crate) fn resolve_new_terminal_cwd(
|
||||
policy: &NewTerminalCwdConfig,
|
||||
follow_cwd: Option<PathBuf>,
|
||||
) -> PathBuf {
|
||||
match policy {
|
||||
NewTerminalCwdConfig::Follow => follow_cwd
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("/")),
|
||||
NewTerminalCwdConfig::Home => std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("/")),
|
||||
NewTerminalCwdConfig::Current => {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))
|
||||
}
|
||||
NewTerminalCwdConfig::Path(path) => crate::worktree::expand_tilde_path(path),
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<std::path::PathBuf> {
|
||||
pub(super) fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<PathBuf> {
|
||||
self.state
|
||||
.workspaces
|
||||
.get(ws_idx)?
|
||||
.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_new_terminal_cwd(&self, follow_cwd: Option<PathBuf>) -> PathBuf {
|
||||
resolve_new_terminal_cwd(&self.state.new_terminal_cwd, follow_cwd)
|
||||
}
|
||||
|
||||
pub(super) fn workspace_creation_source(&self) -> Option<usize> {
|
||||
if self.state.mode == Mode::Navigate
|
||||
&& self.state.workspaces.get(self.state.selected).is_some()
|
||||
|
|
@ -31,11 +56,10 @@ impl App {
|
|||
|
||||
/// Create a workspace with a real PTY (needs event_tx).
|
||||
pub(crate) fn create_workspace(&mut self) {
|
||||
let initial_cwd = self
|
||||
let follow_cwd = self
|
||||
.workspace_creation_source()
|
||||
.and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx))
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/"));
|
||||
.and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx));
|
||||
let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd);
|
||||
if let Err(e) = self.create_workspace_with_options(initial_cwd, true) {
|
||||
error!(err = %e, "failed to create workspace");
|
||||
self.state.mode = Mode::Navigate;
|
||||
|
|
@ -44,12 +68,11 @@ impl App {
|
|||
|
||||
pub(crate) fn create_tab(&mut self) {
|
||||
let custom_name = self.state.requested_new_tab_name.take();
|
||||
let initial_cwd = self
|
||||
let follow_cwd = self
|
||||
.state
|
||||
.active
|
||||
.and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx))
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/"));
|
||||
.and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx));
|
||||
let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd);
|
||||
match self.create_tab_with_options(initial_cwd, true) {
|
||||
Ok(tab_idx) => {
|
||||
if let Some(name) = custom_name {
|
||||
|
|
@ -73,7 +96,7 @@ impl App {
|
|||
|
||||
pub(super) fn create_tab_with_options(
|
||||
&mut self,
|
||||
initial_cwd: std::path::PathBuf,
|
||||
initial_cwd: PathBuf,
|
||||
focus: bool,
|
||||
) -> std::io::Result<usize> {
|
||||
let Some(ws_idx) = self.state.active else {
|
||||
|
|
@ -107,7 +130,7 @@ impl App {
|
|||
|
||||
pub(crate) fn create_workspace_with_options(
|
||||
&mut self,
|
||||
initial_cwd: std::path::PathBuf,
|
||||
initial_cwd: PathBuf,
|
||||
focus: bool,
|
||||
) -> std::io::Result<usize> {
|
||||
let (rows, cols) = self.state.estimate_pane_size();
|
||||
|
|
|
|||
|
|
@ -256,13 +256,17 @@ impl AppState {
|
|||
let new_rows = (rows / 2).max(4);
|
||||
let new_cols = (cols / 2).max(10);
|
||||
|
||||
let cwd = self
|
||||
let follow_cwd = self
|
||||
.active
|
||||
.and_then(|i| self.workspaces.get(i))
|
||||
.and_then(|ws| {
|
||||
let tab = ws.active_tab()?;
|
||||
tab.cwd_for_pane(tab.layout.focused(), &self.terminals, terminal_runtimes)
|
||||
});
|
||||
let cwd = Some(super::creation::resolve_new_terminal_cwd(
|
||||
&self.new_terminal_cwd,
|
||||
follow_cwd,
|
||||
));
|
||||
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if let Ok(new_pane) = ws.split_focused(
|
||||
|
|
|
|||
|
|
@ -454,6 +454,7 @@ impl App {
|
|||
cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(),
|
||||
kitty_graphics_enabled: config.experimental.kitty_graphics,
|
||||
default_shell: config.terminal.default_shell.clone(),
|
||||
new_terminal_cwd: config.terminal.new_cwd.clone(),
|
||||
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
|
||||
accent: crate::config::parse_color(&config.ui.accent),
|
||||
sound: config.ui.sound.clone(),
|
||||
|
|
@ -1004,6 +1005,7 @@ impl App {
|
|||
|
||||
if !invalid_section("terminal") {
|
||||
self.state.default_shell = config.terminal.default_shell.clone();
|
||||
self.state.new_terminal_cwd = config.terminal.new_cwd.clone();
|
||||
}
|
||||
|
||||
if !invalid_section("worktrees") {
|
||||
|
|
@ -1461,7 +1463,7 @@ mod tests {
|
|||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"prefix+g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
|
||||
"[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||||
|
|
@ -1486,6 +1488,10 @@ mod tests {
|
|||
state::AgentPanelScope::CurrentWorkspace
|
||||
);
|
||||
assert_eq!(app.state.default_shell, "nu");
|
||||
assert_eq!(
|
||||
app.state.new_terminal_cwd,
|
||||
crate::config::NewTerminalCwdConfig::Home
|
||||
);
|
||||
assert!(app.state.config_diagnostic.is_none());
|
||||
let toast = app.state.toast.as_ref().unwrap();
|
||||
assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled);
|
||||
|
|
@ -2054,6 +2060,26 @@ mod tests {
|
|||
assert_eq!(seed_cwd, std::path::PathBuf::from("/tmp/pion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_terminal_cwd_follow_uses_source_cwd() {
|
||||
let cwd = creation::resolve_new_terminal_cwd(
|
||||
&crate::config::NewTerminalCwdConfig::Follow,
|
||||
Some(std::path::PathBuf::from("/tmp/herdr-source")),
|
||||
);
|
||||
|
||||
assert_eq!(cwd, std::path::PathBuf::from("/tmp/herdr-source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_terminal_cwd_path_uses_configured_path() {
|
||||
let cwd = creation::resolve_new_terminal_cwd(
|
||||
&crate::config::NewTerminalCwdConfig::Path("/tmp/herdr-fixed".into()),
|
||||
Some(std::path::PathBuf::from("/tmp/herdr-source")),
|
||||
);
|
||||
|
||||
assert_eq!(cwd, std::path::PathBuf::from("/tmp/herdr-fixed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_stop_request_sets_should_quit_flag() {
|
||||
let mut app = test_app();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::config::{Keybinds, SoundConfig, ToastConfig, ToastDelivery};
|
||||
use crate::config::{Keybinds, NewTerminalCwdConfig, SoundConfig, ToastConfig, ToastDelivery};
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use ratatui::layout::{Direction, Rect};
|
||||
use ratatui::style::Color;
|
||||
|
|
@ -1075,6 +1075,7 @@ pub struct AppState {
|
|||
pub cjk_ime_cursor_shape: u8,
|
||||
pub kitty_graphics_enabled: bool,
|
||||
pub default_shell: String,
|
||||
pub new_terminal_cwd: NewTerminalCwdConfig,
|
||||
pub pane_scrollback_limit_bytes: usize,
|
||||
#[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth
|
||||
pub accent: Color,
|
||||
|
|
@ -1362,6 +1363,7 @@ impl AppState {
|
|||
cjk_ime_cursor_shape: 2, // steady_block
|
||||
kitty_graphics_enabled: false,
|
||||
default_shell: String::new(),
|
||||
new_terminal_cwd: NewTerminalCwdConfig::Follow,
|
||||
pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES,
|
||||
accent: Color::Cyan,
|
||||
sound: SoundConfig {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub use self::{
|
|||
},
|
||||
model::{
|
||||
validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport,
|
||||
ConfigReloadStatus, KeysConfig, ToastConfig, ToastDelivery,
|
||||
ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ToastConfig, ToastDelivery,
|
||||
},
|
||||
sound::SoundConfig,
|
||||
theme::{parse_color, CustomThemeColors, ThemeConfig},
|
||||
|
|
|
|||
|
|
@ -39,11 +39,37 @@ pub struct ToastConfig {
|
|||
pub delivery: ToastDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum NewTerminalCwdConfig {
|
||||
#[default]
|
||||
Follow,
|
||||
Home,
|
||||
Current,
|
||||
Path(String),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NewTerminalCwdConfig {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
match value.trim() {
|
||||
"" | "follow" => Ok(Self::Follow),
|
||||
"home" => Ok(Self::Home),
|
||||
"current" => Ok(Self::Current),
|
||||
_ => Ok(Self::Path(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TerminalConfig {
|
||||
/// Executable used for new interactive panes. Empty means SHELL, then /bin/sh.
|
||||
pub default_shell: String,
|
||||
/// CWD policy for new interactive panes, tabs, and workspaces.
|
||||
pub new_cwd: NewTerminalCwdConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
|
|
@ -433,6 +459,36 @@ default_shell = "nu"
|
|||
assert_eq!(config.terminal.default_shell, "nu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_new_cwd_defaults_follow_and_parses() {
|
||||
let default_config = Config::default();
|
||||
assert_eq!(
|
||||
default_config.terminal.new_cwd,
|
||||
NewTerminalCwdConfig::Follow
|
||||
);
|
||||
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[terminal]
|
||||
new_cwd = "home"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.terminal.new_cwd, NewTerminalCwdConfig::Home);
|
||||
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[terminal]
|
||||
new_cwd = "~/Projects"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.terminal.new_cwd,
|
||||
NewTerminalCwdConfig::Path("~/Projects".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_panel_scope_config_parses() {
|
||||
let toml = r#"
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|||
# Empty means $SHELL, then /bin/sh.
|
||||
# default_shell = ""
|
||||
|
||||
# CWD policy for new panes, tabs, and workspaces when no explicit --cwd is provided.
|
||||
# Use "follow" to inherit the source pane/workspace, "home" for $HOME,
|
||||
# "current" for Herdr's process directory, or a fixed path such as "~/Projects".
|
||||
# new_cwd = "follow"
|
||||
|
||||
[keys]
|
||||
# Prefix key to enter prefix mode (default: "ctrl+b")
|
||||
# Examples: "ctrl+b", "f12", "esc", "-"
|
||||
|
|
|
|||
Loading…
Reference in New Issue