Repository: Canop/bacon (language: rust)

Issue:
Feature Suggestion: Show number of files changed since last run in header line
I often make changes to files while bacon is still compiling (esp. before the pause functionality was added). This usually results in bacon showing slightly outdated information (which is okay), but unfortunately without any hint that it is outdated.

Ideally bacon would show a `files-changed=5` hint in the first row if the file system watcher detects that a file changed while bacon is still compiling (and thus bacon not re-starting the compilation). This should be updated immediately and should be reset to 0 when the job / a new compilation starts which includes the changed files.

I'm not sure if/when exactly cargo detects/includes these files into the compilation, but such a hint in bacon would at least indicate that the output might not include all changed files. It would also be useful when bacon is paused, to indicate how many files you've changed since bacon was paused (that is of course assuming that the filesystem watcher is still running while compiling and while bacon is paused).


Discussion / hints:
> This usually results in bacon showing slightly outdated information (which is okay)

This isn't okay, I noticed it, it didn't happen in first versions of bacon. I'll investigate this as soon as I find some time.
That was a quick response :)

> This isn't okay, I noticed it, it didn't happen in first versions of bacon.

I agree, but I'm not sure what the best way to handle this case would be:

- Cancel the currently running checks by killing the process, thus delaying the results but resulting in high accuracy
    -  Should the warnings/errors found be displayed or dropped?
- Finish the currently running check (and display the results) and queue another run just after it finishes, thus giving (potentially outdated) results early, but delaying the real/final results
- (current behavior) Do not automatically add another compilation/check to the queue until any file is saved after the compilation finishes (or the user hitting F5)

Regardless of which one it is: I think it would be useful to indicate when this happens (hence the suggestion of listing the amount of files changed since the last compilation started). And perhaps whether a re-compilation is already scheduled to start when the currently running one finishes.

Fix patch:
diff --git a/defaults/default-prefs.toml b/defaults/default-prefs.toml
index 680c73b..8807425 100644
--- a/defaults/default-prefs.toml
+++ b/defaults/default-prefs.toml
@@ -21,6 +21,18 @@
 #
 # help_line = false
 
+# Uncomment and change the value (true/false) to
+# set whether to display the count of changes since last job start
+#
+# show_changes_count = false
+
+# Uncomment one of those lines if you don't want the default
+# behavior triggered by a file change. This property can also
+# be set directly in a specific job.
+#
+# on_change_strategy = "kill_then_restart"
+# on_change_strategy = "wait_then_restart"
+
 # Exporting "locations" lets you use them in an external
 # tool, for example as a list of jump locations in an IDE
 # or in a language server.
