diff --git a/Cargo.lock b/Cargo.lock index 7bc9f96f95..9714d93cd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5170,8 +5170,12 @@ dependencies = [ "anyhow", "collections", "db", + "editor", + "futures 0.3.31", "git", "gpui", + "language", + "menu", "project", "schemars", "serde", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 20040bd58c..d0571f78fe 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -128,6 +128,7 @@ use multi_buffer::{ ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16, }; use project::{ + buffer_store::BufferChangeSet, lsp_store::{FormatTarget, FormatTrigger, OpenLspBufferHandle}, project_settings::{GitGutterSetting, ProjectSettings}, CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink, @@ -12950,6 +12951,14 @@ impl Editor { .and_then(|item| item.to_any().downcast_ref::()) } + pub fn add_change_set( + &mut self, + change_set: Model, + cx: &mut ViewContext, + ) { + self.diff_map.add_change_set(change_set, cx); + } + fn character_size(&self, cx: &mut ViewContext) -> gpui::Point { let text_layout_details = self.text_layout_details(cx); let style = &text_layout_details.editor_style; diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 87ba730bf9..69e70805e7 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -15,7 +15,11 @@ path = "src/git_ui.rs" [dependencies] anyhow.workspace = true db.workspace = true +editor.workspace = true +futures.workspace = true gpui.workspace = true +language.workspace = true +menu.workspace = true project.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 9e273d545e..caef1d761b 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,13 +1,19 @@ -use anyhow::Result; +use anyhow::{Context as _, Result}; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; -use git::repository::GitFileStatus; +use editor::{ + scroll::{Autoscroll, AutoscrollStrategy}, + Editor, MultiBuffer, DEFAULT_MULTIBUFFER_CONTEXT, +}; +use git::{diff::DiffHunk, repository::GitFileStatus}; use gpui::{ actions, prelude::*, uniform_list, Action, AppContext, AsyncWindowContext, ClickEvent, CursorStyle, EventEmitter, FocusHandle, FocusableView, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, Modifiers, ModifiersChangedEvent, - MouseButton, Stateful, Task, UniformListScrollHandle, View, WeakView, + MouseButton, ScrollStrategy, Stateful, Task, UniformListScrollHandle, View, WeakView, }; +use language::{Buffer, BufferRow, OffsetRangeExt}; +use menu::{SelectNext, SelectPrev}; use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, WorktreeId}; use serde::{Deserialize, Serialize}; use settings::Settings as _; @@ -15,17 +21,22 @@ use std::{ cell::OnceCell, collections::HashSet, ffi::OsStr, - ops::Range, + ops::{Deref, Range}, path::{Path, PathBuf}, + rc::Rc, sync::Arc, time::Duration, + usize, }; use ui::{ - prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip, + prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, ListItem, Scrollbar, + ScrollbarState, Tooltip, }; use util::{ResultExt, TryFutureExt}; -use workspace::dock::{DockPosition, Panel, PanelEvent}; -use workspace::Workspace; +use workspace::{ + dock::{DockPosition, Panel, PanelEvent}, + ItemHandle, Workspace, +}; use crate::{git_status_icon, settings::GitPanelSettings}; use crate::{CommitAllChanges, CommitStagedChanges, DiscardAll, StageAll, UnstageAll}; @@ -34,6 +45,8 @@ actions!(git_panel, [ToggleFocus]); const GIT_PANEL_KEY: &str = "GitPanel"; +const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); + pub fn init(cx: &mut AppContext) { cx.observe_new_views( |workspace: &mut Workspace, _cx: &mut ViewContext| { @@ -61,6 +74,8 @@ struct EntryDetails { depth: usize, is_expanded: bool, status: Option, + hunks: Rc>>, + index: usize, } impl EntryDetails { @@ -75,7 +90,7 @@ struct SerializedGitPanel { } pub struct GitPanel { - _workspace: WeakView, + workspace: WeakView, current_modifiers: Modifiers, focus_handle: FocusHandle, fs: Arc, @@ -90,8 +105,43 @@ pub struct GitPanel { // The entries that are currently shown in the panel, aka // not hidden by folding or such - visible_entries: Vec<(WorktreeId, Vec, OnceCell>>)>, + visible_entries: Vec, width: Option, + git_diff_editor: View, + git_diff_editor_updates: Task<()>, + reveal_in_editor: Task<()>, +} + +#[derive(Debug, Clone)] +struct WorktreeEntries { + worktree_id: WorktreeId, + visible_entries: Vec, + paths: Rc>>>, +} + +#[derive(Debug, Clone)] +struct GitPanelEntry { + entry: Entry, + hunks: Rc>>, +} + +impl Deref for GitPanelEntry { + type Target = Entry; + + fn deref(&self) -> &Self::Target { + &self.entry + } +} + +impl WorktreeEntries { + fn paths(&self) -> &HashSet> { + self.paths.get_or_init(|| { + self.visible_entries + .iter() + .map(|e| (e.entry.path.clone())) + .collect() + }) + } } impl GitPanel { @@ -118,18 +168,28 @@ impl GitPanel { this.hide_scrollbar(cx); }) .detach(); - cx.subscribe(&project, |this, _project, event, cx| match event { + cx.subscribe(&project, |this, project, event, cx| match event { project::Event::WorktreeRemoved(id) => { this.expanded_dir_ids.remove(id); - this.update_visible_entries(None, cx); + this.update_visible_entries(None, None, cx); cx.notify(); } - project::Event::WorktreeUpdatedEntries(_, _) - | project::Event::WorktreeAdded(_) - | project::Event::WorktreeOrderChanged => { - this.update_visible_entries(None, cx); + project::Event::WorktreeOrderChanged => { + this.update_visible_entries(None, None, cx); cx.notify(); } + project::Event::WorktreeUpdatedEntries(id, _) + | project::Event::WorktreeAdded(id) + | project::Event::WorktreeUpdatedGitRepositories(id) => { + this.update_visible_entries(Some(*id), None, cx); + cx.notify(); + } + project::Event::Closed => { + this.git_diff_editor_updates = Task::ready(()); + this.expanded_dir_ids.clear(); + this.visible_entries.clear(); + this.git_diff_editor = diff_display_editor(project.clone(), cx); + } _ => {} }) .detach(); @@ -137,11 +197,10 @@ impl GitPanel { let scroll_handle = UniformListScrollHandle::new(); let mut this = Self { - _workspace: weak_workspace, + workspace: weak_workspace, focus_handle: cx.focus_handle(), fs, pending_serialization: Task::ready(None), - project, visible_entries: Vec::new(), current_modifiers: cx.modifiers(), expanded_dir_ids: Default::default(), @@ -152,8 +211,12 @@ impl GitPanel { selected_item: None, show_scrollbar: !Self::should_autohide_scrollbar(cx), hide_scrollbar_task: None, + git_diff_editor: diff_display_editor(project.clone(), cx), + git_diff_editor_updates: Task::ready(()), + reveal_in_editor: Task::ready(()), + project, }; - this.update_visible_entries(None, cx); + this.update_visible_entries(None, None, cx); this }); @@ -254,6 +317,82 @@ impl GitPanel { (depth, difference) } + + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + let item_count = self + .visible_entries + .iter() + .map(|worktree_entries| worktree_entries.visible_entries.len()) + .sum::(); + if item_count == 0 { + return; + } + let selection = match self.selected_item { + Some(i) => { + if i < item_count - 1 { + self.selected_item = Some(i + 1); + i + 1 + } else { + self.selected_item = Some(0); + 0 + } + } + None => { + self.selected_item = Some(0); + 0 + } + }; + self.scroll_handle + .scroll_to_item(selection, ScrollStrategy::Center); + + let mut hunks = None; + self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| { + hunks = Some(entry.hunks.clone()); + }); + if let Some(hunks) = hunks { + self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx); + } + + cx.notify(); + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + let item_count = self + .visible_entries + .iter() + .map(|worktree_entries| worktree_entries.visible_entries.len()) + .sum::(); + if item_count == 0 { + return; + } + let selection = match self.selected_item { + Some(i) => { + if i > 0 { + self.selected_item = Some(i - 1); + i - 1 + } else { + self.selected_item = Some(item_count - 1); + item_count - 1 + } + } + None => { + self.selected_item = Some(0); + 0 + } + }; + self.scroll_handle + .scroll_to_item(selection, ScrollStrategy::Center); + + let mut hunks = None; + self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| { + hunks = Some(entry.hunks.clone()); + }); + if let Some(hunks) = hunks { + self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx); + } + + cx.notify(); + } } impl GitPanel { @@ -296,8 +435,9 @@ impl GitPanel { fn entry_count(&self) -> usize { self.visible_entries .iter() - .map(|(_, entries, _)| { - entries + .map(|worktree_entries| { + worktree_entries + .visible_entries .iter() .filter(|entry| entry.git_status.is_some()) .count() @@ -312,19 +452,23 @@ impl GitPanel { mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext), ) { let mut ix = 0; - for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries { + for worktree_entries in &self.visible_entries { if ix >= range.end { return; } - if ix + visible_worktree_entries.len() <= range.start { - ix += visible_worktree_entries.len(); + if ix + worktree_entries.visible_entries.len() <= range.start { + ix += worktree_entries.visible_entries.len(); continue; } - let end_ix = range.end.min(ix + visible_worktree_entries.len()); + let end_ix = range.end.min(ix + worktree_entries.visible_entries.len()); // let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) { + if let Some(worktree) = self + .project + .read(cx) + .worktree_for_id(worktree_entries.worktree_id, cx) + { let snapshot = worktree.read(cx).snapshot(); let root_name = OsStr::new(snapshot.root_name()); let expanded_entry_ids = self @@ -334,14 +478,14 @@ impl GitPanel { .unwrap_or(&[]); let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - let entries = entries_paths.get_or_init(|| { - visible_worktree_entries - .iter() - .map(|e| (e.path.clone())) - .collect() - }); + let entries = worktree_entries.paths(); - for entry in visible_worktree_entries[entry_range].iter() { + let index_start = entry_range.start; + for (i, entry) in worktree_entries.visible_entries[entry_range] + .iter() + .enumerate() + { + let index = index_start + i; let status = entry.git_status; let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); @@ -363,16 +507,16 @@ impl GitPanel { .unwrap_or_else(|| root_name.to_string_lossy().to_string()), }; - let display_name = entry.path.to_string_lossy().into_owned(); - let details = EntryDetails { filename, - display_name, + display_name: entry.path.to_string_lossy().into_owned(), kind: entry.kind, is_expanded, path: entry.path.clone(), status, + hunks: entry.hunks.clone(), depth, + index, }; callback(entry.id, details, cx); } @@ -382,44 +526,75 @@ impl GitPanel { } // TODO: Update expanded directory state + // TODO: Updates happen in the main loop, could be long for large workspaces fn update_visible_entries( &mut self, + for_worktree: Option, new_selected_entry: Option<(WorktreeId, ProjectEntryId)>, cx: &mut ViewContext, ) { let project = self.project.read(cx); - self.visible_entries.clear(); - for worktree in project.visible_worktrees(cx) { - let snapshot = worktree.read(cx).snapshot(); - let worktree_id = snapshot.id(); - - let mut visible_worktree_entries = Vec::new(); - let mut entry_iter = snapshot.entries(true, 0); - while let Some(entry) = entry_iter.entry() { - // Only include entries with a git status - if entry.git_status.is_some() { - visible_worktree_entries.push(entry.clone()); + let mut old_entries_removed = false; + let mut after_update = Vec::new(); + self.visible_entries + .retain(|worktree_entries| match for_worktree { + Some(for_worktree) => { + if worktree_entries.worktree_id == for_worktree { + old_entries_removed = true; + false + } else if old_entries_removed { + after_update.push(worktree_entries.clone()); + false + } else { + true + } } - entry_iter.advance(); + None => false, + }); + for worktree in project.visible_worktrees(cx) { + let worktree_id = worktree.read(cx).id(); + if for_worktree.is_some() && for_worktree != Some(worktree_id) { + continue; } + let snapshot = worktree.read(cx).snapshot(); + let mut visible_worktree_entries = snapshot + .entries(false, 0) + .filter(|entry| !entry.is_external) + .filter(|entry| entry.git_status.is_some()) + .cloned() + .collect::>(); snapshot.propagate_git_statuses(&mut visible_worktree_entries); project::sort_worktree_entries(&mut visible_worktree_entries); if !visible_worktree_entries.is_empty() { - self.visible_entries - .push((worktree_id, visible_worktree_entries, OnceCell::new())); + self.visible_entries.push(WorktreeEntries { + worktree_id, + visible_entries: visible_worktree_entries + .into_iter() + .map(|entry| GitPanelEntry { + entry, + hunks: Rc::default(), + }) + .collect(), + paths: Rc::default(), + }); } } + self.visible_entries.extend(after_update); if let Some((worktree_id, entry_id)) = new_selected_entry { self.selected_item = self.visible_entries.iter().enumerate().find_map( - |(worktree_index, (id, entries, _))| { - if *id == worktree_id { - entries + |(worktree_index, worktree_entries)| { + if worktree_entries.worktree_id == worktree_id { + worktree_entries + .visible_entries .iter() .position(|entry| entry.id == entry_id) - .map(|entry_index| worktree_index * entries.len() + entry_index) + .map(|entry_index| { + worktree_index * worktree_entries.visible_entries.len() + + entry_index + }) } else { None } @@ -427,6 +602,163 @@ impl GitPanel { ); } + let project = self.project.clone(); + self.git_diff_editor_updates = cx.spawn(|git_panel, mut cx| async move { + cx.background_executor() + .timer(UPDATE_DEBOUNCE) + .await; + let Some(project_buffers) = git_panel + .update(&mut cx, |git_panel, cx| { + futures::future::join_all(git_panel.visible_entries.iter_mut().flat_map( + move |worktree_entries| { + worktree_entries + .visible_entries + .iter() + .filter_map(|entry| { + let git_status = entry.git_status()?; + let entry_hunks = entry.hunks.clone(); + let (entry_path, unstaged_changes_task) = + project.update(cx, |project, cx| { + let entry_path = + project.path_for_entry(entry.id, cx)?; + let open_task = + project.open_path(entry_path.clone(), cx); + let unstaged_changes_task = + cx.spawn(|project, mut cx| async move { + let (_, opened_model) = open_task + .await + .context("opening buffer")?; + let buffer = opened_model + .downcast::() + .map_err(|_| { + anyhow::anyhow!( + "accessing buffer for entry" + ) + })?; + // TODO added files have noop changes and those are not expanded properly in the multi buffer + let unstaged_changes = project + .update(&mut cx, |project, cx| { + project.open_unstaged_changes( + buffer.clone(), + cx, + ) + })? + .await + .context("opening unstaged changes")?; + + let hunks = cx.update(|cx| { + entry_hunks + .get_or_init(|| { + match git_status { + GitFileStatus::Added => { + let buffer_snapshot = buffer.read(cx).snapshot(); + let entire_buffer_range = + buffer_snapshot.anchor_after(0) + ..buffer_snapshot + .anchor_before( + buffer_snapshot.len(), + ); + let entire_buffer_point_range = + entire_buffer_range + .clone() + .to_point(&buffer_snapshot); + + vec![DiffHunk { + row_range: entire_buffer_point_range + .start + .row + ..entire_buffer_point_range + .end + .row, + buffer_range: entire_buffer_range, + diff_base_byte_range: 0..0, + }] + } + GitFileStatus::Modified => { + let buffer_snapshot = + buffer.read(cx).snapshot(); + unstaged_changes.read(cx) + .diff_to_buffer + .hunks_in_row_range( + 0..BufferRow::MAX, + &buffer_snapshot, + ) + .collect() + } + // TODO support conflicts display + GitFileStatus::Conflict => Vec::new(), + } + }).clone() + })?; + + anyhow::Ok((buffer, unstaged_changes, hunks)) + }); + Some((entry_path, unstaged_changes_task)) + })?; + Some((entry_path, unstaged_changes_task)) + }) + .map(|(entry_path, open_task)| async move { + (entry_path, open_task.await) + }) + .collect::>() + }, + )) + }) + .ok() + else { + return; + }; + + let project_buffers = project_buffers.await; + if project_buffers.is_empty() { + return; + } + let mut change_sets = Vec::with_capacity(project_buffers.len()); + if let Some(buffer_update_task) = git_panel + .update(&mut cx, |git_panel, cx| { + let editor = git_panel.git_diff_editor.clone(); + let multi_buffer = editor.read(cx).buffer().clone(); + let mut buffers_with_ranges = Vec::with_capacity(project_buffers.len()); + for (buffer_path, open_result) in project_buffers { + if let Some((buffer, unstaged_changes, diff_hunks)) = open_result + .with_context(|| format!("opening buffer {buffer_path:?}")) + .log_err() + { + change_sets.push(unstaged_changes); + buffers_with_ranges.push(( + buffer, + diff_hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect(), + )); + } + } + + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.clear(cx); + multi_buffer.push_multiple_excerpts_with_context_lines( + buffers_with_ranges, + DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ) + }) + }) + .ok() + { + buffer_update_task.await; + git_panel + .update(&mut cx, |git_panel, cx| { + git_panel.git_diff_editor.update(cx, |editor, cx| { + for change_set in change_sets { + editor.add_change_set(change_set, cx); + } + }) + }) + .ok(); + } + }); + cx.notify(); } } @@ -629,17 +961,23 @@ impl GitPanel { let item_count = self .visible_entries .iter() - .map(|(_, worktree_entries, _)| worktree_entries.len()) + .map(|worktree_entries| worktree_entries.visible_entries.len()) .sum(); + let selected_entry = self.selected_item; h_flex() .size_full() .overflow_hidden() .child( uniform_list(cx.view().clone(), "entries", item_count, { - |this, range, cx| { + move |git_panel, range, cx| { let mut items = Vec::with_capacity(range.end - range.start); - this.for_each_visible_entry(range, cx, |id, details, cx| { - items.push(this.render_entry(id, details, cx)); + git_panel.for_each_visible_entry(range, cx, |id, details, cx| { + items.push(git_panel.render_entry( + id, + Some(details.index) == selected_entry, + details, + cx, + )); }); items } @@ -656,12 +994,14 @@ impl GitPanel { fn render_entry( &self, id: ProjectEntryId, + selected: bool, details: EntryDetails, cx: &ViewContext, ) -> impl IntoElement { let id = id.to_proto() as usize; let checkbox_id = ElementId::Name(format!("checkbox_{}", id).into()); let is_staged = ToggleState::Selected; + let handle = cx.view().clone(); h_flex() .id(id) @@ -679,7 +1019,113 @@ impl GitPanel { .when_some(details.status, |this, status| { this.child(git_status_icon(status)) }) - .child(h_flex().gap_1p5().child(details.display_name.clone())) + .child( + ListItem::new(("label", id)) + .toggle_state(selected) + .child(h_flex().gap_1p5().child(details.display_name.clone())) + .on_click(move |e, cx| { + handle.update(cx, |git_panel, cx| { + git_panel.selected_item = Some(details.index); + let change_focus = e.down.click_count > 1; + git_panel.reveal_entry_in_git_editor( + details.hunks.clone(), + change_focus, + None, + cx, + ); + }); + }), + ) + } + + fn reveal_entry_in_git_editor( + &mut self, + hunks: Rc>>, + change_focus: bool, + debounce: Option, + cx: &mut ViewContext<'_, Self>, + ) { + let workspace = self.workspace.clone(); + let diff_editor = self.git_diff_editor.clone(); + self.reveal_in_editor = cx.spawn(|_, mut cx| async move { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + + let Some(editor) = workspace + .update(&mut cx, |workspace, cx| { + let git_diff_editor = workspace + .items_of_type::(cx) + .find(|editor| &diff_editor == editor); + match git_diff_editor { + Some(existing_editor) => { + workspace.activate_item(&existing_editor, true, change_focus, cx); + existing_editor + } + None => { + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + diff_editor.boxed_clone(), + true, + change_focus, + None, + cx, + ) + }); + diff_editor.clone() + } + } + }) + .ok() + else { + return; + }; + + if let Some(first_hunk) = hunks.get().and_then(|hunks| hunks.first()) { + let hunk_buffer_range = &first_hunk.buffer_range; + if let Some(buffer_id) = hunk_buffer_range + .start + .buffer_id + .or_else(|| first_hunk.buffer_range.end.buffer_id) + { + editor + .update(&mut cx, |editor, cx| { + let multi_buffer = editor.buffer().read(cx); + let buffer = multi_buffer.buffer(buffer_id)?; + let buffer_snapshot = buffer.read(cx).snapshot(); + let (excerpt_id, _) = multi_buffer + .excerpts_for_buffer(&buffer, cx) + .into_iter() + .find(|(_, excerpt)| { + hunk_buffer_range + .start + .cmp(&excerpt.context.start, &buffer_snapshot) + .is_ge() + && hunk_buffer_range + .end + .cmp(&excerpt.context.end, &buffer_snapshot) + .is_le() + })?; + let multi_buffer_hunk_start = multi_buffer + .snapshot(cx) + .anchor_in_excerpt(excerpt_id, hunk_buffer_range.start)?; + editor.change_selections( + Some(Autoscroll::Strategy(AutoscrollStrategy::Center)), + cx, + |s| { + s.select_ranges(Some( + multi_buffer_hunk_start..multi_buffer_hunk_start, + )) + }, + ); + cx.notify(); + Some(()) + }) + .ok() + .flatten(); + } + } + }); } } @@ -707,6 +1153,8 @@ impl Render for GitPanel { this.commit_all_changes(&CommitAllChanges, cx) })) }) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) .on_hover(cx.listener(|this, hovered, cx| { if *hovered { this.show_scrollbar = true; @@ -787,3 +1235,14 @@ impl Panel for GitPanel { Box::new(ToggleFocus) } } + +fn diff_display_editor(project: Model, cx: &mut WindowContext) -> View { + cx.new_view(|cx| { + let multi_buffer = cx.new_model(|cx| { + MultiBuffer::new(project.read(cx).capability()).with_title("Project diff".to_string()) + }); + let mut editor = Editor::for_multibuffer(multi_buffer, Some(project), true, cx); + editor.set_expand_all_diff_hunks(); + editor + }) +}