task: Add re-run task button to terminal title (#12379)

Jason Lee and Piotr Osiewicz created

Release Notes:

- Added re-run task button to terminal title.

Close #12277

## Demo


https://github.com/zed-industries/zed/assets/5518/4cd05fa5-4255-412b-8583-68e22f86561e

---------

Co-authored-by: Piotr Osiewicz <piotr@zed.dev>

Change summary

assets/icons/rerun.svg                    |  7 +++
crates/project/src/task_inventory.rs      | 17 ++++++-
crates/task/src/lib.rs                    |  4 
crates/tasks_ui/src/lib.rs                |  7 ++
crates/tasks_ui/src/modal.rs              |  5 +
crates/terminal_view/src/terminal_view.rs | 51 +++++++++++++++++++++---
crates/ui/src/components/icon.rs          |  2 
7 files changed, 77 insertions(+), 16 deletions(-)

Detailed changes

assets/icons/rerun.svg 🔗

@@ -0,0 +1,7 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 12C3 9.61305 3.94821 7.32387 5.63604 5.63604C7.32387 3.94821 9.61305 3 12 3C14.516 3.00947 16.931 3.99122 18.74 5.74L21 8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M21 3V8H16" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M21 12C21 14.3869 20.0518 16.6761 18.364 18.364C16.6761 20.0518 14.3869 21 12 21C9.48395 20.9905 7.06897 20.0088 5.26 18.26L3 16" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 16H3V21" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 9.37052C10 8.98462 10.4186 8.74419 10.7519 8.93863L15.2596 11.5681C15.5904 11.761 15.5904 12.2389 15.2596 12.4319L10.7519 15.0614C10.4186 15.2558 10 15.0154 10 14.6295V9.37052Z" fill="black"/>
+</svg>

crates/project/src/task_inventory.rs 🔗

@@ -348,9 +348,20 @@ impl Inventory {
         })
     }
 
