vim: View Marks (#26885)

Closes #26884

Release Notes:

- vim: Added `:marks` which brings up list of current marks
- confirming on selected mark in the view jumps to that mark

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
AidanV 2025-03-21 21:46:04 -07:00 committed by GitHub
parent 4c86cda909
commit d82b547596
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 381 additions and 9 deletions

View file

@ -39,7 +39,7 @@ use crate::{
object::Object,
state::{Mark, Mode},
visual::VisualDeleteLine,
ToggleRegistersView, Vim,
ToggleMarksView, ToggleRegistersView, Vim,
};
#[derive(Clone, Debug, PartialEq)]
@ -860,6 +860,7 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
)
}),
VimCommand::new(("reg", "isters"), ToggleRegistersView).bang(ToggleRegistersView),
VimCommand::new(("marks", ""), ToggleMarksView).bang(ToggleMarksView),
VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(select_range),
VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(select_range),
VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),

View file

@ -183,10 +183,13 @@ impl Vim {
&mut self,
text: Arc<str>,
line: bool,
should_pop_operator: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.pop_operator(window, cx);
if should_pop_operator {
self.pop_operator(window, cx);
}
let mark = self
.update_editor(window, cx, |vim, editor, window, cx| {
vim.get_mark(&text, editor, window, cx)

View file

@ -2,27 +2,29 @@ use crate::command::command_interceptor;
use crate::normal::repeat::Replayer;
use crate::surrounds::SurroundsType;
use crate::{motion::Motion, object::Object};
use crate::{ToggleRegistersView, UseSystemClipboard, Vim, VimSettings};
use crate::{ToggleMarksView, ToggleRegistersView, UseSystemClipboard, Vim, VimAddon, VimSettings};
use anyhow::Result;
use collections::HashMap;
use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
use db::define_connection;
use db::sqlez_macros::sql;
use editor::display_map::{is_invisible, replacement};
use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer};
use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer, ToPoint as EditorToPoint};
use gpui::{
Action, App, AppContext, BorrowAppContext, ClipboardEntry, ClipboardItem, Entity, EntityId,
Global, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity,
Action, App, AppContext, BorrowAppContext, ClipboardEntry, ClipboardItem, DismissEvent, Entity,
EntityId, Global, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity,
};
use language::{Buffer, BufferEvent, BufferId, Point};
use language::{Buffer, BufferEvent, BufferId, Chunk, Point};
use multi_buffer::MultiBufferRow;
use picker::{Picker, PickerDelegate};
use project::{Project, ProjectItem, ProjectPath};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use std::borrow::BorrowMut;
use std::collections::HashSet;
use std::path::Path;
use std::{fmt::Display, ops::Range, sync::Arc};
use text::Bias;
use text::{Bias, ToPoint};
use theme::ThemeSettings;
use ui::{
h_flex, rems, ActiveTheme, Context, Div, FluentBuilder, KeyBinding, ParentElement,
@ -627,6 +629,11 @@ impl VimGlobals {
})
.detach();
cx.observe_new(move |workspace: &mut Workspace, window, _| {
MarksView::register(workspace, window);
})
.detach();
let mut was_enabled = None;
cx.observe_global::<SettingsStore>(move |cx| {
@ -1232,6 +1239,366 @@ impl RegistersView {
}
}
enum MarksMatchInfo {
Path(Arc<Path>),
Title(String),
Content {
line: String,
highlights: Vec<(Range<usize>, HighlightStyle)>,
},
}
impl MarksMatchInfo {
fn from_chunks<'a>(chunks: impl Iterator<Item = Chunk<'a>>, cx: &App) -> Self {
let mut line = String::new();
let mut highlights = Vec::new();
let mut offset = 0;
for chunk in chunks {
line.push_str(chunk.text);
if let Some(highlight_style) = chunk.syntax_highlight_id {
if let Some(highlight) = highlight_style.style(cx.theme().syntax()) {
highlights.push((offset..offset + chunk.text.len(), highlight))
}
}
offset += chunk.text.len();
}
MarksMatchInfo::Content { line, highlights }
}
}
struct MarksMatch {
name: String,
position: Point,
info: MarksMatchInfo,
}
pub struct MarksViewDelegate {
selected_index: usize,
matches: Vec<MarksMatch>,
point_column_width: usize,
workspace: WeakEntity<Workspace>,
}
impl PickerDelegate for MarksViewDelegate {
type ListItem = Div;
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.selected_index = ix;
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
Arc::default()
}
fn update_matches(
&mut self,
_: String,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> gpui::Task<()> {
let Some(workspace) = self.workspace.upgrade().clone() else {
return Task::ready(());
};
cx.spawn(async move |picker, cx| {
let mut matches = Vec::new();
let _ = workspace.update(cx, |workspace, cx| {
let entity_id = cx.entity_id();
let Some(editor) = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
else {
return;
};
let editor = editor.read(cx);
let mut has_seen = HashSet::new();
let Some(marks_state) = cx.global::<VimGlobals>().marks.get(&entity_id) else {
return;
};
let marks_state = marks_state.read(cx);
if let Some(map) = marks_state
.multibuffer_marks
.get(&editor.buffer().entity_id())
{
for (name, anchors) in map {
if has_seen.contains(name) {
continue;
}
has_seen.insert(name.clone());
let Some(anchor) = anchors.first() else {
continue;
};
let snapshot = editor.buffer().read(cx).snapshot(cx);
let position = anchor.to_point(&snapshot);
let chunks = snapshot.chunks(
Point::new(position.row, 0)
..Point::new(
position.row,
snapshot.line_len(MultiBufferRow(position.row)),
),
true,
);
matches.push(MarksMatch {
name: name.clone(),
position,
info: MarksMatchInfo::from_chunks(chunks, cx),
})
}
}
if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
let buffer = buffer.read(cx);
if let Some(map) = marks_state.buffer_marks.get(&buffer.remote_id()) {
for (name, anchors) in map {
if has_seen.contains(name) {
continue;
}
has_seen.insert(name.clone());
let Some(anchor) = anchors.first() else {
continue;
};
let snapshot = buffer.snapshot();
let position = anchor.to_point(&snapshot);
let chunks = snapshot.chunks(
Point::new(position.row, 0)
..Point::new(position.row, snapshot.line_len(position.row)),
true,
);
matches.push(MarksMatch {
name: name.clone(),
position,
info: MarksMatchInfo::from_chunks(chunks, cx),
})
}
}
}
for (name, mark_location) in marks_state.global_marks.iter() {
if has_seen.contains(name) {
continue;
}
has_seen.insert(name.clone());
match mark_location {
MarkLocation::Buffer(entity_id) => {
if let Some(&anchor) = marks_state
.multibuffer_marks
.get(entity_id)
.and_then(|map| map.get(name))
.and_then(|anchors| anchors.first())
{
let Some((info, snapshot)) = workspace
.items(cx)
.filter_map(|item| item.act_as::<Editor>(cx))
.map(|entity| entity.read(cx).buffer())
.find(|buffer| buffer.entity_id().eq(entity_id))
.map(|buffer| {
(
MarksMatchInfo::Title(
buffer.read(cx).title(cx).to_string(),
),
buffer.read(cx).snapshot(cx),
)
})
else {
continue;
};
matches.push(MarksMatch {
name: name.clone(),
position: anchor.to_point(&snapshot),
info,
});
}
}
MarkLocation::Path(path) => {
if let Some(&position) = marks_state
.serialized_marks
.get(path.as_ref())
.and_then(|map| map.get(name))
.and_then(|points| points.first())
{
let info = MarksMatchInfo::Path(path.clone());
matches.push(MarksMatch {
name: name.clone(),
position,
info,
});
}
}
}
}
});
let _ = picker.update(cx, |picker, cx| {
matches.sort_by_key(|a| {
(
a.name.chars().next().map(|c| c.is_ascii_uppercase()),
a.name.clone(),
)
});
let digits = matches
.iter()
.map(|m| (m.position.row + 1).ilog10() + (m.position.column + 1).ilog10())
.max()
.unwrap_or_default();
picker.delegate.matches = matches;
picker.delegate.point_column_width = (digits + 4) as usize;
cx.notify();
});
})
}
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(vim) = self
.workspace
.upgrade()
.map(|w| w.read(cx))
.and_then(|w| w.focused_pane(window, cx).read(cx).active_item())
.and_then(|item| item.act_as::<Editor>(cx))
.and_then(|editor| editor.read(cx).addon::<VimAddon>().cloned())
.map(|addon| addon.entity)
else {
return;
};
let Some(text): Option<Arc<str>> = self
.matches
.get(self.selected_index)
.map(|m| Arc::from(m.name.to_string().into_boxed_str()))
else {
return;
};
vim.update(cx, |vim, cx| {
vim.jump(text, false, false, window, cx);
});
cx.emit(DismissEvent);
}
fn dismissed(&mut self, _: &mut Window, _: &mut Context<Picker<Self>>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let mark_match = self
.matches
.get(ix)
.expect("Invalid matches state: no element for index {ix}");
let mut left_output = String::new();
let mut left_runs = Vec::new();
left_output.push('`');
left_output.push_str(&mark_match.name);
left_runs.push((
0..left_output.len(),
HighlightStyle::color(cx.theme().colors().text_accent),
));
left_output.push(' ');
left_output.push(' ');
let point_column = format!(
"{},{}",
mark_match.position.row + 1,
mark_match.position.column + 1
);
left_output.push_str(&point_column);
if let Some(padding) = self.point_column_width.checked_sub(point_column.len()) {
left_output.push_str(&" ".repeat(padding));
}
let (right_output, right_runs): (String, Vec<_>) = match &mark_match.info {
MarksMatchInfo::Path(path) => {
let s = path.to_string_lossy().to_string();
(
s.clone(),
vec![(0..s.len(), HighlightStyle::color(cx.theme().colors().text))],
)
}
MarksMatchInfo::Title(title) => (
title.clone(),
vec![(
0..title.len(),
HighlightStyle::color(cx.theme().colors().text),
)],
),
MarksMatchInfo::Content { line, highlights } => (line.clone(), highlights.clone()),
};
let theme = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color: cx.theme().colors().editor_foreground,
font_family: theme.buffer_font.family.clone(),
font_features: theme.buffer_font.features.clone(),
font_fallbacks: theme.buffer_font.fallbacks.clone(),
font_size: theme.buffer_font_size(cx).into(),
line_height: (theme.line_height() * theme.buffer_font_size(cx)).into(),
font_weight: theme.buffer_font.weight,
font_style: theme.buffer_font.style,
..Default::default()
};
Some(
h_flex()
.when(selected, |el| el.bg(cx.theme().colors().element_selected))
.font_buffer(cx)
.text_buffer(cx)
.h(theme.buffer_font_size(cx) * theme.line_height())
.px_2()
.child(StyledText::new(left_output).with_default_highlights(&text_style, left_runs))
.child(
StyledText::new(right_output).with_default_highlights(&text_style, right_runs),
),
)
}
}
pub struct MarksView {}
impl MarksView {
fn register(workspace: &mut Workspace, _window: Option<&mut Window>) {
workspace.register_action(|workspace, _: &ToggleMarksView, window, cx| {
Self::toggle(workspace, window, cx);
});
}
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
let handle = cx.weak_entity();
workspace.toggle_modal(window, cx, move |window, cx| {
MarksView::new(handle, window, cx)
});
}
fn new(
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Picker<MarksViewDelegate>>,
) -> Picker<MarksViewDelegate> {
let matches = Vec::default();
let delegate = MarksViewDelegate {
selected_index: 0,
point_column_width: 0,
matches,
workspace,
};
Picker::nonsearchable_uniform_list(delegate, window, cx)
.width(rems(36.))
.modal(true)
}
}
define_connection! (
pub static ref DB: VimDb<WorkspaceDb> = &[
sql! (

View file

@ -144,6 +144,7 @@ actions!(
PushReplace,
PushDeleteSurrounds,
PushMark,
ToggleMarksView,
PushIndent,
PushOutdent,
PushAutoIndent,
@ -1599,7 +1600,7 @@ impl Vim {
self.select_register(text, window, cx);
}
},
Some(Operator::Jump { line }) => self.jump(text, line, window, cx),
Some(Operator::Jump { line }) => self.jump(text, line, true, window, cx),
_ => {
if self.mode == Mode::Replace {
self.multi_replace(text, window, cx)