diff --git a/src/app.rs b/src/app.rs
index d7e7249..252c380 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -37,6 +37,11 @@ pub fn run(
     let keybindings = mission.settings.keybindings.clone();
     let mut ignorer = time!(Info, mission.ignorer());
     let (watch_sender, watch_receiver) = bounded(0);
+    let on_change_strategy = mission
+        .job
+        .on_change_strategy
+        .or(mission.settings.on_change_strategy)
+        .unwrap_or(OnChangeStrategy::WaitThenRestart);
     let mut watcher =
         notify::recommended_watcher(move |res: notify::Result<notify::Event>| match res {
             Ok(we) => {
@@ -98,11 +103,11 @@ pub fn run(
         let mut action: Option<&Action> = None;
         select! {
             recv(watch_receiver) -> _ => {
+                state.receive_watch_event();
                 if state.auto_refresh.is_enabled() {
-                    task_executor.die();
-                    task_executor = state.start_computation(&mut executor)?;
-                } else {
-                    state.auto_refresh = AutoRefresh::PausedWithMisses;
+                    if !state.is_computing() || on_change_strategy == OnChangeStrategy::KillThenRestart {
+                        action = Some(&Action::Internal(Internal::ReRun));
+                    }
                 }
             }
             recv(executor.line_receiver) -> info => {
@@ -153,6 +158,7 @@ pub fn run(
                 event_source.unblock(false);
             }
         }
+        info!("action: {action:?}");
         if let Some(action) = action.take() {
             debug!("requested action: {action:?}");
             match action {
@@ -200,7 +206,7 @@ pub fn run(
                         state.auto_refresh = AutoRefresh::Paused;
                     }
                     Internal::Unpause => {
-                        if state.auto_refresh == AutoRefresh::PausedWithMisses {
+                        if state.changes_since_last_job_start > 0 {
                             state.clear();
                             task_executor.die();
                             task_executor = state.start_computation(&mut executor)?;
@@ -212,14 +218,13 @@ pub fn run(
                             state.auto_refresh = AutoRefresh::Paused;
                         }
                         AutoRefresh::Paused => {
+                            if state.changes_since_last_job_start > 0 {
+                                state.clear();
+                                task_executor.die();
+                                task_executor = state.start_computation(&mut executor)?;
+                            }
                             state.auto_refresh = AutoRefresh::Enabled;
                         }
-                        AutoRefresh::PausedWithMisses => {
-                            state.auto_refresh = AutoRefresh::Enabled;
-                            state.clear();
-                            task_executor.die();
-                            task_executor = state.start_computation(&mut executor)?;
-                        }
                     },
                 },
                 Action::Job(job_ref) => {
diff --git a/src/auto_refresh.rs b/src/auto_refresh.rs
index c19c7b9..8f4f24e 100644
--- a/src/auto_refresh.rs
+++ b/src/auto_refresh.rs
@@ -2,9 +2,6 @@
 pub enum AutoRefresh {
     /// Don't rerun the job on file changes.
     Paused,
-    /// Don't rerun the job, also we already missed some changes
-    /// (so if we enable, we should immediately rerun the job).
-    PausedWithMisses,
     /// Run the job on file changes.
     Enabled,
 }
@@ -15,6 +12,6 @@ impl AutoRefresh {
     }
 
     pub fn is_paused(self) -> bool {
-        matches!(self, AutoRefresh::Paused | AutoRefresh::PausedWithMisses)
+        matches!(self, AutoRefresh::Paused)
     }
 }
diff --git a/src/config.rs b/src/config.rs
index cad9ef9..39e958a 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -31,6 +31,8 @@ pub struct Config {
     pub jobs: HashMap<String, Job>,
     pub default_job: Option<ConcreteJobRef>,
     pub export: Option<ExportConfig>,
+    pub show_changes_count: Option<bool>,
+    pub on_change_strategy: Option<OnChangeStrategy>,
 }
 
 impl Config {
diff --git a/src/drawing.rs b/src/drawing.rs
index dbad417..bc4ad88 100644
--- a/src/drawing.rs
+++ b/src/drawing.rs
@@ -15,6 +15,8 @@ pub fn goto(
     execute!(w, cursor::MoveTo(0, y))?;
     Ok(())
 }
+
+/// Clear from the current position to the end of the line
 pub fn clear_line(w: &mut W) -> Result<()> {
     execute!(w, terminal::Clear(terminal::ClearType::UntilNewLine))?;
     Ok(())
diff --git a/src/executor.rs b/src/executor.rs
index f3422a4..e251f94 100644
--- a/src/executor.rs
+++ b/src/executor.rs
@@ -138,7 +138,7 @@ impl MissionExecutor {
                             Ok(_) => {
                                 let response = CommandExecInfo::Line(CommandOutputLine {
                                     content: TLine::from_tty(&line),
-                                    origin: CommandStream::StdErr,
+                                    origin: CommandStream::StdOut,
                                 });
                                 if sender.send(response).is_err() {
                                     break; // channel closed
diff --git a/src/job.rs b/src/job.rs
index 0020a19..ffd04d4 100644
--- a/src/job.rs
+++ b/src/job.rs
@@ -78,6 +78,11 @@ pub struct Job {
     // Eg: --all-features or anything after -- in bacon incantation
     #[serde(default = "default_true")]
     pub extraneous_args: bool,
+
+    /// How to handle changes: either immediately kill the current job
+    /// then restart it, or wait for the current job to finish before
+    /// restarting it.
+    pub on_change_strategy: Option<OnChangeStrategy>,
 }
 
 static DEFAULT_ARGS: &[&str] = &["--color", "always"];
@@ -117,6 +122,7 @@ impl Job {
             env: Default::default(),
             background: true,
             extraneous_args: true,
+            on_change_strategy: None,
         }
     }
 }
diff --git a/src/main.rs b/src/main.rs
index d822955..2f893b8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -27,6 +27,7 @@ mod line_type;
 mod list_jobs;
 mod mission;
 mod mission_location;
+mod on_change_strategy;
 mod report;
 mod scroll;
 mod settings;
@@ -66,6 +67,7 @@ pub use {
     list_jobs::*,
     mission::*,
     mission_location::*,
+    on_change_strategy::*,
     report::*,
     scroll::*,
     settings::*,
diff --git a/src/on_change_strategy.rs b/src/on_change_strategy.rs
new file mode 100644
index 0000000..4c097e4
--- /dev/null
+++ b/src/on_change_strategy.rs
@@ -0,0 +1,8 @@
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Copy, Deserialize, PartialEq)]
+#[serde(rename_all = "snake_case")]
+pub enum OnChangeStrategy {
+    KillThenRestart,
+    WaitThenRestart,
+}
diff --git a/src/settings.rs b/src/settings.rs
index 71082d2..ab45ce4 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -28,6 +28,8 @@ pub struct Settings {
     pub jobs: HashMap<String, Job>,
     pub default_job: ConcreteJobRef,
     pub export: ExportSettings,
+    pub show_changes_count: bool,
+    pub on_change_strategy: Option<OnChangeStrategy>,
 }
 
 impl Default for Settings {
@@ -47,6 +49,8 @@ impl Default for Settings {
             jobs: Default::default(),
             default_job: Default::default(),
             export: Default::default(),
+            show_changes_count: false,
+            on_change_strategy: None,
         }
     }
 }
@@ -94,6 +98,12 @@ impl Settings {
         if let Some(export_config) = &config.export {
             self.export.apply_config(export_config);
         }
+        if let Some(b) = config.show_changes_count {
+            self.show_changes_count = b;
+        }
+        if let Some(b) = config.on_change_strategy {
+            self.on_change_strategy = Some(b);
+        }
     }
     pub fn apply_args(
         &mut self,
diff --git a/src/state.rs b/src/state.rs
index 793333a..6b88994 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -63,6 +63,10 @@ pub struct AppState<'s> {
     raw_output: bool,
     /// whether auto-refresh is enabled
     pub auto_refresh: AutoRefresh,
+    /// How many watch events were received since last job start
+    pub changes_since_last_job_start: usize,
+    /// whether to display the count of changes
+    pub show_changes_count: bool,
 }
 
 impl<'s> AppState<'s> {
@@ -90,6 +94,7 @@ impl<'s> AppState<'s> {
             wrap: mission.settings.wrap,
             backtrace: false,
             reverse: mission.settings.reverse,
+            show_changes_count: mission.settings.show_changes_count,
             status_skin,
             scroll: 0,
             top_item_idx: 0,
@@ -98,6 +103,7 @@ impl<'s> AppState<'s> {
             mission,
             raw_output: false,
             auto_refresh: AutoRefresh::Enabled,
+            changes_since_last_job_start: 0,
         })
     }
 
@@ -191,6 +197,9 @@ impl<'s> AppState<'s> {
             self.update_wrap(self.width - 1);
         }
     }
+    pub fn is_computing(&self) -> bool {
+        self.computing
+    }
     pub fn clear(&mut self) {
         debug!("state.clear");
         self.take_output();
@@ -211,10 +220,14 @@ impl<'s> AppState<'s> {
             self.clear();
         }
         self.computing = true;
+        self.changes_since_last_job_start = 0;
     }
     pub fn computation_stops(&mut self) {
         self.computing = false;
     }
+    pub fn receive_watch_event(&mut self) {
+        self.changes_since_last_job_start += 1;
+    }
     fn scroll_to_top(&mut self) {
         self.scroll = 0;
         self.top_item_idx = 0;
@@ -416,6 +429,14 @@ impl<'s> AppState<'s> {
                 9,
             ));
         }
+        if self.show_changes_count {
+            t_line.add_badge(TString::num_badge(
+                self.changes_since_last_job_start,
+                "change",
+                235,
+                6,
+            ));
+        }
         let width = self.width as usize;
         let cols = t_line.draw_in(w, width)?;
         clear_line(w)?;
@@ -445,10 +466,17 @@ impl<'s> AppState<'s> {
     pub fn action(&self) -> Option<&Action> {
         if let CommandResult::Report(report) = &self.cmd_result {
             if self.mission.is_success(report) {
-                return self.mission.on_success().as_ref();
+                let on_success = self.mission.on_success().as_ref();
+                if on_success.is_some() {
+                    return on_success;
+                }
             }
         }
-        None
+        if self.changes_since_last_job_start > 0 && self.auto_refresh.is_enabled() {
+            Some(&Action::Internal(Internal::ReRun))
+        } else {
+            None
+        }
     }
     fn report_to_draw(&self) -> Option<&Report> {
         self.cmd_result
diff --git a/src/tty.rs b/src/tty.rs
index 9fcfe9a..d171512 100644
--- a/src/tty.rs
+++ b/src/tty.rs
@@ -73,8 +73,8 @@ impl TString {
         fg: u8,
         bg: u8,
     ) -> Self {
-        let raw = if num == 1 {
-            format!(" 1 {} ", cat)
+        let raw = if num < 2 {
+            format!(" {} {} ", num, cat)
         } else {
             format!(" {} {}s ", num, cat)
         };
diff --git a/website/docs/config.md b/website/docs/config.md
index eb9ef61..9ac494d 100644
--- a/website/docs/config.md
+++ b/website/docs/config.md
@@ -103,6 +103,7 @@ env | a map of environment vars, for example `env.LOG_LEVEL="die"` |
 kill | a command replacing the default job interruption (platform dependant, `SIGKILL` on unix). For example `kill = ["kill", "-s", "INT"]` |
 extraneous_args | if `false`, the action is run "as is" from `bacon.toml`, eg: no `--all-features` or `--features` inclusion | `true`
 need_stdout |whether we need to capture stdout too (stderr is always captured) | `false`
+on_change_strategy | `wait_then_restart` or `kill_then_restart` |
 on_success | the action to run when there's no error, warning or test failures |
 watch | a list of files and directories that will be watched if the job is run on a package. Usual source directories are implicitly included unless `default_watch` is set to false |
 


Test patch: