diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index abfea741aeac56bfb921560a505e11281a254fe2..39e0cc9a0a00f4cd5861e60b1b100a8afef93eb8 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -6131,30 +6131,48 @@ impl ProjectGroupKey { Self { paths, host } } - pub fn display_name(&self) -> SharedString { + pub fn path_list(&self) -> &PathList { + &self.paths + } + + pub fn display_name( + &self, + path_detail_map: &std::collections::HashMap, + ) -> SharedString { let mut names = Vec::with_capacity(self.paths.paths().len()); for abs_path in self.paths.paths() { - if let Some(name) = abs_path.file_name() { - names.push(name.to_string_lossy().to_string()); + let detail = path_detail_map.get(abs_path).copied().unwrap_or(0); + let suffix = path_suffix(abs_path, detail); + if !suffix.is_empty() { + names.push(suffix); } } if names.is_empty() { - // TODO: Can we do something better in this case? "Empty Workspace".into() } else { names.join(", ").into() } } - pub fn path_list(&self) -> &PathList { - &self.paths - } - pub fn host(&self) -> Option { self.host.clone() } } +pub fn path_suffix(path: &Path, detail: usize) -> String { + let mut components: Vec<_> = path + .components() + .rev() + .filter_map(|component| match component { + std::path::Component::Normal(s) => Some(s.to_string_lossy()), + _ => None, + }) + .take(detail + 1) + .collect(); + components.reverse(); + components.join("/") +} + pub struct PathMatchCandidateSet { pub snapshot: Snapshot, pub include_ignored: bool, diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 9bf496817103246abece6891ead8fd32196cef3b..9a6015ba843b06dfe678fee1b5de2fac38295849 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -99,27 +99,40 @@ pub async fn get_recent_projects( .await .unwrap_or_default(); - let entries: Vec = workspaces + let filtered: Vec<_> = workspaces .into_iter() .filter(|(id, _, _, _)| Some(*id) != current_workspace_id) .filter(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local)) + .collect(); + + let mut all_paths: Vec = filtered + .iter() + .flat_map(|(_, _, path_list, _)| path_list.paths().iter().cloned()) + .collect(); + all_paths.sort(); + all_paths.dedup(); + let path_details = + util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| { + project::path_suffix(path, detail) + }); + let path_detail_map: std::collections::HashMap = + all_paths.into_iter().zip(path_details).collect(); + + let entries: Vec = filtered + .into_iter() .map(|(workspace_id, _, path_list, timestamp)| { let paths: Vec = path_list.paths().to_vec(); let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect(); - let name = if ordered_paths.len() == 1 { - ordered_paths[0] - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string()) - } else { - ordered_paths - .iter() - .filter_map(|p| p.file_name()) - .map(|n| n.to_string_lossy().to_string()) - .collect::>() - .join(", ") - }; + let name = ordered_paths + .iter() + .map(|p| { + let detail = path_detail_map.get(*p).copied().unwrap_or(0); + project::path_suffix(p, detail) + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join(", "); let full_path = ordered_paths .iter() @@ -172,6 +185,19 @@ fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec { .map(|wt| wt.read(cx).id()) }); + let mut all_paths: Vec = visible_worktrees + .iter() + .map(|wt| wt.read(cx).abs_path().to_path_buf()) + .collect(); + all_paths.sort(); + all_paths.dedup(); + let path_details = + util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| { + project::path_suffix(path, detail) + }); + let path_detail_map: std::collections::HashMap = + all_paths.into_iter().zip(path_details).collect(); + let git_store = project.git_store().read(cx); let repositories: Vec<_> = git_store.repositories().values().cloned().collect(); @@ -180,8 +206,9 @@ fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec { .map(|worktree| { let worktree_ref = worktree.read(cx); let worktree_id = worktree_ref.id(); - let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string()); let path = worktree_ref.abs_path().to_path_buf(); + let detail = path_detail_map.get(&path).copied().unwrap_or(0); + let name = SharedString::from(project::path_suffix(&path, detail)); let branch = get_branch_for_worktree(worktree_ref, &repositories, cx); let is_active = active_worktree_id == Some(worktree_id); OpenFolderEntry { diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 5d660b2087198ffa96cc363764088826150d6c6b..57fe5a04ac6e656f790d72b1a99dff3e14fa8ead 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -883,12 +883,27 @@ impl Sidebar { (icon, icon_from_external_svg) }; - for (group_key, group_workspaces) in mw.project_groups(cx) { + let groups: Vec<_> = mw.project_groups(cx).collect(); + + let mut all_paths: Vec = groups + .iter() + .flat_map(|(key, _)| key.path_list().paths().iter().cloned()) + .collect(); + all_paths.sort(); + all_paths.dedup(); + let path_details = + util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| { + project::path_suffix(path, detail) + }); + let path_detail_map: HashMap = + all_paths.into_iter().zip(path_details).collect(); + + for (group_key, group_workspaces) in &groups { if group_key.path_list().paths().is_empty() { continue; } - let label = group_key.display_name(); + let label = group_key.display_name(&path_detail_map); let is_collapsed = self.collapsed_groups.contains(&group_key); let should_load_threads = !is_collapsed || !query.is_empty(); @@ -989,7 +1004,7 @@ impl Sidebar { // Load any legacy threads for any single linked wortree of this project group. let mut linked_worktree_paths = HashSet::new(); - for workspace in &group_workspaces { + for workspace in group_workspaces { if workspace.read(cx).visible_worktrees(cx).count() != 1 { continue; } @@ -1192,7 +1207,7 @@ impl Sidebar { None }; let thread_store = ThreadMetadataStore::global(cx); - for ws in &group_workspaces { + for ws in group_workspaces { if Some(ws.entity_id()) == draft_ws_id { continue; } diff --git a/crates/util/src/disambiguate.rs b/crates/util/src/disambiguate.rs new file mode 100644 index 0000000000000000000000000000000000000000..490182598b52ab3419633d0c56700e85f91d81a9 --- /dev/null +++ b/crates/util/src/disambiguate.rs @@ -0,0 +1,202 @@ +use std::collections::HashMap; +use std::hash::Hash; + +/// Computes the minimum detail level needed for each item so that no two items +/// share the same description. Items whose descriptions are unique at level 0 +/// stay at 0; items that collide get their detail level incremented until either +/// the collision is resolved or increasing the level no longer changes the +/// description (preventing infinite loops for truly identical items). +/// +/// The `get_description` closure must return a sequence that eventually reaches +/// a "fixed point" where increasing `detail` no longer changes the output. If +/// an item reaches its fixed point, it is assumed it will no longer change and +/// will no longer be checked for collisions. +pub fn compute_disambiguation_details( + items: &[T], + get_description: impl Fn(&T, usize) -> D, +) -> Vec +where + D: Eq + Hash + Clone, +{ + let mut details = vec![0usize; items.len()]; + let mut descriptions: HashMap> = HashMap::default(); + let mut current_descriptions: Vec = + items.iter().map(|item| get_description(item, 0)).collect(); + + loop { + let mut any_collisions = false; + + for (index, (item, &detail)) in items.iter().zip(&details).enumerate() { + if detail > 0 { + let new_description = get_description(item, detail); + if new_description == current_descriptions[index] { + continue; + } + current_descriptions[index] = new_description; + } + descriptions + .entry(current_descriptions[index].clone()) + .or_insert_with(Vec::new) + .push(index); + } + + for (_, indices) in descriptions.drain() { + if indices.len() > 1 { + any_collisions = true; + for index in indices { + details[index] += 1; + } + } + } + + if !any_collisions { + break; + } + } + + details +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_no_conflicts() { + let items = vec!["alpha", "beta", "gamma"]; + let details = compute_disambiguation_details(&items, |item, _detail| item.to_string()); + assert_eq!(details, vec![0, 0, 0]); + } + + #[test] + fn test_simple_two_way_conflict() { + // Two items with the same base name but different parents. + let items = vec![("src/foo.rs", "foo.rs"), ("lib/foo.rs", "foo.rs")]; + let details = compute_disambiguation_details(&items, |item, detail| match detail { + 0 => item.1.to_string(), + _ => item.0.to_string(), + }); + assert_eq!(details, vec![1, 1]); + } + + #[test] + fn test_three_way_conflict() { + let items = vec![ + ("foo.rs", "a/foo.rs"), + ("foo.rs", "b/foo.rs"), + ("foo.rs", "c/foo.rs"), + ]; + let details = compute_disambiguation_details(&items, |item, detail| match detail { + 0 => item.0.to_string(), + _ => item.1.to_string(), + }); + assert_eq!(details, vec![1, 1, 1]); + } + + #[test] + fn test_deeper_conflict() { + // At detail 0, all three show "file.rs". + // At detail 1, items 0 and 1 both show "src/file.rs", item 2 shows "lib/file.rs". + // At detail 2, item 0 shows "a/src/file.rs", item 1 shows "b/src/file.rs". + let items = vec![ + vec!["file.rs", "src/file.rs", "a/src/file.rs"], + vec!["file.rs", "src/file.rs", "b/src/file.rs"], + vec!["file.rs", "lib/file.rs", "x/lib/file.rs"], + ]; + let details = compute_disambiguation_details(&items, |item, detail| { + let clamped = detail.min(item.len() - 1); + item[clamped].to_string() + }); + assert_eq!(details, vec![2, 2, 1]); + } + + #[test] + fn test_mixed_conflicting_and_unique() { + let items = vec![ + ("src/foo.rs", "foo.rs"), + ("lib/foo.rs", "foo.rs"), + ("src/bar.rs", "bar.rs"), + ]; + let details = compute_disambiguation_details(&items, |item, detail| match detail { + 0 => item.1.to_string(), + _ => item.0.to_string(), + }); + assert_eq!(details, vec![1, 1, 0]); + } + + #[test] + fn test_identical_items_terminates() { + // All items return the same description at every detail level. + // The algorithm must terminate rather than looping forever. + let items = vec!["same", "same", "same"]; + let details = compute_disambiguation_details(&items, |item, _detail| item.to_string()); + // After bumping to 1, the description doesn't change from level 0, + // so the items are skipped and the loop terminates. + assert_eq!(details, vec![1, 1, 1]); + } + + #[test] + fn test_single_item() { + let items = vec!["only"]; + let details = compute_disambiguation_details(&items, |item, _detail| item.to_string()); + assert_eq!(details, vec![0]); + } + + #[test] + fn test_empty_input() { + let items: Vec<&str> = vec![]; + let details = compute_disambiguation_details(&items, |item, _detail| item.to_string()); + let expected: Vec = vec![]; + assert_eq!(details, expected); + } + + #[test] + fn test_duplicate_paths_from_multiple_groups() { + use std::path::Path; + + // Simulates the sidebar scenario: a path like /Users/rtfeldman/code/zed + // appears in two project groups (e.g. "zed" alone and "zed, roc"). + // After deduplication, only unique paths should be disambiguated. + // + // Paths: + // /Users/rtfeldman/code/worktrees/zed/focal-arrow/zed (group 1) + // /Users/rtfeldman/code/zed (group 2) + // /Users/rtfeldman/code/zed (group 3, same path as group 2) + // /Users/rtfeldman/code/roc (group 3) + // + // A naive flat_map collects duplicates. The duplicate /code/zed entries + // collide with each other and drive the detail to the full path. + // The fix is to deduplicate before disambiguating. + + fn path_suffix(path: &Path, detail: usize) -> String { + let mut components: Vec<_> = path + .components() + .rev() + .filter_map(|c| match c { + std::path::Component::Normal(s) => Some(s.to_string_lossy()), + _ => None, + }) + .take(detail + 1) + .collect(); + components.reverse(); + components.join("/") + } + + let all_paths: Vec<&Path> = vec![ + Path::new("/Users/rtfeldman/code/worktrees/zed/focal-arrow/zed"), + Path::new("/Users/rtfeldman/code/zed"), + Path::new("/Users/rtfeldman/code/roc"), + ]; + + let details = + compute_disambiguation_details(&all_paths, |path, detail| path_suffix(path, detail)); + + // focal-arrow/zed and code/zed both end in "zed", so they need detail 1. + // "roc" is unique at detail 0. + assert_eq!(details, vec![1, 1, 0]); + + assert_eq!(path_suffix(all_paths[0], details[0]), "focal-arrow/zed"); + assert_eq!(path_suffix(all_paths[1], details[1]), "code/zed"); + assert_eq!(path_suffix(all_paths[2], details[2]), "roc"); + } +} diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index bd8ab4e2d4d99864c5e0dc228410904f3338d7c6..3b704e50a531c5302024e215754cb9a866f0036b 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -1,5 +1,6 @@ pub mod archive; pub mod command; +pub mod disambiguate; pub mod fs; pub mod markdown; pub mod path_list; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index cbcd60b734644cb61473bef85e27f2403e3c7d3c..785d4111a38ad859f415983209bcd1eeae484d5e 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -4897,36 +4897,9 @@ fn dirty_message_for(buffer_path: Option, path_style: PathStyle) -> } pub fn tab_details(items: &[Box], _window: &Window, cx: &App) -> Vec { - let mut tab_details = items.iter().map(|_| 0).collect::>(); - let mut tab_descriptions = HashMap::default(); - let mut done = false; - while !done { - done = true; - - // Store item indices by their tab description. - for (ix, (item, detail)) in items.iter().zip(&tab_details).enumerate() { - let description = item.tab_content_text(*detail, cx); - if *detail == 0 || description != item.tab_content_text(detail - 1, cx) { - tab_descriptions - .entry(description) - .or_insert(Vec::new()) - .push(ix); - } - } - - // If two or more items have the same tab description, increase their level - // of detail and try again. - for (_, item_ixs) in tab_descriptions.drain() { - if item_ixs.len() > 1 { - done = false; - for ix in item_ixs { - tab_details[ix] += 1; - } - } - } - } - - tab_details + util::disambiguate::compute_disambiguation_details(items, |item, detail| { + item.tab_content_text(detail, cx) + }) } pub fn render_item_indicator(item: Box, cx: &App) -> Option { diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index af1a60589483443e56506e7eeb7a8424d16a4143..2f043bfb0c9e66d4ee56bfc78d0b9d69244d3777 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -573,6 +573,27 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> } } + // Run Test: Sidebar with duplicate project names + println!("\n--- Test: sidebar_duplicate_names ---"); + match run_sidebar_duplicate_project_names_visual_tests( + app_state.clone(), + &mut cx, + update_baseline, + ) { + Ok(TestResult::Passed) => { + println!("✓ sidebar_duplicate_names: PASSED"); + passed += 1; + } + Ok(TestResult::BaselineUpdated(_)) => { + println!("✓ sidebar_duplicate_names: Baselines updated"); + updated += 1; + } + Err(e) => { + eprintln!("✗ sidebar_duplicate_names: FAILED - {}", e); + failed += 1; + } + } + // Run Test 9: Tool Permissions Settings UI visual test println!("\n--- Test 9: tool_permissions_settings ---"); match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) { @@ -3069,6 +3090,279 @@ fn run_git_command(args: &[&str], dir: &std::path::Path) -> Result<()> { Ok(()) } +#[cfg(target_os = "macos")] +/// Helper to create a project, add a worktree at the given path, and return the project. +fn create_project_with_worktree( + worktree_dir: &Path, + app_state: &Arc, + cx: &mut VisualTestAppContext, +) -> Result> { + let project = cx.update(|cx| { + project::Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags { + init_worktree_trust: false, + ..Default::default() + }, + cx, + ) + }); + + let add_task = cx.update(|cx| { + project.update(cx, |project, cx| { + project.find_or_create_worktree(worktree_dir, true, cx) + }) + }); + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_task) + .context("Failed to add worktree")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + Ok(project) +} + +#[cfg(target_os = "macos")] +fn open_sidebar_test_window( + projects: Vec>, + app_state: &Arc, + cx: &mut VisualTestAppContext, +) -> Result> { + anyhow::ensure!(!projects.is_empty(), "need at least one project"); + + let window_size = size(px(400.0), px(600.0)); + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size: window_size, + }; + + let mut projects_iter = projects.into_iter(); + let first_project = projects_iter + .next() + .ok_or_else(|| anyhow::anyhow!("need at least one project"))?; + let remaining: Vec<_> = projects_iter.collect(); + + let multi_workspace_window: WindowHandle = cx + .update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: false, + ..Default::default() + }, + |window, cx| { + let first_ws = cx.new(|cx| { + Workspace::new(None, first_project.clone(), app_state.clone(), window, cx) + }); + cx.new(|cx| { + let mut mw = MultiWorkspace::new(first_ws, window, cx); + for project in remaining { + let ws = cx.new(|cx| { + Workspace::new(None, project, app_state.clone(), window, cx) + }); + mw.activate(ws, window, cx); + } + mw + }) + }, + ) + }) + .context("Failed to open MultiWorkspace window")?; + + cx.run_until_parked(); + + // Create the sidebar outside the MultiWorkspace update to avoid a + // re-entrant read panic (Sidebar::new reads the MultiWorkspace). + let sidebar = cx + .update_window(multi_workspace_window.into(), |root_view, window, cx| { + let mw_handle: Entity = root_view + .downcast() + .map_err(|_| anyhow::anyhow!("Failed to downcast root view to MultiWorkspace"))?; + Ok::<_, anyhow::Error>(cx.new(|cx| sidebar::Sidebar::new(mw_handle, window, cx))) + }) + .context("Failed to create sidebar")??; + + multi_workspace_window + .update(cx, |mw, _window, cx| { + mw.register_sidebar(sidebar.clone(), cx); + }) + .context("Failed to register sidebar")?; + + cx.run_until_parked(); + + // Open the sidebar + multi_workspace_window + .update(cx, |mw, window, cx| { + mw.toggle_sidebar(window, cx); + }) + .context("Failed to toggle sidebar")?; + + // Let rendering settle + for _ in 0..10 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + // Refresh the window + cx.update_window(multi_workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + + cx.run_until_parked(); + + Ok(multi_workspace_window) +} + +#[cfg(target_os = "macos")] +fn cleanup_sidebar_test_window( + window: WindowHandle, + cx: &mut VisualTestAppContext, +) -> Result<()> { + window.update(cx, |mw, _window, cx| { + for workspace in mw.workspaces() { + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + let ids: Vec<_> = project.worktrees(cx).map(|wt| wt.read(cx).id()).collect(); + for id in ids { + project.remove_worktree(id, cx); + } + }); + } + })?; + + cx.run_until_parked(); + + cx.update_window(window.into(), |_, window, _cx| { + window.remove_window(); + })?; + + cx.run_until_parked(); + + for _ in 0..15 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn run_sidebar_duplicate_project_names_visual_tests( + app_state: Arc, + cx: &mut VisualTestAppContext, + update_baseline: bool, +) -> Result { + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.keep(); + let canonical_temp = temp_path.canonicalize()?; + + // Create directory structure where every leaf directory is named "zed" but + // lives at a distinct path. This lets us test that the sidebar correctly + // disambiguates projects whose names would otherwise collide. + // + // code/zed/ — project1 (single worktree) + // code/foo/zed/ — project2 (single worktree) + // code/bar/zed/ — project3, first worktree + // code/baz/zed/ — project3, second worktree + // + // No two projects share a worktree path, so ProjectGroupBuilder will + // place each in its own group. + let code_zed = canonical_temp.join("code").join("zed"); + let foo_zed = canonical_temp.join("code").join("foo").join("zed"); + let bar_zed = canonical_temp.join("code").join("bar").join("zed"); + let baz_zed = canonical_temp.join("code").join("baz").join("zed"); + std::fs::create_dir_all(&code_zed)?; + std::fs::create_dir_all(&foo_zed)?; + std::fs::create_dir_all(&bar_zed)?; + std::fs::create_dir_all(&baz_zed)?; + + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + let mut has_baseline_update = None; + + // Two single-worktree projects whose leaf name is "zed" + { + let project1 = create_project_with_worktree(&code_zed, &app_state, cx)?; + let project2 = create_project_with_worktree(&foo_zed, &app_state, cx)?; + + let window = open_sidebar_test_window(vec![project1, project2], &app_state, cx)?; + + let result = run_visual_test( + "sidebar_two_projects_same_leaf_name", + window.into(), + cx, + update_baseline, + ); + + cleanup_sidebar_test_window(window, cx)?; + match result? { + TestResult::Passed => {} + TestResult::BaselineUpdated(path) => { + has_baseline_update = Some(path); + } + } + } + + // Three projects, third has two worktrees (all leaf names "zed") + // + // project1: code/zed + // project2: code/foo/zed + // project3: code/bar/zed + code/baz/zed + // + // Each project has a unique set of worktree paths, so they form + // separate groups. The sidebar must disambiguate all three. + { + let project1 = create_project_with_worktree(&code_zed, &app_state, cx)?; + let project2 = create_project_with_worktree(&foo_zed, &app_state, cx)?; + + let project3 = create_project_with_worktree(&bar_zed, &app_state, cx)?; + let add_second_worktree = cx.update(|cx| { + project3.update(cx, |project, cx| { + project.find_or_create_worktree(&baz_zed, true, cx) + }) + }); + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_second_worktree) + .context("Failed to add second worktree to project 3")?; + cx.background_executor.forbid_parking(); + cx.run_until_parked(); + + let window = open_sidebar_test_window(vec![project1, project2, project3], &app_state, cx)?; + + let result = run_visual_test( + "sidebar_three_projects_with_multi_worktree", + window.into(), + cx, + update_baseline, + ); + + cleanup_sidebar_test_window(window, cx)?; + match result? { + TestResult::Passed => {} + TestResult::BaselineUpdated(path) => { + has_baseline_update = Some(path); + } + } + } + + if let Some(path) = has_baseline_update { + Ok(TestResult::BaselineUpdated(path)) + } else { + Ok(TestResult::Passed) + } +} + #[cfg(all(target_os = "macos", feature = "visual-tests"))] fn run_start_thread_in_selector_visual_tests( app_state: Arc,