For future reference: WIP branch of copy/pasting a mixture of images and text: https://github.com/zed-industries/zed/tree/copy-paste-images - we'll come back to that one after landing this one. Release Notes: - You can now paste images into the Assistant Panel to include them as context. Currently works only on Mac, and with Anthropic models. Future support is planned for more models, operating systems, and image clipboard operations. --------- Co-authored-by: Antonio <antonio@zed.dev> Co-authored-by: Mikayla <mikayla@zed.dev> Co-authored-by: Jason <jason@zed.dev> Co-authored-by: Kyle <kylek@zed.dev>
636 lines
21 KiB
Rust
636 lines
21 KiB
Rust
use anyhow::Result;
|
|
use call::report_call_event_for_channel;
|
|
use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelStore};
|
|
use client::{
|
|
proto::{self, PeerId},
|
|
ChannelId, Collaborator, ParticipantIndex,
|
|
};
|
|
use collections::HashMap;
|
|
use editor::{
|
|
display_map::ToDisplayPoint, scroll::Autoscroll, CollaborationHub, DisplayPoint, Editor,
|
|
EditorEvent,
|
|
};
|
|
use gpui::{
|
|
actions, AnyView, AppContext, ClipboardItem, Entity as _, EventEmitter, FocusableView, Model,
|
|
Pixels, Point, Render, Subscription, Task, View, ViewContext, VisualContext as _, WeakView,
|
|
WindowContext,
|
|
};
|
|
use project::Project;
|
|
use rpc::proto::ChannelVisibility;
|
|
use std::{
|
|
any::{Any, TypeId},
|
|
sync::Arc,
|
|
};
|
|
use ui::prelude::*;
|
|
use util::ResultExt;
|
|
use workspace::item::TabContentParams;
|
|
use workspace::{item::Dedup, notifications::NotificationId};
|
|
use workspace::{
|
|
item::{FollowableItem, Item, ItemEvent, ItemHandle},
|
|
searchable::SearchableItemHandle,
|
|
ItemNavHistory, Pane, SaveIntent, Toast, ViewId, Workspace, WorkspaceId,
|
|
};
|
|
|
|
actions!(collab, [CopyLink]);
|
|
|
|
pub fn init(cx: &mut AppContext) {
|
|
workspace::FollowableViewRegistry::register::<ChannelView>(cx)
|
|
}
|
|
|
|
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,
|
|
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| {
|
|
report_call_event_for_channel(
|
|
"open channel notes",
|
|
channel_id,
|
|
&workspace.read(cx).app_state().client,
|
|
cx,
|
|
);
|
|
pane.add_item(Box::new(channel_view.clone()), true, true, None, cx);
|
|
})?;
|
|
anyhow::Ok(channel_view)
|
|
})
|
|
}
|
|
|
|
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 channel_view = Self::load(channel_id, workspace, cx);
|
|
cx.spawn(|mut cx| async move {
|
|
let channel_view = channel_view.await?;
|
|
|
|
pane.update(&mut cx, |pane, cx| {
|
|
let buffer_id = channel_view.read(cx).channel_buffer.read(cx).remote_id(cx);
|
|
|
|
let existing_view = pane
|
|
.items_of_type::<Self>()
|
|
.find(|view| view.read(cx).channel_buffer.read(cx).remote_id(cx) == buffer_id);
|
|
|
|
// 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_view.read(cx).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;
|
|
}
|
|
}
|
|
|
|
// If the pane contained a disconnected view for this channel buffer,
|
|
// replace that.
|
|
if let Some(existing_item) = existing_view {
|
|
if let Some(ix) = pane.index_for_item(&existing_item) {
|
|
pane.close_item_by_id(existing_item.entity_id(), SaveIntent::Skip, cx)
|
|
.detach();
|
|
pane.add_item(Box::new(channel_view.clone()), true, true, Some(ix), cx);
|
|
}
|
|
}
|
|
|
|
if let Some(link_position) = link_position {
|
|
channel_view.update(cx, |channel_view, cx| {
|
|
channel_view.focus_position_from_link(link_position, true, cx)
|
|
});
|
|
}
|
|
|
|
channel_view
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn load(
|
|
channel_id: ChannelId,
|
|
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);
|
|
let language_registry = workspace.app_state().languages.clone();
|
|
let markdown = language_registry.language_for_name("Markdown");
|
|
let channel_buffer =
|
|
channel_store.update(cx, |store, cx| store.open_channel_buffer(channel_id, cx));
|
|
|
|
cx.spawn(|mut cx| async move {
|
|
let channel_buffer = channel_buffer.await?;
|
|
let markdown = markdown.await.log_err();
|
|
|
|
channel_buffer.update(&mut cx, |channel_buffer, cx| {
|
|
channel_buffer.buffer().update(cx, |buffer, cx| {
|
|
buffer.set_language_registry(language_registry);
|
|
let Some(markdown) = markdown else {
|
|
return;
|
|
};
|
|
buffer.set_language(Some(markdown), cx);
|
|
})
|
|
})?;
|
|
|
|
cx.new_view(|cx| {
|
|
let mut this =
|
|
Self::new(project, weak_workspace, channel_store, channel_buffer, cx);
|
|
this.acknowledge_buffer_version(cx);
|
|
this
|
|
})
|
|
})
|
|
}
|
|
|
|
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, cx))
|
|
.ok();
|
|
})
|
|
}))
|
|
});
|
|
editor
|
|
});
|
|
let _editor_event_subscription =
|
|
cx.subscribe(&editor, |_, _, e: &EditorEvent, cx| cx.emit(e.clone()));
|
|
|
|
cx.subscribe(&channel_buffer, Self::handle_channel_buffer_event)
|
|
.detach();
|
|
|
|
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);
|
|
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
|
self.workspace
|
|
.update(cx, |workspace, cx| {
|
|
struct CopyLinkForPositionToast;
|
|
|
|
workspace.show_toast(
|
|
Toast::new(
|
|
NotificationId::unique::<CopyLinkForPositionToast>(),
|
|
"Link copied to clipboard",
|
|
),
|
|
cx,
|
|
);
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
pub fn channel(&self, cx: &AppContext) -> Option<Arc<Channel>> {
|
|
self.channel_buffer.read(cx).channel(cx)
|
|
}
|
|
|
|
fn handle_channel_buffer_event(
|
|
&mut self,
|
|
_: Model<ChannelBuffer>,
|
|
event: &ChannelBufferEvent,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| {
|
|
editor.set_read_only(true);
|
|
cx.notify();
|
|
}),
|
|
ChannelBufferEvent::ChannelChanged => {
|
|
self.editor.update(cx, |_, cx| {
|
|
cx.emit(editor::EditorEvent::TitleChanged);
|
|
cx.notify()
|
|
});
|
|
}
|
|
ChannelBufferEvent::BufferEdited => {
|
|
if self.editor.read(cx).is_focused(cx) {
|
|
self.acknowledge_buffer_version(cx);
|
|
} else {
|
|
self.channel_store.update(cx, |store, cx| {
|
|
let channel_buffer = self.channel_buffer.read(cx);
|
|
store.update_latest_notes_version(
|
|
channel_buffer.channel_id,
|
|
channel_buffer.epoch(),
|
|
&channel_buffer.buffer().read(cx).version(),
|
|
cx,
|
|
)
|
|
});
|
|
}
|
|
}
|
|
ChannelBufferEvent::CollaboratorsChanged => {}
|
|
}
|
|
}
|
|
|
|
fn acknowledge_buffer_version(&mut self, cx: &mut ViewContext<ChannelView>) {
|
|
self.channel_store.update(cx, |store, cx| {
|
|
let channel_buffer = self.channel_buffer.read(cx);
|
|
store.acknowledge_notes_version(
|
|
channel_buffer.channel_id,
|
|
channel_buffer.epoch(),
|
|
&channel_buffer.buffer().read(cx).version(),
|
|
cx,
|
|
)
|
|
});
|
|
self.channel_buffer.update(cx, |buffer, cx| {
|
|
buffer.acknowledge_buffer_version(cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<EditorEvent> for ChannelView {}
|
|
|
|
impl Render for ChannelView {
|
|
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
|
div()
|
|
.size_full()
|
|
.on_action(cx.listener(Self::copy_link))
|
|
.child(self.editor.clone())
|
|
}
|
|
}
|
|
|
|
impl FocusableView for ChannelView {
|
|
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
|
|
self.editor.read(cx).focus_handle(cx)
|
|
}
|
|
}
|
|
|
|
impl Item for ChannelView {
|
|
type Event = EditorEvent;
|
|
|
|
fn act_as_type<'a>(
|
|
&'a self,
|
|
type_id: TypeId,
|
|
self_handle: &'a View<Self>,
|
|
_: &'a AppContext,
|
|
) -> Option<AnyView> {
|
|
if type_id == TypeId::of::<Self>() {
|
|
Some(self_handle.to_any())
|
|
} else if type_id == TypeId::of::<Editor>() {
|
|
Some(self.editor.to_any())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn tab_icon(&self, cx: &WindowContext) -> Option<Icon> {
|
|
let channel = self.channel(cx)?;
|
|
let icon = match channel.visibility {
|
|
ChannelVisibility::Public => IconName::Public,
|
|
ChannelVisibility::Members => IconName::Hash,
|
|
};
|
|
|
|
Some(Icon::new(icon))
|
|
}
|
|
|
|
fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> gpui::AnyElement {
|
|
let (channel_name, status) = if let Some(channel) = self.channel(cx) {
|
|
let status = match (
|
|
self.channel_buffer.read(cx).buffer().read(cx).read_only(),
|
|
self.channel_buffer.read(cx).is_connected(),
|
|
) {
|
|
(false, true) => None,
|
|
(true, true) => Some("read-only"),
|
|
(_, false) => Some("disconnected"),
|
|
};
|
|
|
|
(channel.name.clone(), status)
|
|
} else {
|
|
("<unknown>".into(), Some("disconnected"))
|
|
};
|
|
|
|
h_flex()
|
|
.gap_2()
|
|
.child(
|
|
Label::new(channel_name)
|
|
.color(params.text_color())
|
|
.italic(params.preview),
|
|
)
|
|
.when_some(status, |element, status| {
|
|
element.child(
|
|
Label::new(status)
|
|
.size(LabelSize::XSmall)
|
|
.color(Color::Muted),
|
|
)
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
fn telemetry_event_text(&self) -> Option<&'static str> {
|
|
None
|
|
}
|
|
|
|
fn clone_on_split(
|
|
&self,
|
|
_: Option<WorkspaceId>,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> Option<View<Self>> {
|
|
Some(cx.new_view(|cx| {
|
|
Self::new(
|
|
self.project.clone(),
|
|
self.workspace.clone(),
|
|
self.channel_store.clone(),
|
|
self.channel_buffer.clone(),
|
|
cx,
|
|
)
|
|
}))
|
|
}
|
|
|
|
fn is_singleton(&self, _cx: &AppContext) -> bool {
|
|
false
|
|
}
|
|
|
|
fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
|
|
self.editor
|
|
.update(cx, |editor, cx| editor.navigate(data, cx))
|
|
}
|
|
|
|
fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
|
|
self.editor
|
|
.update(cx, |editor, cx| Item::deactivated(editor, cx))
|
|
}
|
|
|
|
fn set_nav_history(&mut self, history: ItemNavHistory, cx: &mut ViewContext<Self>) {
|
|
self.editor
|
|
.update(cx, |editor, cx| Item::set_nav_history(editor, history, cx))
|
|
}
|
|
|
|
fn as_searchable(&self, _: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
|
|
Some(Box::new(self.editor.clone()))
|
|
}
|
|
|
|
fn show_toolbar(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Point<Pixels>> {
|
|
self.editor.read(cx).pixel_position_of_cursor(cx)
|
|
}
|
|
|
|
fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
|
|
Editor::to_item_events(event, f)
|
|
}
|
|
}
|
|
|
|
impl FollowableItem for ChannelView {
|
|
fn remote_id(&self) -> Option<workspace::ViewId> {
|
|
self.remote_id
|
|
}
|
|
|
|
fn to_state_proto(&self, cx: &WindowContext) -> Option<proto::view::Variant> {
|
|
let channel_buffer = self.channel_buffer.read(cx);
|
|
if !channel_buffer.is_connected() {
|
|
return None;
|
|
}
|
|
|
|
Some(proto::view::Variant::ChannelView(
|
|
proto::view::ChannelView {
|
|
channel_id: channel_buffer.channel_id.0,
|
|
editor: if let Some(proto::view::Variant::Editor(proto)) =
|
|
self.editor.read(cx).to_state_proto(cx)
|
|
{
|
|
Some(proto)
|
|
} else {
|
|
None
|
|
},
|
|
},
|
|
))
|
|
}
|
|
|
|
fn from_state_proto(
|
|
workspace: View<workspace::Workspace>,
|
|
remote_id: workspace::ViewId,
|
|
state: &mut Option<proto::view::Variant>,
|
|
cx: &mut WindowContext,
|
|
) -> Option<gpui::Task<anyhow::Result<View<Self>>>> {
|
|
let Some(proto::view::Variant::ChannelView(_)) = state else {
|
|
return None;
|
|
};
|
|
let Some(proto::view::Variant::ChannelView(state)) = state.take() else {
|
|
unreachable!()
|
|
};
|
|
|
|
let open = ChannelView::load(ChannelId(state.channel_id), workspace, cx);
|
|
|
|
Some(cx.spawn(|mut cx| async move {
|
|
let this = open.await?;
|
|
|
|
let task = this.update(&mut cx, |this, cx| {
|
|
this.remote_id = Some(remote_id);
|
|
|
|
if let Some(state) = state.editor {
|
|
Some(this.editor.update(cx, |editor, cx| {
|
|
editor.apply_update_proto(
|
|
&this.project,
|
|
proto::update_view::Variant::Editor(proto::update_view::Editor {
|
|
selections: state.selections,
|
|
pending_selection: state.pending_selection,
|
|
scroll_top_anchor: state.scroll_top_anchor,
|
|
scroll_x: state.scroll_x,
|
|
scroll_y: state.scroll_y,
|
|
..Default::default()
|
|
}),
|
|
cx,
|
|
)
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
})?;
|
|
|
|
if let Some(task) = task {
|
|
task.await?;
|
|
}
|
|
|
|
Ok(this)
|
|
}))
|
|
}
|
|
|
|
fn add_event_to_update_proto(
|
|
&self,
|
|
event: &EditorEvent,
|
|
update: &mut Option<proto::update_view::Variant>,
|
|
cx: &WindowContext,
|
|
) -> bool {
|
|
self.editor
|
|
.read(cx)
|
|
.add_event_to_update_proto(event, update, cx)
|
|
}
|
|
|
|
fn apply_update_proto(
|
|
&mut self,
|
|
project: &Model<Project>,
|
|
message: proto::update_view::Variant,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> gpui::Task<anyhow::Result<()>> {
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.apply_update_proto(project, message, cx)
|
|
})
|
|
}
|
|
|
|
fn set_leader_peer_id(&mut self, leader_peer_id: Option<PeerId>, cx: &mut ViewContext<Self>) {
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.set_leader_peer_id(leader_peer_id, cx)
|
|
})
|
|
}
|
|
|
|
fn is_project_item(&self, _cx: &WindowContext) -> bool {
|
|
false
|
|
}
|
|
|
|
fn to_follow_event(event: &Self::Event) -> Option<workspace::item::FollowEvent> {
|
|
Editor::to_follow_event(event)
|
|
}
|
|
|
|
fn dedup(&self, existing: &Self, cx: &WindowContext) -> Option<Dedup> {
|
|
let existing = existing.channel_buffer.read(cx);
|
|
if self.channel_buffer.read(cx).channel_id == existing.channel_id {
|
|
if existing.is_connected() {
|
|
Some(Dedup::KeepExisting)
|
|
} else {
|
|
Some(Dedup::ReplaceExisting)
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ChannelBufferCollaborationHub(Model<ChannelBuffer>);
|
|
|
|
impl CollaborationHub for ChannelBufferCollaborationHub {
|
|
fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap<PeerId, Collaborator> {
|
|
self.0.read(cx).collaborators()
|
|
}
|
|
|
|
fn user_participant_indices<'a>(
|
|
&self,
|
|
cx: &'a AppContext,
|
|
) -> &'a HashMap<u64, ParticipantIndex> {
|
|
self.0.read(cx).user_store().read(cx).participant_indices()
|
|
}
|
|
|
|
fn user_names(&self, cx: &AppContext) -> HashMap<u64, SharedString> {
|
|
let user_ids = self.collaborators(cx).values().map(|c| c.user_id);
|
|
self.0
|
|
.read(cx)
|
|
.user_store()
|
|
.read(cx)
|
|
.participant_names(user_ids, cx)
|
|
}
|
|
}
|