Files
zed/crates/recent_projects/src/dev_container_suggest.rs
T
Richard Feldman 24d400d04c Persist dev container suggestion dismissal across git worktrees (#58576)
Opening a new git worktree of a repository that has a dev container
configuration re-prompted you to reopen in a dev container, even after
you'd clicked "Don't Show Again" in another worktree of the same repo.
The dismissal was keyed on each worktree's own absolute path, so sibling
worktrees never saw the stored choice.

This keys the dismissal on the repository's common Git directory
instead, which is shared across all linked git worktrees of the same
repo, so dismissing in one worktree now suppresses the prompt in the
others. Projects that aren't Git repositories fall back to the worktree
path as before.

Closes AI-357

Release Notes:

- Fixed the "Reopen in Dev Container" suggestion re-appearing in new git
worktrees after choosing "Don't Show Again"
2026-06-11 20:36:42 +00:00

196 lines
7.2 KiB
Rust

use db::kvp::KeyValueStore;
use dev_container::find_configs_in_snapshot;
use gpui::{App, SharedString, Window};
use project::{Project, WorktreeId};
use std::path::Path;
use std::sync::LazyLock;
use ui::Tooltip;
use ui::prelude::*;
use util::ResultExt;
use util::rel_path::RelPath;
use workspace::Workspace;
use workspace::notifications::NotificationId;
use workspace::notifications::simple_message_notification::MessageNotification;
use worktree::UpdatedEntriesSet;
const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed";
fn devcontainer_dir_path() -> &'static RelPath {
static PATH: LazyLock<&'static RelPath> =
LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path"));
*PATH
}
fn devcontainer_json_path() -> &'static RelPath {
static PATH: LazyLock<&'static RelPath> =
LazyLock::new(|| RelPath::unix(".devcontainer.json").expect("valid path"));
*PATH
}
fn project_devcontainer_key(project_path: &str) -> String {
format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path)
}
/// Returns the path used to remember the user's "Don't Show Again" choice for a
/// worktree's dev container suggestion. This is keyed on the repository's common
/// Git directory rather than the worktree's own path, so that dismissing the
/// suggestion in one git worktree also suppresses it in sibling worktrees of the
/// same repository. Falls back to the worktree path when it isn't part of a Git
/// repository.
fn dismiss_path_for_worktree(
project: &gpui::Entity<Project>,
worktree_abs_path: &Path,
cx: &App,
) -> String {
let common_dir = project
.read(cx)
.repositories(cx)
.values()
.filter_map(|repo| {
let repo = repo.read(cx);
let work_dir = repo.work_directory_abs_path.clone();
// The folder opened in Zed isn't necessarily the repo root; it may be
// a subdirectory of it, e.g. opening `~/code/myrepo/backend` when the
// repo lives at `~/code/myrepo`. So match any repo whose work directory
// contains the folder. Nested repos can produce multiple matches, e.g.
// opening `~/code/myrepo/vendor/lib` where `vendor/lib` is a submodule
// matches both `myrepo` and the submodule; `max_by_key` then picks the
// innermost match (the submodule), which the folder actually belongs to.
worktree_abs_path
.starts_with(work_dir.as_ref())
.then(|| (work_dir.as_os_str().len(), repo.common_dir_abs_path.clone()))
})
.max_by_key(|(work_dir_len, _)| *work_dir_len)
.map(|(_, common_dir)| common_dir);
match common_dir {
Some(common_dir) => common_dir.to_string_lossy().to_string(),
None => worktree_abs_path.to_string_lossy().to_string(),
}
}
pub fn suggest_on_worktree_updated(
workspace: &mut Workspace,
worktree_id: WorktreeId,
updated_entries: &UpdatedEntriesSet,
project: &gpui::Entity<Project>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let cli_auto_open = workspace.open_in_dev_container();
let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| {
path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path()
});
if !devcontainer_updated && !cli_auto_open {
return;
}
let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
return;
};
let worktree = worktree.read(cx);
if !worktree.is_local() {
return;
}
let has_configs = !find_configs_in_snapshot(worktree).is_empty();
if cli_auto_open {
workspace.set_open_in_dev_container(false);
let task = cx.spawn_in(window, async move |workspace, cx| {
let scans_complete =
workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?;
scans_complete.await;
workspace.update_in(cx, |workspace, window, cx| {
let has_configs = workspace
.project()
.read(cx)
.worktrees(cx)
.any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty());
if has_configs {
cx.on_next_frame(window, move |_workspace, window, cx| {
window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
});
} else {
log::warn!("--dev-container: no devcontainer configuration found in project");
}
})
});
workspace.set_dev_container_task(task);
return;
}
if !has_configs {
return;
}
let abs_path = worktree.abs_path();
let project_path = abs_path.to_string_lossy().to_string();
let worktree_name = worktree.root_name_str().to_string();
let dismiss_path = dismiss_path_for_worktree(project, abs_path.as_ref(), cx);
let key_for_dismiss = project_devcontainer_key(&dismiss_path);
let already_dismissed = KeyValueStore::global(cx)
.read_kvp(&key_for_dismiss)
.ok()
.flatten()
.is_some();
if already_dismissed {
return;
}
cx.on_next_frame(window, move |workspace, _window, cx| {
struct DevContainerSuggestionNotification;
let notification_id = NotificationId::composite::<DevContainerSuggestionNotification>(
SharedString::from(project_path.clone()),
);
workspace.show_notification(notification_id, cx, |cx| {
cx.new(move |cx| {
let message: SharedString = format!(
"{worktree_name} contains a Dev Container configuration file. Would you like to re-open it in a container?"
)
.into();
let tooltip_text: SharedString = project_path.clone().into();
MessageNotification::new_from_builder(cx, move |_window, _cx| {
div()
.id("dev-container-suggest-message")
.child(Label::new(message.clone()))
.tooltip(Tooltip::text(tooltip_text.clone()))
.into_any_element()
})
.primary_message("Yes, Open in Container")
.primary_icon(IconName::Check)
.primary_icon_color(Color::Success)
.primary_on_click({
move |window, cx| {
window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
}
})
.secondary_message("Don't Show Again")
.secondary_icon(IconName::Close)
.secondary_icon_color(Color::Error)
.secondary_on_click({
move |_window, cx| {
let key = key_for_dismiss.clone();
let kvp = KeyValueStore::global(cx);
cx.background_spawn(async move {
kvp.write_kvp(key, "dismissed".to_string())
.await
.log_err();
})
.detach();
}
})
})
});
});
}