From 71f2cbe79825275bff0cd2452fa28b22a6b6e7bf Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 4 Feb 2025 01:15:09 -0800 Subject: [PATCH] Git Panel: separate new and changed (#24181) Release Notes: - N/A --------- Co-authored-by: conrad Co-authored-by: nate --- crates/git/src/status.rs | 1 + crates/git_ui/src/git_panel.rs | 795 ++++++++++++------- crates/project/src/git.rs | 354 +++------ crates/project/src/project.rs | 37 +- crates/remote_server/src/headless_project.rs | 40 +- crates/ui/src/components/toggle.rs | 11 +- crates/ui/src/traits/toggleable.rs | 17 + 7 files changed, 666 insertions(+), 589 deletions(-) diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index 6d2a976933..a882a5a14b 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -153,6 +153,7 @@ impl FileStatus { (StatusCode::Added, _) | (_, StatusCode::Added) => true, _ => false, }, + FileStatus::Untracked => true, _ => false, } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index d6176a8491..4b0f4e2337 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -5,12 +5,11 @@ use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, }; use anyhow::{Context as _, Result}; +use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::actions::MoveToEnd; use editor::scroll::ScrollbarAutoHide; use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar}; -use futures::channel::mpsc; -use futures::{SinkExt, StreamExt as _}; use git::repository::RepoPath; use git::status::FileStatus; use git::{ @@ -24,13 +23,13 @@ use project::{CreateOptions, Fs, Project, ProjectPath}; use rpc::proto; use serde::{Deserialize, Serialize}; use settings::Settings as _; -use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize}; +use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize}; use theme::ThemeSettings; use ui::{ - prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, ListItem, - ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, + prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, IndentGuideColors, + ListHeader, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, }; -use util::{ResultExt, TryFutureExt}; +use util::{maybe, ResultExt, TryFutureExt}; use workspace::notifications::{DetachAndPromptErr, NotificationId}; use workspace::Toast; use workspace::{ @@ -78,7 +77,55 @@ struct SerializedGitPanel { } #[derive(Debug, PartialEq, Eq, Clone)] -pub struct GitListEntry { +enum Section { + Changed, + New, +} + +impl Section { + pub fn contains(&self, status: FileStatus) -> bool { + match self { + Section::Changed => !status.is_created(), + Section::New => status.is_created(), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitHeaderEntry { + header: Section, + all_staged: ToggleState, +} + +impl GitHeaderEntry { + pub fn contains(&self, status_entry: &GitStatusEntry) -> bool { + self.header.contains(status_entry.status) + } + pub fn title(&self) -> &'static str { + match self.header { + Section::Changed => "Changed", + Section::New => "New", + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +enum GitListEntry { + GitStatusEntry(GitStatusEntry), + Header(GitHeaderEntry), +} + +impl GitListEntry { + fn status_entry(&self) -> Option { + match self { + GitListEntry::GitStatusEntry(entry) => Some(entry.clone()), + _ => None, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct GitStatusEntry { depth: usize, display_name: String, repo_path: RepoPath, @@ -102,11 +149,11 @@ pub struct GitPanel { update_visible_entries_task: Task<()>, repository_selector: Entity, commit_editor: Entity, - visible_entries: Vec, - all_staged: Option, + entries: Vec, + entries_by_path: collections::HashMap, width: Option, - err_sender: mpsc::Sender, - commit_task: Task<()>, + pending: HashMap, + commit_task: Task>, commit_pending: bool, } @@ -237,7 +284,6 @@ impl GitPanel { let project = workspace.project().clone(); let git_state = project.read(cx).git_state().clone(); let active_repository = project.read(cx).active_repository(cx); - let (err_sender, mut err_receiver) = mpsc::channel(1); let workspace = cx.entity().downgrade(); let git_panel = cx.new(|cx| { @@ -269,8 +315,9 @@ impl GitPanel { let mut git_panel = Self { focus_handle: cx.focus_handle(), pending_serialization: Task::ready(None), - visible_entries: Vec::new(), - all_staged: None, + entries: Vec::new(), + entries_by_path: HashMap::default(), + pending: HashMap::default(), current_modifiers: window.modifiers(), width: Some(px(360.)), scrollbar_state: ScrollbarState::new(scroll_handle.clone()) @@ -280,14 +327,13 @@ impl GitPanel { show_scrollbar: false, hide_scrollbar_task: None, update_visible_entries_task: Task::ready(()), - commit_task: Task::ready(()), + commit_task: Task::ready(Ok(())), commit_pending: false, active_repository, scroll_handle, fs, commit_editor, project, - err_sender, workspace, }; git_panel.schedule_update(window, cx); @@ -295,24 +341,6 @@ impl GitPanel { git_panel }); - let handle = git_panel.downgrade(); - cx.spawn(|_, mut cx| async move { - while let Some(e) = err_receiver.next().await { - let Some(this) = handle.upgrade() else { - break; - }; - if this - .update(&mut cx, |this, cx| { - this.show_err_toast("git operation error", e, cx); - }) - .is_err() - { - break; - } - } - }) - .detach(); - cx.subscribe_in( &git_panel, window, @@ -339,13 +367,11 @@ impl GitPanel { let Some(repo_path) = git_repo.project_path_to_repo_path(&path) else { return; }; - let Ok(ix) = self - .visible_entries - .binary_search_by_key(&&repo_path, |entry| &entry.repo_path) - else { + let Some(ix) = self.entries_by_path.get(&repo_path) else { return; }; - self.selected_entry = Some(ix); + + self.selected_entry = Some(*ix); cx.notify(); } @@ -489,14 +515,14 @@ impl GitPanel { } fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - if self.visible_entries.first().is_some() { + if self.entries.first().is_some() { self.selected_entry = Some(0); self.scroll_to_selected_entry(cx); } } fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context) { - let item_count = self.visible_entries.len(); + let item_count = self.entries.len(); if item_count == 0 { return; } @@ -517,7 +543,7 @@ impl GitPanel { } fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { - let item_count = self.visible_entries.len(); + let item_count = self.entries.len(); if item_count == 0 { return; } @@ -538,8 +564,8 @@ impl GitPanel { } fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { - if self.visible_entries.last().is_some() { - self.selected_entry = Some(self.visible_entries.len() - 1); + if self.entries.last().is_some() { + self.selected_entry = Some(self.entries.len() - 1); self.scroll_to_selected_entry(cx); } } @@ -578,15 +604,11 @@ impl GitPanel { } fn get_selected_entry(&self) -> Option<&GitListEntry> { - self.selected_entry - .and_then(|i| self.visible_entries.get(i)) + self.selected_entry.and_then(|i| self.entries.get(i)) } fn open_selected(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - if let Some(entry) = self - .selected_entry - .and_then(|i| self.visible_entries.get(i)) - { + if let Some(entry) = self.selected_entry.and_then(|i| self.entries.get(i)) { self.open_entry(entry, cx); } } @@ -600,16 +622,60 @@ impl GitPanel { let Some(active_repository) = self.active_repository.as_ref() else { return; }; - let result = if entry.status.is_staged().unwrap_or(false) { - active_repository - .unstage_entries(vec![entry.repo_path.clone()], self.err_sender.clone()) - } else { - active_repository.stage_entries(vec![entry.repo_path.clone()], self.err_sender.clone()) + let (stage, repo_paths) = match entry { + GitListEntry::GitStatusEntry(status_entry) => { + if status_entry.status.is_staged().unwrap_or(false) { + (false, vec![status_entry.repo_path.clone()]) + } else { + (true, vec![status_entry.repo_path.clone()]) + } + } + GitListEntry::Header(section) => { + let goal_staged_state = !section.all_staged.selected(); + let entries = self + .entries + .iter() + .filter_map(|entry| entry.status_entry()) + .filter(|status_entry| { + section.contains(&status_entry) + && status_entry.is_staged != Some(goal_staged_state) + }) + .map(|status_entry| status_entry.repo_path) + .collect::>(); + + (!section.all_staged.selected(), entries) + } }; - if let Err(e) = result { - self.show_err_toast("toggle staged error", e, cx); + for repo_path in repo_paths.iter() { + self.pending.insert(repo_path.clone(), stage); } - cx.notify(); + + cx.spawn({ + let repo_paths = repo_paths.clone(); + let active_repository = active_repository.clone(); + |this, mut cx| async move { + let result = if stage { + active_repository.stage_entries(repo_paths.clone()).await + } else { + active_repository.unstage_entries(repo_paths.clone()).await + }; + + this.update(&mut cx, |this, cx| { + for repo_path in repo_paths { + if this.pending.get(&repo_path) == Some(&stage) { + this.pending.remove(&repo_path); + } + } + result + .map_err(|e| { + this.show_err_toast(e, cx); + }) + .ok(); + cx.notify(); + }) + } + }) + .detach(); } fn toggle_staged_for_selected( @@ -624,10 +690,14 @@ impl GitPanel { } fn open_entry(&self, entry: &GitListEntry, cx: &mut Context) { + let Some(status_entry) = entry.status_entry() else { + return; + }; let Some(active_repository) = self.active_repository.as_ref() else { return; }; - let Some(path) = active_repository.repo_path_to_project_path(&entry.repo_path) else { + let Some(path) = active_repository.repo_path_to_project_path(&status_entry.repo_path) + else { return; }; let path_exists = self.project.update(cx, |project, cx| { @@ -641,30 +711,59 @@ impl GitPanel { } fn stage_all(&mut self, _: &git::StageAll, _window: &mut Window, cx: &mut Context) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.as_ref().cloned() else { return; }; - for entry in &mut self.visible_entries { - entry.is_staged = Some(true); + let mut pending_paths = Vec::new(); + for entry in self.entries.iter() { + if let Some(status_entry) = entry.status_entry() { + self.pending.insert(status_entry.repo_path.clone(), true); + pending_paths.push(status_entry.repo_path.clone()); + } } - self.all_staged = Some(true); - if let Err(e) = active_repository.stage_all(self.err_sender.clone()) { - self.show_err_toast("stage all error", e, cx); - }; + cx.spawn(|this, mut cx| async move { + if let Err(e) = active_repository.stage_all().await { + this.update(&mut cx, |this, cx| { + this.show_err_toast(e, cx); + }) + .ok(); + }; + this.update(&mut cx, |this, _cx| { + for repo_path in pending_paths { + this.pending.remove(&repo_path); + } + }) + }) + .detach(); } fn unstage_all(&mut self, _: &git::UnstageAll, _window: &mut Window, cx: &mut Context) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.as_ref().cloned() else { return; }; - for entry in &mut self.visible_entries { - entry.is_staged = Some(false); + let mut pending_paths = Vec::new(); + for entry in self.entries.iter() { + if let Some(status_entry) = entry.status_entry() { + self.pending.insert(status_entry.repo_path.clone(), false); + pending_paths.push(status_entry.repo_path.clone()); + } } - self.all_staged = Some(false); - if let Err(e) = active_repository.unstage_all(self.err_sender.clone()) { - self.show_err_toast("unstage all error", e, cx); - }; + + cx.spawn(|this, mut cx| async move { + if let Err(e) = active_repository.unstage_all().await { + this.update(&mut cx, |this, cx| { + this.show_err_toast(e, cx); + }) + .ok(); + }; + this.update(&mut cx, |this, _cx| { + for repo_path in pending_paths { + this.pending.remove(&repo_path); + } + }) + }) + .detach(); } fn discard_all(&mut self, _: &git::RevertAll, _window: &mut Window, _cx: &mut Context) { @@ -693,37 +792,30 @@ impl GitPanel { let save_task = self.commit_editor.update(cx, |editor, cx| { editor.save(false, self.project.clone(), window, cx) }); - let mut err_sender = self.err_sender.clone(); let commit_editor = self.commit_editor.clone(); self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move { - match save_task.await { - Ok(()) => { - if let Some(Ok(())) = cx - .update(|_, cx| { - active_repository.commit(name_and_email, err_sender.clone(), cx) - }) - .ok() - { - cx.update(|window, cx| { - commit_editor.update(cx, |editor, cx| editor.clear(window, cx)); - }) - .ok(); - } - } - Err(e) => { - err_sender.send(e).await.ok(); - } - } - git_panel - .update(&mut cx, |git_panel, _| { - git_panel.commit_pending = false; + let result = maybe!(async { + save_task.await?; + active_repository.commit(name_and_email).await?; + cx.update(|window, cx| { + commit_editor.update(cx, |editor, cx| editor.clear(window, cx)); }) - .ok(); + }) + .await; + + git_panel.update(&mut cx, |git_panel, cx| { + git_panel.commit_pending = false; + result + .map_err(|e| { + git_panel.show_err_toast(e, cx); + }) + .ok(); + }) }); } /// Commit all changes, regardless of whether they are staged or not - fn commit_all_changes( + fn commit_tracked_changes( &mut self, _: &git::CommitAllChanges, name_and_email: Option<(SharedString, SharedString)>, @@ -743,32 +835,43 @@ impl GitPanel { let save_task = self.commit_editor.update(cx, |editor, cx| { editor.save(false, self.project.clone(), window, cx) }); - let mut err_sender = self.err_sender.clone(); + let commit_editor = self.commit_editor.clone(); + let tracked_files = self + .entries + .iter() + .filter_map(|entry| entry.status_entry()) + .filter(|status_entry| { + Section::Changed.contains(status_entry.status) + && !status_entry.is_staged.unwrap_or(false) + }) + .map(|status_entry| status_entry.repo_path) + .collect::>(); + self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move { - match save_task.await { - Ok(()) => { - if let Some(Ok(())) = cx - .update(|_, cx| { - active_repository.commit_all(name_and_email, err_sender.clone(), cx) - }) - .ok() - { - cx.update(|window, cx| { - commit_editor.update(cx, |editor, cx| editor.clear(window, cx)); + let result = maybe!(async { + save_task.await?; + active_repository.stage_entries(tracked_files).await?; + active_repository.commit(name_and_email).await + }) + .await; + cx.update(|window, cx| match result { + Ok(_) => commit_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + }), + + Err(e) => { + git_panel + .update(cx, |git_panel, cx| { + git_panel.show_err_toast(e, cx); }) .ok(); - } } - Err(e) => { - err_sender.send(e).await.ok(); - } - } - git_panel - .update(&mut cx, |git_panel, _| { - git_panel.commit_pending = false; - }) - .ok(); + })?; + + git_panel.update(&mut cx, |git_panel, _| { + git_panel.commit_pending = false; + }) }); } @@ -837,39 +940,6 @@ impl GitPanel { }); } - fn for_each_visible_entry( - &self, - range: Range, - cx: &mut Context, - mut callback: impl FnMut(usize, GitListEntry, &mut Context), - ) { - let visible_entries = &self.visible_entries; - - for (ix, entry) in visible_entries - .iter() - .enumerate() - .skip(range.start) - .take(range.end - range.start) - { - let status = entry.status; - let filename = entry - .repo_path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned()); - - let details = GitListEntry { - repo_path: entry.repo_path.clone(), - status, - depth: 0, - display_name: filename, - is_staged: entry.is_staged, - }; - - callback(ix, details, cx); - } - } - fn schedule_update(&mut self, window: &mut Window, cx: &mut Context) { let project = self.project.clone(); let handle = cx.entity().downgrade(); @@ -910,7 +980,10 @@ impl GitPanel { } fn update_visible_entries(&mut self, cx: &mut Context) { - self.visible_entries.clear(); + self.entries.clear(); + self.entries_by_path.clear(); + let mut changed_entries = Vec::new(); + let mut new_entries = Vec::new(); let Some(repo) = self.active_repository.as_ref() else { // Just clear entries if no repository is active. @@ -922,19 +995,26 @@ impl GitPanel { let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path)); // Second pass - create entries with proper depth calculation - let mut all_staged = None; - for (ix, entry) in repo.status().enumerate() { + let mut new_any_staged = false; + let mut new_all_staged = true; + let mut changed_any_staged = false; + let mut changed_all_staged = true; + + for entry in repo.status() { let (depth, difference) = Self::calculate_depth_and_difference(&entry.repo_path, &path_set); + + let is_new = entry.status.is_created(); let is_staged = entry.status.is_staged(); - all_staged = if ix == 0 { - is_staged + + let new_is_staged = is_staged.unwrap_or(false); + if is_new { + new_any_staged |= new_is_staged; + new_all_staged &= new_is_staged; } else { - match (all_staged, is_staged) { - (None, _) | (_, None) => None, - (Some(a), Some(b)) => (a == b).then_some(a), - } - }; + changed_any_staged |= new_is_staged; + changed_all_staged &= new_is_staged; + } let display_name = if difference > 1 { // Show partial path for deeply nested files @@ -955,7 +1035,7 @@ impl GitPanel { .unwrap_or_default() }; - let entry = GitListEntry { + let entry = GitStatusEntry { depth, display_name, repo_path: entry.repo_path.clone(), @@ -963,24 +1043,56 @@ impl GitPanel { is_staged, }; - self.visible_entries.push(entry); + if is_new { + new_entries.push(entry); + } else { + changed_entries.push(entry); + } } - self.all_staged = all_staged; // Sort entries by path to maintain consistent order - self.visible_entries - .sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + + if changed_entries.len() > 0 { + let toggle_state = + ToggleState::from_any_and_all(changed_any_staged, changed_all_staged); + self.entries.push(GitListEntry::Header(GitHeaderEntry { + header: Section::Changed, + all_staged: toggle_state, + })); + self.entries.extend( + changed_entries + .into_iter() + .map(GitListEntry::GitStatusEntry), + ); + } + if new_entries.len() > 0 { + let toggle_state = ToggleState::from_any_and_all(new_any_staged, new_all_staged); + self.entries.push(GitListEntry::Header(GitHeaderEntry { + header: Section::New, + all_staged: toggle_state, + })); + self.entries + .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry)); + } + + for (ix, entry) in self.entries.iter().enumerate() { + if let Some(status_entry) = entry.status_entry() { + self.entries_by_path.insert(status_entry.repo_path, ix); + } + } self.select_first_entry_if_none(cx); cx.notify(); } - fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut Context) { + fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) { let Some(workspace) = self.workspace.upgrade() else { return; }; - let notif_id = NotificationId::Named(id.into()); + let notif_id = NotificationId::Named("git-operation-error".into()); let message = e.to_string(); workspace.update(cx, |workspace, cx| { let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| { @@ -1020,7 +1132,6 @@ impl GitPanel { _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let focus_handle = self.focus_handle(cx).clone(); let all_repositories = self .project .read(cx) @@ -1058,43 +1169,6 @@ impl GitPanel { self.render_repository_selector(cx).into_any_element() })) .child(div().flex_grow()) - .child(h_flex().gap_2().child(if self.all_staged.unwrap_or(false) { - self.panel_button("unstage-all", "Unstage All") - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Unstage all changes", - &UnstageAll, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.unstage_all(&UnstageAll, window, cx) - })) - } else { - self.panel_button("stage-all", "Stage All") - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Stage all changes", - &StageAll, - &focus_handle, - window, - cx, - ) - } - }) - .on_click( - cx.listener(move |this, _, window, cx| { - this.stage_all(&StageAll, window, cx) - }), - ) - })) } pub fn render_repository_selector(&self, cx: &mut Context) -> impl IntoElement { @@ -1104,7 +1178,7 @@ impl GitPanel { .map(|repo| repo.display_name(self.project.read(cx), cx)) .unwrap_or_default(); - let entry_count = self.visible_entries.len(); + let entry_count = self.entries.len(); RepositorySelectorPopoverMenu::new( self.repository_selector.clone(), @@ -1194,7 +1268,12 @@ impl GitPanel { .on_click({ let name_and_email = name_and_email.clone(); cx.listener(move |this, _: &ClickEvent, window, cx| { - this.commit_all_changes(&CommitAllChanges, name_and_email.clone(), window, cx) + this.commit_tracked_changes( + &CommitAllChanges, + name_and_email.clone(), + window, + cx, + ) }) }); @@ -1294,21 +1373,94 @@ impl GitPanel { } fn render_entries(&self, has_write_access: bool, cx: &mut Context) -> impl IntoElement { - let entry_count = self.visible_entries.len(); + let entry_count = self.entries.len(); v_flex() .size_full() .overflow_hidden() .child( uniform_list(cx.entity().clone(), "entries", entry_count, { - move |git_panel, range, _window, cx| { + move |this, range, _window, cx| { let mut items = Vec::with_capacity(range.end - range.start); - git_panel.for_each_visible_entry(range, cx, |ix, details, cx| { - items.push(git_panel.render_entry(ix, details, has_write_access, cx)); - }); + + for ix in range { + match &this.entries.get(ix) { + Some(GitListEntry::GitStatusEntry(entry)) => { + items.push(this.render_entry(ix, entry, has_write_access, cx)); + } + Some(GitListEntry::Header(header)) => { + items.push(this.render_header( + ix, + header, + has_write_access, + cx, + )); + } + None => {} + } + } + items } }) + .with_decoration( + ui::indent_guides( + cx.entity().clone(), + px(10.0), + IndentGuideColors::panel(cx), + |this, range, _windows, _cx| { + this.entries + .iter() + .skip(range.start) + .map(|entry| match entry { + GitListEntry::GitStatusEntry(_) => 1, + GitListEntry::Header(_) => 0, + }) + .collect() + }, + ) + .with_render_fn( + cx.entity().clone(), + move |_, params, window, cx| { + let left_offset = Checkbox::container_size(cx) + .to_pixels(window.rem_size()) + .half(); + const PADDING_Y: f32 = 4.; + let indent_size = params.indent_size; + let item_height = params.item_height; + + params + .indent_guides + .into_iter() + .enumerate() + .map(|(_, layout)| { + let offset = if layout.continues_offscreen { + px(0.) + } else { + px(PADDING_Y) + }; + let bounds = Bounds::new( + point( + px(layout.offset.x as f32) * indent_size + left_offset, + px(layout.offset.y as f32) * item_height + offset, + ), + size( + px(1.), + px(layout.length as f32) * item_height + - px(offset.0 * 2.), + ), + ); + ui::RenderedIndentGuide { + bounds, + layout, + is_active: false, + hitbox: None, + } + }) + .collect() + }, + ), + ) .size_full() .with_sizing_behavior(ListSizingBehavior::Infer) .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) @@ -1321,17 +1473,62 @@ impl GitPanel { Label::new(label.into()).color(color).single_line() } + fn render_header( + &self, + ix: usize, + header: &GitHeaderEntry, + has_write_access: bool, + cx: &Context, + ) -> AnyElement { + let checkbox = Checkbox::new(header.title(), header.all_staged) + .disabled(!has_write_access) + .fill() + .elevation(ElevationIndex::Surface); + let selected = self.selected_entry == Some(ix); + + div() + .w_full() + .px_0p5() + .child( + ListHeader::new(header.title()) + .start_slot(checkbox) + .toggle_state(selected) + .on_toggle({ + let header = header.clone(); + cx.listener(move |this, _, window, cx| { + if !has_write_access { + return; + } + this.selected_entry = Some(ix); + this.toggle_staged_for_entry( + &GitListEntry::Header(header.clone()), + window, + cx, + ) + }) + }), + ) + .into_any_element() + } + fn render_entry( &self, ix: usize, - entry_details: GitListEntry, + entry: &GitStatusEntry, has_write_access: bool, cx: &Context, - ) -> impl IntoElement { - let repo_path = entry_details.repo_path.clone(); + ) -> AnyElement { + let display_name = entry + .repo_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned()); + + let pending = self.pending.get(&entry.repo_path).copied(); + let repo_path = entry.repo_path.clone(); let selected = self.selected_entry == Some(ix); let status_style = GitPanelSettings::get_global(cx).status_style; - let status = entry_details.status; + let status = entry.status; let has_conflict = status.is_conflicted(); let is_modified = status.is_modified(); let is_deleted = status.is_deleted(); @@ -1357,101 +1554,93 @@ impl GitPanel { Color::Muted }; - let id: ElementId = ElementId::Name(format!("entry_{}", entry_details.display_name).into()); + let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into()); - let checkbox = Checkbox::new( - id, - entry_details - .is_staged - .map_or(ToggleState::Indeterminate, ToggleState::from), - ) - .disabled(!has_write_access) - .fill() - .elevation(ElevationIndex::Surface) - .on_click({ - let handle = cx.entity().downgrade(); - let repo_path = repo_path.clone(); - move |toggle, _window, cx| { - let Some(this) = handle.upgrade() else { - return; - }; - this.update(cx, |this, cx| { - this.visible_entries[ix].is_staged = match *toggle { - ToggleState::Selected => Some(true), - ToggleState::Unselected => Some(false), - ToggleState::Indeterminate => None, - }; - let repo_path = repo_path.clone(); - let Some(active_repository) = this.active_repository.as_ref() else { - return; - }; - let result = match toggle { - ToggleState::Selected | ToggleState::Indeterminate => active_repository - .stage_entries(vec![repo_path], this.err_sender.clone()), - ToggleState::Unselected => active_repository - .unstage_entries(vec![repo_path], this.err_sender.clone()), - }; - if let Err(e) = result { - this.show_err_toast("toggle staged error", e, cx); - } - }); - } - }); + let is_staged = pending + .or_else(|| entry.is_staged) + .map(ToggleState::from) + .unwrap_or(ToggleState::Indeterminate); + + let checkbox = Checkbox::new(id, is_staged) + .disabled(!has_write_access) + .fill() + .elevation(ElevationIndex::Surface) + .on_click({ + let entry = entry.clone(); + cx.listener(move |this, _, window, cx| { + this.toggle_staged_for_entry( + &GitListEntry::GitStatusEntry(entry.clone()), + window, + cx, + ); + }) + }); let start_slot = h_flex() .gap(DynamicSpacing::Base04.rems(cx)) .child(checkbox) .child(git_status_icon(status, cx)); - let id = ElementId::Name(format!("entry_{}", entry_details.display_name).into()); + let id = ElementId::Name(format!("entry_{}", display_name).into()); - div().w_full().px_0p5().child( - ListItem::new(id) - .spacing(ListItemSpacing::Sparse) - .start_slot(start_slot) - .toggle_state(selected) - .disabled(!has_write_access) - .on_click({ - let repo_path = entry_details.repo_path.clone(); - cx.listener(move |this, _, window, cx| { - this.selected_entry = Some(ix); - window.dispatch_action(Box::new(OpenSelected), cx); - cx.notify(); - let Some(workspace) = this.workspace.upgrade() else { - return; - }; - let Some(git_repo) = this.active_repository.as_ref() else { - return; - }; - let Some(path) = git_repo.repo_path_to_project_path(&repo_path).and_then( - |project_path| this.project.read(cx).absolute_path(&project_path, cx), - ) else { - return; - }; - workspace.update(cx, |workspace, cx| { - ProjectDiff::deploy_at(workspace, Some(path.into()), window, cx); + div() + .w_full() + .px_0p5() + .child( + ListItem::new(id) + .indent_level(1) + .indent_step_size(px(10.0)) + .spacing(ListItemSpacing::Sparse) + .start_slot(start_slot) + .toggle_state(selected) + .disabled(!has_write_access) + .on_click({ + let repo_path = entry.repo_path.clone(); + cx.listener(move |this, _, window, cx| { + this.selected_entry = Some(ix); + window.dispatch_action(Box::new(OpenSelected), cx); + cx.notify(); + let Some(workspace) = this.workspace.upgrade() else { + return; + }; + let Some(git_repo) = this.active_repository.as_ref() else { + return; + }; + let Some(path) = git_repo + .repo_path_to_project_path(&repo_path) + .and_then(|project_path| { + this.project.read(cx).absolute_path(&project_path, cx) + }) + else { + return; + }; + workspace.update(cx, |workspace, cx| { + ProjectDiff::deploy_at(workspace, Some(path.into()), window, cx); + }) }) }) - }) - .child( - h_flex() - .when_some(repo_path.parent(), |this, parent| { - let parent_str = parent.to_string_lossy(); - if !parent_str.is_empty() { - this.child( - self.entry_label(format!("{}/", parent_str), path_color) - .when(status.is_deleted(), |this| this.strikethrough(true)), - ) - } else { - this - } - }) - .child( - self.entry_label(entry_details.display_name.clone(), label_color) - .when(status.is_deleted(), |this| this.strikethrough(true)), - ), - ), - ) + .child( + h_flex() + .when_some(repo_path.parent(), |this, parent| { + let parent_str = parent.to_string_lossy(); + if !parent_str.is_empty() { + this.child( + self.entry_label(format!("{}/", parent_str), path_color) + .when(status.is_deleted(), |this| { + this.strikethrough(true) + }), + ) + } else { + this + } + }) + .child( + self.entry_label(display_name.clone(), label_color) + .when(status.is_deleted(), |this| this.strikethrough(true)), + ), + ), + ) + .into_any_element() } } @@ -1543,7 +1732,7 @@ impl Render for GitPanel { .on_action({ let name_and_email = name_and_email.clone(); cx.listener(move |git_panel, &CommitAllChanges, window, cx| { - git_panel.commit_all_changes( + git_panel.commit_tracked_changes( &CommitAllChanges, name_and_email.clone(), window, diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index 325a363c10..af86d1d14b 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git.rs @@ -2,8 +2,8 @@ use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent}; use crate::{Project, ProjectPath}; use anyhow::{anyhow, Context as _}; use client::ProjectId; -use futures::channel::mpsc; -use futures::{SinkExt as _, StreamExt as _}; +use futures::channel::{mpsc, oneshot}; +use futures::StreamExt as _; use git::{ repository::{GitRepository, RepoPath}, status::{GitSummary, TrackedSummary}, @@ -20,7 +20,7 @@ pub struct GitState { client: Option, repositories: Vec, active_index: Option, - update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender)>, + update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, _subscription: Subscription, } @@ -30,7 +30,7 @@ pub struct RepositoryHandle { pub worktree_id: WorktreeId, pub repository_entry: RepositoryEntry, pub git_repo: GitRepo, - update_sender: mpsc::UnboundedSender<(Message, mpsc::Sender)>, + update_sender: mpsc::UnboundedSender<(Message, oneshot::Sender>)>, } #[derive(Clone)] @@ -61,11 +61,6 @@ impl PartialEq for RepositoryHandle { } enum Message { - StageAndCommit { - git_repo: GitRepo, - paths: Vec, - name_and_email: Option<(SharedString, SharedString)>, - }, Commit { git_repo: GitRepo, name_and_email: Option<(SharedString, SharedString)>, @@ -87,151 +82,7 @@ impl GitState { project_id: Option, cx: &mut Context<'_, Self>, ) -> Self { - let (update_sender, mut update_receiver) = - mpsc::unbounded::<(Message, mpsc::Sender)>(); - cx.spawn(|_, cx| async move { - while let Some((msg, mut err_sender)) = update_receiver.next().await { - let result = - cx.background_executor() - .spawn(async move { - match msg { - Message::StageAndCommit { - git_repo, - name_and_email, - paths, - } => { - match git_repo { - GitRepo::Local(repo) => { - repo.stage_paths(&paths)?; - repo.commit(name_and_email.as_ref().map( - |(name, email)| (name.as_ref(), email.as_ref()), - ))?; - } - GitRepo::Remote { - project_id, - client, - worktree_id, - work_directory_id, - } => { - client - .request(proto::Stage { - project_id: project_id.0, - worktree_id: worktree_id.to_proto(), - work_directory_id: work_directory_id.to_proto(), - paths: paths - .into_iter() - .map(|repo_path| repo_path.to_proto()) - .collect(), - }) - .await - .context("sending stage request")?; - let (name, email) = name_and_email.unzip(); - client - .request(proto::Commit { - project_id: project_id.0, - worktree_id: worktree_id.to_proto(), - work_directory_id: work_directory_id.to_proto(), - name: name.map(String::from), - email: email.map(String::from), - }) - .await - .context("sending commit request")?; - } - } - - Ok(()) - } - Message::Stage(repo, paths) => { - match repo { - GitRepo::Local(repo) => repo.stage_paths(&paths)?, - GitRepo::Remote { - project_id, - client, - worktree_id, - work_directory_id, - } => { - client - .request(proto::Stage { - project_id: project_id.0, - worktree_id: worktree_id.to_proto(), - work_directory_id: work_directory_id.to_proto(), - paths: paths - .into_iter() - .map(|repo_path| repo_path.to_proto()) - .collect(), - }) - .await - .context("sending stage request")?; - } - } - Ok(()) - } - Message::Unstage(repo, paths) => { - match repo { - GitRepo::Local(repo) => repo.unstage_paths(&paths)?, - GitRepo::Remote { - project_id, - client, - worktree_id, - work_directory_id, - } => { - client - .request(proto::Unstage { - project_id: project_id.0, - worktree_id: worktree_id.to_proto(), - work_directory_id: work_directory_id.to_proto(), - paths: paths - .into_iter() - .map(|repo_path| repo_path.to_proto()) - .collect(), - }) - .await - .context("sending unstage request")?; - } - } - Ok(()) - } - Message::Commit { - git_repo, - name_and_email, - } => { - match git_repo { - GitRepo::Local(repo) => { - repo.commit(name_and_email.as_ref().map( - |(name, email)| (name.as_ref(), email.as_ref()), - ))? - } - GitRepo::Remote { - project_id, - client, - worktree_id, - work_directory_id, - } => { - let (name, email) = name_and_email.unzip(); - client - .request(proto::Commit { - project_id: project_id.0, - worktree_id: worktree_id.to_proto(), - work_directory_id: work_directory_id.to_proto(), - name: name.map(String::from), - email: email.map(String::from), - }) - .await - .context("sending commit request")?; - } - } - Ok(()) - } - } - }) - .await; - if let Err(e) = result { - err_sender.send(e).await.ok(); - } - } - }) - .detach(); - + let update_sender = Self::spawn_git_worker(cx); let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event); GitState { @@ -327,6 +178,110 @@ impl GitState { pub fn all_repositories(&self) -> Vec { self.repositories.clone() } + + fn spawn_git_worker( + cx: &mut Context<'_, GitState>, + ) -> mpsc::UnboundedSender<(Message, oneshot::Sender>)> { + let (update_sender, mut update_receiver) = + mpsc::unbounded::<(Message, oneshot::Sender>)>(); + cx.spawn(|_, cx| async move { + while let Some((msg, respond)) = update_receiver.next().await { + let result = cx + .background_executor() + .spawn(Self::process_git_msg(msg)) + .await; + respond.send(result).ok(); + } + }) + .detach(); + update_sender + } + + async fn process_git_msg(msg: Message) -> Result<(), anyhow::Error> { + match msg { + Message::Stage(repo, paths) => { + match repo { + GitRepo::Local(repo) => repo.stage_paths(&paths)?, + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => { + client + .request(proto::Stage { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + paths: paths + .into_iter() + .map(|repo_path| repo_path.to_proto()) + .collect(), + }) + .await + .context("sending stage request")?; + } + } + Ok(()) + } + Message::Unstage(repo, paths) => { + match repo { + GitRepo::Local(repo) => repo.unstage_paths(&paths)?, + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => { + client + .request(proto::Unstage { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + paths: paths + .into_iter() + .map(|repo_path| repo_path.to_proto()) + .collect(), + }) + .await + .context("sending unstage request")?; + } + } + Ok(()) + } + Message::Commit { + git_repo, + name_and_email, + } => { + match git_repo { + GitRepo::Local(repo) => repo.commit( + name_and_email + .as_ref() + .map(|(name, email)| (name.as_ref(), email.as_ref())), + )?, + GitRepo::Remote { + project_id, + client, + worktree_id, + work_directory_id, + } => { + let (name, email) = name_and_email.unzip(); + client + .request(proto::Commit { + project_id: project_id.0, + worktree_id: worktree_id.to_proto(), + work_directory_id: work_directory_id.to_proto(), + name: name.map(String::from), + email: email.map(String::from), + }) + .await + .context("sending commit request")?; + } + } + Ok(()) + } + } + } } impl RepositoryHandle { @@ -379,54 +334,47 @@ impl RepositoryHandle { self.repository_entry.relativize(&path.path).log_err() } - pub fn stage_entries( - &self, - entries: Vec, - err_sender: mpsc::Sender, - ) -> anyhow::Result<()> { + pub async fn stage_entries(&self, entries: Vec) -> anyhow::Result<()> { if entries.is_empty() { return Ok(()); } + let (result_tx, result_rx) = futures::channel::oneshot::channel(); self.update_sender - .unbounded_send((Message::Stage(self.git_repo.clone(), entries), err_sender)) + .unbounded_send((Message::Stage(self.git_repo.clone(), entries), result_tx)) .map_err(|_| anyhow!("Failed to submit stage operation"))?; - Ok(()) + + result_rx.await? } - pub fn unstage_entries( - &self, - entries: Vec, - err_sender: mpsc::Sender, - ) -> anyhow::Result<()> { + pub async fn unstage_entries(&self, entries: Vec) -> anyhow::Result<()> { if entries.is_empty() { return Ok(()); } + let (result_tx, result_rx) = futures::channel::oneshot::channel(); self.update_sender - .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), err_sender)) + .unbounded_send((Message::Unstage(self.git_repo.clone(), entries), result_tx)) .map_err(|_| anyhow!("Failed to submit unstage operation"))?; - Ok(()) + result_rx.await? } - pub fn stage_all(&self, err_sender: mpsc::Sender) -> anyhow::Result<()> { + pub async fn stage_all(&self) -> anyhow::Result<()> { let to_stage = self .repository_entry .status() .filter(|entry| !entry.status.is_staged().unwrap_or(false)) .map(|entry| entry.repo_path.clone()) .collect(); - self.stage_entries(to_stage, err_sender)?; - Ok(()) + self.stage_entries(to_stage).await } - pub fn unstage_all(&self, err_sender: mpsc::Sender) -> anyhow::Result<()> { + pub async fn unstage_all(&self) -> anyhow::Result<()> { let to_unstage = self .repository_entry .status() .filter(|entry| entry.status.is_staged().unwrap_or(true)) .map(|entry| entry.repo_path.clone()) .collect(); - self.unstage_entries(to_unstage, err_sender)?; - Ok(()) + self.unstage_entries(to_unstage).await } /// Get a count of all entries in the active repository, including @@ -447,64 +395,18 @@ impl RepositoryHandle { return self.have_changes() && (commit_all || self.have_staged_changes()); } - pub fn commit( + pub async fn commit( &self, name_and_email: Option<(SharedString, SharedString)>, - mut err_sender: mpsc::Sender, - cx: &mut App, ) -> anyhow::Result<()> { - let result = self.update_sender.unbounded_send(( + let (result_tx, result_rx) = futures::channel::oneshot::channel(); + self.update_sender.unbounded_send(( Message::Commit { git_repo: self.git_repo.clone(), name_and_email, }, - err_sender.clone(), - )); - if result.is_err() { - cx.spawn(|_| async move { - err_sender - .send(anyhow!("Failed to submit commit operation")) - .await - .ok(); - }) - .detach(); - anyhow::bail!("Failed to submit commit operation"); - } else { - Ok(()) - } - } - - pub fn commit_all( - &self, - name_and_email: Option<(SharedString, SharedString)>, - mut err_sender: mpsc::Sender, - cx: &mut App, - ) -> anyhow::Result<()> { - let to_stage = self - .repository_entry - .status() - .filter(|entry| !entry.status.is_staged().unwrap_or(false)) - .map(|entry| entry.repo_path.clone()) - .collect(); - let result = self.update_sender.unbounded_send(( - Message::StageAndCommit { - git_repo: self.git_repo.clone(), - paths: to_stage, - name_and_email, - }, - err_sender.clone(), - )); - if result.is_err() { - cx.spawn(|_| async move { - err_sender - .send(anyhow!("Failed to submit commit all operation")) - .await - .ok(); - }) - .detach(); - anyhow::bail!("Failed to submit commit all operation"); - } else { - Ok(()) - } + result_tx, + ))?; + result_rx.await? } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index c3e19bfeb0..47515084d7 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4006,15 +4006,9 @@ impl Project { .map(PathBuf::from) .map(RepoPath::new) .collect(); - let (err_sender, mut err_receiver) = mpsc::channel(1); - repository_handle - .stage_entries(entries, err_sender) - .context("staging entries")?; - if let Some(error) = err_receiver.next().await { - Err(error.context("error during staging")) - } else { - Ok(proto::Ack {}) - } + + repository_handle.stage_entries(entries).await?; + Ok(proto::Ack {}) } async fn handle_unstage( @@ -4034,15 +4028,9 @@ impl Project { .map(PathBuf::from) .map(RepoPath::new) .collect(); - let (err_sender, mut err_receiver) = mpsc::channel(1); - repository_handle - .unstage_entries(entries, err_sender) - .context("unstaging entries")?; - if let Some(error) = err_receiver.next().await { - Err(error.context("error during unstaging")) - } else { - Ok(proto::Ack {}) - } + + repository_handle.unstage_entries(entries).await?; + Ok(proto::Ack {}) } async fn handle_commit( @@ -4057,17 +4045,8 @@ impl Project { let name = envelope.payload.name.map(SharedString::from); let email = envelope.payload.email.map(SharedString::from); - let (err_sender, mut err_receiver) = mpsc::channel(1); - cx.update(|cx| { - repository_handle - .commit(name.zip(email), err_sender, cx) - .context("unstaging entries") - })??; - if let Some(error) = err_receiver.next().await { - Err(error.context("error during unstaging")) - } else { - Ok(proto::Ack {}) - } + repository_handle.commit(name.zip(email)).await?; + Ok(proto::Ack {}) } async fn handle_open_commit_message_buffer( diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 8d387583fd..d9af25173d 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -2,7 +2,6 @@ use anyhow::{anyhow, Context as _, Result}; use extension::ExtensionHostProxy; use extension_host::headless_host::HeadlessExtensionStore; use fs::{CreateOptions, Fs}; -use futures::channel::mpsc; use git::{repository::RepoPath, COMMIT_MESSAGE}; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, SharedString}; use http_client::HttpClient; @@ -637,15 +636,9 @@ impl HeadlessProject { .map(PathBuf::from) .map(RepoPath::new) .collect(); - let (err_sender, mut err_receiver) = mpsc::channel(1); - repository_handle - .stage_entries(entries, err_sender) - .context("staging entries")?; - if let Some(error) = err_receiver.next().await { - Err(error.context("error during staging")) - } else { - Ok(proto::Ack {}) - } + + repository_handle.stage_entries(entries).await?; + Ok(proto::Ack {}) } async fn handle_unstage( @@ -665,15 +658,10 @@ impl HeadlessProject { .map(PathBuf::from) .map(RepoPath::new) .collect(); - let (err_sender, mut err_receiver) = mpsc::channel(1); - repository_handle - .unstage_entries(entries, err_sender) - .context("unstaging entries")?; - if let Some(error) = err_receiver.next().await { - Err(error.context("error during unstaging")) - } else { - Ok(proto::Ack {}) - } + + repository_handle.unstage_entries(entries).await?; + + Ok(proto::Ack {}) } async fn handle_commit( @@ -688,17 +676,9 @@ impl HeadlessProject { let name = envelope.payload.name.map(SharedString::from); let email = envelope.payload.email.map(SharedString::from); - let (err_sender, mut err_receiver) = mpsc::channel(1); - cx.update(|cx| { - repository_handle - .commit(name.zip(email), err_sender, cx) - .context("unstaging entries") - })??; - if let Some(error) = err_receiver.next().await { - Err(error.context("error during unstaging")) - } else { - Ok(proto::Ack {}) - } + + repository_handle.commit(name.zip(email)).await?; + Ok(proto::Ack {}) } async fn handle_open_commit_message_buffer( diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 16a5fb8505..008edd1f07 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -135,6 +135,11 @@ impl Checkbox { ToggleStyle::Custom(color) => color.opacity(0.3), } } + + /// container size + pub fn container_size(cx: &App) -> Rems { + DynamicSpacing::Base20.rems(cx) + } } impl RenderOnce for Checkbox { @@ -163,9 +168,13 @@ impl RenderOnce for Checkbox { let bg_color = self.bg_color(cx); let border_color = self.border_color(cx); + let size = Self::container_size(cx); + let checkbox = h_flex() + .id(self.id.clone()) .justify_center() - .size(DynamicSpacing::Base20.rems(cx)) + .items_center() + .size(size) .group(group_id.clone()) .child( div() diff --git a/crates/ui/src/traits/toggleable.rs b/crates/ui/src/traits/toggleable.rs index 2da5a64ad4..e73a6cd658 100644 --- a/crates/ui/src/traits/toggleable.rs +++ b/crates/ui/src/traits/toggleable.rs @@ -29,6 +29,23 @@ impl ToggleState { Self::Selected => Self::Unselected, } } + + /// Creates a `ToggleState` from the given `any_checked` and `all_checked` flags. + pub fn from_any_and_all(any_checked: bool, all_checked: bool) -> Self { + match (any_checked, all_checked) { + (true, true) => Self::Selected, + (false, false) => Self::Unselected, + _ => Self::Indeterminate, + } + } + + /// Returns whether this toggle state is selected + pub fn selected(&self) -> bool { + match self { + ToggleState::Indeterminate | ToggleState::Unselected => false, + ToggleState::Selected => true, + } + } } impl From for ToggleState {