diff --git a/config.example.toml b/config.example.toml index 948af3be..e145db8b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -89,6 +89,11 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f # program = "C:\\Program Files\\Git\\bin\\bash.exe" # optional; platform default when omitted # command_args = ["-lc"] # optional; platform default when omitted +[terminal] +# Open one empty shell automatically when a project has no explicit startup +# commands. Keep this false to create the first shell only with t. +auto_open = false + # Reserve startup commands: each [[startup_command]] opens its own terminal # pane and runs `command` immediately (via the configured shell). Panes are # per project, so these run again for every project tab you open, not once @@ -99,8 +104,8 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f # project tabs). This caps only the startup # batch — open more anytime with t (panes past the eighth are reached # via focus cycling, Shift+←/→). `name` labels the tab; when omitted the -# command text is used. With no [[startup_command]] entries, nightcrow opens a -# single empty shell. +# command text is used. With no [[startup_command]] entries, no pane is opened +# unless terminal.auto_open is true. # # Editing this table does not need a restart: u in the TUI, or the # reload button in the browser header, re-reads this file. Startup commands take diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 336bcfdf..bcf4b381 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -57,7 +57,7 @@ replay는 1 MiB chunk로 분할하고 daemon frame payload는 4 MiB 이하로 ## Config reload -`POST /api/reload`와 attach의 reload request는 transport와 무관한 같은 operation을 호출한다. `config.toml` 전체를 parse/validate한 뒤에만 적용하며, 파일이 사라졌거나 잘못되면 session을 변경하지 않는다. `[[plugin]]` 변경은 열린 repository hub에 즉시 요청하고, `command`/`args`/`env` 변경 때만 child를 교체한다. `allowed_resume_flags`와 `watch_on_signal`은 다음 판정부터 읽는다. `[[startup_command]]` 변경은 이후 생성되는 hub에만 적용한다. web/listener·log·layout/input/tree/mouse 설정은 재시작 대상이다. +`POST /api/reload`와 attach의 reload request는 transport와 무관한 같은 operation을 호출한다. `config.toml` 전체를 parse/validate한 뒤에만 적용하며, 파일이 사라졌거나 잘못되면 session을 변경하지 않는다. `[[plugin]]` 변경은 열린 repository hub에 즉시 요청하고, `command`/`args`/`env` 변경 때만 child를 교체한다. `allowed_resume_flags`와 `watch_on_signal`은 다음 판정부터 읽는다. `[[startup_command]]`와 `[terminal] auto_open` 변경은 이후 생성되는 hub에만 적용한다. web/listener·log·layout/input/tree/mouse 설정은 재시작 대상이다. reload lock은 concurrent reload를 직렬화하고, catalog transaction은 reload와 project open이 서로 다른 config table을 보는 틈을 막는다. hub queue가 가득 차 전달하지 못한 repository는 보고서의 `unreachable`로 표시하며, reload 결과는 요청한 client에만 반환한다. plugin reload가 기존 pane의 opt-in을 조용히 취소하거나 relaunch budget을 재생성하지 않는다. diff --git a/docs/configuration.md b/docs/configuration.md index 827f6207..46878605 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,6 +10,7 @@ nightcrow reads `~/.nightcrow/config.toml`. Every field is optional and omitted | `[theme]` | `name = "yellow"` | `yellow`, `cyan`, `green`, `magenta`, or `blue`. Seeds the session accent when no saved accent exists. | | `[input]` | `leader = "ctrl+f"` | One `ctrl+` chord. `ctrl+i` and `ctrl+m` are rejected because terminals report them as Tab and Enter. | | `[mouse]` | `enabled = true` | Captures clicks and wheel events for the TUI; `false` gives selection and mouse handling back to the outer terminal. | +| `[terminal]` | `auto_open = false` | With no startup commands, `true` opens one shell per project automatically; `false` waits for ` t`. | | `[agent_indicator]` | `enabled = true`, `hot_window_secs = 15`, `auto_follow = false` | Hot window is `3..=3600` seconds. `auto_follow` selects the freshest recently changed file after 2 seconds of inactivity. | | `[tree]` | `respect_gitignore = true`, `max_depth = 64`, `live_watch = true` | `max_depth` is `1..=1024`; `live_watch = false` refreshes the tree on entry instead of watching expanded directories. | | `[shell]` | `program` omitted; `command_args` platform default | Unix uses `$SHELL` or `/bin/sh` with `[-lc]`; Windows uses `%ComSpec%` or `cmd.exe` with `[/C]`. The command is always the final single argument; interpolation such as `"{}"` is not supported. | @@ -64,7 +65,7 @@ plugin = "recovery" command = "cargo test --watch" ``` -Configured entries and repeated CLI `--exec COMMAND` values share an 8-pane startup limit, in config-first order. `command` cannot be empty. A project with no startup entries starts with one shell; each project may hold up to 8 panes total. +Configured entries and repeated CLI `--exec COMMAND` values share an 8-pane startup limit, in config-first order. `command` cannot be empty. A project with no startup entries starts with no panes by default; set `[terminal] auto_open = true` to restore one automatic shell. Each project may hold up to 8 panes total. ## `[[plugin]]` @@ -92,7 +93,7 @@ See [Plugins](plugins.md) for installation and the bundled recovery plugin. Use ` u` in the TUI or the reload control in the browser. nightcrow parses and validates the whole file before applying anything; a missing, malformed, or invalid file leaves the running session unchanged. - `[[plugin]]` is re-applied immediately to open projects. Changing a plugin's executable, arguments, or environment restarts that plugin and can abandon a pending recovery. -- `[[startup_command]]` applies to projects opened after the reload. Existing project panes keep running; CLI `--exec` panes remain part of the merged startup list. +- `[[startup_command]]` and `[terminal] auto_open` apply to projects opened after the reload. Existing project panes keep running; CLI `--exec` panes remain part of the merged startup list. - All other settings require a daemon restart. A TUI reads its client settings when it attaches, while the running daemon keeps its listener and server settings until restart. Restarting a session stops its terminal programs. Use [Getting started](getting-started.md#detach-and-stop) for the shutdown procedure. diff --git a/docs/decisions.md b/docs/decisions.md index 26bedb3c..b8d9f9dd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -40,7 +40,7 @@ status는 최신 값 하나가 완전한 그림이라 중간 값을 conflate할 ### Reload는 전체 검증 후 제한적으로 적용한다 -살아 있는 pane을 보존하려고 `config.toml`을 부분 적용하지 않는다. 파일 전체를 parse/validate한 뒤 `[[plugin]]`은 열린 hub에, `[[startup_command]]`는 새 hub에만 적용한다. plugin 권한 flag와 watch switch는 다음 판정부터 읽고, child 교체가 필요한 command/args/env만 재시작한다. concurrent reload는 lock으로 직렬화하고 전달하지 못한 hub는 성공으로 가장하지 않는다. +살아 있는 pane을 보존하려고 `config.toml`을 부분 적용하지 않는다. 파일 전체를 parse/validate한 뒤 `[[plugin]]`은 열린 hub에, `[[startup_command]]`와 `[terminal] auto_open`은 새 hub에만 적용한다. plugin 권한 flag와 watch switch는 다음 판정부터 읽고, child 교체가 필요한 command/args/env만 재시작한다. concurrent reload는 lock으로 직렬화하고 전달하지 못한 hub는 성공으로 가장하지 않는다. ## TUI 입력과 git 표시 diff --git a/docs/getting-started.md b/docs/getting-started.md index 61431d4c..bd38ab90 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -39,7 +39,7 @@ nightcrow -d There is one session per running daemon. `nightcrow attach` reuses an existing session and starts one in the background when none is available. The daemon owns the open repositories and terminal programs; clients only attach to it. Startup prints the browser URL and the attach-socket path. A background daemon writes its output to `~/.nightcrow/daemon.out`. -Use `--exec COMMAND` once per startup pane when starting a daemon. Configured `[[startup_command]]` entries run first, followed by these CLI commands. The combined startup list is limited to 8 panes per project; each project gets its own list. With no startup commands, a project starts with one shell. All terminal panes in a project share an 8-pane limit; later panes are opened with ` t` until that limit is reached. +Use `--exec COMMAND` once per startup pane when starting a daemon. Configured `[[startup_command]]` entries run first, followed by these CLI commands. The combined startup list is limited to 8 panes per project; each project gets its own list. With no startup commands, a project starts with no terminal process; press ` t` to open the first shell, or set `[terminal] auto_open = true` to open one automatically. All terminal panes in a project share an 8-pane limit; later panes are opened with ` t` until that limit is reached. The browser and TUI share repositories, terminals, project order, active project, and accent. The TUI's leader is `Ctrl+F` by default; see [Keyboard and mouse](keybindings.md) for all controls. diff --git a/docs/projects.md b/docs/projects.md index d8a2980d..0acdb204 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -2,6 +2,8 @@ The session can serve up to 10 repositories. Each repository is a project tab with its own status, commit-log, tree, and terminal views. A project can hold up to 8 terminal panes; its panes keep running while another project is active. +Projects start without a terminal process unless a startup command is configured. Open the first shell with ` t`, or set `[terminal] auto_open = true` to create one automatically for projects without startup commands. + Open and close projects with ` o` and ` x`; switch among tabs with `F1`–`F10`. Opening a repository that is already open focuses the existing tab instead of creating a duplicate worktree view. The browser and every attached TUI share the project set, order, and active project. If tabs do not fit, the tab row folds inactive tabs behind an overflow marker. A background project shows an attention marker when its terminal reports unread activity; selecting that project acknowledges the marker, and later activity can raise it again. diff --git a/src/application/session_terminals_tests.rs b/src/application/session_terminals_tests.rs index bf2e8e98..df4a1d00 100644 --- a/src/application/session_terminals_tests.rs +++ b/src/application/session_terminals_tests.rs @@ -2,10 +2,9 @@ //! //! One test with a real session behind it, because everything this step added //! only exists between the parts: the tab is built with the repository's end of -//! the connection, the daemon's offer to size the startup terminals is answered -//! from there, and the pane that comes back has to reach that tab's emulator -//! with its output. A fake at any of those seams would assert the seam rather -//! than the crossing. +//! the connection, a pane request crosses to the daemon, and the pane that +//! comes back has to reach that tab's emulator with its output. A fake at any +//! of those seams would assert the seam rather than the crossing. use crate::application::input::dispatch::ProjectContext; use crate::application::session_link::SessionLink; @@ -93,8 +92,21 @@ fn a_tab_shows_the_pane_the_session_is_running_and_the_output_it_produces() { ); assert_eq!(ws.projects().len(), 1); - // And so does its pane: the session opened its startup shell once the client - // answered the offer to size it. + // A project does not spend a process on a shell until somebody asks for it. + let quiet_until = Instant::now() + Duration::from_millis(100); + while Instant::now() < quiet_until { + link.sync(&mut ws, &ctx); + for project in ws.projects_mut() { + project.poll_terminal(); + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + ws.active().is_some_and(|app| app.terminal.panes.is_empty()), + "a default project must not auto-open a terminal" + ); + + ws.active_mut().expect("a tab").open_new_pane(); assert!( tick_until(&mut link, &mut ws, &ctx, |ws| { ws.active() @@ -114,8 +126,8 @@ fn a_tab_shows_the_pane_the_session_is_running_and_the_output_it_produces() { "the pane produced no output the client could render" ); - // The fresh-launch rule, end to end: keystrokes go to the terminal that just - // appeared rather than to the file list the view was built on. + // The explicit-open rule, end to end: keystrokes go to the terminal that + // just appeared rather than to the file list the view was built on. assert_eq!( ws.active().expect("a tab").focus, crate::app::Focus::Terminal diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs index cc70091d..48550c6d 100644 --- a/src/cli/daemon.rs +++ b/src/cli/daemon.rs @@ -79,6 +79,7 @@ pub(crate) fn run_daemon( agent_indicator: &cfg.agent_indicator, theme: &cfg.theme, shell: &cfg.shell, + terminal: &cfg.terminal, paths: &paths, persist: true, startup_commands: startup, diff --git a/src/config.rs b/src/config.rs index df65e58b..b2295cae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ mod log; mod panels; mod plugin; mod shell; +mod terminal; mod web; pub use layout::{Accent, InputConfig, LayoutConfig, StartupCommand, ThemeConfig, parse_leader}; @@ -16,6 +17,7 @@ pub use log::{LogConfig, LogRotation}; pub use panels::{AgentIndicatorConfig, MouseConfig, TreeConfig}; pub use plugin::PluginConfig; pub use shell::ShellConfig; +pub use terminal::TerminalConfig; #[cfg(test)] pub use web::generate_password; pub use web::{WebViewerConfig, ensure_web_viewer_password}; @@ -38,6 +40,7 @@ pub struct Config { pub input: InputConfig, pub tree: TreeConfig, pub mouse: MouseConfig, + pub terminal: TerminalConfig, pub web_viewer: WebViewerConfig, /// The shell every terminal pane is spawned with. When absent, the platform /// default is used. diff --git a/src/config/terminal.rs b/src/config/terminal.rs new file mode 100644 index 00000000..025c0103 --- /dev/null +++ b/src/config/terminal.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +/// Startup behavior shared by every terminal surface in the session. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct TerminalConfig { + /// Open one bare shell when a project has no explicit startup commands. + pub auto_open: bool, +} diff --git a/src/config/tests/mod.rs b/src/config/tests/mod.rs index 3fc5da2b..df8e082f 100644 --- a/src/config/tests/mod.rs +++ b/src/config/tests/mod.rs @@ -5,6 +5,7 @@ mod panels; mod plugin; mod shell; mod startup; +mod terminal; mod theme; mod tree; mod web; diff --git a/src/config/tests/terminal.rs b/src/config/tests/terminal.rs new file mode 100644 index 00000000..30ad3542 --- /dev/null +++ b/src/config/tests/terminal.rs @@ -0,0 +1,14 @@ +use crate::config::Config; + +#[test] +fn terminal_does_not_auto_open_by_default() { + assert!(!Config::default().terminal.auto_open); + let parsed: Config = toml::from_str("").unwrap(); + assert!(!parsed.terminal.auto_open); +} + +#[test] +fn terminal_auto_open_can_be_enabled() { + let parsed: Config = toml::from_str("[terminal]\nauto_open = true\n").unwrap(); + assert!(parsed.terminal.auto_open); +} diff --git a/src/daemon/client_tests.rs b/src/daemon/client_tests.rs index 9ad4588f..b86d53e4 100644 --- a/src/daemon/client_tests.rs +++ b/src/daemon/client_tests.rs @@ -28,6 +28,7 @@ fn daemon(dir: &tempfile::TempDir, repos: &[String]) -> TestDaemon { persist: false, startup_commands: Vec::new(), cli_startup: Vec::new(), + terminal: crate::config::TerminalConfig::default(), shell: crate::config::ShellConfig::default(), prefs: crate::session::prefs::PrefsStore::at(dir.path().join("viewer.json")), status_encoder: crate::session::test_status_encoder, diff --git a/src/daemon/serve_tests/terminals.rs b/src/daemon/serve_tests/terminals.rs index fe17c79f..c02a5bd9 100644 --- a/src/daemon/serve_tests/terminals.rs +++ b/src/daemon/serve_tests/terminals.rs @@ -32,20 +32,12 @@ fn attaching_subscribes_to_the_terminals_of_every_open_repository() { let mut client = Client::attach_raw(daemon.path()); client.hello(); - let (id, _) = client.next_terminal_event(); + let (id, event) = client.next_terminal_event(); assert!(!id.is_empty(), "the event says which repository it is for"); - // And the subscription is live from the start: a fresh hub offers its - // startup terminals to be sized before creating them, so that offer reaches - // a client that has asked for nothing. - let mut offered = false; - for _ in 0..8 { - let (_, event) = client.next_terminal_event(); - if matches!(event, HubServerMessage::Pending { .. }) { - offered = true; - break; - } - } - assert!(offered, "the startup terminals were never offered"); + // The subscription is live from the start even though the default policy + // does not create or offer a shell: the hub handshake reaches a client that + // has asked for no terminal operation and reports an empty replay. + assert!(matches!(event, HubServerMessage::Hello { panes: 0, .. })); drop(repo); } diff --git a/src/session/catalog/catalog_runtime.rs b/src/session/catalog/catalog_runtime.rs index 76e099a2..89f28794 100644 --- a/src/session/catalog/catalog_runtime.rs +++ b/src/session/catalog/catalog_runtime.rs @@ -12,6 +12,7 @@ pub(super) struct CatalogRuntime { startup_commands: Vec, cli_startup: Vec, plugins: Vec, + terminal: crate::config::TerminalConfig, shell: crate::config::ShellConfig, ownership: Arc, status_encoder: StatusEncoder, @@ -24,6 +25,7 @@ impl Default for CatalogRuntime { startup_commands: Vec::new(), cli_startup: Vec::new(), plugins: Vec::new(), + terminal: crate::config::TerminalConfig::default(), shell: crate::config::ShellConfig::default(), ownership: Arc::new(crate::session::size_owner::SizeOwnership::default()), status_encoder: empty_status_payload, @@ -36,6 +38,7 @@ impl CatalogRuntime { startup_commands: Vec, plugins: Vec, cli_startup: Vec, + terminal: crate::config::TerminalConfig, shell: crate::config::ShellConfig, status_encoder: StatusEncoder, ) -> Self { @@ -43,6 +46,7 @@ impl CatalogRuntime { startup_commands, plugins, cli_startup, + terminal, shell, status_encoder, ..Self::default() @@ -65,6 +69,7 @@ impl CatalogRuntime { &member.path, self.startup_commands.clone(), self.plugins.clone(), + self.terminal.auto_open, self.shell.clone(), Arc::clone(&self.ownership), ), @@ -83,10 +88,12 @@ impl CatalogRuntime { pub(super) fn replace_config( &mut self, file_startup: &[crate::config::StartupCommand], + terminal: crate::config::TerminalConfig, plugins: Vec, ) -> anyhow::Result>> { let merged = crate::config::merge_startup_commands(file_startup, &self.cli_startup)?; self.startup_commands = merged; + self.terminal = terminal; self.plugins = plugins; Ok(self.entries.clone()) } @@ -104,6 +111,10 @@ impl CatalogRuntime { self.startup_commands.clone() } + pub(super) fn startup_command_count(&self) -> usize { + self.startup_commands.len() + } + #[cfg(test)] pub(super) fn plugins(&self) -> Vec { self.plugins.clone() diff --git a/src/session/catalog/catalog_tests/config_tables.rs b/src/session/catalog/catalog_tests/config_tables.rs index ed13e7c1..748cf0a0 100644 --- a/src/session/catalog/catalog_tests/config_tables.rs +++ b/src/session/catalog/catalog_tests/config_tables.rs @@ -29,7 +29,11 @@ fn swapping_the_config_tables_keeps_the_cli_startup_panes() { ); catalog - .set_config_tables(&[startup("cargo watch")], Vec::new()) + .set_config_tables( + &[startup("cargo watch")], + crate::config::TerminalConfig::default(), + Vec::new(), + ) .expect("the merge fits the cap"); // The file's table was replaced; the --exec pane is not in the file and @@ -55,7 +59,11 @@ fn a_refused_swap_replaces_neither_table() { }]; let too_many: Vec<_> = (0..8).map(|i| startup(&format!("echo {i}"))).collect(); - assert!(catalog.set_config_tables(&too_many, plugins).is_err()); + assert!( + catalog + .set_config_tables(&too_many, crate::config::TerminalConfig::default(), plugins,) + .is_err() + ); // Neither list moved: a reload does not half-apply. assert_eq!(catalog.startup_commands(), vec![startup("claude")]); @@ -69,7 +77,11 @@ fn a_swap_hands_back_the_repositories_the_caller_must_tell() { catalog.set_paths(std::slice::from_ref(&path)); let told = catalog - .set_config_tables(&[startup("cargo watch")], vec![plugin("recovery")]) + .set_config_tables( + &[startup("cargo watch")], + crate::config::TerminalConfig::default(), + vec![plugin("recovery")], + ) .expect("the merge fits the cap"); // The fan-out list is the served set as of the swap itself, not one fetched @@ -90,7 +102,11 @@ fn a_repo_opened_after_a_swap_gets_the_new_startup_list() { let before = Arc::clone(&catalog.entries()[0].terminals); catalog - .set_config_tables(&[startup("cargo watch")], Vec::new()) + .set_config_tables( + &[startup("cargo watch")], + crate::config::TerminalConfig::default(), + Vec::new(), + ) .expect("the merge fits the cap"); catalog.set_paths(&[a.clone(), b.clone()]); @@ -107,6 +123,25 @@ fn a_repo_opened_after_a_swap_gets_the_new_startup_list() { drop((dir_a, dir_b)); } +#[test] +fn a_repo_opened_after_a_swap_gets_the_new_auto_open_policy() { + let (dir, path) = make_repo(); + let catalog = Catalog::with_startup_and_plugins(Vec::new(), Vec::new()); + + catalog + .set_config_tables( + &[], + crate::config::TerminalConfig { auto_open: true }, + Vec::new(), + ) + .expect("an empty startup table is valid"); + catalog.set_paths(std::slice::from_ref(&path)); + + assert!(catalog.entries()[0].terminals.auto_opens_shell()); + catalog.shutdown(); + drop(dir); +} + #[test] fn concurrent_open_and_config_swap_cannot_miss_each_other() { let (dir_a, a) = make_repo(); @@ -134,7 +169,11 @@ fn concurrent_open_and_config_swap_cannot_miss_each_other() { std::thread::spawn(move || { barrier.wait(); catalog - .set_config_tables(&[startup("new")], Vec::new()) + .set_config_tables( + &[startup("new")], + crate::config::TerminalConfig::default(), + Vec::new(), + ) .expect("the merge fits the cap") }) }; diff --git a/src/session/catalog/config_tables.rs b/src/session/catalog/config_tables.rs index 42c0dbb5..de517228 100644 --- a/src/session/catalog/config_tables.rs +++ b/src/session/catalog/config_tables.rs @@ -1,4 +1,4 @@ -//! The two configured tables a hub is spawned with, and replacing them. +//! The configured startup policy and plugin table a hub is spawned with. //! //! Separate from the served set because they answer a different question. The //! catalog proper is "which repositories exist"; this is "what does a repository @@ -34,6 +34,7 @@ impl Catalog { startup_commands, plugins, cli_startup, + crate::config::TerminalConfig::default(), crate::config::ShellConfig::default(), super::empty_status_payload, )), @@ -46,6 +47,7 @@ impl Catalog { startup_commands: Vec, plugins: Vec, cli_startup: Vec, + terminal: crate::config::TerminalConfig, shell: crate::config::ShellConfig, status_encoder: crate::session::StatusEncoder, ) -> Self { @@ -54,6 +56,7 @@ impl Catalog { startup_commands, plugins, cli_startup, + terminal, shell, status_encoder, )), @@ -61,11 +64,11 @@ impl Catalog { } } - /// Replace both configured tables, as a config reload does. + /// Replace the configured startup policy and plugin table, as reload does. /// /// `file_startup` is the file's `[[startup_command]]` table alone; the /// remembered `--exec` panes are merged back on here. A merge that would - /// exceed the pane cap is refused and *neither* table is replaced. + /// exceed the pane cap is refused and none of the settings are replaced. /// /// Only the hubs spawned after this see the startup list. Telling the ones /// already running is the caller's job (see [`crate::session::reload`]). @@ -79,6 +82,7 @@ impl Catalog { pub fn set_config_tables( &self, file_startup: &[crate::config::StartupCommand], + terminal: crate::config::TerminalConfig, plugins: Vec, ) -> anyhow::Result>> { let _transaction = self @@ -88,7 +92,7 @@ impl Catalog { self.runtime .lock() .expect("catalog runtime poisoned") - .replace_config(file_startup, plugins) + .replace_config(file_startup, terminal, plugins) } /// The `[[plugin]]` table as it stands, for the caller that has to tell the @@ -110,4 +114,12 @@ impl Catalog { .expect("catalog runtime poisoned") .startup_commands() } + + /// Configured and CLI startup panes the next repository will receive. + pub(crate) fn startup_command_count(&self) -> usize { + self.runtime + .lock() + .expect("catalog runtime poisoned") + .startup_command_count() + } } diff --git a/src/session/reload.rs b/src/session/reload.rs index cff37207..01a4062e 100644 --- a/src/session/reload.rs +++ b/src/session/reload.rs @@ -5,12 +5,11 @@ //! an attached terminal must land on exactly the same state change. Neither //! transport authenticates here. //! -//! **What a reload is, and what it is not.** It re-reads two tables and nothing -//! else. `[[plugin]]` reaches even the repositories that are already open, -//! because replacing a plugin child costs the session nothing. -//! `[[startup_command]]` reaches only repositories opened afterwards: a hub -//! creates its startup panes once for its life, and the live children a running -//! repository already spent that list on no file edit may replace. +//! **What a reload is, and what it is not.** `[[plugin]]` reaches even the +//! repositories that are already open, because replacing a plugin child costs +//! the session nothing. `[[startup_command]]` and `[terminal] auto_open` reach +//! only repositories opened afterwards: a hub decides its startup panes once +//! for its life, and no file edit may replace a running repository's children. //! //! **It does not half-apply.** The whole file is parsed and validated first, so //! a typo anywhere leaves the session exactly as it was. @@ -39,8 +38,10 @@ impl std::fmt::Display for ReloadError { pub struct ReloadReport { /// `[[plugin]]` entries now declared. pub plugins: usize, - /// Configured startup panes now on file, before `--exec` is merged in. + /// Startup panes for new projects, including preserved `--exec` commands. pub startup_commands: usize, + /// Whether a project with no startup commands opens one shell. + pub auto_open: bool, /// Repositories whose plugins were re-applied. pub repos: usize, /// Repositories that could not even be asked, because their terminal worker @@ -55,7 +56,9 @@ impl ReloadReport { /// Written here rather than in each client because both surfaces show the /// same sentence, and two wordings of the same outcome would drift. pub fn summary(&self) -> String { - let panes = if self.startup_commands == 0 { + let panes = if self.startup_commands == 0 && self.auto_open { + "1 automatic shell applies to newly opened projects".to_string() + } else if self.startup_commands == 0 { "no startup panes configured".to_string() } else if self.startup_commands == 1 { "1 startup pane applies to newly opened projects".to_string() @@ -82,7 +85,7 @@ impl ReloadReport { } } -/// Re-read the config file and apply the two tables it owns. +/// Re-read the config file and apply the live-reloadable settings it owns. /// /// Serialized against itself by [`SessionState::reload_lock`]: two clients /// pressing at once would otherwise interleave a table swap with another's @@ -116,8 +119,13 @@ pub fn reload_config_at( // spawned from the new tables, never neither. let entries = state .catalog - .set_config_tables(&cfg.startup_commands, cfg.plugins.clone()) + .set_config_tables( + &cfg.startup_commands, + cfg.terminal.clone(), + cfg.plugins.clone(), + ) .map_err(ReloadError::Config)?; + let startup_commands = state.catalog.startup_command_count(); // Then the repositories already open. Each hub is *asked* — the work // happens on its own worker thread, the only thread allowed to touch a @@ -147,14 +155,15 @@ pub fn reload_config_at( tracing::info!( plugins = cfg.plugins.len(), - startup_commands = cfg.startup_commands.len(), + startup_commands, repos = asked, unreachable, "session: re-read the config file" ); Ok(ReloadReport { plugins: cfg.plugins.len(), - startup_commands: cfg.startup_commands.len(), + startup_commands, + auto_open: cfg.terminal.auto_open, repos: asked, unreachable, }) diff --git a/src/session/reload_tests.rs b/src/session/reload_tests.rs index d5fd9ab4..a13ff096 100644 --- a/src/session/reload_tests.rs +++ b/src/session/reload_tests.rs @@ -48,6 +48,53 @@ fn a_reload_replaces_the_startup_list_for_repos_opened_afterwards() { drop(dir); } +#[test] +fn a_reload_applies_auto_open_to_repos_opened_afterwards() { + let dir = tempfile::TempDir::new().unwrap(); + let (repo_dir, repo) = make_repo(); + let state = session_state(&[], dir.path()); + let path = config_file(dir.path(), "[terminal]\nauto_open = true\n"); + + let report = reload_config_at(&state, &path).expect("a valid file must apply"); + assert!(report.auto_open); + + state.catalog.set_paths(std::slice::from_ref(&repo)); + assert!(state.catalog.entries()[0].terminals.auto_opens_shell()); + state.catalog.shutdown(); + drop((repo_dir, dir)); +} + +#[test] +fn a_reload_summary_counts_preserved_cli_panes_before_auto_open() { + let dir = tempfile::TempDir::new().unwrap(); + let state = SessionState::new(crate::session::SessionOptions { + repos: Vec::new(), + persist: false, + startup_commands: vec![crate::config::StartupCommand { + name: None, + command: "cargo watch".to_string(), + plugin: None, + }], + cli_startup: vec!["cargo watch".to_string()], + terminal: crate::config::TerminalConfig::default(), + shell: crate::config::ShellConfig::default(), + prefs: crate::session::prefs::PrefsStore::at(dir.path().join("viewer.json")), + status_encoder: crate::session::test_status_encoder, + }); + let path = config_file(dir.path(), "[terminal]\nauto_open = true\n"); + + let report = reload_config_at(&state, &path).expect("a valid file must apply"); + + assert_eq!(report.startup_commands, 1); + assert_eq!( + report.summary(), + "config reloaded: 0 plugins across 0 open projects; \ + 1 startup pane applies to newly opened projects" + ); + state.catalog.shutdown(); + drop(dir); +} + #[test] fn a_file_that_does_not_parse_changes_nothing() { let dir = tempfile::TempDir::new().unwrap(); @@ -131,6 +178,7 @@ fn the_summary_agrees_with_its_own_counts() { let one = ReloadReport { plugins: 1, startup_commands: 1, + auto_open: false, repos: 1, unreachable: 0, }; @@ -142,6 +190,7 @@ fn the_summary_agrees_with_its_own_counts() { let many = ReloadReport { plugins: 2, startup_commands: 3, + auto_open: false, repos: 4, unreachable: 0, }; @@ -153,6 +202,7 @@ fn the_summary_agrees_with_its_own_counts() { let none = ReloadReport { plugins: 0, startup_commands: 0, + auto_open: false, repos: 0, unreachable: 0, }; @@ -162,6 +212,22 @@ fn the_summary_agrees_with_its_own_counts() { ); } +#[test] +fn the_summary_reports_an_automatic_shell_without_calling_it_configured() { + let report = ReloadReport { + plugins: 0, + startup_commands: 0, + auto_open: true, + repos: 0, + unreachable: 0, + }; + assert_eq!( + report.summary(), + "config reloaded: 0 plugins across 0 open projects; \ + 1 automatic shell applies to newly opened projects" + ); +} + /// A repository that could not be told keeps its plugin children, so the sentence /// must not read as if every open project had been re-applied. #[test] @@ -169,6 +235,7 @@ fn the_summary_owns_up_to_the_repositories_it_could_not_tell() { let one = ReloadReport { plugins: 1, startup_commands: 1, + auto_open: false, repos: 2, unreachable: 1, }; @@ -180,6 +247,7 @@ fn the_summary_owns_up_to_the_repositories_it_could_not_tell() { let many = ReloadReport { plugins: 1, startup_commands: 1, + auto_open: false, repos: 0, unreachable: 3, }; diff --git a/src/session/state.rs b/src/session/state.rs index 23dd5ff1..fe16d560 100644 --- a/src/session/state.rs +++ b/src/session/state.rs @@ -23,6 +23,7 @@ pub struct SessionOptions { pub persist: bool, pub startup_commands: Vec, pub cli_startup: Vec, + pub terminal: crate::config::TerminalConfig, pub shell: crate::config::ShellConfig, pub prefs: PrefsStore, pub status_encoder: StatusEncoder, @@ -58,6 +59,7 @@ impl SessionState { options.startup_commands, plugins, options.cli_startup, + options.terminal, options.shell, options.status_encoder, ); diff --git a/src/session/terminal/hub_connect.rs b/src/session/terminal/hub_connect.rs index 74fc1f15..cfe066e3 100644 --- a/src/session/terminal/hub_connect.rs +++ b/src/session/terminal/hub_connect.rs @@ -124,7 +124,10 @@ impl TerminalHub { // them then (see `claim_startup`). Announced to every client while the // panes are unclaimed, so one that drops mid-handshake does not leave // the hub terminal-less forever. - if !self.stop.load(Ordering::Acquire) && !self.started.load(Ordering::Acquire) { + if self.startup_count() > 0 + && !self.stop.load(Ordering::Acquire) + && !self.started.load(Ordering::Acquire) + { self.send_to( id, &ServerMessage::Pending { diff --git a/src/session/terminal/mod.rs b/src/session/terminal/mod.rs index f7ad4753..93c3cc97 100644 --- a/src/session/terminal/mod.rs +++ b/src/session/terminal/mod.rs @@ -82,9 +82,10 @@ pub struct TerminalHub { next_client_id: AtomicU64, stop: Arc, worker: Mutex>>, - /// The terminals to open once a client has sized them. Empty means a single - /// bare shell (matching the TUI's default). + /// The configured terminals to open once a client has sized them. startup: Vec, + /// Whether an empty startup list still opens one bare shell. + auto_open: bool, /// The `[[plugin]]` table. The worker launches a host for each entry that is /// enabled *and* that some `startup` entry opted into — a plugin no pane /// named is never started. @@ -103,14 +104,16 @@ pub struct TerminalHub { impl TerminalHub { /// Start a hub whose terminals run in `cwd`. `startup` is the list of - /// commands to launch when the first client connects (empty = one shell), - /// and `plugins` the configured plugin table those commands may opt into. + /// commands to launch when the first client connects. `auto_open` adds one + /// bare shell only when that list is empty, and `plugins` is the configured + /// plugin table those commands may opt into. /// /// `ownership` is the session's, shared with every other hub. pub fn spawn( cwd: &str, startup: Vec, plugins: Vec, + auto_open: bool, shell: crate::config::ShellConfig, ownership: Arc, ) -> Arc { @@ -128,6 +131,7 @@ impl TerminalHub { stop: Arc::new(AtomicBool::new(false)), worker: Mutex::new(None), startup, + auto_open, plugins, shell, started: AtomicBool::new(false), @@ -153,6 +157,11 @@ impl TerminalHub { &self.startup } + #[cfg(test)] + pub(crate) fn auto_opens_shell(&self) -> bool { + self.auto_open + } + /// Pane identities owned by this repository's hub, in canonical order. pub(crate) fn pane_ids(&self) -> Vec { self.state @@ -164,10 +173,13 @@ impl TerminalHub { .collect() } - /// How many startup terminals this hub will open. No configured commands - /// means one bare shell, matching the TUI's default. + /// How many startup terminals this hub will open. fn startup_count(&self) -> usize { - self.startup.len().max(1) + if self.startup.is_empty() && self.auto_open { + 1 + } else { + self.startup.len() + } } /// Give back cap slots a startup batch is no longer going to use. diff --git a/src/session/terminal/startup.rs b/src/session/terminal/startup.rs index 8d70e49e..66724ae1 100644 --- a/src/session/terminal/startup.rs +++ b/src/session/terminal/startup.rs @@ -28,12 +28,12 @@ impl TerminalHub { { return; } - // A bare shell when nothing is configured, matching the TUI's default. - let configured: Vec> = if self.startup.is_empty() { - vec![None] - } else { - self.startup.iter().cloned().map(Some).collect() - }; + let configured: Vec> = + if self.startup.is_empty() && self.auto_open { + vec![None] + } else { + self.startup.iter().cloned().map(Some).collect() + }; let panes: Vec = configured .into_iter() .enumerate() @@ -98,6 +98,9 @@ impl TerminalHub { /// Offer the startup terminals to every connected client. fn broadcast_pending(&self) { + if self.startup_count() == 0 { + return; + } let Ok(json) = serde_json::to_string(&ServerMessage::Pending { count: self.startup_count(), }) else { diff --git a/src/session/terminal/tests/backpressure.rs b/src/session/terminal/tests/backpressure.rs index 1add9a99..6ebbc0e8 100644 --- a/src/session/terminal/tests/backpressure.rs +++ b/src/session/terminal/tests/backpressure.rs @@ -63,6 +63,7 @@ fn an_evicted_client_still_releases_the_sizing_when_its_session_ends() { &cwd, Vec::new(), Vec::new(), + true, crate::config::ShellConfig::default(), ownership.clone(), ); diff --git a/src/session/terminal/tests/mod.rs b/src/session/terminal/tests/mod.rs index f84eeae4..0eb28564 100644 --- a/src/session/terminal/tests/mod.rs +++ b/src/session/terminal/tests/mod.rs @@ -21,6 +21,7 @@ mod scrollback_depth; mod size_owner; mod size_owner_resize_race; mod startup; +mod startup_policy; mod wire; mod zoom; @@ -235,11 +236,21 @@ pub(super) fn spawn_hub( cwd: &str, startup: Vec, plugins: Vec, +) -> std::sync::Arc { + spawn_hub_with_auto_open(cwd, startup, plugins, true) +} + +pub(super) fn spawn_hub_with_auto_open( + cwd: &str, + startup: Vec, + plugins: Vec, + auto_open: bool, ) -> std::sync::Arc { super::TerminalHub::spawn( cwd, startup, plugins, + auto_open, crate::config::ShellConfig::default(), Default::default(), ) diff --git a/src/session/terminal/tests/reattach.rs b/src/session/terminal/tests/reattach.rs index d146b419..d9830423 100644 --- a/src/session/terminal/tests/reattach.rs +++ b/src/session/terminal/tests/reattach.rs @@ -92,6 +92,7 @@ fn pane_running_awaiting(sequences: &str, marker: &str) -> Running { &dir.path().to_string_lossy(), Vec::new(), Vec::new(), + true, crate::config::ShellConfig { program: Some("bash".to_string()), command_args: Vec::new(), diff --git a/src/session/terminal/tests/screen_replay.rs b/src/session/terminal/tests/screen_replay.rs index fb97d109..efd17d3c 100644 --- a/src/session/terminal/tests/screen_replay.rs +++ b/src/session/terminal/tests/screen_replay.rs @@ -122,6 +122,7 @@ fn bash_hub(dir: &tempfile::TempDir) -> std::sync::Arc serde_json::Value { "summary": crate::session::reload::ReloadReport { plugins: 1, startup_commands: 2, + auto_open: false, repos: 1, unreachable: 0, } diff --git a/src/web/viewer/server/mod.rs b/src/web/viewer/server/mod.rs index 31941b63..4c691dfa 100644 --- a/src/web/viewer/server/mod.rs +++ b/src/web/viewer/server/mod.rs @@ -65,6 +65,7 @@ pub struct ViewerLaunch<'a> { pub agent_indicator: &'a crate::config::AgentIndicatorConfig, pub theme: &'a crate::config::ThemeConfig, pub shell: &'a crate::config::ShellConfig, + pub terminal: &'a crate::config::TerminalConfig, pub paths: &'a [String], pub persist: bool, pub startup_commands: Vec, @@ -128,6 +129,7 @@ impl ViewerServer { persist: launch.persist, startup_commands: launch.startup_commands, cli_startup: launch.cli_startup, + terminal: launch.terminal.clone(), shell: launch.shell.clone(), prefs: PrefsStore::load_seeded(launch.theme.preset_index()), status_encoder: crate::web::viewer::status_payload::encode, diff --git a/src/web/viewer/server/tests/mod.rs b/src/web/viewer/server/tests/mod.rs index ce589b04..466fa04f 100644 --- a/src/web/viewer/server/tests/mod.rs +++ b/src/web/viewer/server/tests/mod.rs @@ -53,6 +53,7 @@ pub(super) fn server_with( persist: false, startup_commands: Vec::new(), cli_startup: Vec::new(), + terminal: crate::config::TerminalConfig::default(), shell: crate::config::ShellConfig::default(), prefs, status_encoder: crate::web::viewer::status_payload::encode,