use std::collections::HashMap; #[cfg(feature = "local_fs")] use std::collections::HashSet; #[cfg(feature = "local_fs")] use std::path::Path; #[cfg(feature = "local_fs")] use std::path::PathBuf; use galaxy_core::SessionId; #[cfg(feature = "local_fs")] use indexmap::IndexSet; #[cfg(feature = "local_fs")] use remote_server::manager::RemoteServerManager; #[cfg(feature = "local_fs")] use repo_metadata::repositories::DetectedRepositories; #[cfg(feature = "local_fs")] use warp_util::remote_path::RemotePath; #[cfg(feature = "local_fs")] use warpui::{AppContext, SingletonEntity as _}; use warpui::{Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; use crate::code::buffer_location::LocalOrRemotePath; #[cfg(feature = "local_fs")] use crate::code::file_tree::FileTreeView; use crate::code_review::code_review_view::CodeReviewView; use crate::code_review::comments::{ AttachedReviewComment, PendingImportedReviewComment, ReviewCommentBatch, }; use crate::code_review::diff_state::{DiffMode, DiffStateModel}; use crate::workspace::view::global_search::view::GlobalSearchView; /// Type-safe wrapper around the map of `LocalOrRemotePath` → `DiffStateModel`. /// /// Enforces that local keys are always paired with local-backend models and /// remote keys with remote-backend models via dedicated insertion methods. #[cfg(feature = "local_fs")] #[derive(Default)] struct DiffStateModelMap { models: HashMap>, } #[cfg(feature = "local_fs")] impl DiffStateModelMap { fn get(&self, key: &LocalOrRemotePath) -> Option<&ModelHandle> { self.models.get(key) } /// Insert a model that was created from a `LocalOrRemotePath::Local` key. fn insert_local( &mut self, path: PathBuf, model: ModelHandle, ctx: &AppContext, ) { debug_assert!( matches!(model.as_ref(ctx), DiffStateModel::Local(_)), "insert_local called with a remote-backend DiffStateModel", ); self.models.insert(LocalOrRemotePath::Local(path), model); } /// Insert a model that was created from a `LocalOrRemotePath::Remote` key. fn insert_remote( &mut self, remote_id: RemotePath, model: ModelHandle, ctx: &AppContext, ) { debug_assert!( matches!(model.as_ref(ctx), DiffStateModel::Remote(_)), "insert_remote called with a local-backend DiffStateModel", ); self.models .insert(LocalOrRemotePath::Remote(remote_id), model); } fn remove(&mut self, key: &LocalOrRemotePath) -> Option> { self.models.remove(key) } } /// Bidirectional map of pane groups to the repository roots they reference. /// /// Maintains both a forward map (`pane_group_id -> ordered set of repo paths`) /// and a reverse map (`repo path -> set of pane group ids that reference it`) /// in lockstep, so callers can answer "is this repo still referenced by any /// pane group?" in O(1) without scanning every pane group's set. /// /// All mutations go through methods on this wrapper to guarantee the two /// maps stay in sync. #[cfg(feature = "local_fs")] #[derive(Default)] struct PaneGroupRepositoryRoots { /// Forward: per-pane-group ordered set of repository roots. /// IndexSet maintains insertion order so most recently added repos appear later. pane_group_to_paths: HashMap>, /// Reverse: which pane groups reference each repo path. /// Maintained in lockstep with `pane_group_to_paths`. path_to_pane_groups: HashMap>, } #[cfg(feature = "local_fs")] impl PaneGroupRepositoryRoots { /// Read-only view of a pane group's repository roots, preserving the /// existing `HashMap::get(&pane_group_id)` semantics. fn get(&self, pane_group_id: EntityId) -> Option<&IndexSet> { self.pane_group_to_paths.get(&pane_group_id) } /// Insert a single repo for a pane group. Returns `true` if it was newly /// added to the pane group (matching `IndexSet::insert` semantics). /// /// Always keeps the reverse map in sync: if the path was newly added to /// the pane group, the pane group is added to the path's reverse entry. fn insert(&mut self, pane_group_id: EntityId, path: LocalOrRemotePath) -> bool { let added = self .pane_group_to_paths .entry(pane_group_id) .or_default() .insert(path.clone()); if added { self.path_to_pane_groups .entry(path) .or_default() .insert(pane_group_id); } added } /// Set the full list of repository roots for a pane group, /// preserving the insertion-order of the previous set. /// /// Returns the paths that left this pane group's set AND no longer have /// any other pane group referencing them — i.e. the paths whose shared /// `DiffStateModel` is now safe to drop. Any paths that were already referenced /// by other pane groups are kept in their original order. fn set_paths( &mut self, pane_group_id: EntityId, new_paths: impl IntoIterator, ) -> Vec { let new_paths: Vec = new_paths.into_iter().collect(); let new_set: HashSet<&LocalOrRemotePath> = new_paths.iter().collect(); // Update the forward map and capture which paths left this pane group // (`removed`) and which were newly inserted into it (`newly_added`). // Tracking `newly_added` separately lets us skip redundant reverse-map // updates for paths the pane group already referenced. let (removed, newly_added): (Vec, Vec) = { let forward = self.pane_group_to_paths.entry(pane_group_id).or_default(); let removed: Vec = forward .iter() .filter(|item| !new_set.contains(*item)) .cloned() .collect(); forward.retain(|item| new_set.contains(item)); let mut newly_added: Vec = Vec::new(); for item in &new_paths { if forward.insert(item.clone()) { newly_added.push(item.clone()); } } (removed, newly_added) }; // Add pane_group_id only for paths that are newly referenced by this // pane group; paths it already referenced are already in the reverse // entry by the invariant maintained on every mutation. for path in newly_added { self.path_to_pane_groups .entry(path) .or_default() .insert(pane_group_id); } // Drop pane_group_id from the reverse map for paths it no longer // references; collect the paths whose reverse entry became empty. removed .into_iter() .filter(|path| self.remove_path(pane_group_id, path)) .collect() } /// Drop all entries for a pane group (used when a tab is closed or the /// pane group becomes empty). Returns `Some(orphans)` when the pane group /// had a `repository_roots` entry, where `orphans` are the paths that no /// longer have any pane group referencing them. Returns `None` when the /// pane group was not tracked, so callers can distinguish "present with /// no orphans" from "not present" in a single call. fn remove_pane_group(&mut self, pane_group_id: EntityId) -> Option> { let paths = self.pane_group_to_paths.remove(&pane_group_id)?; Some( paths .into_iter() .filter(|path| self.remove_path(pane_group_id, path)) .collect(), ) } /// Remove `pane_group_id` from the reverse-map entry for `path`. /// Returns `true` if removing this reference left `path` with no pane groups /// referencing it — i.e. the path is now globally orphaned. /// /// This only mutates the reverse map; callers are responsible for /// removing `path` from `pane_group_id`'s forward entry before (or /// after) calling this. fn remove_path(&mut self, pane_group_id: EntityId, path: &LocalOrRemotePath) -> bool { let became_empty = self.path_to_pane_groups.get_mut(path).is_some_and(|set| { set.remove(&pane_group_id); set.is_empty() }); if became_empty { self.path_to_pane_groups.remove(path); } became_empty } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct WorkingDirectory { pub path: LocalOrRemotePath, pub terminal_id: Option, } /// Events emitted when the set of working directories changes #[derive(Clone, Debug)] pub enum WorkingDirectoriesEvent { /// The set of working directories has changed for a specific pane group. DirectoriesChanged { /// The PaneGroup whose directories changed pane_group_id: EntityId, /// All active directories (deduplicated) in most to least recently added order. directories: Vec, }, /// The set of repositories has changed for a specific pane group. RepositoriesChanged { /// The PaneGroup whose repositories changed pane_group_id: EntityId, /// All active repository roots (deduplicated) in most to least recently added order. repositories: Vec, }, /// The focused repository changed for a specific pane group. /// This fires when the user focuses a different pane or CDs within the focused pane. FocusedRepoChanged { /// The PaneGroup whose focused repo changed pane_group_id: EntityId, /// All active repository-terminal ID pairs (deduplicated) repository_terminal_map: HashMap, /// The repository path of the focused terminal, if any focused_repo: Option, }, } #[derive(Default)] #[cfg(feature = "local_fs")] /// Workspace model that tracks working directories across all pane groups. /// Emits events when the set of directories changes for any pane group. pub struct WorkingDirectoriesModel { /// Per-pane-group tracking of active directories (both local and remote) as a /// deduplicated, ordered set. /// /// This stores the *display roots* for the left panel (file tree / global search), /// not the raw working directories reported by each pane. /// /// Concretely, for each pane group's active paths we store: /// - the detected repository root when the path belongs to a repo /// - otherwise, the normalized path itself (local) or the remote CWD/editor path /// /// IndexSet maintains insertion order - most recently added directories appear later. pane_groups: HashMap>, /// Per-pane-group tracking of active repository roots as a deduplicated, ordered set. /// Covers both local and remote repositories in a single map. repository_roots: PaneGroupRepositoryRoots, /// Per-pane-group mapping from root paths to a matching terminal view ID. /// This allows looking up which terminal is associated with each root path. /// Note, a single root path can be associated with multiple terminals. /// we're just storing an arbitrary terminal ID for each root path. directory_to_terminal: HashMap>, /// Global mapping from repository keys to their DiffStateModel. /// Since git state is inherently tied to a repository (not a pane group), /// this is stored globally and shared across all pane groups viewing the same repo. diff_state_models: DiffStateModelMap, /// Global mapping from repository locations to their CommentBatch. /// Like the DiffStateModel mapping, comments are inherently tied to git diffs /// and are shared across all pane groups viewing the same repo. comment_models: HashMap>, /// Per-pane-group mapping from repository root locations to their CodeReviewView. /// This allows reusing code review views across multiple requests for the same repo. code_review_views: HashMap>>, /// Per-pane-group tracking of the focused repository root path. focused_repo: HashMap>, /// Per-pane-group tracking of the repository the user has manually selected for the /// code review (right) panel. This is the repo that should be restored when the user /// leaves the pane group's session and returns to it later, even if the auto-selection /// logic would otherwise pick a different default. selected_review_repo: HashMap, global_search_views: HashMap>, file_tree_views: HashMap>, } #[derive(Default)] #[cfg(not(feature = "local_fs"))] /// Does nothing without a local file system pub struct WorkingDirectoriesModel {} /// Index Sets are ordered by insertion order. This function updates an index set to match a new set of items. #[cfg(feature = "local_fs")] pub fn update_index_set( index_set: &mut IndexSet, new_items: impl IntoIterator, ) { let new_items: Vec = new_items.into_iter().collect(); index_set.retain(|item| new_items.iter().any(|new_item| new_item == item)); for item in new_items { index_set.insert(item); } } #[cfg(feature = "local_fs")] impl WorkingDirectoriesModel { pub fn new() -> Self { Self::default() } /// Get the unique directories for a specific pane group in insertion order (oldest first). fn least_recent_directories_for_pane_group( &self, pane_group_id: EntityId, ) -> Option<&IndexSet> { self.pane_groups.get(&pane_group_id) } /// Get the unique directories for a specific pane group in most to least recently added order. pub fn most_recent_directories_for_pane_group( &self, pane_group_id: EntityId, ) -> Option + '_> { self.least_recent_directories_for_pane_group(pane_group_id) .map(move |dirs| { dirs.iter().rev().map(move |lor| WorkingDirectory { path: lor.clone(), terminal_id: self.get_terminal_id_for_root_path(pane_group_id, lor), }) }) } /// Get the unique repository roots for a specific pane group in insertion order (oldest first). fn least_recent_repositories_for_pane_group( &self, pane_group_id: EntityId, ) -> Option<&IndexSet> { self.repository_roots.get(pane_group_id) } /// Get the unique repository roots for a specific pane group in most to least recently added order. pub fn most_recent_repositories_for_pane_group( &self, pane_group_id: EntityId, ) -> Option + '_> { self.least_recent_repositories_for_pane_group(pane_group_id) .map(|repos| repos.iter().rev().cloned()) } /// Get the terminal view ID associated with a specific root path in a pane group. pub fn get_terminal_id_for_root_path( &self, pane_group_id: EntityId, root_path: &LocalOrRemotePath, ) -> Option { self.directory_to_terminal .get(&pane_group_id) .and_then(|roots| roots.get(root_path).copied()) } /// Get or create a DiffStateModel for a specific repository. /// /// If the model doesn't exist, it will be created. For remote /// repositories we require a connected session for the host; returns /// `None` when none exists so callers treat the panel as unavailable /// for that repo rather than producing a model that cannot subscribe. pub fn get_or_create_diff_state_model( &mut self, key: LocalOrRemotePath, preferred_session: Option, ctx: &mut ModelContext, ) -> Option> { if let Some(model) = self.diff_state_models.get(&key) { return Some(model.clone()); } let diff_state_model = match &key { LocalOrRemotePath::Local(path) => { let path = path.clone(); ctx.add_model(|ctx| DiffStateModel::new_local(path, ctx)) } LocalOrRemotePath::Remote(remote_path) => { let mgr_handle = RemoteServerManager::handle(ctx); mgr_handle .as_ref(ctx) .client_for_host(&remote_path.host_id)?; let remote_path = remote_path.clone(); ctx.add_model(|ctx| DiffStateModel::new_remote(remote_path, preferred_session, ctx)) } }; match key { LocalOrRemotePath::Local(path) => { self.diff_state_models .insert_local(path, diff_state_model.clone(), ctx); } LocalOrRemotePath::Remote(remote_id) => { self.diff_state_models .insert_remote(remote_id, diff_state_model.clone(), ctx); } } Some(diff_state_model) } /// Drops diff state models for repos that are no longer referenced by any /// pane group. The input must already be pre-filtered to orphans, so this /// method stops the watcher and removes stale model and view cache entries. fn drop_unused_diff_state_models( &mut self, orphaned_repos: impl IntoIterator, ctx: &mut ModelContext, ) { for repo_key in orphaned_repos { if let Some(model) = self.diff_state_models.remove(&repo_key) { model.update(ctx, |model, ctx| { model.stop_active_watcher(ctx); }); } for views in self.code_review_views.values_mut() { views.remove(&repo_key); } } } /// Get or create a ReviewCommentBatch for a specific repository. /// If the model doesn't exist, it will be created. pub fn get_or_create_code_review_comments( &mut self, repo_path: &LocalOrRemotePath, ctx: &mut ModelContext, ) -> Option> { if let Some(existing) = self.comment_models.get(repo_path) { return Some(existing.clone()); } let model = ctx.add_model(|_ctx| ReviewCommentBatch::default()); self.comment_models.insert(repo_path.clone(), model.clone()); Some(model) } /// Store a CodeReviewView for a specific repository in a pane group. pub fn store_code_review_view( &mut self, pane_group_id: EntityId, repo_path: LocalOrRemotePath, view: ViewHandle, ) { let pane_group_views = self.code_review_views.entry(pane_group_id).or_default(); pane_group_views.insert(repo_path, view); // Remove any inactive code reviews here. This allows these to be garbage collected. self.remove_inactive_code_reviews(pane_group_id); } /// Remove any code review view state that is not active in any of the terminal views that belong to this pane group. fn remove_inactive_code_reviews(&mut self, pane_group_id: EntityId) { let Some(code_review_views) = self.code_review_views.get_mut(&pane_group_id) else { return; }; let Some(terminal_mapping) = self.directory_to_terminal.get(&pane_group_id) else { return; }; code_review_views.retain(|path, _| terminal_mapping.contains_key(path)); } /// Get an existing CodeReviewView for a specific repository in a pane group. /// Returns None if no view exists for this combination. pub fn get_code_review_view( &self, pane_group_id: EntityId, repo_path: &LocalOrRemotePath, ) -> Option> { self.code_review_views .get(&pane_group_id) .and_then(|pane_group_views| pane_group_views.get(repo_path)) .cloned() } /// Get the repository path the user has manually selected for the code review /// panel in a given pane group, if any. Used to restore the selection when the /// user navigates back to the pane group's session. pub fn get_selected_review_repo(&self, pane_group_id: EntityId) -> Option<&LocalOrRemotePath> { self.selected_review_repo.get(&pane_group_id) } /// Persist the repository the user manually selected for the code review panel /// in a given pane group. This is only called for explicit user-driven /// selections (e.g. via the dropdown), not for auto-selected defaults. pub fn set_selected_review_repo( &mut self, pane_group_id: EntityId, repo_path: LocalOrRemotePath, ) { self.selected_review_repo.insert(pane_group_id, repo_path); } /// Clear the saved code review panel selection for a pane group. pub fn clear_selected_review_repo(&mut self, pane_group_id: EntityId) { self.selected_review_repo.remove(&pane_group_id); } pub fn store_global_search_view( &mut self, pane_group_id: EntityId, view: ViewHandle, ) { self.global_search_views.insert(pane_group_id, view); } pub fn get_global_search_view( &self, pane_group_id: EntityId, ) -> Option> { self.global_search_views.get(&pane_group_id).cloned() } pub fn store_file_tree_view( &mut self, pane_group_id: EntityId, view: ViewHandle, ) { self.file_tree_views.insert(pane_group_id, view); } pub fn get_file_tree_view(&self, pane_group_id: EntityId) -> Option> { self.file_tree_views.get(&pane_group_id).cloned() } /// Permanently removes all state associated with a pane group. /// This should be called when a tab is closed (pane group is destroyed), /// as opposed to handle_empty_pane_group which is called when working directories /// become empty but the pane group still exists (e.g., settings page). pub fn remove_pane_group(&mut self, pane_group_id: EntityId, ctx: &mut ModelContext) { // Clean up directories, terminals, and repos (emits events for subscribers) self.handle_empty_pane_group(pane_group_id, ctx); // Clean up views that should persist in handle_empty_pane_group e.g. there's only a settings pane in the pane group // but need to be removed when the pane group is destroyed self.global_search_views.remove(&pane_group_id); self.file_tree_views.remove(&pane_group_id); self.code_review_views.remove(&pane_group_id); self.focused_repo.remove(&pane_group_id); self.selected_review_repo.remove(&pane_group_id); } fn handle_empty_pane_group(&mut self, pane_group_id: EntityId, ctx: &mut ModelContext) { let did_remove_dirs = self.pane_groups.remove(&pane_group_id).is_some(); let did_remove_terminals = self.directory_to_terminal.remove(&pane_group_id).is_some(); let orphaned_repos = self.repository_roots.remove_pane_group(pane_group_id); let did_remove_repos = orphaned_repos.is_some(); if let Some(orphaned_repos) = orphaned_repos { self.drop_unused_diff_state_models(orphaned_repos, ctx); } if did_remove_dirs { ctx.emit(WorkingDirectoriesEvent::DirectoriesChanged { pane_group_id, directories: vec![], }); } if did_remove_repos { ctx.emit(WorkingDirectoriesEvent::RepositoriesChanged { pane_group_id, repositories: vec![], }); } if did_remove_terminals { ctx.emit(WorkingDirectoriesEvent::FocusedRepoChanged { pane_group_id, repository_terminal_map: HashMap::new(), focused_repo: None, }); } } /// Refreshes the working directories for a pane group from terminal CWDs /// (both local and remote) and code editor paths. /// /// If `focused_terminal_id` is provided, the repo_to_terminal map will prioritize pub fn refresh_working_directories_for_pane_group( &mut self, pane_group_id: EntityId, terminal_cwds: Vec<(EntityId, LocalOrRemotePath)>, editor_paths: Vec<(EntityId, LocalOrRemotePath)>, focused_terminal_id: Option, ctx: &mut ModelContext, ) { if terminal_cwds.is_empty() && editor_paths.is_empty() { self.handle_empty_pane_group(pane_group_id, ctx); return; } let old_directories: Vec = self .least_recent_directories_for_pane_group(pane_group_id) .map(|dirs| { dirs.iter() .map(|lor| WorkingDirectory { path: lor.clone(), terminal_id: self.get_terminal_id_for_root_path(pane_group_id, lor), }) .collect() }) .unwrap_or_default(); let old_repos: Vec = self .least_recent_repositories_for_pane_group(pane_group_id) .map(|repos| repos.iter().cloned().collect()) .unwrap_or_default(); let old_focused_repo: Option = self.focused_repo.get(&pane_group_id).cloned().flatten(); // Resolve a local path to its detected repository root, or keep the path as-is if no repo is found. let root_for_path = |path: PathBuf| { DetectedRepositories::as_ref(ctx) .get_root_for_path(&LocalOrRemotePath::Local(path.clone())) .and_then(|r| PathBuf::try_from(r).ok()) .unwrap_or(path) }; let root_for_raw_path = |raw_path: &str| normalize_cwd(raw_path).map(root_for_path); // Split terminal CWDs into local and remote buckets. let mut local_terminal_cwds: Vec<(EntityId, String)> = Vec::new(); let mut remote_terminal_cwds: Vec<(EntityId, RemotePath)> = Vec::new(); for (terminal_id, cwd) in &terminal_cwds { match cwd { LocalOrRemotePath::Local(path) => { local_terminal_cwds.push((*terminal_id, path.to_string_lossy().into_owned())); } LocalOrRemotePath::Remote(remote_path) => { remote_terminal_cwds.push((*terminal_id, remote_path.clone())); } } } // Collapse working directories to their nearest repository root (when detected). let mut file_path_ancestors: HashSet = local_terminal_cwds .iter() .filter_map(|(_, cwd)| root_for_raw_path(cwd)) .collect(); // Split editor paths into local and remote buckets. let mut local_editor_paths: Vec<(EntityId, String)> = Vec::new(); let mut remote_editor_paths: Vec<(EntityId, RemotePath)> = Vec::new(); for (view_id, path) in &editor_paths { match path { LocalOrRemotePath::Local(p) => { local_editor_paths.push((*view_id, p.to_string_lossy().into_owned())); } LocalOrRemotePath::Remote(remote_path) => { remote_editor_paths.push((*view_id, remote_path.clone())); } } } let local_cwds: Vec<(EntityId, String)> = local_editor_paths .into_iter() .filter_map(|(view_id, path)| { let path_buf = PathBuf::from(&path); let resolved_path = self .get_repo_root_for_path(&path_buf, ctx) .or_else(|| path_buf.parent().map(|p| p.to_path_buf()))?; if file_path_ancestors.insert(resolved_path.clone()) { Some((view_id, resolved_path.display().to_string())) } else { None } }) .collect(); // Build the local root paths for pane_groups. let new_local_root_paths: Vec = local_terminal_cwds .iter() .chain(local_cwds.iter()) .filter_map(|(_, cwd)| root_for_raw_path(cwd)) .collect(); // Build remote root paths for pane_groups from remote terminal CWDs // and remote editor paths (resolved to repo root when possible). let mut new_remote_display_roots: Vec = Vec::new(); for (_terminal_id, remote_path) in &remote_terminal_cwds { let remote_key = LocalOrRemotePath::Remote(remote_path.clone()); let root = DetectedRepositories::as_ref(ctx) .get_root_for_path(&remote_key) .unwrap_or(remote_key); new_remote_display_roots.push(root); } for (_view_id, remote_path) in &remote_editor_paths { let remote_key = LocalOrRemotePath::Remote(remote_path.clone()); if let Some(repo_root) = DetectedRepositories::as_ref(ctx).get_root_for_path(&remote_key) { new_remote_display_roots.push(repo_root); } else if let Some(parent) = remote_path.path.parent() { // Fall back to the parent directory, matching the local editor path behavior. new_remote_display_roots.push(LocalOrRemotePath::Remote(RemotePath::new( remote_path.host_id.clone(), parent, ))); } } // Combine local + remote into the unified display roots set. let new_display_roots: Vec = new_local_root_paths .iter() .cloned() .map(LocalOrRemotePath::Local) .chain(new_remote_display_roots.iter().cloned()) .collect(); // Get or create the IndexSet for this pane group // (IndexSet maintains insertion order and auto-deduplicates) let pane_group_roots = self.pane_groups.entry(pane_group_id).or_default(); update_index_set(pane_group_roots, new_display_roots); // Build repo roots and their terminal associations // First pass: collect all local repo roots and build initial mapping let new_local_repo_roots: Vec = self .pane_groups .get(&pane_group_id) .into_iter() .flat_map(|dirs| dirs.iter()) .filter_map(|lor| lor.to_local_path()) .filter_map(|dir| self.get_repo_root_for_path(dir, ctx)) .collect(); let mut new_roots: HashSet = HashSet::from_iter(new_local_repo_roots.iter().cloned()); new_roots.extend(new_local_root_paths.iter().cloned()); // Build mapping from directories to their terminal IDs (keyed by LocalOrRemotePath). // Local paths come from `root_for_raw_path` → `normalize_cwd`. let mut new_root_to_terminal: HashMap = local_terminal_cwds .iter() .filter_map(|(terminal_id, cwd)| { root_for_raw_path(cwd).map(|p| (LocalOrRemotePath::Local(p), *terminal_id)) }) .collect(); new_root_to_terminal .retain(|cwd, _terminal_id| cwd.to_local_path().is_some_and(|p| new_roots.contains(p))); // Resolve remote terminal CWDs to their repo roots and add to mappings. let mut new_remote_repo_roots: Vec = Vec::new(); for (terminal_id, remote_path) in &remote_terminal_cwds { let remote_key = LocalOrRemotePath::Remote(remote_path.clone()); if let Some(repo_root) = DetectedRepositories::as_ref(ctx).get_root_for_path(&remote_key) { new_root_to_terminal.insert(repo_root.clone(), *terminal_id); new_remote_repo_roots.push(repo_root); } else { // No repo detected — still track the CWD → terminal mapping // so `find_review_terminal` can resolve it. new_root_to_terminal.insert(remote_key, *terminal_id); } } // Resolve remote editor paths to their repo roots. for (_view_id, remote_path) in &remote_editor_paths { let remote_key = LocalOrRemotePath::Remote(remote_path.clone()); if let Some(repo_root) = DetectedRepositories::as_ref(ctx).get_root_for_path(&remote_key) { new_remote_repo_roots.push(repo_root); } } // Second pass: if we have a focused terminal, ensure its repo maps to it // This ensures the dropdown selects the correct repo when a pane is focused or CD'd let mut focused_repo: Option = None; if let Some(focused_id) = focused_terminal_id { let mut repos_to_insert = Vec::new(); for (dir, terminal_id) in &new_root_to_terminal { if *terminal_id == focused_id { if let Some(repo_root) = DetectedRepositories::as_ref(ctx).get_root_for_path(dir) { repos_to_insert.push((repo_root.clone(), focused_id)); focused_repo = Some(repo_root); } } } for (repo_key, focused_id) in repos_to_insert { new_root_to_terminal.insert(repo_key, focused_id); } } // Build the unified set of repo roots (local + remote). let mut new_repo_roots_wrapped: Vec = new_local_repo_roots .into_iter() .map(LocalOrRemotePath::Local) .chain(new_remote_repo_roots) .chain(new_remote_display_roots) .collect(); // Deduplicate (IndexSet handles this, but avoid duplicates in the input). let seen: HashSet<_> = new_repo_roots_wrapped.iter().cloned().collect(); new_repo_roots_wrapped.retain({ let mut first_seen = HashSet::new(); move |item| first_seen.insert(item.clone()) }); let _ = seen; // consumed by retain closure above let orphaned_repos = self .repository_roots .set_paths(pane_group_id, new_repo_roots_wrapped); self.directory_to_terminal .insert(pane_group_id, new_root_to_terminal); let new_directories: Vec = self .pane_groups .get(&pane_group_id) .map(|dirs| { dirs.iter() .map(|lor| WorkingDirectory { path: lor.clone(), terminal_id: self.get_terminal_id_for_root_path(pane_group_id, lor), }) .collect() }) .unwrap_or_default(); let new_deduplicated_repos: Vec = self .repository_roots .get(pane_group_id) .map(|repos| repos.iter().cloned().collect()) .unwrap_or_default(); if old_directories != new_directories { self.emit_directories_changed(pane_group_id, ctx); } if old_repos != new_deduplicated_repos { self.drop_unused_diff_state_models(orphaned_repos, ctx); self.emit_repositories_changed(pane_group_id, ctx); } if old_focused_repo != focused_repo { self.focused_repo .insert(pane_group_id, focused_repo.clone()); self.emit_focused_repo_changed(pane_group_id, focused_repo, ctx); } } /// Maps a repository to a specific terminal view ID so that /// `get_terminal_id_for_root_path` can resolve the preferred terminal /// for that repo (used by `find_review_terminal`). pub fn register_terminal_for_repo( &mut self, pane_group_id: EntityId, repo_key: LocalOrRemotePath, terminal_id: EntityId, ) { self.directory_to_terminal .entry(pane_group_id) .or_default() .insert(repo_key, terminal_id); } /// Registers a remote repository root for a pane group. Inserts it into /// the unified `repository_roots` map and emits `RepositoriesChanged` if /// the repo was newly added. pub fn register_remote_repo( &mut self, pane_group_id: EntityId, repo_key: LocalOrRemotePath, ctx: &mut ModelContext, ) { if self.repository_roots.insert(pane_group_id, repo_key) { self.emit_repositories_changed(pane_group_id, ctx); } } /// Get the repository root for a given path. fn get_repo_root_for_path(&self, path: &Path, ctx: &AppContext) -> Option { DetectedRepositories::as_ref(ctx) .get_root_for_path(&LocalOrRemotePath::Local(path.to_path_buf())) .and_then(|r| PathBuf::try_from(r).ok()) } /// Emit a DirectoriesChanged event with the current state for a specific pane group. /// Directories are returned in most recent first order for use in the UI. fn emit_directories_changed(&mut self, pane_group_id: EntityId, ctx: &mut ModelContext) { ctx.emit(WorkingDirectoriesEvent::DirectoriesChanged { pane_group_id, directories: self .most_recent_directories_for_pane_group(pane_group_id) .map(|iter| iter.collect()) .unwrap_or_default(), }); } /// Emit a RepositoriesChanged event with the current state for a specific pane group. /// Repositories are returned in most recent first order for use in the UI. fn emit_repositories_changed(&mut self, pane_group_id: EntityId, ctx: &mut ModelContext) { ctx.emit(WorkingDirectoriesEvent::RepositoriesChanged { pane_group_id, repositories: self .most_recent_repositories_for_pane_group(pane_group_id) .map(|iter| iter.collect()) .unwrap_or_default(), }); } fn emit_focused_repo_changed( &mut self, pane_group_id: EntityId, focused_repo: Option, ctx: &mut ModelContext, ) { ctx.emit(WorkingDirectoriesEvent::FocusedRepoChanged { pane_group_id, repository_terminal_map: self .directory_to_terminal .get(&pane_group_id) .cloned() .unwrap_or_default(), focused_repo, }); } pub(crate) fn insert_code_review_comments( &mut self, pane_group_id: EntityId, repo_path: &LocalOrRemotePath, comments: &Vec, diff_mode: &DiffMode, ctx: &mut ModelContext, ) { if let Some(code_review_view) = self.get_code_review_view(pane_group_id, repo_path) { code_review_view.update(ctx, |code_review_view, ctx| { code_review_view.set_diff_base(diff_mode.to_owned(), ctx); code_review_view.expand_comment_list(ctx); }) } else { log::error!( "WorkingDirectoriesModel did not find CodeReviewView for repo path {:?}", repo_path ); } if let Some(comment_batch) = self.get_or_create_code_review_comments(repo_path, ctx) { let comments = comments.to_owned(); comment_batch.update(ctx, |comment_batch, ctx| { comment_batch.add_pending_imported_comments(comments, diff_mode.to_owned(), ctx); }) } } /// Inserts pre-flattened (already attached) review comments into the comment batch for the /// given repository, creating the batch if needed. Unlike `insert_code_review_comments`, these /// comments have already been thread-flattened and converted to `AttachedReviewComment`, so /// they are ready to be repositioned onto diff editors immediately. pub(crate) fn upsert_flattened_code_review_comments( &mut self, repo_path: &LocalOrRemotePath, comments: Vec, ctx: &mut ModelContext, ) { if let Some(comment_batch) = self.get_or_create_code_review_comments(repo_path, ctx) { comment_batch.update(ctx, |comment_batch, ctx| { comment_batch.upsert_imported_comments(comments, ctx); }); } } } #[cfg(not(feature = "local_fs"))] impl WorkingDirectoriesModel { pub fn new() -> Self { Self::default() } /// Get the unique directories for a specific pane group in most to least recently added order. pub fn most_recent_directories_for_pane_group( &self, _pane_group_id: EntityId, ) -> Option + '_> { Option::>::None } /// Get the unique repository roots for a specific pane group in most to least recently added order. pub fn most_recent_repositories_for_pane_group( &self, _pane_group_id: EntityId, ) -> Option + '_> { Option::>::None } /// Get the terminal view ID associated with a specific repository in a pane group. pub fn get_terminal_id_for_root_path( &self, _pane_group_id: EntityId, _root_path: &LocalOrRemotePath, ) -> Option { None } pub fn refresh_working_directories_for_pane_group( &mut self, _pane_group_id: EntityId, _terminal_cwds: Vec<(EntityId, LocalOrRemotePath)>, _editor_paths: Vec<(EntityId, LocalOrRemotePath)>, _focused_terminal_id: Option, _ctx: &mut ModelContext, ) { } pub fn get_or_create_diff_state_model( &mut self, _key: LocalOrRemotePath, _preferred_session: Option, _ctx: &mut ModelContext, ) -> Option> { None } pub fn get_or_create_code_review_comments( &mut self, _repo_path: &LocalOrRemotePath, _ctx: &mut ModelContext, ) -> Option> { None } pub fn store_code_review_view( &mut self, _pane_group_id: EntityId, _repo_path: LocalOrRemotePath, _view: ViewHandle, ) { } pub fn get_code_review_view( &self, _pane_group_id: EntityId, _repo_path: &LocalOrRemotePath, ) -> Option> { None } pub fn get_selected_review_repo(&self, _pane_group_id: EntityId) -> Option<&LocalOrRemotePath> { None } pub fn set_selected_review_repo( &mut self, _pane_group_id: EntityId, _repo_path: LocalOrRemotePath, ) { } pub fn clear_selected_review_repo(&mut self, _pane_group_id: EntityId) {} pub fn store_global_search_view( &mut self, _pane_group_id: EntityId, _view: ViewHandle, ) { } pub fn get_global_search_view( &self, _pane_group_id: EntityId, ) -> Option> { None } pub fn store_file_tree_view( &mut self, _pane_group_id: EntityId, _view: ViewHandle, ) { } pub fn get_file_tree_view( &self, _pane_group_id: EntityId, ) -> Option> { None } pub fn remove_pane_group(&mut self, _pane_group_id: EntityId, _ctx: &mut ModelContext) {} pub(crate) fn insert_code_review_comments( &mut self, _pane_group_id: EntityId, _repo_path: &LocalOrRemotePath, _comments: &Vec, _diff_mode: &DiffMode, _ctx: &mut ModelContext, ) { } pub(crate) fn upsert_flattened_code_review_comments( &mut self, _repo_path: &LocalOrRemotePath, _comments: Vec, _ctx: &mut ModelContext, ) { } } impl Entity for WorkingDirectoriesModel { type Event = WorkingDirectoriesEvent; } /// Normalize a CWD path string to a canonical PathBuf /// /// This function attempts to canonicalize (resolve symlinks, make absolute) /// /// Returns None if the path is empty, invalid, or cannot be canonicalized. /// Canonicalization failure may indicate remote paths or non-existent directories, /// which could be supported in the future. #[cfg(feature = "local_fs")] fn normalize_cwd(raw_cwd: &str) -> Option { if raw_cwd.is_empty() { return None; } let path = PathBuf::from(raw_cwd.to_string()); // Use dunce::canonicalize to avoid Windows extended-length path prefix (\\?\) // which would cause path comparison mismatches with CanonicalizedPath. dunce::canonicalize(&path).ok() } #[cfg(test)] #[path = "working_directories_tests.rs"] mod tests;