From 69e0ea92e400f4b37af289996f1af10b402a661e Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 1 Feb 2024 22:22:02 -0700 Subject: [PATCH] Links to channel notes (#7262) Release Notes: - Added outline support for Markdown files - Added the ability to link to channel notes: https://zed.dev/channel/zed-283/notes#Roadmap --- Cargo.lock | 1 + crates/channel/src/channel_store.rs | 15 +- .../collab/src/tests/channel_buffer_tests.rs | 8 +- crates/collab/src/tests/following_tests.rs | 4 +- crates/collab_ui/src/channel_view.rs | 156 ++++++++++++++++-- crates/collab_ui/src/collab_panel.rs | 2 +- crates/editor/src/editor.rs | 15 ++ crates/editor/src/mouse_context_menu.rs | 55 +++--- crates/editor/src/scroll/autoscroll.rs | 15 ++ crates/zed/Cargo.toml | 1 + crates/zed/src/languages/markdown/outline.scm | 5 + crates/zed/src/main.rs | 30 ++-- crates/zed/src/open_listener.rs | 18 +- 13 files changed, 260 insertions(+), 65 deletions(-) create mode 100644 crates/zed/src/languages/markdown/outline.scm diff --git a/Cargo.lock b/Cargo.lock index 7b3a5ba6f3..4ae19e764c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10344,6 +10344,7 @@ dependencies = [ "indexmap 1.9.3", "install_cli", "isahc", + "itertools 0.11.0", "journal", "language", "language_selector", diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 0d0752aec8..8379a92aae 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -74,11 +74,19 @@ impl Channel { pub fn link(&self) -> String { RELEASE_CHANNEL.link_prefix().to_owned() + "channel/" - + &self.slug() + + &Self::slug(&self.name) + "-" + &self.id.to_string() } + pub fn notes_link(&self, heading: Option) -> String { + self.link() + + "/notes" + + &heading + .map(|h| format!("#{}", Self::slug(&h))) + .unwrap_or_default() + } + pub fn is_root_channel(&self) -> bool { self.parent_path.is_empty() } @@ -90,9 +98,8 @@ impl Channel { .unwrap_or(self.id) } - pub fn slug(&self) -> String { - let slug: String = self - .name + pub fn slug(str: &str) -> String { + let slug: String = str .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect(); diff --git a/crates/collab/src/tests/channel_buffer_tests.rs b/crates/collab/src/tests/channel_buffer_tests.rs index cf5b999ef6..0ba7024683 100644 --- a/crates/collab/src/tests/channel_buffer_tests.rs +++ b/crates/collab/src/tests/channel_buffer_tests.rs @@ -161,15 +161,15 @@ async fn test_channel_notes_participant_indices( // Clients A, B, and C open the channel notes let channel_view_a = cx_a - .update(|cx| ChannelView::open(channel_id, workspace_a.clone(), cx)) + .update(|cx| ChannelView::open(channel_id, None, workspace_a.clone(), cx)) .await .unwrap(); let channel_view_b = cx_b - .update(|cx| ChannelView::open(channel_id, workspace_b.clone(), cx)) + .update(|cx| ChannelView::open(channel_id, None, workspace_b.clone(), cx)) .await .unwrap(); let channel_view_c = cx_c - .update(|cx| ChannelView::open(channel_id, workspace_c.clone(), cx)) + .update(|cx| ChannelView::open(channel_id, None, workspace_c.clone(), cx)) .await .unwrap(); @@ -644,7 +644,7 @@ async fn test_channel_buffer_changes( let project_b = client_b.build_empty_local_project(cx_b); let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); let channel_view_b = cx_b - .update(|cx| ChannelView::open(channel_id, workspace_b.clone(), cx)) + .update(|cx| ChannelView::open(channel_id, None, workspace_b.clone(), cx)) .await .unwrap(); deterministic.run_until_parked(); diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index f4ec70d0a9..bfebd0d257 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -1905,7 +1905,7 @@ async fn test_following_to_channel_notes_without_a_shared_project( // Client A opens the notes for channel 1. let channel_notes_1_a = cx_a - .update(|cx| ChannelView::open(channel_1_id, workspace_a.clone(), cx)) + .update(|cx| ChannelView::open(channel_1_id, None, workspace_a.clone(), cx)) .await .unwrap(); channel_notes_1_a.update(cx_a, |notes, cx| { @@ -1951,7 +1951,7 @@ async fn test_following_to_channel_notes_without_a_shared_project( // Client A opens the notes for channel 2. let channel_notes_2_a = cx_a - .update(|cx| ChannelView::open(channel_2_id, workspace_a.clone(), cx)) + .update(|cx| ChannelView::open(channel_2_id, None, workspace_a.clone(), cx)) .await .unwrap(); channel_notes_2_a.update(cx_a, |notes, cx| { diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 2c0ff77459..939276bf1b 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -6,11 +6,14 @@ use client::{ Collaborator, ParticipantIndex, }; use collections::HashMap; -use editor::{CollaborationHub, Editor, EditorEvent}; +use editor::{ + display_map::ToDisplayPoint, scroll::Autoscroll, CollaborationHub, DisplayPoint, Editor, + EditorEvent, +}; use gpui::{ - actions, AnyElement, AnyView, AppContext, Entity as _, EventEmitter, FocusableView, - IntoElement as _, Model, Pixels, Point, Render, Subscription, Task, View, ViewContext, - VisualContext as _, WindowContext, + actions, AnyElement, AnyView, AppContext, ClipboardItem, Entity as _, EventEmitter, + FocusableView, IntoElement as _, Model, Pixels, Point, Render, Subscription, Task, View, + ViewContext, VisualContext as _, WeakView, WindowContext, }; use project::Project; use std::{ @@ -23,10 +26,10 @@ use workspace::{ item::{FollowableItem, Item, ItemEvent, ItemHandle}, register_followable_item, searchable::SearchableItemHandle, - ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId, + ItemNavHistory, Pane, SaveIntent, Toast, ViewId, Workspace, WorkspaceId, }; -actions!(collab, [Deploy]); +actions!(collab, [CopyLink]); pub fn init(cx: &mut AppContext) { register_followable_item::(cx) @@ -34,21 +37,30 @@ pub fn init(cx: &mut AppContext) { pub struct ChannelView { pub editor: View, + workspace: WeakView, project: Model, channel_store: Model, channel_buffer: Model, remote_id: Option, _editor_event_subscription: Subscription, + _reparse_subscription: Option, } impl ChannelView { pub fn open( channel_id: ChannelId, + link_position: Option, workspace: View, cx: &mut WindowContext, ) -> Task>> { let pane = workspace.read(cx).active_pane().clone(); - let channel_view = Self::open_in_pane(channel_id, pane.clone(), workspace.clone(), cx); + let channel_view = Self::open_in_pane( + channel_id, + link_position, + pane.clone(), + workspace.clone(), + cx, + ); cx.spawn(|mut cx| async move { let channel_view = channel_view.await?; pane.update(&mut cx, |pane, cx| { @@ -66,10 +78,12 @@ impl ChannelView { pub fn open_in_pane( channel_id: ChannelId, + link_position: Option, pane: View, workspace: View, cx: &mut WindowContext, ) -> Task>> { + let weak_workspace = workspace.downgrade(); let workspace = workspace.read(cx); let project = workspace.project().to_owned(); let channel_store = ChannelStore::global(cx); @@ -82,12 +96,13 @@ impl ChannelView { let channel_buffer = channel_buffer.await?; let markdown = markdown.await.log_err(); - channel_buffer.update(&mut cx, |buffer, cx| { - buffer.buffer().update(cx, |buffer, cx| { + channel_buffer.update(&mut cx, |channel_buffer, cx| { + channel_buffer.buffer().update(cx, |buffer, cx| { buffer.set_language_registry(language_registry); - if let Some(markdown) = markdown { - buffer.set_language(Some(markdown), cx); - } + let Some(markdown) = markdown else { + return; + }; + buffer.set_language(Some(markdown), cx); }) })?; @@ -101,12 +116,18 @@ impl ChannelView { // If this channel buffer is already open in this pane, just return it. if let Some(existing_view) = existing_view.clone() { if existing_view.read(cx).channel_buffer == channel_buffer { + if let Some(link_position) = link_position { + existing_view.update(cx, |channel_view, cx| { + channel_view.focus_position_from_link(link_position, true, cx) + }); + } return existing_view; } } let view = cx.new_view(|cx| { - let mut this = Self::new(project, channel_store, channel_buffer, cx); + let mut this = + Self::new(project, weak_workspace, channel_store, channel_buffer, cx); this.acknowledge_buffer_version(cx); this }); @@ -121,6 +142,12 @@ impl ChannelView { } } + if let Some(link_position) = link_position { + view.update(cx, |channel_view, cx| { + channel_view.focus_position_from_link(link_position, true, cx) + }); + } + view }) }) @@ -128,16 +155,29 @@ impl ChannelView { pub fn new( project: Model, + workspace: WeakView, channel_store: Model, channel_buffer: Model, cx: &mut ViewContext, ) -> Self { let buffer = channel_buffer.read(cx).buffer(); + let this = cx.view().downgrade(); let editor = cx.new_view(|cx| { let mut editor = Editor::for_buffer(buffer, None, cx); editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub( channel_buffer.clone(), ))); + editor.set_custom_context_menu(move |_, position, cx| { + let this = this.clone(); + Some(ui::ContextMenu::build(cx, move |menu, _| { + menu.entry("Copy link to section", None, move |cx| { + this.update(cx, |this, cx| { + this.copy_link_for_position(position.clone(), cx) + }) + .ok(); + }) + })) + }); editor }); let _editor_event_subscription = @@ -148,14 +188,94 @@ impl ChannelView { Self { editor, + workspace, project, channel_store, channel_buffer, remote_id: None, _editor_event_subscription, + _reparse_subscription: None, } } + fn focus_position_from_link( + &mut self, + position: String, + first_attempt: bool, + cx: &mut ViewContext, + ) { + let position = Channel::slug(&position).to_lowercase(); + let snapshot = self.editor.update(cx, |editor, cx| editor.snapshot(cx)); + + if let Some(outline) = snapshot.buffer_snapshot.outline(None) { + if let Some(item) = outline + .items + .iter() + .find(|item| &Channel::slug(&item.text).to_lowercase() == &position) + { + self.editor.update(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::focused()), cx, |s| { + s.replace_cursors_with(|map| vec![item.range.start.to_display_point(&map)]) + }) + }); + return; + } + } + + if !first_attempt { + return; + } + self._reparse_subscription = Some(cx.subscribe( + &self.editor, + move |this, _, e: &EditorEvent, cx| { + match e { + EditorEvent::Reparsed => { + this.focus_position_from_link(position.clone(), false, cx); + this._reparse_subscription.take(); + } + EditorEvent::Edited | EditorEvent::SelectionsChanged { local: true } => { + this._reparse_subscription.take(); + } + _ => {} + }; + }, + )); + } + + fn copy_link(&mut self, _: &CopyLink, cx: &mut ViewContext) { + let position = self + .editor + .update(cx, |editor, cx| editor.selections.newest_display(cx).start); + self.copy_link_for_position(position, cx) + } + + fn copy_link_for_position(&self, position: DisplayPoint, cx: &mut ViewContext) { + let snapshot = self.editor.update(cx, |editor, cx| editor.snapshot(cx)); + + let mut closest_heading = None; + + if let Some(outline) = snapshot.buffer_snapshot.outline(None) { + for item in outline.items { + if item.range.start.to_display_point(&snapshot) > position { + break; + } + closest_heading = Some(item); + } + } + + let Some(channel) = self.channel(cx) else { + return; + }; + + let link = channel.notes_link(closest_heading.map(|heading| heading.text)); + cx.write_to_clipboard(ClipboardItem::new(link)); + self.workspace + .update(cx, |workspace, cx| { + workspace.show_toast(Toast::new(0, "Link copied to clipboard"), cx); + }) + .ok(); + } + pub fn channel(&self, cx: &AppContext) -> Option> { self.channel_buffer.read(cx).channel(cx) } @@ -215,8 +335,11 @@ impl ChannelView { impl EventEmitter for ChannelView {} impl Render for ChannelView { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - self.editor.clone() + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .size_full() + .on_action(cx.listener(Self::copy_link)) + .child(self.editor.clone()) } } @@ -274,6 +397,7 @@ impl Item for ChannelView { Some(cx.new_view(|cx| { Self::new( self.project.clone(), + self.workspace.clone(), self.channel_store.clone(), self.channel_buffer.clone(), cx, @@ -356,7 +480,7 @@ impl FollowableItem for ChannelView { unreachable!() }; - let open = ChannelView::open_in_pane(state.channel_id, pane, workspace, cx); + let open = ChannelView::open_in_pane(state.channel_id, None, pane, workspace, cx); Some(cx.spawn(|mut cx| async move { let this = open.await?; diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index e9cd02620f..f33c6a13ef 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1678,7 +1678,7 @@ impl CollabPanel { fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { if let Some(workspace) = self.workspace.upgrade() { - ChannelView::open(channel_id, workspace, cx).detach(); + ChannelView::open(channel_id, None, workspace, cx).detach(); } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1983cb5d82..86090b0f05 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -413,6 +413,12 @@ pub struct Editor { editor_actions: Vec)>>, show_copilot_suggestions: bool, use_autoclose: bool, + custom_context_menu: Option< + Box< + dyn 'static + + Fn(&mut Self, DisplayPoint, &mut ViewContext) -> Option>, + >, + >, } pub struct EditorSnapshot { @@ -1476,6 +1482,7 @@ impl Editor { hovered_cursors: Default::default(), editor_actions: Default::default(), show_copilot_suggestions: mode == EditorMode::Full, + custom_context_menu: None, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), cx.subscribe(&buffer, Self::on_buffer_event), @@ -1665,6 +1672,14 @@ impl Editor { self.collaboration_hub = Some(hub); } + pub fn set_custom_context_menu( + &mut self, + f: impl 'static + + Fn(&mut Self, DisplayPoint, &mut ViewContext) -> Option>, + ) { + self.custom_context_menu = Some(Box::new(f)) + } + pub fn set_completion_provider(&mut self, hub: Box) { self.completion_provider = Some(hub); } diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 24f3b22a5c..bda55e01ed 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -25,31 +25,40 @@ pub fn deploy_context_menu( return; } - // Don't show the context menu if there isn't a project associated with this editor - if editor.project.is_none() { - return; - } + let context_menu = if let Some(custom) = editor.custom_context_menu.take() { + let menu = custom(editor, point, cx); + editor.custom_context_menu = Some(custom); + if menu.is_none() { + return; + } + menu.unwrap() + } else { + // Don't show the context menu if there isn't a project associated with this editor + if editor.project.is_none() { + return; + } - // Move the cursor to the clicked location so that dispatched actions make sense - editor.change_selections(None, cx, |s| { - s.clear_disjoint(); - s.set_pending_display_range(point..point, SelectMode::Character); - }); + // Move the cursor to the clicked location so that dispatched actions make sense + editor.change_selections(None, cx, |s| { + s.clear_disjoint(); + s.set_pending_display_range(point..point, SelectMode::Character); + }); - let context_menu = ui::ContextMenu::build(cx, |menu, _cx| { - menu.action("Rename Symbol", Box::new(Rename)) - .action("Go to Definition", Box::new(GoToDefinition)) - .action("Go to Type Definition", Box::new(GoToTypeDefinition)) - .action("Find All References", Box::new(FindAllReferences)) - .action( - "Code Actions", - Box::new(ToggleCodeActions { - deployed_from_indicator: false, - }), - ) - .separator() - .action("Reveal in Finder", Box::new(RevealInFinder)) - }); + ui::ContextMenu::build(cx, |menu, _cx| { + menu.action("Rename Symbol", Box::new(Rename)) + .action("Go to Definition", Box::new(GoToDefinition)) + .action("Go to Type Definition", Box::new(GoToTypeDefinition)) + .action("Find All References", Box::new(FindAllReferences)) + .action( + "Code Actions", + Box::new(ToggleCodeActions { + deployed_from_indicator: false, + }), + ) + .separator() + .action("Reveal in Finder", Box::new(RevealInFinder)) + }) + }; let context_menu_focus = context_menu.focus_handle(cx); cx.focus(&context_menu_focus); diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 955b970540..191dbd04dc 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -12,17 +12,26 @@ pub enum Autoscroll { } impl Autoscroll { + /// scrolls the minimal amount to (try) and fit all cursors onscreen pub fn fit() -> Self { Self::Strategy(AutoscrollStrategy::Fit) } + /// scrolls the minimal amount to fit the newest cursor pub fn newest() -> Self { Self::Strategy(AutoscrollStrategy::Newest) } + /// scrolls so the newest cursor is vertically centered pub fn center() -> Self { Self::Strategy(AutoscrollStrategy::Center) } + + /// scrolls so the neweset cursor is near the top + /// (offset by vertical_scroll_margin) + pub fn focused() -> Self { + Self::Strategy(AutoscrollStrategy::Focused) + } } #[derive(PartialEq, Eq, Default, Clone, Copy)] @@ -31,6 +40,7 @@ pub enum AutoscrollStrategy { Newest, #[default] Center, + Focused, Top, Bottom, } @@ -155,6 +165,11 @@ impl Editor { scroll_position.y = (target_top - margin).max(0.0); self.set_scroll_position_internal(scroll_position, local, true, cx); } + AutoscrollStrategy::Focused => { + scroll_position.y = + (target_top - self.scroll_manager.vertical_scroll_margin).max(0.0); + self.set_scroll_position_internal(scroll_position, local, true, cx); + } AutoscrollStrategy::Top => { scroll_position.y = (target_top).max(0.0); self.set_scroll_position_internal(scroll_position, local, true, cx); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index b87b79e2f0..5b202aef43 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -57,6 +57,7 @@ image = "0.23" indexmap = "1.6.2" install_cli = { path = "../install_cli" } isahc.workspace = true +itertools = "0.11" journal = { path = "../journal" } language = { path = "../language" } language_selector = { path = "../language_selector" } diff --git a/crates/zed/src/languages/markdown/outline.scm b/crates/zed/src/languages/markdown/outline.scm new file mode 100644 index 0000000000..3f8311136c --- /dev/null +++ b/crates/zed/src/languages/markdown/outline.scm @@ -0,0 +1,5 @@ +(atx_heading + . + (_) @context + . + (_) @name ) @item diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 11e9667a8e..1666b23f07 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -314,7 +314,10 @@ fn main() { }) .detach_and_log_err(cx); } - Ok(Some(OpenRequest::OpenChannelNotes { channel_id })) => { + Ok(Some(OpenRequest::OpenChannelNotes { + channel_id, + heading, + })) => { triggered_authentication = true; let app_state = app_state.clone(); let client = client.clone(); @@ -323,11 +326,11 @@ fn main() { let _ = authenticate(client, &cx).await; let workspace_window = workspace::get_any_active_workspace(app_state, cx.clone()).await?; - let _ = workspace_window - .update(&mut cx, |_, cx| { - ChannelView::open(channel_id, cx.view().clone(), cx) - })? - .await?; + let workspace = workspace_window.root_view(&cx)?; + cx.update_window(workspace_window.into(), |_, cx| { + ChannelView::open(channel_id, heading, workspace, cx) + })? + .await?; anyhow::Ok(()) }) .detach_and_log_err(cx); @@ -369,16 +372,19 @@ fn main() { }) .log_err(); } - OpenRequest::OpenChannelNotes { channel_id } => { + OpenRequest::OpenChannelNotes { + channel_id, + heading, + } => { let app_state = app_state.clone(); let open_notes_task = cx.spawn(|mut cx| async move { let workspace_window = workspace::get_any_active_workspace(app_state, cx.clone()).await?; - let _ = workspace_window - .update(&mut cx, |_, cx| { - ChannelView::open(channel_id, cx.view().clone(), cx) - })? - .await?; + let workspace = workspace_window.root_view(&cx)?; + cx.update_window(workspace_window.into(), |_, cx| { + ChannelView::open(channel_id, heading, workspace, cx) + })? + .await?; anyhow::Ok(()) }); cx.update(|cx| open_notes_task.detach_and_log_err(cx)) diff --git a/crates/zed/src/open_listener.rs b/crates/zed/src/open_listener.rs index 8ef5d61c6b..012b5a2413 100644 --- a/crates/zed/src/open_listener.rs +++ b/crates/zed/src/open_listener.rs @@ -7,6 +7,7 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::channel::{mpsc, oneshot}; use futures::{FutureExt, SinkExt, StreamExt}; use gpui::{AppContext, AsyncAppContext, Global}; +use itertools::Itertools; use language::{Bias, Point}; use release_channel::parse_zed_link; use std::collections::HashMap; @@ -34,6 +35,7 @@ pub enum OpenRequest { }, OpenChannelNotes { channel_id: u64, + heading: Option, }, } @@ -100,10 +102,20 @@ impl OpenListener { if let Some(slug) = parts.next() { if let Some(id_str) = slug.split("-").last() { if let Ok(channel_id) = id_str.parse::() { - if Some("notes") == parts.next() { - return Some(OpenRequest::OpenChannelNotes { channel_id }); - } else { + let Some(next) = parts.next() else { return Some(OpenRequest::JoinChannel { channel_id }); + }; + + if let Some(heading) = next.strip_prefix("notes#") { + return Some(OpenRequest::OpenChannelNotes { + channel_id, + heading: Some([heading].into_iter().chain(parts).join("/")), + }); + } else if next == "notes" { + return Some(OpenRequest::OpenChannelNotes { + channel_id, + heading: None, + }); } } }