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())])
|
||||
|
||||
@@ -176,6 +176,9 @@ pub enum UpdateEffect {
|
||||
Message {
|
||||
text: String,
|
||||
},
|
||||
TablineLabel {
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
+59
-5
@@ -34,6 +34,7 @@ pub struct Config {
|
||||
pub file_picker_view: String,
|
||||
pub file_explorer_view: String,
|
||||
pub clipboard_mode: String,
|
||||
pub tabline_label: String,
|
||||
pub diff_context_lines: usize,
|
||||
pub diff_expand_lines: usize,
|
||||
}
|
||||
@@ -70,6 +71,7 @@ impl Default for Config {
|
||||
file_picker_view: "tree".into(),
|
||||
file_explorer_view: "tree".into(),
|
||||
clipboard_mode: "system".into(),
|
||||
tabline_label: String::new(),
|
||||
diff_context_lines: 10,
|
||||
diff_expand_lines: 15,
|
||||
}
|
||||
@@ -92,6 +94,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
file_picker_view,
|
||||
file_explorer_view,
|
||||
clipboard_mode,
|
||||
tabline_label,
|
||||
diff_context_lines,
|
||||
diff_expand_lines,
|
||||
..
|
||||
@@ -116,6 +119,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
config.file_picker_view = file_picker_view;
|
||||
config.file_explorer_view = file_explorer_view;
|
||||
config.clipboard_mode = clipboard_mode;
|
||||
config.tabline_label = tabline_label;
|
||||
config.diff_context_lines = diff_context_lines;
|
||||
config.diff_expand_lines = diff_expand_lines;
|
||||
Ok(config)
|
||||
@@ -346,6 +350,7 @@ fn convert_effects(effects: Vec<WireEffect>) -> Vec<Effect> {
|
||||
WireEffect::Insert { text } => Effect::Insert(text),
|
||||
WireEffect::Replace { text } => Effect::Replace(text),
|
||||
WireEffect::Message { text } => Effect::Message(text),
|
||||
WireEffect::TablineLabel { text } => Effect::TablineLabel(text),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -433,6 +438,7 @@ enum Response {
|
||||
file_picker_view: String,
|
||||
file_explorer_view: String,
|
||||
clipboard_mode: String,
|
||||
tabline_label: String,
|
||||
#[serde(deserialize_with = "diff_context_lines")]
|
||||
diff_context_lines: usize,
|
||||
#[serde(deserialize_with = "diff_expand_lines")]
|
||||
@@ -494,6 +500,8 @@ enum WireEffect {
|
||||
Replace { text: String },
|
||||
#[serde(rename = "message")]
|
||||
Message { text: String },
|
||||
#[serde(rename = "tabline_label")]
|
||||
TablineLabel { text: String },
|
||||
}
|
||||
|
||||
struct OutboundFrame {
|
||||
@@ -882,6 +890,8 @@ Vamp.command "greet" do |editor, args|
|
||||
editor.insert "Hello #{args}!"
|
||||
editor.replace editor.text
|
||||
editor.message "Greeting inserted"
|
||||
editor.tabline_label "界 e\u0301 👩🚀\t\n\e"
|
||||
editor.tabline_label ""
|
||||
end
|
||||
"#);
|
||||
assert_eq!(feature.name, "greeting");
|
||||
@@ -892,8 +902,9 @@ end
|
||||
for _ in 0..2 {
|
||||
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
|
||||
assert!(
|
||||
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message)]
|
||||
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted")
|
||||
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::TablineLabel(label), Effect::TablineLabel(cleared)]
|
||||
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted"
|
||||
&& label == "界 e\u{301} 👩🚀\t\n\u{1b}" && cleared.is_empty())
|
||||
);
|
||||
}
|
||||
plugin.disable();
|
||||
@@ -1152,14 +1163,53 @@ end
|
||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||
}
|
||||
for frame in [
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline_label":"","diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline_label":"","diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline_label":"","diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_tabline_label_defaults_accepts_empty_and_rejects_non_strings() {
|
||||
let fixture = Fixture::new();
|
||||
for (value, expected) in [
|
||||
(r#""""#, ""),
|
||||
(r#""界 e\u0301 👩🚀\t\n\e""#, "界 e\u{301} 👩🚀\t\n\u{1b}"),
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.tabline_label = {value} }}"),
|
||||
);
|
||||
assert_eq!(load_config(&path).unwrap().tabline_label, expected);
|
||||
}
|
||||
for value in ["7", "nil", "true", "{}", "[]", "-> { 'label' }"] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.tabline_label = {value} }}"),
|
||||
);
|
||||
let error = load_config(&path).unwrap_err();
|
||||
assert!(
|
||||
error.contains("tabline label must be a String"),
|
||||
"{value}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabline_label_wire_effects_require_string_text() {
|
||||
for frame in [
|
||||
serde_json::json!({"type": "tabline_label"}),
|
||||
serde_json::json!({"type": "tabline_label", "text": null}),
|
||||
serde_json::json!({"type": "tabline_label", "text": 7}),
|
||||
serde_json::json!({"type": "tabline_label", "text": "", "extra": true}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<WireEffect>(frame.clone()).is_err());
|
||||
assert!(serde_json::from_value::<UpdateEffect>(frame).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1180,6 +1230,7 @@ end
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"tabline_label": "",
|
||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
|
||||
});
|
||||
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
||||
@@ -1592,6 +1643,7 @@ end"#,
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"tabline_label": "",
|
||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
|
||||
});
|
||||
let Response::Config { lsp, .. } =
|
||||
@@ -1674,6 +1726,8 @@ end"#,
|
||||
for mutation in [
|
||||
"return '['",
|
||||
"frame.delete('lsp')",
|
||||
"frame.delete('tabline_label')",
|
||||
"frame['tabline_label'] = 7",
|
||||
"frame['leader'] = 7",
|
||||
"frame['unexpected'] = true",
|
||||
] {
|
||||
|
||||
+16
-3
@@ -16,6 +16,7 @@ module Vamp
|
||||
@file_picker_view = "tree"
|
||||
@file_explorer_view = "tree"
|
||||
@clipboard_mode = "system"
|
||||
@tabline_label = ""
|
||||
@diff_context_lines = 10
|
||||
@diff_expand_lines = 15
|
||||
end
|
||||
@@ -62,6 +63,12 @@ module Vamp
|
||||
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
||||
end
|
||||
|
||||
# @param text [String] plain text displayed at the right of the tab line; empty clears it
|
||||
# @return [String]
|
||||
def tabline_label=(text)
|
||||
@tabline_label = Vamp.string!(text, "tabline label")
|
||||
end
|
||||
|
||||
# @param lines [Integer] nonnegative context lines around each diff change
|
||||
# @return [Integer]
|
||||
def diff_context_lines=(lines)
|
||||
@@ -104,7 +111,7 @@ module Vamp
|
||||
def response
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
||||
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode, "tabline_label" => @tabline_label,
|
||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
|
||||
end
|
||||
|
||||
@@ -203,7 +210,7 @@ module Vamp
|
||||
attr_reader :path
|
||||
# @return [Integer] positive invocation ID
|
||||
attr_reader :request_id
|
||||
# @return [Array<Hash{String => String}>] queued insert/replace/message effects
|
||||
# @return [Array<Hash{String => String}>] queued text and presentation effects
|
||||
attr_reader :effects
|
||||
|
||||
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
||||
@@ -237,6 +244,12 @@ module Vamp
|
||||
queue("message", text)
|
||||
end
|
||||
|
||||
# @param text [String] plain text displayed at the right of the tab line; empty clears it
|
||||
# @return [nil]
|
||||
def tabline_label(text)
|
||||
queue("tabline_label", text)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @param context [Hash{String => String, Integer, nil}]
|
||||
@@ -281,7 +294,7 @@ module Vamp
|
||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||
end
|
||||
|
||||
# @param type [String] insert, replace, or message
|
||||
# @param type [String] insert, replace, message, or tabline_label
|
||||
# @param text [String]
|
||||
# @return [nil]
|
||||
def queue(type, text)
|
||||
|
||||
+83
-4
@@ -220,6 +220,7 @@ impl EditorSession {
|
||||
));
|
||||
}
|
||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||
editor.set_tabline_label(config.tabline_label);
|
||||
let mut plugins = BTreeMap::new();
|
||||
for configured in config.plugins {
|
||||
let path = configured.path;
|
||||
@@ -756,6 +757,11 @@ impl EditorSession {
|
||||
}
|
||||
UpdateEffect::Completion { .. } => {}
|
||||
UpdateEffect::Message { .. } => {}
|
||||
UpdateEffect::TablineLabel { text } => {
|
||||
if text.len() > 4 * 1024 * 1024 {
|
||||
return Err("Tabline label exceeds 4 MiB".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -851,6 +857,9 @@ impl EditorSession {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
}
|
||||
UpdateEffect::TablineLabel { text } => {
|
||||
changed |= self.editor.set_tabline_label(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.editor.refresh_live_diff();
|
||||
@@ -878,13 +887,19 @@ impl EditorSession {
|
||||
}
|
||||
if effects
|
||||
.iter()
|
||||
.all(|effect| matches!(effect, Effect::Message(_)))
|
||||
.all(|effect| matches!(effect, Effect::Message(_) | Effect::TablineLabel(_)))
|
||||
{
|
||||
let mut changed = false;
|
||||
for effect in effects {
|
||||
if let Effect::Message(text) = effect {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
match effect {
|
||||
Effect::Message(text) => {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
}
|
||||
Effect::TablineLabel(text) => {
|
||||
changed |= self.editor.set_tabline_label(text);
|
||||
}
|
||||
_ => unreachable!("presentation-only effects checked above"),
|
||||
}
|
||||
}
|
||||
return Ok(changed);
|
||||
@@ -2980,6 +2995,70 @@ end
|
||||
drain_until(session, |session| session.editor().message() == delivered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabline_updates_survive_tab_switches_and_remain_atomic_in_readonly_views() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.asynchronous(None);
|
||||
publish(
|
||||
&mut session,
|
||||
vec![serde_json::json!({
|
||||
"type": "tabline_label", "text": "bat 37%"
|
||||
})],
|
||||
);
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor().tabline_label() == "bat 37%"
|
||||
});
|
||||
let gate = fixture.0.join("label.gate");
|
||||
hold(
|
||||
&mut session,
|
||||
&gate,
|
||||
vec![serde_json::json!({
|
||||
"type": "tabline_label", "text": "bat 38%"
|
||||
})],
|
||||
);
|
||||
barrier(&mut session, "label request received");
|
||||
session.execute("tab.new");
|
||||
release(&mut session, &gate);
|
||||
assert_eq!(session.editor().tabline_label(), "bat 38%");
|
||||
session.execute("tab.previous");
|
||||
assert_eq!(session.editor().tabline_label(), "bat 38%");
|
||||
let update = |text: &str| PluginUpdate {
|
||||
request_id: None,
|
||||
effects: vec![UpdateEffect::TablineLabel { text: text.into() }],
|
||||
};
|
||||
assert!(
|
||||
!session
|
||||
.apply_update("generic_fixture", update("bat 38%"))
|
||||
.unwrap()
|
||||
);
|
||||
let rejected = PluginUpdate {
|
||||
request_id: None,
|
||||
effects: vec![
|
||||
UpdateEffect::TablineLabel {
|
||||
text: "must not leak".into(),
|
||||
},
|
||||
UpdateEffect::SemanticTokens {
|
||||
buffer_id: session.editor().buffer_id(),
|
||||
revision: session.editor().text_revision(),
|
||||
tokens: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(session.apply_update("generic_fixture", rejected).is_err());
|
||||
assert_eq!(session.editor().tabline_label(), "bat 38%");
|
||||
session.editor.execute("diff");
|
||||
assert!(session.editor.mutation_blocked());
|
||||
assert!(
|
||||
session
|
||||
.apply_update("generic_fixture", update("bat\t39%\n"))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(session.editor().tabline_label(), "bat�39%�");
|
||||
assert_eq!(session.editor().lines(), [""]);
|
||||
assert!(session.apply_update("generic_fixture", update("")).unwrap());
|
||||
assert_eq!(session.editor().tabline_label(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
+191
-4
@@ -1002,7 +1002,7 @@ fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool {
|
||||
}
|
||||
|
||||
fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
|
||||
if editor.tab_count() <= 1 || area.height == 0 {
|
||||
if (editor.tab_count() <= 1 && editor.tabline_label().is_empty()) || area.height == 0 {
|
||||
return area;
|
||||
}
|
||||
let bar = Rect::new(area.x, area.y, area.width, 1);
|
||||
@@ -1044,7 +1044,16 @@ fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
|
||||
let fade_width = if active == 0 { 3 } else { 6 };
|
||||
let fade = usize::from(bar.width) >= labels[active].width() + fade_width;
|
||||
let mut width = labels[active].width() + if fade { fade_width } else { 0 };
|
||||
while first > 0 && width + labels[first - 1].width() <= usize::from(bar.width) {
|
||||
let status = editor.tabline_label();
|
||||
let status_width = status.width() + 5; // Left fade and one space on each side.
|
||||
let show_status = !status.is_empty() && width + 1 + status_width <= usize::from(bar.width);
|
||||
let tabs_right = bar.right()
|
||||
- if show_status {
|
||||
status_width as u16 + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
while first > 0 && width + labels[first - 1].width() <= usize::from(tabs_right - bar.x) {
|
||||
first -= 1;
|
||||
width += labels[first].width();
|
||||
}
|
||||
@@ -1053,7 +1062,7 @@ fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
|
||||
for (index, label) in labels.iter().enumerate().skip(first) {
|
||||
let decorated = index == active && fade;
|
||||
let width = (label.width() + if decorated { fade_width } else { 0 })
|
||||
.min(usize::from(bar.right() - x)) as u16;
|
||||
.min(usize::from(tabs_right - x)) as u16;
|
||||
let highlight = Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
@@ -1073,10 +1082,28 @@ fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
|
||||
};
|
||||
frame.render_widget(Paragraph::new(line), Rect::new(x, bar.y, width, 1));
|
||||
x += width;
|
||||
if x == bar.right() {
|
||||
if x == tabs_right {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if show_status {
|
||||
let highlight = Style::default().fg(Color::Black).bg(Color::Cyan);
|
||||
let edge = Style::default().fg(Color::Cyan);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("░▒▓", edge),
|
||||
Span::styled(" ", highlight),
|
||||
Span::styled(status, highlight),
|
||||
Span::styled(" ", highlight),
|
||||
])),
|
||||
Rect::new(
|
||||
bar.right() - status_width as u16,
|
||||
bar.y,
|
||||
status_width as u16,
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
workspace
|
||||
}
|
||||
|
||||
@@ -5249,6 +5276,7 @@ mod tests {
|
||||
"edit {}",
|
||||
directory.file("active.txt", "ACTIVE").display()
|
||||
));
|
||||
editor.set_tabline_label("status".into());
|
||||
let mut views = BufferViewports::default();
|
||||
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
|
||||
let mut terminal =
|
||||
@@ -5259,6 +5287,7 @@ mod tests {
|
||||
if width > 0 && height > 0 {
|
||||
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
|
||||
assert!(title.trim_start().starts_with('3'), "{title}");
|
||||
assert!(!title.contains("status"), "{title}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5273,6 +5302,164 @@ mod tests {
|
||||
assert!(titles.contains("2 [No Name]"), "{titles}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_tabline_label_sanitizes_unicode_and_redraws_through_clear() {
|
||||
let directory = Directory::new();
|
||||
let mut editor =
|
||||
Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let original = screen_text(&terminal);
|
||||
assert_eq!(views.document.geometry.unwrap().1.y, 0);
|
||||
|
||||
editor.set_tabline_label("電池 e\u{301}\t90%".into());
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||
assert!(title.contains("1 source.txt"), "{title}");
|
||||
assert!(title.contains("電") && title.contains("池"), "{title}");
|
||||
assert!(title.contains("e\u{301}�90%"), "{title}");
|
||||
let start = 80 - (editor.tabline_label().width() + 5) as u16;
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (offset, glyph) in [(0, "░"), (1, "▒"), (2, "▓"), (3, " ")] {
|
||||
assert_eq!(buffer[(start + offset, 0)].symbol(), glyph);
|
||||
}
|
||||
assert_eq!(buffer[(78, 0)].symbol(), "%");
|
||||
assert_eq!(buffer[(79, 0)].symbol(), " ");
|
||||
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
||||
assert!(pane_screen(&terminal, views.rects[0].1).contains("DOCUMENT"));
|
||||
assert!(terminal.get_cursor_position().unwrap().y >= 1);
|
||||
|
||||
editor.set_tabline_label("live".into());
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).ends_with("░▒▓ live "));
|
||||
assert!(!screen_text(&terminal).contains("90%"));
|
||||
editor.set_tabline_label(String::new());
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert_eq!(views.document.geometry.unwrap().1.y, 0);
|
||||
assert_eq!(screen_text(&terminal), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabline_label_keeps_a_gap_and_yields_to_the_full_active_title() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = Editor::open(
|
||||
Some(directory.file("a-very-long-inactive-title.txt", "first")),
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
editor.execute("tab.new");
|
||||
editor.execute("tab.new");
|
||||
editor.execute(&format!(
|
||||
"edit {}",
|
||||
directory.file("active.txt", "ACTIVE").display()
|
||||
));
|
||||
editor.set_tabline_label("90%".into());
|
||||
for width in [28, 29, 80] {
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
assert_eq!(draw_tab_bar(frame, &editor, area).y, 1);
|
||||
})
|
||||
.unwrap();
|
||||
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
|
||||
assert!(title.contains("3 active.txt"), "{title}");
|
||||
if width == 28 {
|
||||
assert!(!title.contains("90%"), "{title}");
|
||||
} else {
|
||||
assert!(title.ends_with(" ░▒▓ 90% "), "{title}");
|
||||
}
|
||||
}
|
||||
editor.set_tabline_label("界".repeat(1024));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 2)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
draw_tab_bar(frame, &editor, area);
|
||||
})
|
||||
.unwrap();
|
||||
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||
assert!(title.contains("3 active.txt"), "{title}");
|
||||
assert!(!title.contains('界'), "{title}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabline_label_stays_above_split_overlays_and_diff_geometry() {
|
||||
let directory = Directory::new();
|
||||
let mut editor =
|
||||
Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap();
|
||||
editor.execute("pane.split_vertical");
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let original = views.rects.clone();
|
||||
editor.set_tabline_label("live".into());
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
for ((id, before), (after_id, after)) in original.iter().zip(&views.rects) {
|
||||
assert_eq!(id, after_id);
|
||||
assert_eq!(
|
||||
*after,
|
||||
Rect::new(before.x, before.y + 1, before.width, before.height - 1)
|
||||
);
|
||||
}
|
||||
let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Commands"));
|
||||
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title);
|
||||
|
||||
let mut editor = readonly_diff_editor(deletion_file());
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let regions = views.panels.diff.as_ref().unwrap().regions;
|
||||
editor.set_tabline_label("live".into());
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
for ((before, focus), (after, after_focus)) in regions
|
||||
.into_iter()
|
||||
.zip(views.panels.diff.as_ref().unwrap().regions)
|
||||
{
|
||||
assert_eq!(focus, after_focus);
|
||||
if before == Rect::default() {
|
||||
assert_eq!(after, before);
|
||||
} else {
|
||||
assert_eq!(
|
||||
after,
|
||||
Rect::new(before.x, before.y + 1, before.width, before.height - 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(views.panels.files.unwrap().0.y, 2);
|
||||
let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
views.panels.files.unwrap().0,
|
||||
floating_panel(Rect::new(0, 1, 120, 29))
|
||||
);
|
||||
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() {
|
||||
let directory = Directory::new();
|
||||
|
||||
Reference in New Issue
Block a user