feat: support configurable tabline status label
This commit is contained in:
+136
-13
@@ -179,6 +179,7 @@ pub enum Effect {
|
||||
Insert(String),
|
||||
Replace(String),
|
||||
Message(String),
|
||||
TablineLabel(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -1349,6 +1350,7 @@ pub struct Editor {
|
||||
owned_system_clipboard: Option<(String, Vec<String>, bool)>,
|
||||
last_edit: Option<EditRecipe>,
|
||||
message: String,
|
||||
tabline_label: String,
|
||||
quit: bool,
|
||||
capabilities: Vec<String>,
|
||||
features: HashMap<String, Feature>,
|
||||
@@ -1434,6 +1436,7 @@ impl Editor {
|
||||
owned_system_clipboard: None,
|
||||
last_edit: None,
|
||||
message: String::new(),
|
||||
tabline_label: String::new(),
|
||||
quit: false,
|
||||
capabilities,
|
||||
features: HashMap::new(),
|
||||
@@ -3823,6 +3826,9 @@ impl Editor {
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
pub fn tabline_label(&self) -> &str {
|
||||
&self.tabline_label
|
||||
}
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.active.path.as_deref()
|
||||
}
|
||||
@@ -3862,6 +3868,21 @@ impl Editor {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn set_tabline_label(&mut self, text: String) -> bool {
|
||||
let text = if text.chars().any(char::is_control) {
|
||||
text.chars()
|
||||
.map(|ch| if ch.is_control() { '\u{fffd}' } else { ch })
|
||||
.collect()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if self.tabline_label == text {
|
||||
return false;
|
||||
}
|
||||
self.tabline_label = text;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn register(&mut self, feature: Feature) -> Result<(), String> {
|
||||
self.install(feature, false)
|
||||
}
|
||||
@@ -5737,7 +5758,7 @@ impl Editor {
|
||||
|
||||
/// Applies one validated plugin response. Successful text effects collapse
|
||||
/// to the primary caret: Insert runs once there, Replace replaces the buffer.
|
||||
/// Rejected responses and message-only effects retain the complete set.
|
||||
/// Rejected responses and presentation-only effects retain the complete set.
|
||||
pub fn apply(&mut self, effects: Vec<Effect>) -> Result<(), String> {
|
||||
if self.mutation_blocked()
|
||||
&& effects
|
||||
@@ -5749,7 +5770,10 @@ impl Editor {
|
||||
let mut total = 0usize;
|
||||
for effect in &effects {
|
||||
let text = match effect {
|
||||
Effect::Insert(text) | Effect::Replace(text) | Effect::Message(text) => text,
|
||||
Effect::Insert(text)
|
||||
| Effect::Replace(text)
|
||||
| Effect::Message(text)
|
||||
| Effect::TablineLabel(text) => text,
|
||||
};
|
||||
total = total
|
||||
.checked_add(text.len())
|
||||
@@ -5757,17 +5781,26 @@ impl Editor {
|
||||
if total > MAX_EDIT_BYTES {
|
||||
return Err("Plugin effects exceed 4 MiB".into());
|
||||
}
|
||||
validate_text(text)?;
|
||||
if !matches!(effect, Effect::TablineLabel(_)) {
|
||||
validate_text(text)?;
|
||||
}
|
||||
}
|
||||
if self.mutation_blocked() {
|
||||
if !effects
|
||||
.iter()
|
||||
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
|
||||
{
|
||||
for effect in effects {
|
||||
if let Effect::Message(text) = effect {
|
||||
self.set_message(text);
|
||||
match effect {
|
||||
Effect::Message(text) => self.set_message(text),
|
||||
Effect::TablineLabel(text) => {
|
||||
self.set_tabline_label(text);
|
||||
}
|
||||
_ => unreachable!("presentation-only effects"),
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Validation precedes even history/message changes, so a rejected response is atomic.
|
||||
// Validation precedes even history/presentation changes, so rejection is atomic.
|
||||
let previous_mode = self.active.view.mode;
|
||||
let inserting = self.active.view.mode == Mode::Insert;
|
||||
if inserting {
|
||||
@@ -5804,6 +5837,9 @@ impl Editor {
|
||||
self.normalize_targets();
|
||||
}
|
||||
Effect::Message(text) => self.set_message(text),
|
||||
Effect::TablineLabel(text) => {
|
||||
self.set_tabline_label(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if before.is_some() {
|
||||
@@ -16921,7 +16957,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_text_effects_and_invocation_are_single_primary_but_messages_are_not() {
|
||||
fn plugin_text_effects_collapse_selection_but_presentation_effects_do_not() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("aa\nbb\ncc".into())])
|
||||
@@ -16933,8 +16969,15 @@ mod tests {
|
||||
editor.handle(Input::Char('v'));
|
||||
let ranges = editor.selections();
|
||||
assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err());
|
||||
editor.apply(vec![Effect::Message("ok".into())]).unwrap();
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::Message("ok".into()),
|
||||
Effect::TablineLabel("ready".into()),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(editor.tabline_label(), "ready");
|
||||
assert_eq!(editor.selections(), ranges);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
editor.apply(vec![Effect::Insert("!".into())]).unwrap();
|
||||
assert_eq!(editor.context().text, "aa\n!bb\ncc");
|
||||
assert_eq!(editor.selection_count(), 1);
|
||||
@@ -17631,12 +17674,80 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_effects_validate_before_any_mutation_and_group_undo() {
|
||||
fn tabline_label_sanitizes_once_and_is_global_until_cleared() {
|
||||
let mut editor = editor();
|
||||
editor.set_message("unchanged");
|
||||
assert_eq!(editor.tabline_label(), "");
|
||||
let text = String::from("界 e\u{301} 👩🚀");
|
||||
assert!(editor.set_tabline_label(text));
|
||||
assert!(!editor.set_tabline_label("界 e\u{301} 👩🚀".into()));
|
||||
assert!(editor.set_tabline_label("界\0\t\r\n\u{1b}\u{85}👩🚀".into()));
|
||||
assert_eq!(editor.tabline_label(), "界������👩🚀");
|
||||
editor.set_message("transient");
|
||||
editor.execute("tab.new");
|
||||
assert_eq!(editor.tabline_label(), "界������👩🚀");
|
||||
editor.execute("tab.previous");
|
||||
assert_eq!(editor.tabline_label(), "界������👩🚀");
|
||||
assert!(editor.set_tabline_label(String::new()));
|
||||
editor.execute("tab.next");
|
||||
assert_eq!(editor.tabline_label(), "");
|
||||
assert!(!editor.set_tabline_label(String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_diff_accepts_atomic_label_updates_without_editing_state() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["base".into()],
|
||||
vec!["head".into()],
|
||||
);
|
||||
editor.handle(Input::Char('v'));
|
||||
let selection = editor.selections();
|
||||
let revision = editor.text_revision();
|
||||
let text = editor.context().text;
|
||||
let history = (editor.active.undo.len(), editor.active.redo.len());
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("界\n\u{1b}".into()),
|
||||
Effect::Message("read only".into()),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(editor.tabline_label(), "界��");
|
||||
assert_eq!(editor.message(), "read only");
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.selections(), selection);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(
|
||||
(editor.active.undo.len(), editor.active.redo.len()),
|
||||
history
|
||||
);
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("must not leak".into()),
|
||||
Effect::Insert("rejected".into()),
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(editor.tabline_label(), "界��");
|
||||
editor
|
||||
.apply(vec![Effect::TablineLabel(String::new())])
|
||||
.unwrap();
|
||||
assert_eq!(editor.tabline_label(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_effects_validate_before_any_mutation_and_group_undo() {
|
||||
let mut editor = editor();
|
||||
editor.set_message("unchanged");
|
||||
editor.set_tabline_label("unchanged".into());
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("must not leak".into()),
|
||||
Effect::Insert("good".into()),
|
||||
Effect::Message("bad\u{1b}".into())
|
||||
])
|
||||
@@ -17644,27 +17755,31 @@ mod tests {
|
||||
);
|
||||
assert_eq!(editor.context().text, "");
|
||||
assert_eq!(editor.message(), "unchanged");
|
||||
assert_eq!(editor.tabline_label(), "unchanged");
|
||||
assert!(!editor.dirty());
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::Insert("a".repeat(MAX_EDIT_BYTES)),
|
||||
Effect::Insert("x".into())
|
||||
Effect::TablineLabel("x".into()),
|
||||
Effect::Insert("a".repeat(MAX_EDIT_BYTES))
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(editor.context().text, "");
|
||||
assert_eq!(editor.tabline_label(), "unchanged");
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::Insert("one".into()),
|
||||
Effect::Insert("\ntwo".into()),
|
||||
Effect::Message("ok".into()),
|
||||
Effect::TablineLabel("updated".into()),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(editor.context().text, "one\ntwo");
|
||||
assert_eq!(editor.message(), "ok");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "");
|
||||
assert_eq!(editor.tabline_label(), "updated");
|
||||
editor.handle(Input::Char('U'));
|
||||
assert_eq!(editor.context().text, "one\ntwo");
|
||||
editor
|
||||
@@ -17680,6 +17795,14 @@ mod tests {
|
||||
editor.handle(Input::Char('i'));
|
||||
type_text(&mut editor, "before");
|
||||
let cursor = editor.cursor();
|
||||
let undo_len = editor.active.undo.len();
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("typing".into()),
|
||||
Effect::Message("notice".into()),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(editor.active.undo.len(), undo_len);
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![Effect::Replace("invalid\0".into())])
|
||||
|
||||
Reference in New Issue
Block a user