diff --git a/Cargo.lock b/Cargo.lock index 37563d4770c56f36b960789911679013f3d28de0..bcc70f50440c3d2ad35c8400d50c197a18f2eaec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5198,6 +5198,33 @@ dependencies = [ "zlog", ] +[[package]] +name = "edit_prediction_tools" +version = "0.1.0" +dependencies = [ + "clap", + "collections", + "edit_prediction_context", + "editor", + "futures 0.3.31", + "gpui", + "indoc", + "language", + "log", + "pretty_assertions", + "project", + "serde", + "serde_json", + "settings", + "text", + "ui", + "ui_input", + "util", + "workspace", + "workspace-hack", + "zlog", +] + [[package]] name = "editor" version = "0.1.0" @@ -21217,6 +21244,7 @@ dependencies = [ "debugger_ui", "diagnostics", "edit_prediction_button", + "edit_prediction_tools", "editor", "env_logger 0.11.8", "extension", diff --git a/Cargo.toml b/Cargo.toml index 03b630e142d13beaa411c598c6fefe12086e617e..bfcfe09945f0e4a7b294ea064115d99c2ddc4d39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ members = [ "crates/edit_prediction", "crates/edit_prediction_button", "crates/edit_prediction_context", + "crates/edit_prediction_tools", "crates/editor", "crates/eval", "crates/explorer_command_injector", @@ -314,6 +315,7 @@ image_viewer = { path = "crates/image_viewer" } edit_prediction = { path = "crates/edit_prediction" } edit_prediction_button = { path = "crates/edit_prediction_button" } edit_prediction_context = { path = "crates/edit_prediction_context" } +edit_prediction_tools = { path = "crates/edit_prediction_tools" } inspector_ui = { path = "crates/inspector_ui" } install_cli = { path = "crates/install_cli" } jj = { path = "crates/jj" } diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index e3925b7ecd9d5ec50722ff9b3f521243c264e6ec..de255830cd154e4180afd32538f13df200bc36d5 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -3505,10 +3505,14 @@ async fn test_local_settings( assert_eq!( store .local_settings(worktree_b.read(cx).id()) + .map(|(path, content)| ( + path, + content.all_languages.defaults.tab_size.map(Into::into) + )) .collect::>(), &[ - (Path::new("").into(), r#"{"tab_size":2}"#.to_string()), - (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()), + (Path::new("").into(), Some(2)), + (Path::new("a").into(), Some(8)), ] ) }); @@ -3524,10 +3528,14 @@ async fn test_local_settings( assert_eq!( store .local_settings(worktree_b.read(cx).id()) + .map(|(path, content)| ( + path, + content.all_languages.defaults.tab_size.map(Into::into) + )) .collect::>(), &[ - (Path::new("").into(), r#"{}"#.to_string()), - (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()), + (Path::new("").into(), None), + (Path::new("a").into(), Some(8)), ] ) }); @@ -3553,10 +3561,14 @@ async fn test_local_settings( assert_eq!( store .local_settings(worktree_b.read(cx).id()) + .map(|(path, content)| ( + path, + content.all_languages.defaults.tab_size.map(Into::into) + )) .collect::>(), &[ - (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()), - (Path::new("b").into(), r#"{"tab_size":4}"#.to_string()), + (Path::new("a").into(), Some(8)), + (Path::new("b").into(), Some(4)), ] ) }); @@ -3585,8 +3597,9 @@ async fn test_local_settings( assert_eq!( store .local_settings(worktree_b.read(cx).id()) + .map(|(path, content)| (path, content.all_languages.defaults.hard_tabs)) .collect::>(), - &[(Path::new("a").into(), r#"{"hard_tabs":true}"#.to_string()),] + &[(Path::new("a").into(), Some(true))], ) }); } diff --git a/crates/edit_prediction_context/src/declaration.rs b/crates/edit_prediction_context/src/declaration.rs index fcf54fead80194fe97a2719971f86318a57ad75c..8fba85367c70e2cb1211e343bba7a6675a5a5360 100644 --- a/crates/edit_prediction_context/src/declaration.rs +++ b/crates/edit_prediction_context/src/declaration.rs @@ -41,14 +41,14 @@ impl Declaration { } } - pub fn project_entry_id(&self) -> Option { + pub fn project_entry_id(&self) -> ProjectEntryId { match self { Declaration::File { project_entry_id, .. - } => Some(*project_entry_id), + } => *project_entry_id, Declaration::Buffer { project_entry_id, .. - } => Some(*project_entry_id), + } => *project_entry_id, } } diff --git a/crates/edit_prediction_context/src/declaration_scoring.rs b/crates/edit_prediction_context/src/declaration_scoring.rs index dc442710516a935a65e755393fdfc15026ff1f0e..4cbc4e83c02e0cae912813a261d99f8fe8c41b55 100644 --- a/crates/edit_prediction_context/src/declaration_scoring.rs +++ b/crates/edit_prediction_context/src/declaration_scoring.rs @@ -119,15 +119,13 @@ pub fn scored_snippets( ) }) } else { - // TODO should we prefer the current file instead? - Some((false, 0, declaration)) + Some((false, u32::MAX, declaration)) } } Declaration::File { .. } => { - // TODO should we prefer the current file instead? // We can assume that a file declaration is in a different file, // because the current one must be open - Some((false, 0, declaration)) + Some((false, u32::MAX, declaration)) } }) .sorted_by_key(|&(_, distance, _)| distance) diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 5d73dc7f7dcf2223ae1f23b22c2f104842206e12..aed2953777d82d65b7e9cb42229d78634d5e4a3d 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -6,8 +6,12 @@ mod reference; mod syntax_index; mod text_similarity; +use std::time::Instant; + pub use declaration::{BufferDeclaration, Declaration, FileDeclaration, Identifier}; +pub use declaration_scoring::SnippetStyle; pub use excerpt::{EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionExcerptText}; + use gpui::{App, AppContext as _, Entity, Task}; use language::BufferSnapshot; pub use reference::references_in_excerpt; @@ -16,10 +20,12 @@ use text::{Point, ToOffset as _}; use crate::declaration_scoring::{ScoredSnippet, scored_snippets}; +#[derive(Debug)] pub struct EditPredictionContext { pub excerpt: EditPredictionExcerpt, pub excerpt_text: EditPredictionExcerptText, pub snippets: Vec, + pub retrieval_duration: std::time::Duration, } impl EditPredictionContext { @@ -29,14 +35,14 @@ impl EditPredictionContext { excerpt_options: EditPredictionExcerptOptions, syntax_index: Entity, cx: &mut App, - ) -> Task { + ) -> Task> { + let start = Instant::now(); let index_state = syntax_index.read_with(cx, |index, _cx| index.state().clone()); cx.background_spawn(async move { let index_state = index_state.lock().await; let excerpt = - EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &excerpt_options) - .unwrap(); + EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &excerpt_options)?; let excerpt_text = excerpt.text(&buffer); let references = references_in_excerpt(&excerpt, &excerpt_text, &buffer); let cursor_offset = cursor_point.to_offset(&buffer); @@ -50,11 +56,12 @@ impl EditPredictionContext { &buffer, ); - Self { + Some(Self { excerpt, excerpt_text, snippets, - } + retrieval_duration: start.elapsed(), + }) }) } } @@ -107,7 +114,8 @@ mod tests { cx, ) }) - .await; + .await + .unwrap(); assert_eq!(context.snippets.len(), 1); assert_eq!(context.snippets[0].identifier.name.as_ref(), "process_data"); diff --git a/crates/edit_prediction_context/src/excerpt.rs b/crates/edit_prediction_context/src/excerpt.rs index da1de042623167d17f078c1e85b461fb0ecc8c24..3fde142093efd095129f51a83f836953a76d20cb 100644 --- a/crates/edit_prediction_context/src/excerpt.rs +++ b/crates/edit_prediction_context/src/excerpt.rs @@ -38,7 +38,7 @@ pub struct EditPredictionExcerpt { pub size: usize, } -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct EditPredictionExcerptText { pub body: String, pub parent_signatures: Vec, diff --git a/crates/edit_prediction_context/src/reference.rs b/crates/edit_prediction_context/src/reference.rs index ee2fc7ba573c3909b5a650e3ca0ff20155272b9f..975f15c81f44dad9ee1d3105f6a5863a4685b164 100644 --- a/crates/edit_prediction_context/src/reference.rs +++ b/crates/edit_prediction_context/src/reference.rs @@ -89,7 +89,8 @@ pub fn identifiers_in_range( } let identifier_text = - &range_text[node_range.start - range.start..node_range.end - range.start]; + // TODO we changed this to saturating_sub for now, but we should fix the actually issue + &range_text[node_range.start.saturating_sub(range.start)..node_range.end.saturating_sub(range.start)]; references.push(Reference { identifier: Identifier { name: identifier_text.into(), diff --git a/crates/edit_prediction_context/src/syntax_index.rs b/crates/edit_prediction_context/src/syntax_index.rs index 852973dd7296647b0f868c3f9242ed59b81b6743..64982f5805f08a3ba791578e28778f0c8399fde8 100644 --- a/crates/edit_prediction_context/src/syntax_index.rs +++ b/crates/edit_prediction_context/src/syntax_index.rs @@ -9,7 +9,7 @@ use project::worktree_store::{WorktreeStore, WorktreeStoreEvent}; use project::{PathChange, Project, ProjectEntryId, ProjectPath}; use slotmap::SlotMap; use text::BufferId; -use util::{ResultExt as _, debug_panic, some_or_debug_panic}; +use util::{debug_panic, some_or_debug_panic}; use crate::declaration::{ BufferDeclaration, Declaration, DeclarationId, FileDeclaration, Identifier, @@ -332,7 +332,7 @@ impl SyntaxIndex { let language = language_registry .language_for_file_path(&project_path.path) .await - .log_err(); + .ok(); let buffer = cx.new(|cx| { let mut buffer = Buffer::local(loaded_file.text, cx); diff --git a/crates/edit_prediction_tools/Cargo.toml b/crates/edit_prediction_tools/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..ffd34abb2537006dd914d9bf9d30b735de91c5ba --- /dev/null +++ b/crates/edit_prediction_tools/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "edit_prediction_tools" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/edit_prediction_tools.rs" + +[dependencies] +edit_prediction_context.workspace = true +collections.workspace = true +editor.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +project.workspace = true +serde.workspace = true +text.workspace = true +ui.workspace = true +ui_input.workspace = true +workspace-hack.workspace = true +workspace.workspace = true + +[dev-dependencies] +clap.workspace = true +futures.workspace = true +gpui = { workspace = true, features = ["test-support"] } +indoc.workspace = true +language = { workspace = true, features = ["test-support"] } +pretty_assertions.workspace = true +project = {workspace= true, features = ["test-support"]} +serde_json.workspace = true +settings = {workspace= true, features = ["test-support"]} +text = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/edit_prediction_tools/src/edit_prediction_tools.rs b/crates/edit_prediction_tools/src/edit_prediction_tools.rs new file mode 100644 index 0000000000000000000000000000000000000000..f00a16e026704f1d1da318956f41128a9783a54c --- /dev/null +++ b/crates/edit_prediction_tools/src/edit_prediction_tools.rs @@ -0,0 +1,457 @@ +use std::{ + collections::hash_map::Entry, + ffi::OsStr, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, + time::Duration, +}; + +use collections::HashMap; +use editor::{Editor, EditorEvent, EditorMode, ExcerptRange, MultiBuffer}; +use gpui::{ + Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, actions, + prelude::*, +}; +use language::{Buffer, DiskState}; +use project::{Project, WorktreeId}; +use text::ToPoint; +use ui::prelude::*; +use ui_input::SingleLineInput; +use workspace::{Item, SplitDirection, Workspace}; + +use edit_prediction_context::{ + EditPredictionContext, EditPredictionExcerptOptions, SnippetStyle, SyntaxIndex, +}; + +actions!( + dev, + [ + /// Opens the language server protocol logs viewer. + OpenEditPredictionContext + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(move |workspace: &mut Workspace, _, _cx| { + workspace.register_action( + move |workspace, _: &OpenEditPredictionContext, window, cx| { + let workspace_entity = cx.entity(); + let project = workspace.project(); + let active_editor = workspace.active_item_as::(cx); + workspace.split_item( + SplitDirection::Right, + Box::new(cx.new(|cx| { + EditPredictionTools::new( + &workspace_entity, + &project, + active_editor, + window, + cx, + ) + })), + window, + cx, + ); + }, + ); + }) + .detach(); +} + +pub struct EditPredictionTools { + focus_handle: FocusHandle, + project: Entity, + last_context: Option, + max_bytes_input: Entity, + min_bytes_input: Entity, + cursor_context_ratio_input: Entity, + // TODO move to project or provider? + syntax_index: Entity, + last_editor: WeakEntity, + _active_editor_subscription: Option, + _edit_prediction_context_task: Task<()>, +} + +struct ContextState { + context_editor: Entity, + retrieval_duration: Duration, +} + +impl EditPredictionTools { + pub fn new( + workspace: &Entity, + project: &Entity, + active_editor: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| { + if let workspace::Event::ActiveItemChanged = event { + if let Some(editor) = workspace.read(cx).active_item_as::(cx) { + this._active_editor_subscription = Some(cx.subscribe_in( + &editor, + window, + |this, editor, event, window, cx| { + if let EditorEvent::SelectionsChanged { .. } = event { + this.update_context(editor, window, cx); + } + }, + )); + this.update_context(&editor, window, cx); + } else { + this._active_editor_subscription = None; + } + } + }) + .detach(); + let syntax_index = cx.new(|cx| SyntaxIndex::new(project, cx)); + + let number_input = |label: &'static str, + value: &'static str, + window: &mut Window, + cx: &mut Context| + -> Entity { + let input = cx.new(|cx| { + let input = SingleLineInput::new(window, cx, "") + .label(label) + .label_min_width(px(64.)); + input.set_text(value, window, cx); + input + }); + cx.subscribe_in( + &input.read(cx).editor().clone(), + window, + |this, _, event, window, cx| { + if let EditorEvent::BufferEdited = event + && let Some(editor) = this.last_editor.upgrade() + { + this.update_context(&editor, window, cx); + } + }, + ) + .detach(); + input + }; + + let mut this = Self { + focus_handle: cx.focus_handle(), + project: project.clone(), + last_context: None, + max_bytes_input: number_input("Max Bytes", "512", window, cx), + min_bytes_input: number_input("Min Bytes", "128", window, cx), + cursor_context_ratio_input: number_input("Cursor Context Ratio", "0.5", window, cx), + syntax_index, + last_editor: WeakEntity::new_invalid(), + _active_editor_subscription: None, + _edit_prediction_context_task: Task::ready(()), + }; + + if let Some(editor) = active_editor { + this.update_context(&editor, window, cx); + } + + this + } + + fn update_context( + &mut self, + editor: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.last_editor = editor.downgrade(); + + let editor = editor.read(cx); + let buffer = editor.buffer().clone(); + let cursor_position = editor.selections.newest_anchor().start; + + let Some(buffer) = buffer.read(cx).buffer_for_anchor(cursor_position, cx) else { + self.last_context.take(); + return; + }; + let current_buffer_snapshot = buffer.read(cx).snapshot(); + let cursor_position = cursor_position + .text_anchor + .to_point(¤t_buffer_snapshot); + + let language = current_buffer_snapshot.language().cloned(); + let Some(worktree_id) = self + .project + .read(cx) + .worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).id()) + else { + log::error!("Open a worktree to use edit prediction debug view"); + self.last_context.take(); + return; + }; + + self._edit_prediction_context_task = cx.spawn_in(window, { + let language_registry = self.project.read(cx).languages().clone(); + async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + + let Ok(task) = this.update(cx, |this, cx| { + fn number_input_value( + input: &Entity, + cx: &App, + ) -> T { + input + .read(cx) + .editor() + .read(cx) + .text(cx) + .parse::() + .unwrap_or_default() + } + + let options = EditPredictionExcerptOptions { + max_bytes: number_input_value(&this.max_bytes_input, cx), + min_bytes: number_input_value(&this.min_bytes_input, cx), + target_before_cursor_over_total_bytes: number_input_value( + &this.cursor_context_ratio_input, + cx, + ), + // TODO Display and add to options + include_parent_signatures: false, + }; + + EditPredictionContext::gather( + cursor_position, + current_buffer_snapshot, + options, + this.syntax_index.clone(), + cx, + ) + }) else { + this.update(cx, |this, _cx| { + this.last_context.take(); + }) + .ok(); + return; + }; + + let Some(context) = task.await else { + // TODO: Display message + this.update(cx, |this, _cx| { + this.last_context.take(); + }) + .ok(); + return; + }; + + let mut languages = HashMap::default(); + for snippet in context.snippets.iter() { + let lang_id = snippet.declaration.identifier().language_id; + if let Entry::Vacant(entry) = languages.entry(lang_id) { + // Most snippets are gonna be the same language, + // so we think it's fine to do this sequentially for now + entry.insert(language_registry.language_for_id(lang_id).await.ok()); + } + } + + this.update_in(cx, |this, window, cx| { + let context_editor = cx.new(|cx| { + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(language::Capability::ReadOnly); + let excerpt_file = Arc::new(ExcerptMetadataFile { + title: PathBuf::from("Cursor Excerpt").into(), + worktree_id, + }); + + let excerpt_buffer = cx.new(|cx| { + let mut buffer = Buffer::local(context.excerpt_text.body, cx); + buffer.set_language(language, cx); + buffer.file_updated(excerpt_file, cx); + buffer + }); + + multibuffer.push_excerpts( + excerpt_buffer, + [ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], + cx, + ); + + for snippet in context.snippets { + let path = this + .project + .read(cx) + .path_for_entry(snippet.declaration.project_entry_id(), cx); + + let snippet_file = Arc::new(ExcerptMetadataFile { + title: PathBuf::from(format!( + "{} (Score density: {})", + path.map(|p| p.path.to_string_lossy().to_string()) + .unwrap_or_else(|| "".to_string()), + snippet.score_density(SnippetStyle::Declaration) + )) + .into(), + worktree_id, + }); + + let excerpt_buffer = cx.new(|cx| { + let mut buffer = + Buffer::local(snippet.declaration.item_text().0, cx); + buffer.file_updated(snippet_file, cx); + if let Some(language) = + languages.get(&snippet.declaration.identifier().language_id) + { + buffer.set_language(language.clone(), cx); + } + buffer + }); + + multibuffer.push_excerpts( + excerpt_buffer, + [ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], + cx, + ); + } + + multibuffer + }); + + Editor::new(EditorMode::full(), multibuffer, None, window, cx) + }); + + this.last_context = Some(ContextState { + context_editor, + retrieval_duration: context.retrieval_duration, + }); + cx.notify(); + }) + .ok(); + } + }); + } +} + +impl Focusable for EditPredictionTools { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for EditPredictionTools { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Edit Prediction Context Debug View".into() + } + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::ZedPredict)) + } +} + +impl EventEmitter<()> for EditPredictionTools {} + +impl Render for EditPredictionTools { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .items_start() + .w_full() + .child( + v_flex() + .flex_1() + .p_4() + .gap_2() + .child(Headline::new("Excerpt Options").size(HeadlineSize::Small)) + .child( + h_flex() + .gap_2() + .child(self.max_bytes_input.clone()) + .child(self.min_bytes_input.clone()) + .child(self.cursor_context_ratio_input.clone()), + ), + ) + .child(ui::Divider::vertical()) + .when_some(self.last_context.as_ref(), |this, last_context| { + this.child( + v_flex() + .p_4() + .gap_2() + .min_w(px(160.)) + .child(Headline::new("Stats").size(HeadlineSize::Small)) + .child( + h_flex() + .gap_1() + .child( + Label::new("Time to retrieve") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + Label::new( + if last_context.retrieval_duration.as_micros() + > 1000 + { + format!( + "{} ms", + last_context.retrieval_duration.as_millis() + ) + } else { + format!( + "{} µs", + last_context.retrieval_duration.as_micros() + ) + }, + ) + .size(LabelSize::Small), + ), + ), + ) + }), + ) + .children(self.last_context.as_ref().map(|c| c.context_editor.clone())) + } +} + +// Using same approach as commit view + +struct ExcerptMetadataFile { + title: Arc, + worktree_id: WorktreeId, +} + +impl language::File for ExcerptMetadataFile { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + None + } + + fn disk_state(&self) -> DiskState { + DiskState::New + } + + fn path(&self) -> &Arc { + &self.title + } + + fn full_path(&self, _: &App) -> PathBuf { + self.title.as_ref().into() + } + + fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr { + self.title.file_name().unwrap() + } + + fn worktree_id(&self, _: &App) -> WorktreeId { + self.worktree_id + } + + fn to_proto(&self, _: &App) -> language::proto::File { + unimplemented!() + } + + fn is_private(&self) -> bool { + false + } +} diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 549c788dfac6acbb69fec8c715fb2a31b3674040..b608d0fec65a80057445fb3598102297f445ad4f 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -1,5 +1,5 @@ use crate::{FakeFs, FakeFsEntry, Fs}; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, bail}; use collections::{HashMap, HashSet}; use futures::future::{self, BoxFuture, join_all}; use git::{ @@ -354,6 +354,19 @@ impl GitRepository for FakeGitRepository { }) } + fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + if !state.branches.remove(&branch) { + bail!("no such branch: {branch}"); + } + state.branches.insert(new_name.clone()); + if state.current_branch_name == Some(branch) { + state.current_branch_name = Some(new_name); + } + Ok(()) + }) + } + fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result> { self.with_state_async(false, move |state| { state diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 73d32ac9e468b57e13fc9bf714bc96d55549167c..2028a0f374578d0c0f35bdc8c80ec09462ab0875 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -11,6 +11,7 @@ pub use crate::remote::*; use anyhow::{Context as _, Result}; pub use git2 as libgit; use gpui::{Action, actions}; +pub use repository::RemoteCommandOutput; pub use repository::WORK_DIRECTORY_REPO_PATH; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -101,6 +102,18 @@ actions!( ] ); +/// Renames a git branch. +#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = git)] +#[serde(deny_unknown_fields)] +pub struct RenameBranch { + /// The branch to rename. + /// + /// Default: the current branch. + #[serde(default)] + pub branch: Option, +} + /// Restores a file to its last committed state, discarding local changes. #[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = git, deprecated_aliases = ["editor::RevertFile"])] diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 10aaca38bbb3f7326e9bae27d4e6b1e9c20bb59a..29e2dab240e83da8d4343a370970ec0cc2256601 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -346,6 +346,7 @@ pub trait GitRepository: Send + Sync { fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; + fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>; fn reset( &self, @@ -1095,11 +1096,11 @@ impl GitRepository for RealGitRepository { let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?; let revision = revision.get(); let branch_commit = revision.peel_to_commit()?; - let mut branch = repo.branch(branch_name, &branch_commit, false)?; + let mut branch = repo.branch(&branch_name, &branch_commit, false)?; branch.set_upstream(Some(&name))?; branch } else { - anyhow::bail!("Branch not found"); + anyhow::bail!("Branch '{}' not found", name); }; Ok(branch @@ -1115,7 +1116,6 @@ impl GitRepository for RealGitRepository { GitBinary::new(git_binary_path, working_directory?, executor) .run(&["checkout", &branch]) .await?; - anyhow::Ok(()) }) .boxed() @@ -1133,6 +1133,21 @@ impl GitRepository for RealGitRepository { .boxed() } + fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { + let git_binary_path = self.git_binary_path.clone(); + let working_directory = self.working_directory(); + let executor = self.executor.clone(); + + self.executor + .spawn(async move { + GitBinary::new(git_binary_path, working_directory?, executor) + .run(&["branch", "-m", &branch, &new_name]) + .await?; + anyhow::Ok(()) + }) + .boxed() + } + fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let git_binary_path = self.git_binary_path.clone(); diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 000b6639b440914f117e30cc3272bf4cc38d8be6..cede717d53b257be2570c4b0c067fb46341c0fc5 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -4,20 +4,28 @@ use ::settings::Settings; use command_palette_hooks::CommandPaletteFilter; use commit_modal::CommitModal; use editor::{Editor, actions::DiffClipboardWithSelectionData}; +use ui::{ + Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled, + StyledExt, div, h_flex, rems, v_flex, +}; + mod blame_ui; + use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, }; use git_panel_settings::GitPanelSettings; use gpui::{ - Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, - actions, + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString, + Window, actions, }; +use menu::{Cancel, Confirm}; use onboarding::GitOnboardingModal; +use project::git_store::Repository; use project_diff::ProjectDiff; use ui::prelude::*; -use workspace::{ModalView, Workspace}; +use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr}; use zed_actions; use crate::{git_panel::GitPanel, text_diff_view::TextDiffView}; @@ -202,6 +210,9 @@ pub fn init(cx: &mut App) { workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| { open_modified_files(workspace, window, cx); }); + workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| { + rename_current_branch(workspace, window, cx); + }); workspace.register_action( |workspace, action: &DiffClipboardWithSelectionData, window, cx| { if let Some(task) = TextDiffView::open(action, workspace, window, cx) { @@ -245,6 +256,122 @@ pub fn git_status_icon(status: FileStatus) -> impl IntoElement { GitStatusIcon::new(status) } +struct RenameBranchModal { + current_branch: SharedString, + editor: Entity, + repo: Entity, +} + +impl RenameBranchModal { + fn new( + current_branch: String, + repo: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(current_branch.clone(), window, cx); + editor + }); + Self { + current_branch: current_branch.into(), + editor, + repo, + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let new_name = self.editor.read(cx).text(cx); + if new_name.is_empty() || new_name == self.current_branch.as_ref() { + cx.emit(DismissEvent); + return; + } + + let repo = self.repo.clone(); + let current_branch = self.current_branch.to_string(); + cx.spawn(async move |_, cx| { + match repo + .update(cx, |repo, _| { + repo.rename_branch(current_branch, new_name.clone()) + })? + .await + { + Ok(Ok(_)) => Ok(()), + Ok(Err(error)) => Err(error), + Err(_) => Err(anyhow::anyhow!("Operation was canceled")), + } + }) + .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None); + cx.emit(DismissEvent); + } +} + +impl EventEmitter for RenameBranchModal {} +impl ModalView for RenameBranchModal {} +impl Focusable for RenameBranchModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for RenameBranchModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("RenameBranchModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .w_full() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child( + Headline::new(format!("Rename Branch ({})", self.current_branch)) + .size(HeadlineSize::XSmall), + ), + ) + .child(div().px_3().pb_3().w_full().child(self.editor.clone())) + } +} + +fn rename_current_branch( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + let current_branch: Option = panel.update(cx, |panel, cx| { + let repo = panel.active_repository.as_ref()?; + let repo = repo.read(cx); + repo.branch.as_ref().map(|branch| branch.name().to_string()) + }); + + let Some(current_branch_name) = current_branch else { + return; + }; + + let repo = panel.read(cx).active_repository.clone(); + let Some(repo) = repo else { + return; + }; + + workspace.toggle_modal(window, cx, |window, cx| { + RenameBranchModal::new(current_branch_name, repo, window, cx) + }); +} + fn render_remote_button( id: impl Into, branch: &Branch, diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index 9ec57b90b39f40b9530bef7ff02753ea8f37d2e5..92efe122aa5a13e0d4b1c196c019d9090ce3aa22 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -646,6 +646,24 @@ impl LanguageRegistry { async move { rx.await? } } + pub async fn language_for_id(self: &Arc, id: LanguageId) -> Result> { + let available_language = { + let state = self.state.read(); + + let Some(available_language) = state + .available_languages + .iter() + .find(|lang| lang.id == id) + .cloned() + else { + anyhow::bail!(LanguageNotFound); + }; + available_language + }; + + self.load_language(&available_language).await? + } + pub fn language_name_for_extension(self: &Arc, extension: &str) -> Option { self.state.try_read().and_then(|state| { state diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 4b9ee462529e980c782c555157e0f1ff34029fb7..a3d777ac774216967b2a5ffab03c72cf51dd9e7d 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -398,6 +398,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_get_default_branch); client.add_entity_request_handler(Self::handle_change_branch); client.add_entity_request_handler(Self::handle_create_branch); + client.add_entity_request_handler(Self::handle_rename_branch); client.add_entity_request_handler(Self::handle_git_init); client.add_entity_request_handler(Self::handle_push); client.add_entity_request_handler(Self::handle_pull); @@ -1944,6 +1945,25 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_rename_branch( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let branch = envelope.payload.branch; + let new_name = envelope.payload.new_name; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.rename_branch(branch, new_name) + })? + .await??; + + Ok(proto::Ack {}) + } + async fn handle_show( this: Entity, envelope: TypedEnvelope, @@ -4331,6 +4351,36 @@ impl Repository { ) } + pub fn rename_branch( + &mut self, + branch: String, + new_name: String, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some(format!("git branch -m {branch} {new_name}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local { backend, .. } => { + backend.rename_branch(branch, new_name).await + } + RepositoryState::Remote { project_id, client } => { + client + .request(proto::GitRenameBranch { + project_id: project_id.0, + repository_id: id.to_proto(), + branch, + new_name, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + pub fn check_for_pushed_commits(&mut self) -> oneshot::Receiver>> { let id = self.id; self.send_job(None, move |repo, _cx| async move { diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 466173b5f4023e3c59810a8591a3d0057756e208..097224e02cd9c1cbbb51ccc6d3d373d3b5a71f85 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -824,6 +824,7 @@ impl SettingsObserver { for worktree in self.worktree_store.read(cx).worktrees() { let worktree_id = worktree.read(cx).id().to_proto(); for (path, content) in store.local_settings(worktree.read(cx).id()) { + let content = serde_json::to_string(&content).unwrap(); downstream_client .send(proto::UpdateWorktreeSettings { project_id, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 3f17f0d0c3483ade36b73e26c7207f6cf667bb63..7004b0c9a0b4aff54434fac6b1f6ecc9be773ed4 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -183,6 +183,13 @@ message GitChangeBranch { string branch_name = 4; } +message GitRenameBranch { + uint64 project_id = 1; + uint64 repository_id = 2; + string branch = 3; + string new_name = 4; +} + message GitDiff { uint64 project_id = 1; reserved 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index b20979081187b3dc7350b08b5c07ae700d86e02e..d9cc166c9b77fdd6cb4c876c4b118598b50895b2 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -414,7 +414,9 @@ message Envelope { NewExternalAgentVersionAvailable new_external_agent_version_available = 377; StashDrop stash_drop = 378; - StashApply stash_apply = 379; // current max + StashApply stash_apply = 379; + + GitRenameBranch git_rename_branch = 380; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 2985fde4d3ff4357628534f0ca3a5daf5476f813..5359ee983d9ceb01cd11e14c4d6dd491e097ea11 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -300,6 +300,7 @@ messages!( (AskPassResponse, Background), (GitCreateBranch, Background), (GitChangeBranch, Background), + (GitRenameBranch, Background), (CheckForPushedCommits, Background), (CheckForPushedCommitsResponse, Background), (GitDiff, Background), @@ -483,6 +484,7 @@ request_messages!( (AskPassRequest, AskPassResponse), (GitCreateBranch, Ack), (GitChangeBranch, Ack), + (GitRenameBranch, Ack), (CheckForPushedCommits, CheckForPushedCommitsResponse), (GitDiff, GitDiffResponse), (GitInit, Ack), @@ -637,6 +639,7 @@ entity_messages!( Pull, AskPassRequest, GitChangeBranch, + GitRenameBranch, GitCreateBranch, CheckForPushedCommits, GitDiff, diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 3f6addb7c2394503098a213f4139fedc9757ba86..9c98985989c2ac0fdcd5a39342dd9911d64dd01a 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -784,7 +784,10 @@ impl<'a> Chunks<'a> { slice_start..slice_end }; - let bitmask = (1u128 << slice_range.end as u128).saturating_sub(1); + // slice range has a bounds between 0 and 128 in non test builds + // We use a non wrapping sub because we want to overflow in the case where slice_range.end == 128 + // because that represents a full chunk and the bitmask shouldn't remove anything + let bitmask = (1u128.unbounded_shl(slice_range.end as u32)).wrapping_sub(1); let chars = (chunk.chars() & bitmask) >> slice_range.start; let tabs = (chunk.tabs & bitmask) >> slice_range.start; diff --git a/crates/ui/src/components/badge.rs b/crates/ui/src/components/badge.rs index f36e03291c5915f70e8370c6cc1e037d097622b0..9db6fd616f56769b03d1856cfda3fdeef66e446f 100644 --- a/crates/ui/src/components/badge.rs +++ b/crates/ui/src/components/badge.rs @@ -58,7 +58,7 @@ impl RenderOnce for Badge { .child(Divider::vertical().color(DividerColor::Border)) .child(Label::new(self.label.clone()).size(LabelSize::Small).ml_1()) .when_some(tooltip, |this, tooltip| { - this.tooltip(move |window, cx| tooltip(window, cx)) + this.hoverable_tooltip(move |window, cx| tooltip(window, cx)) }) } } diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 4949a29a1616d5be98a28608b60452461d353a16..f3c81ab2bf9d6b6799f8a16d2ee72bfbc497eb1b 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -35,11 +35,11 @@ pub mod scrollbars { pub enum ShowScrollbar { /// Show the scrollbar if there's important information or /// follow the system's configured behavior. + #[default] Auto, /// Match the system's configured behavior. System, /// Always show the scrollbar. - #[default] Always, /// Never show the scrollbar. Never, diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index 45c0deba4adfe71ea99d83c1bd081af1fc272671..79bddf6a182f1fefa495e635bd0dd348211fdc94 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/crates/ui_input/src/ui_input.rs @@ -7,7 +7,7 @@ use component::{example_group, single_example}; use editor::{Editor, EditorElement, EditorStyle}; -use gpui::{App, Entity, FocusHandle, Focusable, FontStyle, Hsla, TextStyle}; +use gpui::{App, Entity, FocusHandle, Focusable, FontStyle, Hsla, Length, TextStyle}; use settings::Settings; use std::sync::Arc; use theme::ThemeSettings; @@ -42,6 +42,8 @@ pub struct SingleLineInput { start_icon: Option, /// Whether the text field is disabled. disabled: bool, + /// The minimum width of for the input + min_width: Length, } impl Focusable for SingleLineInput { @@ -67,6 +69,7 @@ impl SingleLineInput { editor, start_icon: None, disabled: false, + min_width: px(192.).into(), } } @@ -85,6 +88,11 @@ impl SingleLineInput { self } + pub fn label_min_width(mut self, width: impl Into) -> Self { + self.min_width = width.into(); + self + } + pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { self.disabled = disabled; self.editor @@ -167,7 +175,7 @@ impl Render for SingleLineInput { }) .child( h_flex() - .min_w_48() + .min_w(self.min_width) .min_h_8() .w_full() .px_2() diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index c85d3e70245ff1ee1ea1253492643b603b8ca70c..5b6cb3924610b89406a37230497fee8ffc511e34 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -52,6 +52,7 @@ debugger_tools.workspace = true debugger_ui.workspace = true diagnostics.workspace = true editor.workspace = true +edit_prediction_tools.workspace = true env_logger.workspace = true extension.workspace = true extension_host.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 5cce6a6e2974d2fad9638f00811273ee202ab7b6..3dbbed0ce50ad84a1717f81afdf95c432b09259d 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -549,6 +549,7 @@ pub fn main() { language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); agent_settings::init(cx); acp_tools::init(cx); + edit_prediction_tools::init(cx); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); snippet_provider::init(cx);