Fix directory context paths (#28459)

Release Notes:

- N/A
This commit is contained in:
Michael Sloan 2025-04-09 15:40:46 -06:00 committed by GitHub
parent 23c3f5f410
commit ba767a1998
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 135 additions and 99 deletions

View file

@ -2840,10 +2840,10 @@ pub(crate) fn open_context(
} }
} }
AssistantContext::Directory(directory_context) => { AssistantContext::Directory(directory_context) => {
let path = directory_context.project_path.clone(); let project_path = directory_context.project_path(cx);
workspace.update(cx, |workspace, cx| { workspace.update(cx, |workspace, cx| {
workspace.project().update(cx, |project, cx| { workspace.project().update(cx, |project, cx| {
if let Some(entry) = project.entry_for_path(&path, cx) { if let Some(entry) = project.entry_for_path(&project_path, cx) {
cx.emit(project::Event::RevealInProjectPanel(entry.id)); cx.emit(project::Event::RevealInProjectPanel(entry.id));
} }
}) })

View file

@ -1,9 +1,9 @@
use std::{ops::Range, sync::Arc}; use std::{ops::Range, path::Path, sync::Arc};
use gpui::{App, Entity, SharedString}; use gpui::{App, Entity, SharedString};
use language::{Buffer, File}; use language::{Buffer, File};
use language_model::LanguageModelRequestMessage; use language_model::LanguageModelRequestMessage;
use project::ProjectPath; use project::{ProjectPath, Worktree};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use text::{Anchor, BufferId}; use text::{Anchor, BufferId};
use ui::IconName; use ui::IconName;
@ -69,10 +69,21 @@ pub struct FileContext {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DirectoryContext { pub struct DirectoryContext {
pub id: ContextId, pub id: ContextId,
pub project_path: ProjectPath, pub worktree: Entity<Worktree>,
pub path: Arc<Path>,
/// Buffers of the files within the directory.
pub context_buffers: Vec<ContextBuffer>, pub context_buffers: Vec<ContextBuffer>,
} }
impl DirectoryContext {
pub fn project_path(&self, cx: &App) -> ProjectPath {
ProjectPath {
worktree_id: self.worktree.read(cx).id(),
path: self.path.clone(),
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SymbolContext { pub struct SymbolContext {
pub id: ContextId, pub id: ContextId,
@ -86,12 +97,11 @@ pub struct FetchedUrlContext {
pub text: SharedString, pub text: SharedString,
} }
// TODO: Model<Thread> holds onto the thread even if the thread is deleted. Can either handle this
// explicitly or have a WeakModel<Thread> and remove during snapshot.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ThreadContext { pub struct ThreadContext {
pub id: ContextId, pub id: ContextId,
// TODO: Entity<Thread> holds onto the thread even if the thread is deleted. Should probably be
// a WeakEntity and handle removal from the UI when it has dropped.
pub thread: Entity<Thread>, pub thread: Entity<Thread>,
pub text: SharedString, pub text: SharedString,
} }
@ -105,12 +115,11 @@ impl ThreadContext {
} }
} }
// TODO: Model<Buffer> holds onto the buffer even if the file is deleted and closed. Should remove
// the context from the message editor in this case.
#[derive(Clone)] #[derive(Clone)]
pub struct ContextBuffer { pub struct ContextBuffer {
pub id: BufferId, pub id: BufferId,
// TODO: Entity<Buffer> holds onto the thread even if the thread is deleted. Should probably be
// a WeakEntity and handle removal from the UI when it has dropped.
pub buffer: Entity<Buffer>, pub buffer: Entity<Buffer>,
pub file: Arc<dyn File>, pub file: Arc<dyn File>,
pub version: clock::Global, pub version: clock::Global,

View file

@ -289,12 +289,14 @@ impl ContextPicker {
path_prefix, path_prefix,
} => { } => {
let context_store = self.context_store.clone(); let context_store = self.context_store.clone();
let worktree_id = project_path.worktree_id;
let path = project_path.path.clone(); let path = project_path.path.clone();
ContextMenuItem::custom_entry( ContextMenuItem::custom_entry(
move |_window, cx| { move |_window, cx| {
render_file_context_entry( render_file_context_entry(
ElementId::NamedInteger("ctx-recent".into(), ix), ElementId::NamedInteger("ctx-recent".into(), ix),
worktree_id,
&path, &path,
&path_prefix, &path_prefix,
false, false,
@ -466,7 +468,7 @@ fn recent_context_picker_entries(
recent.extend( recent.extend(
workspace workspace
.recent_navigation_history_iter(cx) .recent_navigation_history_iter(cx)
.filter(|(path, _)| !current_files.contains(&path.path.to_path_buf())) .filter(|(path, _)| !current_files.contains(path))
.take(4) .take(4)
.filter_map(|(project_path, _)| { .filter_map(|(project_path, _)| {
project project

View file

@ -189,6 +189,7 @@ impl PickerDelegate for FileContextPickerDelegate {
.toggle_state(selected) .toggle_state(selected)
.child(render_file_context_entry( .child(render_file_context_entry(
ElementId::NamedInteger("file-ctx-picker".into(), ix), ElementId::NamedInteger("file-ctx-picker".into(), ix),
WorktreeId::from_usize(mat.worktree_id),
&mat.path, &mat.path,
&mat.path_prefix, &mat.path_prefix,
mat.is_dir, mat.is_dir,
@ -328,19 +329,26 @@ pub fn extract_file_name_and_directory(
pub fn render_file_context_entry( pub fn render_file_context_entry(
id: ElementId, id: ElementId,
path: &Path, worktree_id: WorktreeId,
path: &Arc<Path>,
path_prefix: &Arc<str>, path_prefix: &Arc<str>,
is_directory: bool, is_directory: bool,
context_store: WeakEntity<ContextStore>, context_store: WeakEntity<ContextStore>,
cx: &App, cx: &App,
) -> Stateful<Div> { ) -> Stateful<Div> {
let (file_name, directory) = extract_file_name_and_directory(path, path_prefix); let (file_name, directory) = extract_file_name_and_directory(&path, path_prefix);
let added = context_store.upgrade().and_then(|context_store| { let added = context_store.upgrade().and_then(|context_store| {
let project_path = ProjectPath {
worktree_id,
path: path.clone(),
};
if is_directory { if is_directory {
context_store.read(cx).includes_directory(path) context_store.read(cx).includes_directory(&project_path)
} else { } else {
context_store.read(cx).will_include_file_path(path, cx) context_store
.read(cx)
.will_include_file_path(&project_path, cx)
} }
}); });
@ -380,8 +388,9 @@ pub fn render_file_context_entry(
) )
.child(Label::new("Added").size(LabelSize::Small)), .child(Label::new("Added").size(LabelSize::Small)),
), ),
FileInclusion::InDirectory(dir_name) => { FileInclusion::InDirectory(directory_project_path) => {
let dir_name = dir_name.to_string_lossy().into_owned(); // TODO: Consider using worktree full_path to include worktree name.
let directory_path = directory_project_path.path.to_string_lossy().into_owned();
el.child( el.child(
h_flex() h_flex()
@ -395,7 +404,7 @@ pub fn render_file_context_entry(
) )
.child(Label::new("Included").size(LabelSize::Small)), .child(Label::new("Included").size(LabelSize::Small)),
) )
.tooltip(Tooltip::text(format!("in {dir_name}"))) .tooltip(Tooltip::text(format!("in {directory_path}")))
} }
}) })
} }

View file

@ -1,5 +1,5 @@
use std::ops::Range; use std::ops::Range;
use std::path::{Path, PathBuf}; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
@ -28,7 +28,7 @@ pub struct ContextStore {
// TODO: If an EntityId is used for all context types (like BufferId), can remove ContextId. // TODO: If an EntityId is used for all context types (like BufferId), can remove ContextId.
next_context_id: ContextId, next_context_id: ContextId,
files: BTreeMap<BufferId, ContextId>, files: BTreeMap<BufferId, ContextId>,
directories: HashMap<PathBuf, ContextId>, directories: HashMap<ProjectPath, ContextId>,
symbols: HashMap<ContextSymbolId, ContextId>, symbols: HashMap<ContextSymbolId, ContextId>,
symbol_buffers: HashMap<ContextSymbolId, Entity<Buffer>>, symbol_buffers: HashMap<ContextSymbolId, Entity<Buffer>>,
symbols_by_path: HashMap<ProjectPath, Vec<ContextSymbolId>>, symbols_by_path: HashMap<ProjectPath, Vec<ContextSymbolId>>,
@ -93,7 +93,7 @@ impl ContextStore {
let buffer_id = this.update(cx, |_, cx| buffer.read(cx).remote_id())?; let buffer_id = this.update(cx, |_, cx| buffer.read(cx).remote_id())?;
let already_included = this.update(cx, |this, cx| { let already_included = this.update(cx, |this, cx| {
match this.will_include_buffer(buffer_id, &project_path.path) { match this.will_include_buffer(buffer_id, &project_path) {
Some(FileInclusion::Direct(context_id)) => { Some(FileInclusion::Direct(context_id)) => {
if remove_if_exists { if remove_if_exists {
this.remove_context(context_id, cx); this.remove_context(context_id, cx);
@ -159,7 +159,7 @@ impl ContextStore {
return Task::ready(Err(anyhow!("failed to read project"))); return Task::ready(Err(anyhow!("failed to read project")));
}; };
let already_included = match self.includes_directory(&project_path.path) { let already_included = match self.includes_directory(&project_path) {
Some(FileInclusion::Direct(context_id)) => { Some(FileInclusion::Direct(context_id)) => {
if remove_if_exists { if remove_if_exists {
self.remove_context(context_id, cx); self.remove_context(context_id, cx);
@ -223,14 +223,12 @@ impl ContextStore {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if context_buffers.is_empty() { if context_buffers.is_empty() {
return Err(anyhow!( let full_path = cx.update(|cx| worktree.read(cx).full_path(&project_path.path))?;
"No text files found in {}", return Err(anyhow!("No text files found in {}", &full_path.display()));
&project_path.path.display()
));
} }
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.insert_directory(project_path, context_buffers, cx); this.insert_directory(worktree, project_path, context_buffers, cx);
})?; })?;
anyhow::Ok(()) anyhow::Ok(())
@ -239,17 +237,20 @@ impl ContextStore {
fn insert_directory( fn insert_directory(
&mut self, &mut self,
worktree: Entity<Worktree>,
project_path: ProjectPath, project_path: ProjectPath,
context_buffers: Vec<ContextBuffer>, context_buffers: Vec<ContextBuffer>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let id = self.next_context_id.post_inc(); let id = self.next_context_id.post_inc();
self.directories.insert(project_path.path.to_path_buf(), id); let path = project_path.path.clone();
self.directories.insert(project_path, id);
self.context self.context
.push(AssistantContext::Directory(DirectoryContext { .push(AssistantContext::Directory(DirectoryContext {
id, id,
project_path, worktree,
path,
context_buffers, context_buffers,
})); }));
cx.notify(); cx.notify();
@ -478,23 +479,31 @@ impl ContextStore {
/// Returns whether the buffer is already included directly in the context, or if it will be /// Returns whether the buffer is already included directly in the context, or if it will be
/// included in the context via a directory. Directory inclusion is based on paths rather than /// included in the context via a directory. Directory inclusion is based on paths rather than
/// buffer IDs as the directory will be re-scanned. /// buffer IDs as the directory will be re-scanned.
pub fn will_include_buffer(&self, buffer_id: BufferId, path: &Path) -> Option<FileInclusion> { pub fn will_include_buffer(
&self,
buffer_id: BufferId,
project_path: &ProjectPath,
) -> Option<FileInclusion> {
if let Some(context_id) = self.files.get(&buffer_id) { if let Some(context_id) = self.files.get(&buffer_id) {
return Some(FileInclusion::Direct(*context_id)); return Some(FileInclusion::Direct(*context_id));
} }
self.will_include_file_path_via_directory(path) self.will_include_file_path_via_directory(project_path)
} }
/// Returns whether this file path is already included directly in the context, or if it will be /// Returns whether this file path is already included directly in the context, or if it will be
/// included in the context via a directory. /// included in the context via a directory.
pub fn will_include_file_path(&self, path: &Path, cx: &App) -> Option<FileInclusion> { pub fn will_include_file_path(
&self,
project_path: &ProjectPath,
cx: &App,
) -> Option<FileInclusion> {
if !self.files.is_empty() { if !self.files.is_empty() {
let found_file_context = self.context.iter().find(|context| match &context { let found_file_context = self.context.iter().find(|context| match &context {
AssistantContext::File(file_context) => { AssistantContext::File(file_context) => {
let buffer = file_context.context_buffer.buffer.read(cx); let buffer = file_context.context_buffer.buffer.read(cx);
if let Some(file_path) = buffer_path_log_err(buffer, cx) { if let Some(context_path) = buffer.project_path(cx) {
*file_path == *path &context_path == project_path
} else { } else {
false false
} }
@ -506,31 +515,40 @@ impl ContextStore {
} }
} }
self.will_include_file_path_via_directory(path) self.will_include_file_path_via_directory(project_path)
} }
fn will_include_file_path_via_directory(&self, path: &Path) -> Option<FileInclusion> { fn will_include_file_path_via_directory(
&self,
project_path: &ProjectPath,
) -> Option<FileInclusion> {
if self.directories.is_empty() { if self.directories.is_empty() {
return None; return None;
} }
let mut buf = path.to_path_buf(); let mut path_buf = project_path.path.to_path_buf();
while buf.pop() { while path_buf.pop() {
if let Some(_) = self.directories.get(&buf) { // TODO: This isn't very efficient. Consider using a better representation of the
return Some(FileInclusion::InDirectory(buf)); // directories map.
let directory_project_path = ProjectPath {
worktree_id: project_path.worktree_id,
path: path_buf.clone().into(),
};
if let Some(_) = self.directories.get(&directory_project_path) {
return Some(FileInclusion::InDirectory(directory_project_path));
} }
} }
None None
} }
pub fn includes_directory(&self, path: &Path) -> Option<FileInclusion> { pub fn includes_directory(&self, project_path: &ProjectPath) -> Option<FileInclusion> {
if let Some(context_id) = self.directories.get(path) { if let Some(context_id) = self.directories.get(project_path) {
return Some(FileInclusion::Direct(*context_id)); return Some(FileInclusion::Direct(*context_id));
} }
self.will_include_file_path_via_directory(path) self.will_include_file_path_via_directory(project_path)
} }
pub fn included_symbol(&self, symbol_id: &ContextSymbolId) -> Option<ContextId> { pub fn included_symbol(&self, symbol_id: &ContextSymbolId) -> Option<ContextId> {
@ -564,13 +582,13 @@ impl ContextStore {
} }
} }
pub fn file_paths(&self, cx: &App) -> HashSet<PathBuf> { pub fn file_paths(&self, cx: &App) -> HashSet<ProjectPath> {
self.context self.context
.iter() .iter()
.filter_map(|context| match context { .filter_map(|context| match context {
AssistantContext::File(file) => { AssistantContext::File(file) => {
let buffer = file.context_buffer.buffer.read(cx); let buffer = file.context_buffer.buffer.read(cx);
buffer_path_log_err(buffer, cx).map(|p| p.to_path_buf()) buffer.project_path(cx)
} }
AssistantContext::Directory(_) AssistantContext::Directory(_)
| AssistantContext::Symbol(_) | AssistantContext::Symbol(_)
@ -587,7 +605,7 @@ impl ContextStore {
pub enum FileInclusion { pub enum FileInclusion {
Direct(ContextId), Direct(ContextId),
InDirectory(PathBuf), InDirectory(ProjectPath),
} }
// ContextBuffer without text. // ContextBuffer without text.
@ -654,19 +672,6 @@ fn collect_buffer_info_and_text(
Ok((buffer_info, text_task)) Ok((buffer_info, text_task))
} }
pub fn buffer_path_log_err(buffer: &Buffer, cx: &App) -> Option<Arc<Path>> {
if let Some(file) = buffer.file() {
let mut path = file.path().clone();
if path.as_os_str().is_empty() {
path = file.full_path(cx).into();
}
Some(path)
} else {
log::error!("Buffer that had a path unexpectedly no longer has a path.");
None
}
}
fn to_fenced_codeblock(path: &Path, content: Rope) -> SharedString { fn to_fenced_codeblock(path: &Path, content: Rope) -> SharedString {
let path_extension = path.extension().and_then(|ext| ext.to_str()); let path_extension = path.extension().and_then(|ext| ext.to_str());
let path_string = path.to_string_lossy(); let path_string = path.to_string_lossy();
@ -742,13 +747,13 @@ pub fn refresh_context_store_text(
} }
} }
AssistantContext::Directory(directory_context) => { AssistantContext::Directory(directory_context) => {
let directory_path = directory_context.project_path(cx);
let should_refresh = changed_buffers.is_empty() let should_refresh = changed_buffers.is_empty()
|| changed_buffers.iter().any(|buffer| { || changed_buffers.iter().any(|buffer| {
let buffer = buffer.read(cx); let Some(buffer_path) = buffer.read(cx).project_path(cx) else {
return false;
buffer_path_log_err(&buffer, cx).map_or(false, |path| { };
path.starts_with(&directory_context.project_path.path) buffer_path.starts_with(&directory_path)
})
}); });
if should_refresh { if should_refresh {
@ -835,14 +840,16 @@ fn refresh_directory_text(
let context_buffers = future::join_all(futures); let context_buffers = future::join_all(futures);
let id = directory_context.id; let id = directory_context.id;
let project_path = directory_context.project_path.clone(); let worktree = directory_context.worktree.clone();
let path = directory_context.path.clone();
Some(cx.spawn(async move |cx| { Some(cx.spawn(async move |cx| {
let context_buffers = context_buffers.await; let context_buffers = context_buffers.await;
context_store context_store
.update(cx, |context_store, _| { .update(cx, |context_store, _| {
let new_directory_context = DirectoryContext { let new_directory_context = DirectoryContext {
id, id,
project_path, worktree,
path,
context_buffers, context_buffers,
}; };
context_store.replace_context(AssistantContext::Directory(new_directory_context)); context_store.replace_context(AssistantContext::Directory(new_directory_context));

View file

@ -1,3 +1,4 @@
use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use collections::HashSet; use collections::HashSet;
@ -9,6 +10,7 @@ use gpui::{
}; };
use itertools::Itertools; use itertools::Itertools;
use language::Buffer; use language::Buffer;
use project::ProjectItem;
use ui::{KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; use ui::{KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
use workspace::{Workspace, notifications::NotifyResultExt}; use workspace::{Workspace, notifications::NotifyResultExt};
@ -93,26 +95,23 @@ impl ContextStrip {
let active_buffer_entity = editor.buffer().read(cx).as_singleton()?; let active_buffer_entity = editor.buffer().read(cx).as_singleton()?;
let active_buffer = active_buffer_entity.read(cx); let active_buffer = active_buffer_entity.read(cx);
let path = active_buffer.file()?.full_path(cx); let project_path = active_buffer.project_path(cx)?;
if self if self
.context_store .context_store
.read(cx) .read(cx)
.will_include_buffer(active_buffer.remote_id(), &path) .will_include_buffer(active_buffer.remote_id(), &project_path)
.is_some() .is_some()
{ {
return None; return None;
} }
let name = match path.file_name() { let file_name = active_buffer.file()?.file_name(cx);
Some(name) => name.to_string_lossy().into_owned().into(),
None => path.to_string_lossy().into_owned().into(),
};
let icon_path = FileIcons::get_icon(&path, cx); let icon_path = FileIcons::get_icon(&Path::new(&file_name), cx);
Some(SuggestedContext::File { Some(SuggestedContext::File {
name, name: file_name.to_string_lossy().into_owned().into(),
buffer: active_buffer_entity.downgrade(), buffer: active_buffer_entity.downgrade(),
icon_path, icon_path,
}) })

View file

@ -280,9 +280,10 @@ impl AddedContext {
} }
AssistantContext::Directory(directory_context) => { AssistantContext::Directory(directory_context) => {
// TODO: handle worktree disambiguation. Maybe by storing an `Arc<dyn File>` to also let full_path = directory_context
// handle renames? .worktree
let full_path = &directory_context.project_path.path; .read(cx)
.full_path(&directory_context.path);
let full_path_string: SharedString = let full_path_string: SharedString =
full_path.to_string_lossy().into_owned().into(); full_path.to_string_lossy().into_owned().into();
let name = full_path let name = full_path

View file

@ -334,6 +334,10 @@ impl ProjectPath {
path: Path::new("").into(), path: Path::new("").into(),
} }
} }
pub fn starts_with(&self, other: &ProjectPath) -> bool {
self.worktree_id == other.worktree_id && self.path.starts_with(&other.path)
}
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]

View file

@ -1172,6 +1172,31 @@ impl Worktree {
pub fn is_single_file(&self) -> bool { pub fn is_single_file(&self) -> bool {
self.root_dir().is_none() self.root_dir().is_none()
} }
/// For visible worktrees, returns the path with the worktree name as the first component.
/// Otherwise, returns an absolute path.
pub fn full_path(&self, worktree_relative_path: &Path) -> PathBuf {
let mut full_path = PathBuf::new();
if self.is_visible() {
full_path.push(self.root_name());
} else {
let path = self.abs_path();
if self.is_local() && path.starts_with(home_dir().as_path()) {
full_path.push("~");
full_path.push(path.strip_prefix(home_dir().as_path()).unwrap());
} else {
full_path.push(path)
}
}
if worktree_relative_path.components().next().is_some() {
full_path.push(&worktree_relative_path);
}
full_path
}
} }
impl LocalWorktree { impl LocalWorktree {
@ -3229,27 +3254,7 @@ impl language::File for File {
} }
fn full_path(&self, cx: &App) -> PathBuf { fn full_path(&self, cx: &App) -> PathBuf {
let mut full_path = PathBuf::new(); self.worktree.read(cx).full_path(&self.path)
let worktree = self.worktree.read(cx);
if worktree.is_visible() {
full_path.push(worktree.root_name());
} else {
let path = worktree.abs_path();
if worktree.is_local() && path.starts_with(home_dir().as_path()) {
full_path.push("~");
full_path.push(path.strip_prefix(home_dir().as_path()).unwrap());
} else {
full_path.push(path)
}
}
if self.path.components().next().is_some() {
full_path.push(&self.path);
}
full_path
} }
/// Returns the last component of this handle's absolute path. If this handle refers to the root /// Returns the last component of this handle's absolute path. If this handle refers to the root