Cargo.lock 🔗
@@ -10344,6 +10344,7 @@ dependencies = [
"indexmap 1.9.3",
"install_cli",
"isahc",
+ "itertools 0.11.0",
"journal",
"language",
"language_selector",
Conrad Irwin created
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 +
crates/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(-)
@@ -10344,6 +10344,7 @@ dependencies = [
"indexmap 1.9.3",
"install_cli",
"isahc",
+ "itertools 0.11.0",
"journal",
"language",
"language_selector",
@@ -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>) -> 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();
@@ -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();
@@ -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| {
@@ -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::<ChannelView>(cx)
@@ -34,21 +37,30 @@ pub fn init(cx: &mut AppContext) {
pub struct ChannelView {
pub editor: View<Editor>,
+ workspace: WeakView<Workspace>,
project: Model<Project>,
channel_store: Model<ChannelStore>,
channel_buffer: Model<ChannelBuffer>,
remote_id: Option<ViewId>,
_editor_event_subscription: Subscription,
+ _reparse_subscription: Option<Subscription>,
}
impl ChannelView {
pub fn open(
channel_id: ChannelId,
+ link_position: Option<String>,
workspace: View<Workspace>,
cx: &mut WindowContext,
) -> Task<Result<View<Self>>> {
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<String>,
pane: View<Pane>,
workspace: View<Workspace>,
cx: &mut WindowContext,
) -> Task<Result<View<Self>>> {
+ 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<Project>,
+ workspace: WeakView<Workspace>,
channel_store: Model<ChannelStore>,
channel_buffer: Model<ChannelBuffer>,
cx: &mut ViewContext<Self>,
) -> 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,12 +188,92 @@ 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<Self>,
+ ) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Arc<Channel>> {
@@ -215,8 +335,11 @@ impl ChannelView {
impl EventEmitter<EditorEvent> for ChannelView {}
impl Render for ChannelView {
- fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
- self.editor.clone()
+ fn render(&mut self, cx: &mut ViewContext<Self>) -> 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?;
@@ -1678,7 +1678,7 @@ impl CollabPanel {
fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
- ChannelView::open(channel_id, workspace, cx).detach();
+ ChannelView::open(channel_id, None, workspace, cx).detach();
}
}
@@ -413,6 +413,12 @@ pub struct Editor {
editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
show_copilot_suggestions: bool,
use_autoclose: bool,
+ custom_context_menu: Option<
+ Box<
+ dyn 'static
+ + Fn(&mut Self, DisplayPoint, &mut ViewContext<Self>) -> Option<View<ui::ContextMenu>>,
+ >,
+ >,
}
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<Self>) -> Option<View<ui::ContextMenu>>,
+ ) {
+ self.custom_context_menu = Some(Box::new(f))
+ }
+
pub fn set_completion_provider(&mut self, hub: Box<dyn CompletionProvider>) {
self.completion_provider = Some(hub);
}
@@ -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);
@@ -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);
@@ -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" }
@@ -0,0 +1,5 @@
+(atx_heading
+ .
+ (_) @context
+ .
+ (_) @name ) @item
@@ -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))
@@ -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<String>,
},
}
@@ -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::<u64>() {
- 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,
+ });
}
}
}