-    /// Returns the last scheduled task, if any of the sources contains one with the matching id.
-    pub fn last_scheduled_task(&self) -> Option<(TaskSourceKind, ResolvedTask)> {
-        self.last_scheduled_tasks.back().cloned()
+    /// Returns the last scheduled task by task_id if provided.
+    /// Otherwise, returns the last scheduled task.
+    pub fn last_scheduled_task(
+        &self,
+        task_id: Option<&TaskId>,
+    ) -> Option<(TaskSourceKind, ResolvedTask)> {
+        if let Some(task_id) = task_id {
+            self.last_scheduled_tasks
+                .iter()
+                .find(|(_, task)| &task.id == task_id)
+                .cloned()
+        } else {
+            self.last_scheduled_tasks.back().cloned()
+        }
     }
 
     /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks.

crates/task/src/lib.rs 🔗

@@ -7,7 +7,7 @@ mod vscode_format;
 
 use collections::{hash_map, HashMap, HashSet};
 use gpui::SharedString;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use std::path::PathBuf;
 use std::str::FromStr;
 use std::{borrow::Cow, path::Path};
@@ -17,7 +17,7 @@ pub use vscode_format::VsCodeTaskFile;
 
 /// Task identifier, unique within the application.
 /// Based on it, task reruns and terminal tabs are managed.
-#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize)]
 pub struct TaskId(pub String);
 
 /// TerminalWorkDir describes where a task should be run

crates/tasks_ui/src/lib.rs 🔗

@@ -9,7 +9,7 @@ use workspace::{tasks::schedule_resolved_task, Workspace};
 mod modal;
 mod settings;
 
-pub use modal::Spawn;
+pub use modal::{Rerun, Spawn};
 
 pub fn init(cx: &mut AppContext) {
     settings::TaskSettings::register(cx);
@@ -20,7 +20,10 @@ pub fn init(cx: &mut AppContext) {
                 .register_action(move |workspace, action: &modal::Rerun, cx| {
                     if let Some((task_source_kind, mut last_scheduled_task)) =
                         workspace.project().update(cx, |project, cx| {
-                            project.task_inventory().read(cx).last_scheduled_task()
+                            project
+                                .task_inventory()
+                                .read(cx)
+                                .last_scheduled_task(action.task_id.as_ref())
                         })
                     {
                         if action.reevaluate_context {

crates/tasks_ui/src/modal.rs 🔗

@@ -9,7 +9,7 @@ use gpui::{
 };
 use picker::{highlighted_match_with_paths::HighlightedText, Picker, PickerDelegate};
 use project::{Project, TaskSourceKind};
-use task::{ResolvedTask, TaskContext, TaskTemplate};
+use task::{ResolvedTask, TaskContext, TaskId, TaskTemplate};
 use ui::{
     div, h_flex, v_flex, ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color,
     FluentBuilder as _, Icon, IconButton, IconButtonShape, IconName, IconSize, IntoElement,
@@ -54,6 +54,9 @@ pub struct Rerun {
     /// Default: null
     #[serde(default)]
     pub use_new_terminal: Option<bool>,
+
+    /// If present, rerun the task with this ID, otherwise rerun the last task.
+    pub task_id: Option<TaskId>,
 }
 
 impl_actions!(task, [Rerun, Spawn]);

crates/terminal_view/src/terminal_view.rs 🔗

@@ -24,7 +24,7 @@ use terminal::{
     Clear, Copy, Event, MaybeNavigationTarget, Paste, ShowCharacterPalette, TaskStatus, Terminal,
 };
 use terminal_element::TerminalElement;
-use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label};
+use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label, Tooltip};
 use util::{paths::PathLikeWithPosition, ResultExt};
 use workspace::{
     item::{BreadcrumbText, Item, ItemEvent, TabContentParams},
@@ -787,23 +787,58 @@ impl Item for TerminalView {
     fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
         let terminal = self.terminal().read(cx);
         let title = terminal.title(true);
-        let (icon, icon_color) = match terminal.task() {
+
+        let (icon, icon_color, rerun_btn) = match terminal.task() {
             Some(terminal_task) => match &terminal_task.status {
-                TaskStatus::Unknown => (IconName::ExclamationTriangle, Color::Warning),
-                TaskStatus::Running => (IconName::Play, Color::Default),
+                TaskStatus::Unknown => (IconName::ExclamationTriangle, Color::Warning, None),
+                TaskStatus::Running => (IconName::Play, Color::Disabled, None),
                 TaskStatus::Completed { success } => {
+                    let task_id = terminal_task.id.clone();
+                    let rerun_btn = IconButton::new("rerun-icon", IconName::Rerun)
+                        .icon_size(IconSize::Small)
+                        .size(ButtonSize::Compact)
+                        .icon_color(Color::Default)
+                        .shape(ui::IconButtonShape::Square)
+                        .tooltip(|cx| Tooltip::text("Rerun task", cx))
+                        .on_click(move |_, cx| {
+                            cx.dispatch_action(Box::new(tasks_ui::Rerun {
+                                task_id: Some(task_id.clone()),
+                                ..Default::default()
+                            }));
+                        });
+
                     if *success {
-                        (IconName::Check, Color::Success)
+                        (IconName::Check, Color::Success, Some(rerun_btn))
                     } else {
-                        (IconName::XCircle, Color::Error)
+                        (IconName::XCircle, Color::Error, Some(rerun_btn))
                     }
                 }
             },
-            None => (IconName::Terminal, Color::Muted),
+            None => (IconName::Terminal, Color::Muted, None),
         };
+
         h_flex()
             .gap_2()
-            .child(Icon::new(icon).color(icon_color))
+            .group("term-tab-icon")
+            .child(
+                h_flex()
+                    .group("term-tab-icon")
+                    .child(
+                        div()
+                            .when(rerun_btn.is_some(), |this| {
+                                this.hover(|style| style.invisible().w_0())
+                            })
+                            .child(Icon::new(icon).color(icon_color)),
+                    )
+                    .when_some(rerun_btn, |this, rerun_btn| {
+                        this.child(
+                            div()
+                                .absolute()
+                                .visible_on_hover("term-tab-icon")
+                                .child(rerun_btn),
+                        )
+                    }),
+            )
             .child(Label::new(title).color(if params.selected {
                 Color::Default
             } else {

crates/ui/src/components/icon.rs 🔗

@@ -163,6 +163,7 @@ pub enum IconName {
     ReplaceAll,
     ReplaceNext,
     ReplyArrowRight,
+    Rerun,
     Return,
     Reveal,
     Save,
@@ -284,6 +285,7 @@ impl IconName {
             IconName::ReplaceAll => "icons/replace_all.svg",
             IconName::ReplaceNext => "icons/replace_next.svg",
             IconName::ReplyArrowRight => "icons/reply_arrow_right.svg",
+            IconName::Rerun => "icons/rerun.svg",
             IconName::Return => "icons/return.svg",
             IconName::Save => "icons/save.svg",
             IconName::Screen => "icons/desktop.svg",