Skip to content

Commit 02285f5

Browse files
authored
Merge pull request #2658 from cachix/auto-reload-shell
Auto-reload shell environment instead of requiring Ctrl-Alt-R
2 parents f3b1906 + a8ccb67 commit 02285f5

11 files changed

Lines changed: 140 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- Added Ctrl+X keybinding to stop individual processes from the TUI while keeping them visible and restartable.
2222
- Tasks can now display messages when entering the shell by writing `{"devenv":{"messages":["..."]}}` to `$DEVENV_TASK_OUTPUT_FILE` ([#2500](https://github.com/cachix/devenv/issues/2500)).
2323
- Added `devenv hook <shell>` for native directory based auto-activation without direnv. Supports bash, zsh, fish, and nushell. Automatically deactivates when you leave the project directory. Add `eval "$(devenv hook bash)"` to your shell config to activate. Use `devenv allow` and `devenv revoke` to manage trust ([#2488](https://github.com/cachix/devenv/issues/2488)).
24+
- Shell environment now auto-reloads at the next prompt when watched files change, instead of requiring a manual Ctrl-Alt-R keybind ([#2595](https://github.com/cachix/devenv/issues/2595)).
2425
- Added `nixpkgs.rocmSupport` option to enable ROCm support in nixpkgs configuration.
2526
- Added process management subcommands and MCP tools: `devenv processes list`, `status`, `logs`, `restart`, `start`, `stop` for interacting with running native processes ([#2621](https://github.com/cachix/devenv/issues/2621)).
2627
## 2.0.6 (2026-03-22)

Cargo.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22
# This file was @generated by crate2nix 0.15.0 with the command:
3-
# "generate"
3+
# "generate" "-h" "nix/crate-hashes.json"
44
# See https://github.com/kolloch/crate2nix for more info.
55

66
{ nixpkgs ? <nixpkgs>

devenv-event-sources/src/fs.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,55 @@ impl WatcherHandle {
8383
}
8484
}
8585

86+
/// Force all watched paths to be re-registered with the OS.
87+
///
88+
/// On Linux, inotify watches track file inodes. When an editor does an
89+
/// atomic save (write temp + rename), the inode changes and the old watch
90+
/// becomes stale. The watchexec fs worker's diff logic skips paths already
91+
/// in its pathset, so stale watches are never refreshed.
92+
///
93+
/// This method forces a full refresh by briefly clearing the pathset
94+
/// (causing the fs worker to drop all watches) and then re-setting it
95+
/// (causing fresh watches to be created on current inodes).
96+
pub async fn rewatch_all(&self) {
97+
let mut ready = self.config.as_ref().map(|c| c.fs_ready());
98+
99+
{
100+
let paths = self.watched_paths.lock().unwrap();
101+
if paths.is_empty() {
102+
return;
103+
}
104+
105+
if let Some(ref config) = self.config {
106+
// Clear forces the fs worker to unwatch everything
107+
config.pathset(std::iter::empty::<WatchedPath>());
108+
}
109+
}
110+
111+
// Wait for the clear to be processed
112+
if let Some(ref mut rx) = ready {
113+
let _ = rx.changed().await;
114+
}
115+
116+
// Now re-subscribe for the re-add
117+
let mut ready = self.config.as_ref().map(|c| c.fs_ready());
118+
119+
{
120+
let paths = self.watched_paths.lock().unwrap();
121+
if let Some(ref config) = self.config {
122+
config.pathset(
123+
paths
124+
.iter()
125+
.map(|p| WatchedPath::non_recursive(p.as_path())),
126+
);
127+
}
128+
}
129+
130+
if let Some(ref mut rx) = ready {
131+
let _ = rx.changed().await;
132+
}
133+
}
134+
86135
pub fn watched_paths(&self) -> Vec<PathBuf> {
87136
self.watched_paths.lock().unwrap().iter().cloned().collect()
88137
}

devenv-reload/src/coordinator.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ impl ShellCoordinator {
269269
Event::ReloadBuildComplete { result, activity } => {
270270
current_build = None;
271271

272+
// Refresh all inotify watches. Editors using atomic save
273+
// (write temp + rename) replace the file inode, which
274+
// silently invalidates the kernel-level inotify watch.
275+
// The watchexec diff logic won't re-watch paths it thinks
276+
// are already watched, so we force a full refresh.
277+
watcher_handle.rewatch_all().await;
278+
272279
// Collect changed files as relative paths
273280
let files: Vec<PathBuf> = pending_changes
274281
.drain(..)

devenv-shell/src/dialect/bash.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ if [[ -n "${{GHOSTTY_RESOURCES_DIR:-}}" && -r "${{GHOSTTY_RESOURCES_DIR}}/shell-
6565
source "${{GHOSTTY_RESOURCES_DIR}}/shell-integration/bash/ghostty.bash"
6666
fi
6767
68-
# Hot-reload hook (keybinding and PROMPT_COMMAND integration)
68+
# Hot-reload hook (PROMPT_COMMAND integration)
6969
{reload_hook}
7070
7171
# Re-enable history after init
@@ -235,14 +235,10 @@ __devenv_restore_path() {{
235235
}}
236236
237237
__devenv_reload_hook() {{
238+
__devenv_reload_apply
238239
__devenv_restore_path
239240
}}
240241
241-
if [[ $- == *i* ]] && command -v bind >/dev/null 2>&1; then
242-
__devenv_reload_keybind="${{DEVENV_RELOAD_KEYBIND:-\\e\\C-r}}"
243-
bind -x "\"${{__devenv_reload_keybind}}\":__devenv_reload_apply"
244-
fi
245-
246242
# Append hook so it runs AFTER direnv's _direnv_hook (only if not already added)
247243
if [[ "$PROMPT_COMMAND" != *"__devenv_reload_hook"* ]]; then
248244
PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}__devenv_reload_hook"

devenv-shell/src/dialect/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub trait ShellDialect: Send + Sync {
2727
/// Generate environment diff helper functions (for hot-reload tracking).
2828
fn env_diff_helpers(&self) -> &str;
2929

30-
/// Generate the hot-reload hook script (keybinding + prompt hook).
30+
/// Generate the hot-reload hook script (prompt hook).
3131
fn reload_hook(&self, reload_file: &Path) -> String;
3232

3333
/// Path to the user's shell rc file (e.g., ~/.bashrc, ~/.zshrc).

devenv-shell/src/protocol.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub enum ShellCommand {
2727
changed_files: Vec<PathBuf>,
2828
error: String,
2929
},
30-
/// User applied the reload (pressed keybind). Clear status line.
30+
/// Reload was applied at the prompt. Update status line.
3131
ReloadApplied,
3232
/// File watching paused/resumed.
3333
WatchingPaused { paused: bool },

devenv-shell/src/session.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,21 @@ impl ShellSession {
997997
continue;
998998
}
999999
}
1000+
} else if let Some(remaining) = self.status_line.state().reloaded_remaining() {
1001+
tokio::select! {
1002+
event = event_rx.recv() => event,
1003+
_ = tokio::time::sleep(remaining) => {
1004+
self.status_line.state_mut().clear_reloaded();
1005+
if self.config.show_status_line {
1006+
queue!(stdout, terminal::BeginSynchronizedUpdate)?;
1007+
self.status_line.draw(stdout, self.size.cols, self.size.rows)?;
1008+
renderer.write_cursor(stdout, vt)?;
1009+
queue!(stdout, terminal::EndSynchronizedUpdate)?;
1010+
stdout.flush()?;
1011+
}
1012+
continue;
1013+
}
1014+
}
10001015
} else {
10011016
event_rx.recv().await
10021017
};
@@ -1264,7 +1279,7 @@ impl ShellSession {
12641279
}
12651280

12661281
ShellCommand::ReloadApplied => {
1267-
self.status_line.state_mut().clear();
1282+
self.status_line.state_mut().set_reloaded();
12681283
}
12691284

12701285
ShellCommand::WatchedFiles { files } => {

devenv-shell/src/status_line.rs

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ pub const CHECKMARK: &str = "✓";
4848
pub const XMARK: &str = "✗";
4949

5050
/// Keybind labels (short, long) for status line actions
51-
const KEYBIND_RELOAD: (&str, &str) = ("^⌥r", "Ctrl-Alt-R");
5251
const KEYBIND_ERROR: (&str, &str) = ("^⌥e", "Ctrl-Alt-E");
5352
const KEYBIND_PAUSE: (&str, &str) = ("^⌥d", "Ctrl-Alt-D");
5453

@@ -59,8 +58,12 @@ pub struct StatusState {
5958
pub changed_files: Vec<PathBuf>,
6059
/// Whether a build is in progress (evaluating nix).
6160
pub building: bool,
62-
/// Whether a reload is ready (waiting for user).
61+
/// Whether a reload is ready (auto-applies at next prompt).
6362
pub reload_ready: bool,
63+
/// Whether the environment was just reloaded.
64+
pub reloaded: bool,
65+
/// When the reloaded state was set (for auto-clearing after timeout).
66+
pub reloaded_at: Option<Instant>,
6467
/// Error message if build failed.
6568
pub error: Option<String>,
6669
/// Whether the error details are expanded (toggled by keybind).
@@ -85,6 +88,8 @@ impl StatusState {
8588
pub fn set_building(&mut self, changed_files: Vec<PathBuf>) {
8689
self.building = true;
8790
self.reload_ready = false;
91+
self.reloaded = false;
92+
self.reloaded_at = None;
8893
self.changed_files = changed_files;
8994
self.error = None;
9095
self.show_error = false;
@@ -116,10 +121,43 @@ impl StatusState {
116121
self.error = Some(error);
117122
}
118123

124+
/// Update state after reload was applied.
125+
pub fn set_reloaded(&mut self) {
126+
self.building = false;
127+
self.reload_ready = false;
128+
self.reloaded = true;
129+
self.reloaded_at = Some(Instant::now());
130+
self.changed_files.clear();
131+
self.error = None;
132+
self.show_error = false;
133+
// keep build_duration and watched_file_count
134+
}
135+
136+
/// Duration until the reloaded state should auto-clear.
137+
pub fn reloaded_remaining(&self) -> Option<std::time::Duration> {
138+
const RELOADED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
139+
self.reloaded_at.and_then(|at| {
140+
let elapsed = at.elapsed();
141+
if elapsed >= RELOADED_TIMEOUT {
142+
None
143+
} else {
144+
Some(RELOADED_TIMEOUT - elapsed)
145+
}
146+
})
147+
}
148+
149+
/// Clear the reloaded state (called when timeout expires).
150+
pub fn clear_reloaded(&mut self) {
151+
self.reloaded = false;
152+
self.reloaded_at = None;
153+
}
154+
119155
/// Clear the status.
120156
pub fn clear(&mut self) {
121157
self.building = false;
122158
self.reload_ready = false;
159+
self.reloaded = false;
160+
self.reloaded_at = None;
123161
self.changed_files.clear();
124162
self.error = None;
125163
self.show_error = false;
@@ -139,6 +177,7 @@ impl StatusState {
139177
pub fn has_status(&self) -> bool {
140178
self.building
141179
|| self.reload_ready
180+
|| self.reloaded
142181
|| self.error.is_some()
143182
|| self.paused
144183
|| self.watched_file_count > 0
@@ -380,10 +419,9 @@ impl StatusLine {
380419
}
381420
.into_any()
382421
} else if self.state.reload_ready {
383-
// Ready state
422+
// Ready state (auto-reloads at next prompt)
384423
let duration = duration_elements(&self.state);
385424
let watching = watching_elements(self.state.watched_file_count);
386-
let keybind = keybind_label(KEYBIND_RELOAD, use_short);
387425

388426
element! {
389427
View(width: width as u32, height: 1, flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceBetween, padding_left: 1, padding_right: 1) {
@@ -396,9 +434,24 @@ impl StatusLine {
396434
#(duration)
397435
#(watching)
398436
}
399-
View(flex_direction: FlexDirection::Row, flex_shrink: 0.0, margin_left: 2) {
400-
Text(content: keybind, color: COLOR_INTERACTIVE)
401-
Text(content: " reload")
437+
}
438+
}
439+
.into_any()
440+
} else if self.state.reloaded {
441+
// Reloaded state (environment was applied)
442+
let duration = duration_elements(&self.state);
443+
let watching = watching_elements(self.state.watched_file_count);
444+
445+
element! {
446+
View(width: width as u32, height: 1, flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceBetween, padding_left: 1, padding_right: 1) {
447+
View(flex_direction: FlexDirection::Row, flex_grow: 1.0, min_width: 0, overflow: Overflow::Hidden) {
448+
View(margin_right: 1) {
449+
Text(content: CHECKMARK, color: COLOR_COMPLETED)
450+
}
451+
Text(content: "devenv ", color: COLOR_SECONDARY)
452+
Text(content: "reloaded", weight: Weight::Bold, color: COLOR_COMPLETED)
453+
#(duration)
454+
#(watching)
402455
}
403456
}
404457
}

devenv-shell/tests/snapshots/session_tests__reload_ready.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
source: devenv-shell/tests/session_tests.rs
33
expression: "&rows[23]"
44
---
5-
devenv ready in [TIME] | watching 2 files Ctrl-Alt-R reload
5+
devenv ready in [TIME] | watching 2 files

0 commit comments

Comments
 (0)