Links to channel notes (#7262)

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

Change summary

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

Detailed changes

Cargo.lock 🔗

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

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

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

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

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::<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?;

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<Self>) {
         if let Some(workspace) = self.workspace.upgrade() {
-            ChannelView::open(channel_id, workspace, cx).detach();
+            ChannelView::open(channel_id, None, workspace, cx).detach();
         }
     }
 

crates/editor/src/editor.rs 🔗

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

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

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

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

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

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