Merge pull request #349 from zed-industries/navigation-history
Add a navigation history
This commit is contained in:
commit
7c233ed682
12 changed files with 751 additions and 194 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -5677,6 +5677,7 @@ dependencies = [
|
|||
"project",
|
||||
"serde_json",
|
||||
"theme",
|
||||
"util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
@ -15,9 +15,9 @@ use gpui::{
|
|||
use language::{Bias, Buffer, Diagnostic, DiagnosticEntry, Point, Selection, SelectionGoal};
|
||||
use postage::watch;
|
||||
use project::{Project, ProjectPath, WorktreeId};
|
||||
use std::{cmp::Ordering, mem, ops::Range, sync::Arc};
|
||||
use std::{cmp::Ordering, mem, ops::Range, rc::Rc, sync::Arc};
|
||||
use util::TryFutureExt;
|
||||
use workspace::Workspace;
|
||||
use workspace::{Navigation, Workspace};
|
||||
|
||||
action!(Deploy);
|
||||
action!(OpenExcerpts);
|
||||
|
@ -522,6 +522,7 @@ impl workspace::Item for ProjectDiagnostics {
|
|||
fn build_view(
|
||||
handle: ModelHandle<Self>,
|
||||
workspace: &Workspace,
|
||||
_: Rc<Navigation>,
|
||||
cx: &mut ViewContext<Self::View>,
|
||||
) -> Self::View {
|
||||
ProjectDiagnosticsEditor::new(handle, workspace.weak_handle(), workspace.settings(), cx)
|
||||
|
|
|
@ -41,6 +41,7 @@ use std::{
|
|||
iter::{self, FromIterator},
|
||||
mem,
|
||||
ops::{Deref, Range, RangeInclusive, Sub},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
@ -48,10 +49,11 @@ use sum_tree::Bias;
|
|||
use text::rope::TextDimension;
|
||||
use theme::{DiagnosticStyle, EditorStyle};
|
||||
use util::post_inc;
|
||||
use workspace::{PathOpener, Workspace};
|
||||
use workspace::{Navigation, PathOpener, Workspace};
|
||||
|
||||
const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const MAX_LINE_LEN: usize = 1024;
|
||||
const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10;
|
||||
|
||||
action!(Cancel);
|
||||
action!(Backspace);
|
||||
|
@ -377,6 +379,7 @@ pub struct Editor {
|
|||
mode: EditorMode,
|
||||
placeholder_text: Option<Arc<str>>,
|
||||
highlighted_rows: Option<Range<u32>>,
|
||||
navigation: Option<Rc<Navigation>>,
|
||||
}
|
||||
|
||||
pub struct EditorSnapshot {
|
||||
|
@ -424,6 +427,11 @@ struct ClipboardSelection {
|
|||
is_entire_line: bool,
|
||||
}
|
||||
|
||||
pub struct NavigationData {
|
||||
anchor: Anchor,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn single_line(build_settings: BuildSettings, cx: &mut ViewContext<Self>) -> Self {
|
||||
let buffer = cx.add_model(|cx| Buffer::new(0, String::new(), cx));
|
||||
|
@ -457,6 +465,7 @@ impl Editor {
|
|||
let mut clone = Self::new(self.buffer.clone(), self.build_settings.clone(), cx);
|
||||
clone.scroll_position = self.scroll_position;
|
||||
clone.scroll_top_anchor = self.scroll_top_anchor.clone();
|
||||
clone.navigation = self.navigation.clone();
|
||||
clone
|
||||
}
|
||||
|
||||
|
@ -506,6 +515,7 @@ impl Editor {
|
|||
mode: EditorMode::Full,
|
||||
placeholder_text: None,
|
||||
highlighted_rows: None,
|
||||
navigation: None,
|
||||
};
|
||||
let selection = Selection {
|
||||
id: post_inc(&mut this.next_selection_id),
|
||||
|
@ -628,7 +638,10 @@ impl Editor {
|
|||
|
||||
let first_cursor_top;
|
||||
let last_cursor_bottom;
|
||||
if autoscroll == Autoscroll::Newest {
|
||||
if let Some(highlighted_rows) = &self.highlighted_rows {
|
||||
first_cursor_top = highlighted_rows.start as f32;
|
||||
last_cursor_bottom = first_cursor_top + 1.;
|
||||
} else if autoscroll == Autoscroll::Newest {
|
||||
let newest_selection = self.newest_selection::<Point>(&display_map.buffer_snapshot);
|
||||
first_cursor_top = newest_selection.head().to_display_point(&display_map).row() as f32;
|
||||
last_cursor_bottom = first_cursor_top + 1.;
|
||||
|
@ -694,15 +707,24 @@ impl Editor {
|
|||
) -> bool {
|
||||
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
|
||||
let selections = self.local_selections::<Point>(cx);
|
||||
let mut target_left = std::f32::INFINITY;
|
||||
let mut target_right = 0.0_f32;
|
||||
|
||||
let mut target_left;
|
||||
let mut target_right;
|
||||
|
||||
if self.highlighted_rows.is_some() {
|
||||
target_left = 0.0_f32;
|
||||
target_right = 0.0_f32;
|
||||
} else {
|
||||
target_left = std::f32::INFINITY;
|
||||
target_right = 0.0_f32;
|
||||
for selection in selections {
|
||||
let head = selection.head().to_display_point(&display_map);
|
||||
if head.row() >= start_row && head.row() < start_row + layouts.len() as u32 {
|
||||
let start_column = head.column().saturating_sub(3);
|
||||
let end_column = cmp::min(display_map.line_len(head.row()), head.column() + 3);
|
||||
target_left = target_left.min(
|
||||
layouts[(head.row() - start_row) as usize].x_for_index(start_column as usize),
|
||||
layouts[(head.row() - start_row) as usize]
|
||||
.x_for_index(start_column as usize),
|
||||
);
|
||||
target_right = target_right.max(
|
||||
layouts[(head.row() - start_row) as usize].x_for_index(end_column as usize)
|
||||
|
@ -710,6 +732,8 @@ impl Editor {
|
|||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target_right = target_right.min(scroll_width);
|
||||
|
||||
if target_right - target_left > viewport_width {
|
||||
|
@ -800,6 +824,8 @@ impl Editor {
|
|||
|
||||
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
|
||||
let buffer = &display_map.buffer_snapshot;
|
||||
let newest_selection = self.newest_selection_internal().unwrap().clone();
|
||||
|
||||
let start;
|
||||
let end;
|
||||
let mode;
|
||||
|
@ -834,6 +860,8 @@ impl Editor {
|
|||
}
|
||||
}
|
||||
|
||||
self.push_to_navigation_history(newest_selection.head(), Some(end.to_point(&buffer)), cx);
|
||||
|
||||
let selection = Selection {
|
||||
id: post_inc(&mut self.next_selection_id),
|
||||
start,
|
||||
|
@ -846,7 +874,6 @@ impl Editor {
|
|||
self.update_selections::<usize>(Vec::new(), None, cx);
|
||||
} else if click_count > 1 {
|
||||
// Remove the newest selection since it was only added as part of this multi-click.
|
||||
let newest_selection = self.newest_selection::<usize>(buffer);
|
||||
let mut selections = self.local_selections(cx);
|
||||
selections.retain(|selection| selection.id != newest_selection.id);
|
||||
self.update_selections::<usize>(selections, None, cx)
|
||||
|
@ -1129,8 +1156,8 @@ impl Editor {
|
|||
self.update_selections(selections, autoscroll, cx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn select_display_ranges<'a, T>(&mut self, ranges: T, cx: &mut ViewContext<Self>)
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn select_display_ranges<'a, T>(&mut self, ranges: T, cx: &mut ViewContext<Self>)
|
||||
where
|
||||
T: IntoIterator<Item = &'a Range<DisplayPoint>>,
|
||||
{
|
||||
|
@ -2428,6 +2455,35 @@ impl Editor {
|
|||
self.update_selections(vec![selection], Some(Autoscroll::Fit), cx);
|
||||
}
|
||||
|
||||
fn push_to_navigation_history(
|
||||
&self,
|
||||
position: Anchor,
|
||||
new_position: Option<Point>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(navigation) = &self.navigation {
|
||||
let buffer = self.buffer.read(cx).read(cx);
|
||||
let offset = position.to_offset(&buffer);
|
||||
let point = position.to_point(&buffer);
|
||||
drop(buffer);
|
||||
|
||||
if let Some(new_position) = new_position {
|
||||
let row_delta = (new_position.row as i64 - point.row as i64).abs();
|
||||
if row_delta < MIN_NAVIGATION_HISTORY_ROW_DELTA {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
navigation.push(
|
||||
Some(NavigationData {
|
||||
anchor: position,
|
||||
offset,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_to_end(&mut self, _: &SelectToEnd, cx: &mut ViewContext<Self>) {
|
||||
let mut selection = self.local_selections::<usize>(cx).first().unwrap().clone();
|
||||
selection.set_head(self.buffer.read(cx).read(cx).len());
|
||||
|
@ -3205,14 +3261,14 @@ impl Editor {
|
|||
&self,
|
||||
snapshot: &MultiBufferSnapshot,
|
||||
) -> Selection<D> {
|
||||
self.pending_selection(snapshot)
|
||||
.or_else(|| {
|
||||
self.selections
|
||||
.iter()
|
||||
.max_by_key(|s| s.id)
|
||||
.map(|selection| self.resolve_selection(selection, snapshot))
|
||||
})
|
||||
.unwrap()
|
||||
self.resolve_selection(self.newest_selection_internal().unwrap(), snapshot)
|
||||
}
|
||||
|
||||
pub fn newest_selection_internal(&self) -> Option<&Selection<Anchor>> {
|
||||
self.pending_selection
|
||||
.as_ref()
|
||||
.map(|s| &s.selection)
|
||||
.or_else(|| self.selections.iter().max_by_key(|s| s.id))
|
||||
}
|
||||
|
||||
pub fn update_selections<T>(
|
||||
|
@ -3223,10 +3279,11 @@ impl Editor {
|
|||
) where
|
||||
T: ToOffset + ToPoint + Ord + std::marker::Copy + std::fmt::Debug,
|
||||
{
|
||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||
let old_cursor_position = self.newest_selection_internal().map(|s| s.head());
|
||||
selections.sort_unstable_by_key(|s| s.start);
|
||||
|
||||
// Merge overlapping selections.
|
||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||
let mut i = 1;
|
||||
while i < selections.len() {
|
||||
if selections[i - 1].end >= selections[i].start {
|
||||
|
@ -3267,6 +3324,16 @@ impl Editor {
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(old_cursor_position) = old_cursor_position {
|
||||
let new_cursor_position = selections
|
||||
.iter()
|
||||
.max_by_key(|s| s.id)
|
||||
.map(|s| s.head().to_point(&buffer));
|
||||
if new_cursor_position.is_some() {
|
||||
self.push_to_navigation_history(old_cursor_position, new_cursor_position, cx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(autoscroll) = autoscroll {
|
||||
self.request_autoscroll(autoscroll, cx);
|
||||
}
|
||||
|
@ -3347,7 +3414,7 @@ impl Editor {
|
|||
});
|
||||
}
|
||||
|
||||
fn request_autoscroll(&mut self, autoscroll: Autoscroll, cx: &mut ViewContext<Self>) {
|
||||
pub fn request_autoscroll(&mut self, autoscroll: Autoscroll, cx: &mut ViewContext<Self>) {
|
||||
self.autoscroll_request = Some(autoscroll);
|
||||
cx.notify();
|
||||
}
|
||||
|
@ -4103,6 +4170,63 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_navigation_history(cx: &mut gpui::MutableAppContext) {
|
||||
cx.add_window(Default::default(), |cx| {
|
||||
use workspace::ItemView;
|
||||
let navigation = Rc::new(workspace::Navigation::default());
|
||||
let settings = EditorSettings::test(&cx);
|
||||
let buffer = MultiBuffer::build_simple(&sample_text(30, 5, 'a'), cx);
|
||||
let mut editor = build_editor(buffer.clone(), settings, cx);
|
||||
editor.navigation = Some(navigation.clone());
|
||||
|
||||
// Move the cursor a small distance.
|
||||
// Nothing is added to the navigation history.
|
||||
editor.select_display_ranges(&[DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)], cx);
|
||||
editor.select_display_ranges(&[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)], cx);
|
||||
assert!(navigation.pop_backward().is_none());
|
||||
|
||||
// Move the cursor a large distance.
|
||||
// The history can jump back to the previous position.
|
||||
editor.select_display_ranges(&[DisplayPoint::new(13, 0)..DisplayPoint::new(13, 3)], cx);
|
||||
let nav_entry = navigation.pop_backward().unwrap();
|
||||
editor.navigate(nav_entry.data.unwrap(), cx);
|
||||
assert_eq!(nav_entry.item_view.id(), cx.view_id());
|
||||
assert_eq!(
|
||||
editor.selected_display_ranges(cx),
|
||||
&[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)]
|
||||
);
|
||||
|
||||
// Move the cursor a small distance via the mouse.
|
||||
// Nothing is added to the navigation history.
|
||||
editor.begin_selection(DisplayPoint::new(5, 0), false, 1, cx);
|
||||
editor.end_selection(cx);
|
||||
assert_eq!(
|
||||
editor.selected_display_ranges(cx),
|
||||
&[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)]
|
||||
);
|
||||
assert!(navigation.pop_backward().is_none());
|
||||
|
||||
// Move the cursor a large distance via the mouse.
|
||||
// The history can jump back to the previous position.
|
||||
editor.begin_selection(DisplayPoint::new(15, 0), false, 1, cx);
|
||||
editor.end_selection(cx);
|
||||
assert_eq!(
|
||||
editor.selected_display_ranges(cx),
|
||||
&[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)]
|
||||
);
|
||||
let nav_entry = navigation.pop_backward().unwrap();
|
||||
editor.navigate(nav_entry.data.unwrap(), cx);
|
||||
assert_eq!(nav_entry.item_view.id(), cx.view_id());
|
||||
assert_eq!(
|
||||
editor.selected_display_ranges(cx),
|
||||
&[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)]
|
||||
);
|
||||
|
||||
editor
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_cancel(cx: &mut gpui::MutableAppContext) {
|
||||
let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx);
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
use crate::{Autoscroll, Editor, Event};
|
||||
use crate::{MultiBuffer, ToPoint as _};
|
||||
use crate::{Autoscroll, Editor, Event, MultiBuffer, NavigationData, ToOffset, ToPoint as _};
|
||||
use anyhow::Result;
|
||||
use gpui::{
|
||||
elements::*, AppContext, Entity, ModelContext, ModelHandle, MutableAppContext, RenderContext,
|
||||
Subscription, Task, View, ViewContext, ViewHandle, WeakModelHandle,
|
||||
};
|
||||
use language::{Buffer, Diagnostic, File as _};
|
||||
use language::{Bias, Buffer, Diagnostic, File as _};
|
||||
use postage::watch;
|
||||
use project::{File, ProjectPath, Worktree};
|
||||
use std::fmt::Write;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use text::{Point, Selection};
|
||||
use util::TryFutureExt;
|
||||
use workspace::{
|
||||
ItemHandle, ItemView, ItemViewHandle, PathOpener, Settings, StatusItemView, WeakItemHandle,
|
||||
Workspace,
|
||||
ItemHandle, ItemView, ItemViewHandle, Navigation, PathOpener, Settings, StatusItemView,
|
||||
WeakItemHandle, Workspace,
|
||||
};
|
||||
|
||||
pub struct BufferOpener;
|
||||
|
@ -46,16 +46,19 @@ impl ItemHandle for BufferItemHandle {
|
|||
&self,
|
||||
window_id: usize,
|
||||
workspace: &Workspace,
|
||||
navigation: Rc<Navigation>,
|
||||
cx: &mut MutableAppContext,
|
||||
) -> Box<dyn ItemViewHandle> {
|
||||
let buffer = cx.add_model(|cx| MultiBuffer::singleton(self.0.clone(), cx));
|
||||
let weak_buffer = buffer.downgrade();
|
||||
Box::new(cx.add_view(window_id, |cx| {
|
||||
Editor::for_buffer(
|
||||
let mut editor = Editor::for_buffer(
|
||||
buffer,
|
||||
crate::settings_builder(weak_buffer, workspace.settings()),
|
||||
cx,
|
||||
)
|
||||
);
|
||||
editor.navigation = Some(navigation);
|
||||
editor
|
||||
}))
|
||||
}
|
||||
|
||||
|
@ -102,6 +105,22 @@ impl ItemView for Editor {
|
|||
BufferItemHandle(self.buffer.read(cx).as_singleton().unwrap())
|
||||
}
|
||||
|
||||
fn navigate(&mut self, data: Box<dyn std::any::Any>, cx: &mut ViewContext<Self>) {
|
||||
if let Some(data) = data.downcast_ref::<NavigationData>() {
|
||||
let buffer = self.buffer.read(cx).read(cx);
|
||||
let offset = if buffer.can_resolve(&data.anchor) {
|
||||
data.anchor.to_offset(&buffer)
|
||||
} else {
|
||||
buffer.clip_offset(data.offset, Bias::Left)
|
||||
};
|
||||
|
||||
drop(buffer);
|
||||
let navigation = self.navigation.take();
|
||||
self.select_ranges([offset..offset], Some(Autoscroll::Fit), cx);
|
||||
self.navigation = navigation;
|
||||
}
|
||||
}
|
||||
|
||||
fn title(&self, cx: &AppContext) -> String {
|
||||
let filename = self
|
||||
.buffer()
|
||||
|
@ -129,6 +148,12 @@ impl ItemView for Editor {
|
|||
Some(self.clone(cx))
|
||||
}
|
||||
|
||||
fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if let Some(selection) = self.newest_selection_internal() {
|
||||
self.push_to_navigation_history(selection.head(), None, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dirty(&self, cx: &AppContext) -> bool {
|
||||
self.buffer().read(cx).read(cx).is_dirty()
|
||||
}
|
||||
|
|
|
@ -1545,6 +1545,18 @@ impl MultiBufferSnapshot {
|
|||
panic!("excerpt not found");
|
||||
}
|
||||
|
||||
pub fn can_resolve(&self, anchor: &Anchor) -> bool {
|
||||
if anchor.excerpt_id == ExcerptId::min() || anchor.excerpt_id == ExcerptId::max() {
|
||||
true
|
||||
} else if let Some((buffer_id, buffer_snapshot)) =
|
||||
self.buffer_snapshot_for_excerpt(&anchor.excerpt_id)
|
||||
{
|
||||
anchor.buffer_id == buffer_id && buffer_snapshot.can_resolve(&anchor.text_anchor)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range_contains_excerpt_boundary<T: ToOffset>(&self, range: Range<T>) -> bool {
|
||||
let start = range.start.to_offset(self);
|
||||
let end = range.end.to_offset(self);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
use editor::{display_map::ToDisplayPoint, Autoscroll, Editor, EditorSettings};
|
||||
use editor::{display_map::ToDisplayPoint, Autoscroll, DisplayPoint, Editor, EditorSettings};
|
||||
use gpui::{
|
||||
action, elements::*, geometry::vector::Vector2F, keymap::Binding, Axis, Entity,
|
||||
MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use postage::watch;
|
||||
use std::sync::Arc;
|
||||
use text::{Bias, Point, Selection};
|
||||
use text::{Bias, Point};
|
||||
use workspace::{Settings, Workspace};
|
||||
|
||||
action!(Toggle);
|
||||
|
@ -25,17 +25,11 @@ pub struct GoToLine {
|
|||
settings: watch::Receiver<Settings>,
|
||||
line_editor: ViewHandle<Editor>,
|
||||
active_editor: ViewHandle<Editor>,
|
||||
restore_state: Option<RestoreState>,
|
||||
line_selection_id: Option<usize>,
|
||||
prev_scroll_position: Option<Vector2F>,
|
||||
cursor_point: Point,
|
||||
max_point: Point,
|
||||
}
|
||||
|
||||
struct RestoreState {
|
||||
scroll_position: Vector2F,
|
||||
selections: Vec<Selection<usize>>,
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Dismissed,
|
||||
}
|
||||
|
@ -65,15 +59,11 @@ impl GoToLine {
|
|||
cx.subscribe(&line_editor, Self::on_line_editor_event)
|
||||
.detach();
|
||||
|
||||
let (restore_state, cursor_point, max_point) = active_editor.update(cx, |editor, cx| {
|
||||
let restore_state = Some(RestoreState {
|
||||
scroll_position: editor.scroll_position(cx),
|
||||
selections: editor.local_selections::<usize>(cx),
|
||||
});
|
||||
|
||||
let (scroll_position, cursor_point, max_point) = active_editor.update(cx, |editor, cx| {
|
||||
let scroll_position = editor.scroll_position(cx);
|
||||
let buffer = editor.buffer().read(cx).read(cx);
|
||||
(
|
||||
restore_state,
|
||||
Some(scroll_position),
|
||||
editor.newest_selection(&buffer).head(),
|
||||
buffer.max_point(),
|
||||
)
|
||||
|
@ -83,8 +73,7 @@ impl GoToLine {
|
|||
settings: settings.clone(),
|
||||
line_editor,
|
||||
active_editor,
|
||||
restore_state,
|
||||
line_selection_id: None,
|
||||
prev_scroll_position: scroll_position,
|
||||
cursor_point,
|
||||
max_point,
|
||||
}
|
||||
|
@ -105,7 +94,14 @@ impl GoToLine {
|
|||
}
|
||||
|
||||
fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
|
||||
self.restore_state.take();
|
||||
self.prev_scroll_position.take();
|
||||
self.active_editor.update(cx, |active_editor, cx| {
|
||||
if let Some(rows) = active_editor.highlighted_rows() {
|
||||
let snapshot = active_editor.snapshot(cx).display_snapshot;
|
||||
let position = DisplayPoint::new(rows.start, 0).to_point(&snapshot);
|
||||
active_editor.select_ranges([position..position], Some(Autoscroll::Center), cx);
|
||||
}
|
||||
});
|
||||
cx.emit(Event::Dismissed);
|
||||
}
|
||||
|
||||
|
@ -139,18 +135,13 @@ impl GoToLine {
|
|||
column.map(|column| column.saturating_sub(1)).unwrap_or(0),
|
||||
)
|
||||
}) {
|
||||
self.line_selection_id = self.active_editor.update(cx, |active_editor, cx| {
|
||||
self.active_editor.update(cx, |active_editor, cx| {
|
||||
let snapshot = active_editor.snapshot(cx).display_snapshot;
|
||||
let point = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
|
||||
let display_point = point.to_display_point(&snapshot);
|
||||
let row = display_point.row();
|
||||
active_editor.select_ranges([point..point], Some(Autoscroll::Center), cx);
|
||||
active_editor.set_highlighted_rows(Some(row..row + 1));
|
||||
Some(
|
||||
active_editor
|
||||
.newest_selection::<usize>(&snapshot.buffer_snapshot)
|
||||
.id,
|
||||
)
|
||||
active_editor.request_autoscroll(Autoscroll::Center, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
@ -164,17 +155,11 @@ impl Entity for GoToLine {
|
|||
type Event = Event;
|
||||
|
||||
fn release(&mut self, cx: &mut MutableAppContext) {
|
||||
let line_selection_id = self.line_selection_id.take();
|
||||
let restore_state = self.restore_state.take();
|
||||
let scroll_position = self.prev_scroll_position.take();
|
||||
self.active_editor.update(cx, |editor, cx| {
|
||||
editor.set_highlighted_rows(None);
|
||||
if let Some((line_selection_id, restore_state)) = line_selection_id.zip(restore_state) {
|
||||
let newest_selection =
|
||||
editor.newest_selection::<usize>(&editor.buffer().read(cx).read(cx));
|
||||
if line_selection_id == newest_selection.id {
|
||||
editor.set_scroll_position(restore_state.scroll_position, cx);
|
||||
editor.update_selections(restore_state.selections, None, cx);
|
||||
}
|
||||
if let Some(scroll_position) = scroll_position {
|
||||
editor.set_scroll_position(scroll_position, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use editor::{
|
||||
display_map::ToDisplayPoint, Anchor, AnchorRangeExt, Autoscroll, Editor, EditorSettings,
|
||||
ToPoint,
|
||||
display_map::ToDisplayPoint, Anchor, AnchorRangeExt, Autoscroll, DisplayPoint, Editor,
|
||||
EditorSettings, ToPoint,
|
||||
};
|
||||
use fuzzy::StringMatch;
|
||||
use gpui::{
|
||||
|
@ -12,7 +12,7 @@ use gpui::{
|
|||
AppContext, Axis, Entity, MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
|
||||
WeakViewHandle,
|
||||
};
|
||||
use language::{Outline, Selection};
|
||||
use language::Outline;
|
||||
use ordered_float::OrderedFloat;
|
||||
use postage::watch;
|
||||
use std::{
|
||||
|
@ -45,19 +45,13 @@ struct OutlineView {
|
|||
active_editor: ViewHandle<Editor>,
|
||||
outline: Outline<Anchor>,
|
||||
selected_match_index: usize,
|
||||
restore_state: Option<RestoreState>,
|
||||
symbol_selection_id: Option<usize>,
|
||||
prev_scroll_position: Option<Vector2F>,
|
||||
matches: Vec<StringMatch>,
|
||||
query_editor: ViewHandle<Editor>,
|
||||
list_state: UniformListState,
|
||||
settings: watch::Receiver<Settings>,
|
||||
}
|
||||
|
||||
struct RestoreState {
|
||||
scroll_position: Vector2F,
|
||||
selections: Vec<Selection<usize>>,
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Dismissed,
|
||||
}
|
||||
|
@ -132,20 +126,12 @@ impl OutlineView {
|
|||
cx.subscribe(&query_editor, Self::on_query_editor_event)
|
||||
.detach();
|
||||
|
||||
let restore_state = editor.update(cx, |editor, cx| {
|
||||
Some(RestoreState {
|
||||
scroll_position: editor.scroll_position(cx),
|
||||
selections: editor.local_selections::<usize>(cx),
|
||||
})
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
handle: cx.weak_handle(),
|
||||
active_editor: editor,
|
||||
matches: Default::default(),
|
||||
selected_match_index: 0,
|
||||
restore_state,
|
||||
symbol_selection_id: None,
|
||||
prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))),
|
||||
active_editor: editor,
|
||||
outline,
|
||||
query_editor,
|
||||
list_state: Default::default(),
|
||||
|
@ -207,39 +193,37 @@ impl OutlineView {
|
|||
if navigate {
|
||||
let selected_match = &self.matches[self.selected_match_index];
|
||||
let outline_item = &self.outline.items[selected_match.candidate_id];
|
||||
self.symbol_selection_id = self.active_editor.update(cx, |active_editor, cx| {
|
||||
self.active_editor.update(cx, |active_editor, cx| {
|
||||
let snapshot = active_editor.snapshot(cx).display_snapshot;
|
||||
let buffer_snapshot = &snapshot.buffer_snapshot;
|
||||
let start = outline_item.range.start.to_point(&buffer_snapshot);
|
||||
let end = outline_item.range.end.to_point(&buffer_snapshot);
|
||||
let display_rows = start.to_display_point(&snapshot).row()
|
||||
..end.to_display_point(&snapshot).row() + 1;
|
||||
active_editor.select_ranges([start..start], Some(Autoscroll::Center), cx);
|
||||
active_editor.set_highlighted_rows(Some(display_rows));
|
||||
Some(active_editor.newest_selection::<usize>(&buffer_snapshot).id)
|
||||
active_editor.request_autoscroll(Autoscroll::Center, cx);
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
|
||||
self.restore_state.take();
|
||||
self.prev_scroll_position.take();
|
||||
self.active_editor.update(cx, |active_editor, cx| {
|
||||
if let Some(rows) = active_editor.highlighted_rows() {
|
||||
let snapshot = active_editor.snapshot(cx).display_snapshot;
|
||||
let position = DisplayPoint::new(rows.start, 0).to_point(&snapshot);
|
||||
active_editor.select_ranges([position..position], Some(Autoscroll::Center), cx);
|
||||
}
|
||||
});
|
||||
cx.emit(Event::Dismissed);
|
||||
}
|
||||
|
||||
fn restore_active_editor(&mut self, cx: &mut MutableAppContext) {
|
||||
let symbol_selection_id = self.symbol_selection_id.take();
|
||||
self.active_editor.update(cx, |editor, cx| {
|
||||
editor.set_highlighted_rows(None);
|
||||
if let Some((symbol_selection_id, restore_state)) =
|
||||
symbol_selection_id.zip(self.restore_state.as_ref())
|
||||
{
|
||||
let newest_selection =
|
||||
editor.newest_selection::<usize>(&editor.buffer().read(cx).read(cx));
|
||||
if symbol_selection_id == newest_selection.id {
|
||||
editor.set_scroll_position(restore_state.scroll_position, cx);
|
||||
editor.update_selections(restore_state.selections.clone(), None, cx);
|
||||
}
|
||||
if let Some(scroll_position) = self.prev_scroll_position {
|
||||
editor.set_scroll_position(scroll_position, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1131,12 +1131,6 @@ impl Buffer {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn can_resolve(&self, anchor: &Anchor) -> bool {
|
||||
*anchor == Anchor::min()
|
||||
|| *anchor == Anchor::max()
|
||||
|| self.version.observed(anchor.timestamp)
|
||||
}
|
||||
|
||||
pub fn peek_undo_stack(&self) -> Option<&Transaction> {
|
||||
self.history.undo_stack.last()
|
||||
}
|
||||
|
@ -1648,6 +1642,12 @@ impl BufferSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn can_resolve(&self, anchor: &Anchor) -> bool {
|
||||
*anchor == Anchor::min()
|
||||
|| *anchor == Anchor::max()
|
||||
|| self.version.observed(anchor.timestamp)
|
||||
}
|
||||
|
||||
pub fn clip_offset(&self, offset: usize, bias: Bias) -> usize {
|
||||
self.visible_text.clip_offset(offset, bias)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "workspace"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/workspace.rs"
|
||||
|
@ -17,6 +17,7 @@ gpui = { path = "../gpui" }
|
|||
language = { path = "../language" }
|
||||
project = { path = "../project" }
|
||||
theme = { path = "../theme" }
|
||||
util = { path = "../util" }
|
||||
anyhow = "1.0.38"
|
||||
log = "0.4"
|
||||
parking_lot = "0.11.1"
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
use super::{ItemViewHandle, SplitDirection};
|
||||
use crate::{ItemHandle, Settings, Workspace};
|
||||
use crate::{ItemHandle, ItemView, Settings, WeakItemViewHandle, Workspace};
|
||||
use collections::{HashMap, VecDeque};
|
||||
use gpui::{
|
||||
action,
|
||||
elements::*,
|
||||
geometry::{rect::RectF, vector::vec2f},
|
||||
keymap::Binding,
|
||||
platform::CursorStyle,
|
||||
Entity, MutableAppContext, Quad, RenderContext, View, ViewContext,
|
||||
Entity, MutableAppContext, Quad, RenderContext, Task, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use postage::watch;
|
||||
use std::cmp;
|
||||
use project::ProjectPath;
|
||||
use std::{any::Any, cell::RefCell, cmp, mem, rc::Rc};
|
||||
use util::ResultExt;
|
||||
|
||||
action!(Split, SplitDirection);
|
||||
action!(ActivateItem, usize);
|
||||
|
@ -17,6 +20,10 @@ action!(ActivatePrevItem);
|
|||
action!(ActivateNextItem);
|
||||
action!(CloseActiveItem);
|
||||
action!(CloseItem, usize);
|
||||
action!(GoBack);
|
||||
action!(GoForward);
|
||||
|
||||
const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(|pane: &mut Pane, action: &ActivateItem, cx| {
|
||||
|
@ -37,6 +44,12 @@ pub fn init(cx: &mut MutableAppContext) {
|
|||
cx.add_action(|pane: &mut Pane, action: &Split, cx| {
|
||||
pane.split(action.0, cx);
|
||||
});
|
||||
cx.add_action(|workspace: &mut Workspace, _: &GoBack, cx| {
|
||||
Pane::go_back(workspace, cx).detach();
|
||||
});
|
||||
cx.add_action(|workspace: &mut Workspace, _: &GoForward, cx| {
|
||||
Pane::go_forward(workspace, cx).detach();
|
||||
});
|
||||
|
||||
cx.add_bindings(vec![
|
||||
Binding::new("shift-cmd-{", ActivatePrevItem, Some("Pane")),
|
||||
|
@ -46,6 +59,8 @@ pub fn init(cx: &mut MutableAppContext) {
|
|||
Binding::new("cmd-k down", Split(SplitDirection::Down), Some("Pane")),
|
||||
Binding::new("cmd-k left", Split(SplitDirection::Left), Some("Pane")),
|
||||
Binding::new("cmd-k right", Split(SplitDirection::Right), Some("Pane")),
|
||||
Binding::new("ctrl--", GoBack, Some("Pane")),
|
||||
Binding::new("shift-ctrl-_", GoForward, Some("Pane")),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -57,29 +72,49 @@ pub enum Event {
|
|||
|
||||
const MAX_TAB_TITLE_LEN: usize = 24;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct State {
|
||||
pub tabs: Vec<TabState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct TabState {
|
||||
pub title: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub struct Pane {
|
||||
item_views: Vec<(usize, Box<dyn ItemViewHandle>)>,
|
||||
active_item: usize,
|
||||
active_item_index: usize,
|
||||
settings: watch::Receiver<Settings>,
|
||||
navigation: Rc<Navigation>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Navigation(RefCell<NavigationHistory>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct NavigationHistory {
|
||||
mode: NavigationMode,
|
||||
backward_stack: VecDeque<NavigationEntry>,
|
||||
forward_stack: VecDeque<NavigationEntry>,
|
||||
paths_by_item: HashMap<usize, ProjectPath>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum NavigationMode {
|
||||
Normal,
|
||||
GoingBack,
|
||||
GoingForward,
|
||||
}
|
||||
|
||||
impl Default for NavigationMode {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NavigationEntry {
|
||||
pub item_view: Box<dyn WeakItemViewHandle>,
|
||||
pub data: Option<Box<dyn Any>>,
|
||||
}
|
||||
|
||||
impl Pane {
|
||||
pub fn new(settings: watch::Receiver<Settings>) -> Self {
|
||||
Self {
|
||||
item_views: Vec::new(),
|
||||
active_item: 0,
|
||||
active_item_index: 0,
|
||||
settings,
|
||||
navigation: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,6 +122,98 @@ impl Pane {
|
|||
cx.emit(Event::Activate);
|
||||
}
|
||||
|
||||
pub fn go_back(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Task<()> {
|
||||
Self::navigate_history(
|
||||
workspace,
|
||||
workspace.active_pane().clone(),
|
||||
NavigationMode::GoingBack,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn go_forward(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Task<()> {
|
||||
Self::navigate_history(
|
||||
workspace,
|
||||
workspace.active_pane().clone(),
|
||||
NavigationMode::GoingForward,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn navigate_history(
|
||||
workspace: &mut Workspace,
|
||||
pane: ViewHandle<Pane>,
|
||||
mode: NavigationMode,
|
||||
cx: &mut ViewContext<Workspace>,
|
||||
) -> Task<()> {
|
||||
let to_load = pane.update(cx, |pane, cx| {
|
||||
// Retrieve the weak item handle from the history.
|
||||
let entry = pane.navigation.pop(mode)?;
|
||||
|
||||
// If the item is still present in this pane, then activate it.
|
||||
if let Some(index) = entry
|
||||
.item_view
|
||||
.upgrade(cx)
|
||||
.and_then(|v| pane.index_for_item_view(v.as_ref()))
|
||||
{
|
||||
if let Some(item_view) = pane.active_item() {
|
||||
pane.navigation.set_mode(mode);
|
||||
item_view.deactivated(cx);
|
||||
pane.navigation.set_mode(NavigationMode::Normal);
|
||||
}
|
||||
|
||||
pane.active_item_index = index;
|
||||
pane.focus_active_item(cx);
|
||||
if let Some(data) = entry.data {
|
||||
pane.active_item()?.navigate(data, cx);
|
||||
}
|
||||
cx.notify();
|
||||
None
|
||||
}
|
||||
// If the item is no longer present in this pane, then retrieve its
|
||||
// project path in order to reopen it.
|
||||
else {
|
||||
pane.navigation
|
||||
.0
|
||||
.borrow_mut()
|
||||
.paths_by_item
|
||||
.get(&entry.item_view.id())
|
||||
.cloned()
|
||||
.map(|project_path| (project_path, entry))
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((project_path, entry)) = to_load {
|
||||
// If the item was no longer present, then load it again from its previous path.
|
||||
let pane = pane.downgrade();
|
||||
let task = workspace.load_path(project_path, cx);
|
||||
cx.spawn(|workspace, mut cx| async move {
|
||||
let item = task.await;
|
||||
if let Some(pane) = cx.read(|cx| pane.upgrade(cx)) {
|
||||
if let Some(item) = item.log_err() {
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
pane.update(cx, |p, _| p.navigation.set_mode(mode));
|
||||
let item_view = workspace.open_item_in_pane(item, &pane, cx);
|
||||
pane.update(cx, |p, _| p.navigation.set_mode(NavigationMode::Normal));
|
||||
|
||||
if let Some(data) = entry.data {
|
||||
item_view.navigate(data, cx);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
workspace
|
||||
.update(&mut cx, |workspace, cx| {
|
||||
Self::navigate_history(workspace, pane, mode, cx)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Task::ready(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_item<T>(
|
||||
&mut self,
|
||||
item_handle: T,
|
||||
|
@ -104,18 +231,19 @@ impl Pane {
|
|||
}
|
||||
}
|
||||
|
||||
let item_view = item_handle.add_view(cx.window_id(), workspace, cx);
|
||||
let item_view =
|
||||
item_handle.add_view(cx.window_id(), workspace, self.navigation.clone(), cx);
|
||||
self.add_item_view(item_view.boxed_clone(), cx);
|
||||
item_view
|
||||
}
|
||||
|
||||
pub fn add_item_view(
|
||||
&mut self,
|
||||
item_view: Box<dyn ItemViewHandle>,
|
||||
mut item_view: Box<dyn ItemViewHandle>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
item_view.added_to_pane(cx);
|
||||
let item_idx = cmp::min(self.active_item + 1, self.item_views.len());
|
||||
let item_idx = cmp::min(self.active_item_index + 1, self.item_views.len());
|
||||
self.item_views
|
||||
.insert(item_idx, (item_view.item_handle(cx).id(), item_view));
|
||||
self.activate_item(item_idx, cx);
|
||||
|
@ -135,7 +263,7 @@ impl Pane {
|
|||
|
||||
pub fn active_item(&self) -> Option<Box<dyn ItemViewHandle>> {
|
||||
self.item_views
|
||||
.get(self.active_item)
|
||||
.get(self.active_item_index)
|
||||
.map(|(_, view)| view.clone())
|
||||
}
|
||||
|
||||
|
@ -151,41 +279,68 @@ impl Pane {
|
|||
|
||||
pub fn activate_item(&mut self, index: usize, cx: &mut ViewContext<Self>) {
|
||||
if index < self.item_views.len() {
|
||||
self.active_item = index;
|
||||
let prev_active_item_ix = mem::replace(&mut self.active_item_index, index);
|
||||
if prev_active_item_ix != self.active_item_index {
|
||||
self.item_views[prev_active_item_ix].1.deactivated(cx);
|
||||
}
|
||||
self.focus_active_item(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate_prev_item(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if self.active_item > 0 {
|
||||
self.active_item -= 1;
|
||||
let mut index = self.active_item_index;
|
||||
if index > 0 {
|
||||
index -= 1;
|
||||
} else if self.item_views.len() > 0 {
|
||||
self.active_item = self.item_views.len() - 1;
|
||||
index = self.item_views.len() - 1;
|
||||
}
|
||||
self.focus_active_item(cx);
|
||||
cx.notify();
|
||||
self.activate_item(index, cx);
|
||||
}
|
||||
|
||||
pub fn activate_next_item(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if self.active_item + 1 < self.item_views.len() {
|
||||
self.active_item += 1;
|
||||
let mut index = self.active_item_index;
|
||||
if index + 1 < self.item_views.len() {
|
||||
index += 1;
|
||||
} else {
|
||||
self.active_item = 0;
|
||||
index = 0;
|
||||
}
|
||||
self.focus_active_item(cx);
|
||||
cx.notify();
|
||||
self.activate_item(index, cx);
|
||||
}
|
||||
|
||||
pub fn close_active_item(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if !self.item_views.is_empty() {
|
||||
self.close_item(self.item_views[self.active_item].1.id(), cx)
|
||||
self.close_item(self.item_views[self.active_item_index].1.id(), cx)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_item(&mut self, item_id: usize, cx: &mut ViewContext<Self>) {
|
||||
self.item_views.retain(|(_, item)| item.id() != item_id);
|
||||
self.active_item = cmp::min(self.active_item, self.item_views.len().saturating_sub(1));
|
||||
pub fn close_item(&mut self, item_view_id: usize, cx: &mut ViewContext<Self>) {
|
||||
let mut item_ix = 0;
|
||||
self.item_views.retain(|(_, item_view)| {
|
||||
if item_view.id() == item_view_id {
|
||||
if item_ix == self.active_item_index {
|
||||
item_view.deactivated(cx);
|
||||
}
|
||||
|
||||
let mut navigation = self.navigation.0.borrow_mut();
|
||||
if let Some(path) = item_view.project_path(cx) {
|
||||
navigation.paths_by_item.insert(item_view.id(), path);
|
||||
} else {
|
||||
navigation.paths_by_item.remove(&item_view.id());
|
||||
}
|
||||
|
||||
item_ix += 1;
|
||||
false
|
||||
} else {
|
||||
item_ix += 1;
|
||||
true
|
||||
}
|
||||
});
|
||||
self.active_item_index = cmp::min(
|
||||
self.active_item_index,
|
||||
self.item_views.len().saturating_sub(1),
|
||||
);
|
||||
|
||||
if self.item_views.is_empty() {
|
||||
cx.emit(Event::Remove);
|
||||
}
|
||||
|
@ -210,7 +365,7 @@ impl Pane {
|
|||
let tabs = MouseEventHandler::new::<Tabs, _, _, _>(cx.view_id(), cx, |mouse_state, cx| {
|
||||
let mut row = Flex::row();
|
||||
for (ix, (_, item_view)) in self.item_views.iter().enumerate() {
|
||||
let is_active = ix == self.active_item;
|
||||
let is_active = ix == self.active_item_index;
|
||||
|
||||
row.add_child({
|
||||
let mut title = item_view.title(cx);
|
||||
|
@ -380,3 +535,59 @@ impl View for Pane {
|
|||
self.focus_active_item(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Navigation {
|
||||
pub fn pop_backward(&self) -> Option<NavigationEntry> {
|
||||
self.0.borrow_mut().backward_stack.pop_back()
|
||||
}
|
||||
|
||||
pub fn pop_forward(&self) -> Option<NavigationEntry> {
|
||||
self.0.borrow_mut().forward_stack.pop_back()
|
||||
}
|
||||
|
||||
fn pop(&self, mode: NavigationMode) -> Option<NavigationEntry> {
|
||||
match mode {
|
||||
NavigationMode::Normal => None,
|
||||
NavigationMode::GoingBack => self.pop_backward(),
|
||||
NavigationMode::GoingForward => self.pop_forward(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mode(&self, mode: NavigationMode) {
|
||||
self.0.borrow_mut().mode = mode;
|
||||
}
|
||||
|
||||
pub fn push<D: 'static + Any, T: ItemView>(&self, data: Option<D>, cx: &mut ViewContext<T>) {
|
||||
let mut state = self.0.borrow_mut();
|
||||
match state.mode {
|
||||
NavigationMode::Normal => {
|
||||
if state.backward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN {
|
||||
state.backward_stack.pop_front();
|
||||
}
|
||||
state.backward_stack.push_back(NavigationEntry {
|
||||
item_view: Box::new(cx.weak_handle()),
|
||||
data: data.map(|data| Box::new(data) as Box<dyn Any>),
|
||||
});
|
||||
state.forward_stack.clear();
|
||||
}
|
||||
NavigationMode::GoingBack => {
|
||||
if state.forward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN {
|
||||
state.forward_stack.pop_front();
|
||||
}
|
||||
state.forward_stack.push_back(NavigationEntry {
|
||||
item_view: Box::new(cx.weak_handle()),
|
||||
data: data.map(|data| Box::new(data) as Box<dyn Any>),
|
||||
});
|
||||
}
|
||||
NavigationMode::GoingForward => {
|
||||
if state.backward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN {
|
||||
state.backward_stack.pop_front();
|
||||
}
|
||||
state.backward_stack.push_back(NavigationEntry {
|
||||
item_view: Box::new(cx.weak_handle()),
|
||||
data: data.map(|data| Box::new(data) as Box<dyn Any>),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,9 +33,11 @@ use sidebar::{Side, Sidebar, SidebarItemId, ToggleSidebarItem, ToggleSidebarItem
|
|||
use status_bar::StatusBar;
|
||||
pub use status_bar::StatusItemView;
|
||||
use std::{
|
||||
any::Any,
|
||||
future::Future,
|
||||
hash::{Hash, Hasher},
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use theme::{Theme, ThemeRegistry};
|
||||
|
@ -135,6 +137,7 @@ pub trait Item: Entity + Sized {
|
|||
fn build_view(
|
||||
handle: ModelHandle<Self>,
|
||||
workspace: &Workspace,
|
||||
navigation: Rc<Navigation>,
|
||||
cx: &mut ViewContext<Self::View>,
|
||||
) -> Self::View;
|
||||
|
||||
|
@ -144,6 +147,8 @@ pub trait Item: Entity + Sized {
|
|||
pub trait ItemView: View {
|
||||
type ItemHandle: ItemHandle;
|
||||
|
||||
fn deactivated(&mut self, _: &mut ViewContext<Self>) {}
|
||||
fn navigate(&mut self, _: Box<dyn Any>, _: &mut ViewContext<Self>) {}
|
||||
fn item_handle(&self, cx: &AppContext) -> Self::ItemHandle;
|
||||
fn title(&self, cx: &AppContext) -> String;
|
||||
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
|
||||
|
@ -185,6 +190,7 @@ pub trait ItemHandle: Send + Sync {
|
|||
&self,
|
||||
window_id: usize,
|
||||
workspace: &Workspace,
|
||||
navigation: Rc<Navigation>,
|
||||
cx: &mut MutableAppContext,
|
||||
) -> Box<dyn ItemViewHandle>;
|
||||
fn boxed_clone(&self) -> Box<dyn ItemHandle>;
|
||||
|
@ -204,7 +210,9 @@ pub trait ItemViewHandle {
|
|||
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
|
||||
fn boxed_clone(&self) -> Box<dyn ItemViewHandle>;
|
||||
fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option<Box<dyn ItemViewHandle>>;
|
||||
fn added_to_pane(&self, cx: &mut ViewContext<Pane>);
|
||||
fn added_to_pane(&mut self, cx: &mut ViewContext<Pane>);
|
||||
fn deactivated(&self, cx: &mut MutableAppContext);
|
||||
fn navigate(&self, data: Box<dyn Any>, cx: &mut MutableAppContext);
|
||||
fn id(&self) -> usize;
|
||||
fn to_any(&self) -> AnyViewHandle;
|
||||
fn is_dirty(&self, cx: &AppContext) -> bool;
|
||||
|
@ -220,6 +228,11 @@ pub trait ItemViewHandle {
|
|||
) -> Task<anyhow::Result<()>>;
|
||||
}
|
||||
|
||||
pub trait WeakItemViewHandle {
|
||||
fn id(&self) -> usize;
|
||||
fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemViewHandle>>;
|
||||
}
|
||||
|
||||
impl<T: Item> ItemHandle for ModelHandle<T> {
|
||||
fn id(&self) -> usize {
|
||||
self.id()
|
||||
|
@ -229,9 +242,12 @@ impl<T: Item> ItemHandle for ModelHandle<T> {
|
|||
&self,
|
||||
window_id: usize,
|
||||
workspace: &Workspace,
|
||||
navigation: Rc<Navigation>,
|
||||
cx: &mut MutableAppContext,
|
||||
) -> Box<dyn ItemViewHandle> {
|
||||
Box::new(cx.add_view(window_id, |cx| T::build_view(self.clone(), workspace, cx)))
|
||||
Box::new(cx.add_view(window_id, |cx| {
|
||||
T::build_view(self.clone(), workspace, navigation, cx)
|
||||
}))
|
||||
}
|
||||
|
||||
fn boxed_clone(&self) -> Box<dyn ItemHandle> {
|
||||
|
@ -260,9 +276,10 @@ impl ItemHandle for Box<dyn ItemHandle> {
|
|||
&self,
|
||||
window_id: usize,
|
||||
workspace: &Workspace,
|
||||
navigation: Rc<Navigation>,
|
||||
cx: &mut MutableAppContext,
|
||||
) -> Box<dyn ItemViewHandle> {
|
||||
ItemHandle::add_view(self.as_ref(), window_id, workspace, cx)
|
||||
ItemHandle::add_view(self.as_ref(), window_id, workspace, navigation, cx)
|
||||
}
|
||||
|
||||
fn boxed_clone(&self) -> Box<dyn ItemHandle> {
|
||||
|
@ -330,7 +347,7 @@ impl<T: ItemView> ItemViewHandle for ViewHandle<T> {
|
|||
.map(|handle| Box::new(handle) as Box<dyn ItemViewHandle>)
|
||||
}
|
||||
|
||||
fn added_to_pane(&self, cx: &mut ViewContext<Pane>) {
|
||||
fn added_to_pane(&mut self, cx: &mut ViewContext<Pane>) {
|
||||
cx.subscribe(self, |pane, item, event, cx| {
|
||||
if T::should_close_item_on_event(event) {
|
||||
pane.close_item(item.id(), cx);
|
||||
|
@ -349,6 +366,14 @@ impl<T: ItemView> ItemViewHandle for ViewHandle<T> {
|
|||
.detach();
|
||||
}
|
||||
|
||||
fn deactivated(&self, cx: &mut MutableAppContext) {
|
||||
self.update(cx, |this, cx| this.deactivated(cx));
|
||||
}
|
||||
|
||||
fn navigate(&self, data: Box<dyn Any>, cx: &mut MutableAppContext) {
|
||||
self.update(cx, |this, cx| this.navigate(data, cx));
|
||||
}
|
||||
|
||||
fn save(&self, cx: &mut MutableAppContext) -> Result<Task<Result<()>>> {
|
||||
self.update(cx, |item, cx| item.save(cx))
|
||||
}
|
||||
|
@ -399,6 +424,17 @@ impl Clone for Box<dyn ItemHandle> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<T: ItemView> WeakItemViewHandle for WeakViewHandle<T> {
|
||||
fn id(&self) -> usize {
|
||||
self.id()
|
||||
}
|
||||
|
||||
fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemViewHandle>> {
|
||||
self.upgrade(cx)
|
||||
.map(|v| Box::new(v) as Box<dyn ItemViewHandle>)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceParams {
|
||||
pub project: ModelHandle<Project>,
|
||||
|
@ -722,40 +758,15 @@ impl Workspace {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn open_path(
|
||||
&mut self,
|
||||
path: ProjectPath,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Task<Result<Box<dyn ItemViewHandle>, Arc<anyhow::Error>>> {
|
||||
if let Some(existing_item) = self.item_for_path(&path, cx) {
|
||||
return Task::ready(Ok(self.open_item(existing_item, cx)));
|
||||
}
|
||||
|
||||
let worktree = match self.project.read(cx).worktree_for_id(path.worktree_id, cx) {
|
||||
Some(worktree) => worktree,
|
||||
None => {
|
||||
return Task::ready(Err(Arc::new(anyhow!(
|
||||
"worktree {} does not exist",
|
||||
path.worktree_id
|
||||
))));
|
||||
}
|
||||
};
|
||||
|
||||
let project_path = path.clone();
|
||||
let path_openers = self.path_openers.clone();
|
||||
let open_task = worktree.update(cx, |worktree, cx| {
|
||||
for opener in path_openers.iter() {
|
||||
if let Some(task) = opener.open(worktree, project_path.clone(), cx) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
Task::ready(Err(anyhow!("no opener found for path {:?}", project_path)))
|
||||
});
|
||||
|
||||
let load_task = self.load_path(path, cx);
|
||||
let pane = self.active_pane().clone().downgrade();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let item = open_task.await?;
|
||||
let item = load_task.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let pane = pane
|
||||
.upgrade(&cx)
|
||||
|
@ -765,6 +776,34 @@ impl Workspace {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn load_path(
|
||||
&mut self,
|
||||
path: ProjectPath,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Task<Result<Box<dyn ItemHandle>>> {
|
||||
if let Some(existing_item) = self.item_for_path(&path, cx) {
|
||||
return Task::ready(Ok(existing_item));
|
||||
}
|
||||
|
||||
let worktree = match self.project.read(cx).worktree_for_id(path.worktree_id, cx) {
|
||||
Some(worktree) => worktree,
|
||||
None => {
|
||||
return Task::ready(Err(anyhow!("worktree {} does not exist", path.worktree_id)));
|
||||
}
|
||||
};
|
||||
|
||||
let project_path = path.clone();
|
||||
let path_openers = self.path_openers.clone();
|
||||
worktree.update(cx, |worktree, cx| {
|
||||
for opener in path_openers.iter() {
|
||||
if let Some(task) = opener.open(worktree, project_path.clone(), cx) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
Task::ready(Err(anyhow!("no opener found for path {:?}", project_path)))
|
||||
})
|
||||
}
|
||||
|
||||
fn item_for_path(&self, path: &ProjectPath, cx: &AppContext) -> Option<Box<dyn ItemHandle>> {
|
||||
self.items
|
||||
.iter()
|
||||
|
|
|
@ -124,8 +124,8 @@ fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use editor::Editor;
|
||||
use gpui::MutableAppContext;
|
||||
use editor::{DisplayPoint, Editor};
|
||||
use gpui::{MutableAppContext, TestAppContext, ViewHandle};
|
||||
use project::ProjectPath;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
|
@ -136,11 +136,11 @@ mod tests {
|
|||
use theme::DEFAULT_THEME_NAME;
|
||||
use util::test::temp_tree;
|
||||
use workspace::{
|
||||
open_paths, pane, ItemView, ItemViewHandle, OpenNew, SplitDirection, WorkspaceHandle,
|
||||
open_paths, pane, ItemView, ItemViewHandle, OpenNew, Pane, SplitDirection, WorkspaceHandle,
|
||||
};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_paths_action(mut cx: gpui::TestAppContext) {
|
||||
async fn test_open_paths_action(mut cx: TestAppContext) {
|
||||
let app_state = cx.update(test_app_state);
|
||||
let dir = temp_tree(json!({
|
||||
"a": {
|
||||
|
@ -193,7 +193,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_new_empty_workspace(mut cx: gpui::TestAppContext) {
|
||||
async fn test_new_empty_workspace(mut cx: TestAppContext) {
|
||||
let app_state = cx.update(test_app_state);
|
||||
cx.update(|cx| {
|
||||
workspace::init(cx);
|
||||
|
@ -230,7 +230,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_entry(mut cx: gpui::TestAppContext) {
|
||||
async fn test_open_entry(mut cx: TestAppContext) {
|
||||
let app_state = cx.update(test_app_state);
|
||||
app_state
|
||||
.fs
|
||||
|
@ -350,7 +350,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_paths(mut cx: gpui::TestAppContext) {
|
||||
async fn test_open_paths(mut cx: TestAppContext) {
|
||||
let app_state = cx.update(test_app_state);
|
||||
let fs = app_state.fs.as_fake();
|
||||
fs.insert_dir("/dir1").await.unwrap();
|
||||
|
@ -420,7 +420,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_conflicting_item(mut cx: gpui::TestAppContext) {
|
||||
async fn test_save_conflicting_item(mut cx: TestAppContext) {
|
||||
let app_state = cx.update(test_app_state);
|
||||
let fs = app_state.fs.as_fake();
|
||||
fs.insert_tree("/root", json!({ "a.txt": "" })).await;
|
||||
|
@ -469,7 +469,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_and_save_new_file(mut cx: gpui::TestAppContext) {
|
||||
async fn test_open_and_save_new_file(mut cx: TestAppContext) {
|
||||
let app_state = cx.update(test_app_state);
|
||||
app_state.fs.as_fake().insert_dir("/root").await.unwrap();
|
||||
let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
|
||||
|
@ -585,9 +585,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_setting_language_when_saving_as_single_file_worktree(
|
||||
mut cx: gpui::TestAppContext,
|
||||
) {
|
||||
async fn test_setting_language_when_saving_as_single_file_worktree(mut cx: TestAppContext) {
|
||||
let app_state = cx.update(test_app_state);
|
||||
app_state.fs.as_fake().insert_dir("/root").await.unwrap();
|
||||
let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
|
||||
|
@ -630,7 +628,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_pane_actions(mut cx: gpui::TestAppContext) {
|
||||
async fn test_pane_actions(mut cx: TestAppContext) {
|
||||
cx.update(|cx| pane::init(cx));
|
||||
let app_state = cx.update(test_app_state);
|
||||
app_state
|
||||
|
@ -693,6 +691,182 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_navigation(mut cx: TestAppContext) {
|
||||
let app_state = cx.update(test_app_state);
|
||||
app_state
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"a": {
|
||||
"file1": "contents 1\n".repeat(20),
|
||||
"file2": "contents 2\n".repeat(20),
|
||||
"file3": "contents 3\n".repeat(20),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
|
||||
let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
|
||||
workspace
|
||||
.update(&mut cx, |workspace, cx| {
|
||||
workspace.add_worktree(Path::new("/root"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
|
||||
.await;
|
||||
let entries = cx.read(|cx| workspace.file_project_paths(cx));
|
||||
let file1 = entries[0].clone();
|
||||
let file2 = entries[1].clone();
|
||||
let file3 = entries[2].clone();
|
||||
|
||||
let editor1 = workspace
|
||||
.update(&mut cx, |w, cx| w.open_path(file1.clone(), cx))
|
||||
.await
|
||||
.unwrap()
|
||||
.to_any()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
editor1.update(&mut cx, |editor, cx| {
|
||||
editor.select_display_ranges(&[DisplayPoint::new(10, 0)..DisplayPoint::new(10, 0)], cx);
|
||||
});
|
||||
let editor2 = workspace
|
||||
.update(&mut cx, |w, cx| w.open_path(file2.clone(), cx))
|
||||
.await
|
||||
.unwrap()
|
||||
.to_any()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
let editor3 = workspace
|
||||
.update(&mut cx, |w, cx| w.open_path(file3.clone(), cx))
|
||||
.await
|
||||
.unwrap()
|
||||
.to_any()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
editor3.update(&mut cx, |editor, cx| {
|
||||
editor.select_display_ranges(&[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)], cx);
|
||||
});
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file3.clone(), DisplayPoint::new(15, 0))
|
||||
);
|
||||
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_back(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file3.clone(), DisplayPoint::new(0, 0))
|
||||
);
|
||||
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_back(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file2.clone(), DisplayPoint::new(0, 0))
|
||||
);
|
||||
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_back(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file1.clone(), DisplayPoint::new(10, 0))
|
||||
);
|
||||
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_back(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file1.clone(), DisplayPoint::new(0, 0))
|
||||
);
|
||||
|
||||
// Go back one more time and ensure we don't navigate past the first item in the history.
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_back(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file1.clone(), DisplayPoint::new(0, 0))
|
||||
);
|
||||
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_forward(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file1.clone(), DisplayPoint::new(10, 0))
|
||||
);
|
||||
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_forward(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file2.clone(), DisplayPoint::new(0, 0))
|
||||
);
|
||||
|
||||
// Go forward to an item that has been closed, ensuring it gets re-opened at the same
|
||||
// location.
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
workspace
|
||||
.active_pane()
|
||||
.update(cx, |pane, cx| pane.close_item(editor3.id(), cx));
|
||||
drop(editor3);
|
||||
});
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_forward(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file3.clone(), DisplayPoint::new(0, 0))
|
||||
);
|
||||
|
||||
// Go back to an item that has been closed and removed from disk, ensuring it gets skipped.
|
||||
workspace
|
||||
.update(&mut cx, |workspace, cx| {
|
||||
workspace
|
||||
.active_pane()
|
||||
.update(cx, |pane, cx| pane.close_item(editor2.id(), cx));
|
||||
drop(editor2);
|
||||
app_state.fs.as_fake().remove(Path::new("/root/a/file2"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_back(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file1.clone(), DisplayPoint::new(10, 0))
|
||||
);
|
||||
workspace
|
||||
.update(&mut cx, |w, cx| Pane::go_forward(w, cx))
|
||||
.await;
|
||||
assert_eq!(
|
||||
active_location(&workspace, &mut cx),
|
||||
(file3.clone(), DisplayPoint::new(0, 0))
|
||||
);
|
||||
|
||||
fn active_location(
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (ProjectPath, DisplayPoint) {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let item = workspace.active_item(cx).unwrap();
|
||||
let editor = item.to_any().downcast::<Editor>().unwrap();
|
||||
let selections = editor.update(cx, |editor, cx| editor.selected_display_ranges(cx));
|
||||
(item.project_path(cx).unwrap(), selections[0].start)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_bundled_themes(cx: &mut MutableAppContext) {
|
||||
let app_state = test_app_state(cx);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue