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
This commit is contained in:
Conrad Irwin 2024-02-01 22:22:02 -07:00 committed by GitHub
parent b35a7223b6
commit 69e0ea92e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 260 additions and 65 deletions

1
Cargo.lock generated
View file

@ -10344,6 +10344,7 @@ dependencies = [
"indexmap 1.9.3",
"install_cli",
"isahc",
"itertools 0.11.0",
"journal",
"language",
"language_selector",

View file

@ -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();

View file

@ -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();

View file

@ -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| {

View file

@ -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,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<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>> {
self.channel_buffer.read(cx).channel(cx)
}
@ -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?;

View file

@ -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();
}
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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" }

View file

@ -0,0 +1,5 @@
(atx_heading
.
(_) @context
.
(_) @name ) @item

View file

@ -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))

View file

@ -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,
});
}
}
}