feat: configure chrome segments and anchor groups
This commit is contained in:
+313
@@ -0,0 +1,313 @@
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const STATUS_SEGMENTS: &[&str] = &[
|
||||
"mode",
|
||||
"pending",
|
||||
"buffer",
|
||||
"diagnostics",
|
||||
"path",
|
||||
"selections",
|
||||
"count",
|
||||
"language",
|
||||
"position",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ChromeLine {
|
||||
pub left: Vec<String>,
|
||||
pub center: Vec<String>,
|
||||
pub right: Vec<String>,
|
||||
pub segments: BTreeMap<String, SegmentConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SegmentConfig {
|
||||
pub title: String,
|
||||
pub padding: usize,
|
||||
pub leading_edge: String,
|
||||
pub trailing_edge: String,
|
||||
pub first_leading_edge: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SegmentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: String::new(),
|
||||
padding: 1,
|
||||
leading_edge: String::new(),
|
||||
trailing_edge: String::new(),
|
||||
first_leading_edge: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChromeLine {
|
||||
pub fn tabline() -> Self {
|
||||
Self {
|
||||
left: vec!["tabs".into()],
|
||||
center: Vec::new(),
|
||||
right: Vec::new(),
|
||||
segments: BTreeMap::from([("tabs".into(), default_segment("tabs"))]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn statusline() -> Self {
|
||||
Self {
|
||||
left: STATUS_SEGMENTS[..STATUS_SEGMENTS.len() - 1]
|
||||
.iter()
|
||||
.map(|name| (*name).into())
|
||||
.collect(),
|
||||
center: Vec::new(),
|
||||
right: vec!["position".into()],
|
||||
segments: STATUS_SEGMENTS
|
||||
.iter()
|
||||
.map(|name| ((*name).into(), default_segment(name)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Styles are populated for every referenced name during configuration validation.
|
||||
pub fn segment(&self, name: &str) -> &SegmentConfig {
|
||||
self.segments.get(name).expect("validated chrome segment")
|
||||
}
|
||||
|
||||
pub fn validate(&mut self, tabline: bool) -> Result<(), String> {
|
||||
for name in self
|
||||
.left
|
||||
.iter()
|
||||
.chain(&self.center)
|
||||
.chain(&self.right)
|
||||
.chain(self.segments.keys())
|
||||
{
|
||||
validate_name(name)?;
|
||||
if (tabline && STATUS_SEGMENTS.contains(&name.as_str())) || (!tabline && name == "tabs")
|
||||
{
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} is only available in the {}",
|
||||
if tabline { "statusline" } else { "tabline" }
|
||||
));
|
||||
}
|
||||
}
|
||||
for (name, segment) in &self.segments {
|
||||
if segment.padding > 64 {
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} padding must be between 0 and 64"
|
||||
));
|
||||
}
|
||||
for (option, text) in [
|
||||
("title", Some(&segment.title)),
|
||||
("leading_edge", Some(&segment.leading_edge)),
|
||||
("trailing_edge", Some(&segment.trailing_edge)),
|
||||
("first_leading_edge", segment.first_leading_edge.as_ref()),
|
||||
] {
|
||||
if text.is_some_and(|text| text.chars().any(char::is_control)) {
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} {option} must be single-line text without controls"
|
||||
));
|
||||
}
|
||||
}
|
||||
validate_title(&segment.title)
|
||||
.map_err(|error| format!("Chrome segment {name:?}: {error}"))?;
|
||||
}
|
||||
for name in self.left.iter().chain(&self.center).chain(&self.right) {
|
||||
if !self.segments.contains_key(name) {
|
||||
self.segments.insert(name.clone(), default_segment(name));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_segment(name: &str) -> SegmentConfig {
|
||||
let mut segment = SegmentConfig::default();
|
||||
if matches!(name, "tabs" | "position") {
|
||||
segment.leading_edge = "░▒▓".into();
|
||||
}
|
||||
if matches!(name, "tabs" | "mode") {
|
||||
segment.trailing_edge = "▓▒░".into();
|
||||
}
|
||||
if name == "tabs" {
|
||||
segment.title = "{index} {name}{modified}".into();
|
||||
segment.first_leading_edge = Some(String::new());
|
||||
}
|
||||
segment
|
||||
}
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<(), String> {
|
||||
let mut bytes = name.bytes();
|
||||
if !bytes
|
||||
.next()
|
||||
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
|
||||
|| !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
return Err(format!(
|
||||
"Label/segment name {name:?} must match [A-Za-z_][A-Za-z0-9_]*"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_title(title: &str) -> Result<(), String> {
|
||||
let mut rest = title;
|
||||
while let Some(index) = rest.find(['{', '}']) {
|
||||
rest = &rest[index..];
|
||||
if !rest.starts_with('{') {
|
||||
return Err("title contains an unmatched '}'".into());
|
||||
}
|
||||
let end = rest.find('}').ok_or("title contains an unmatched '{'")?;
|
||||
let placeholder = &rest[1..end];
|
||||
if !matches!(placeholder, "index" | "name" | "path" | "modified") {
|
||||
return Err(format!(
|
||||
"unknown title placeholder {{{placeholder}}}; expected index, name, path, or modified"
|
||||
));
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LineOptions {
|
||||
left: Option<Vec<String>>,
|
||||
center: Option<Vec<String>>,
|
||||
right: Option<Vec<String>>,
|
||||
#[serde(flatten)]
|
||||
segments: BTreeMap<String, SegmentOptions>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SegmentOptions {
|
||||
title: Option<String>,
|
||||
padding: Option<usize>,
|
||||
leading_edge: Option<String>,
|
||||
trailing_edge: Option<String>,
|
||||
first_leading_edge: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_line<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
tabline: bool,
|
||||
) -> Result<ChromeLine, D::Error> {
|
||||
// Decode through a value so explicit null is rejected rather than treated as an omitted option.
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| serde::de::Error::custom("chrome line must be an object"))?;
|
||||
for (name, value) in object {
|
||||
if value.is_null()
|
||||
|| value
|
||||
.as_object()
|
||||
.is_some_and(|options| options.values().any(serde_json::Value::is_null))
|
||||
{
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"Chrome option {name:?} must not be null"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let options: LineOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
|
||||
let mut line = if tabline {
|
||||
ChromeLine::tabline()
|
||||
} else {
|
||||
ChromeLine::statusline()
|
||||
};
|
||||
if let Some(left) = options.left {
|
||||
line.left = left;
|
||||
}
|
||||
if let Some(center) = options.center {
|
||||
line.center = center;
|
||||
}
|
||||
if let Some(right) = options.right {
|
||||
line.right = right;
|
||||
}
|
||||
for (name, options) in options.segments {
|
||||
let segment = line
|
||||
.segments
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| default_segment(&name));
|
||||
if let Some(title) = options.title {
|
||||
segment.title = title;
|
||||
}
|
||||
if let Some(padding) = options.padding {
|
||||
segment.padding = padding;
|
||||
}
|
||||
if let Some(edge) = options.leading_edge {
|
||||
segment.leading_edge = edge;
|
||||
}
|
||||
if let Some(edge) = options.trailing_edge {
|
||||
segment.trailing_edge = edge;
|
||||
}
|
||||
if let Some(edge) = options.first_leading_edge {
|
||||
segment.first_leading_edge = Some(edge);
|
||||
}
|
||||
}
|
||||
line.validate(tabline).map_err(serde::de::Error::custom)?;
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
pub fn tabline<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
|
||||
deserialize_line(deserializer, true)
|
||||
}
|
||||
|
||||
pub fn statusline<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
|
||||
deserialize_line(deserializer, false)
|
||||
}
|
||||
|
||||
pub fn labels<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<String, String>, D::Error> {
|
||||
let labels = BTreeMap::<String, String>::deserialize(deserializer)?;
|
||||
for name in labels.keys() {
|
||||
validate_name(name).map_err(serde::de::Error::custom)?;
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Lines {
|
||||
#[serde(deserialize_with = "tabline")]
|
||||
top: ChromeLine,
|
||||
#[serde(deserialize_with = "statusline")]
|
||||
bottom: ChromeLine,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
|
||||
"bottom": {"left": [], "center": ["workspace"]}
|
||||
})).unwrap();
|
||||
assert_eq!(lines.top.left, ["tabs"]);
|
||||
assert_eq!(lines.top.segment("tabs").leading_edge, "░▒▓");
|
||||
assert_eq!(lines.top.segment("battery").padding, 0);
|
||||
assert!(lines.bottom.left.is_empty());
|
||||
assert_eq!(lines.bottom.right, ["position"]);
|
||||
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_chrome_is_rejected_at_the_boundary() {
|
||||
for top in [
|
||||
serde_json::json!({"left": ["bad-name"]}),
|
||||
serde_json::json!({"left": ["mode"]}),
|
||||
serde_json::json!({"left": null}),
|
||||
serde_json::json!({"tabs": {"padding": 65}}),
|
||||
serde_json::json!({"tabs": {"padding": null}}),
|
||||
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
|
||||
serde_json::json!({"tabs": {"title": "{unknown}"}}),
|
||||
serde_json::json!({"tabs": {"title": "{name"}}),
|
||||
serde_json::json!({"tabs": {"title": "name}"}}),
|
||||
serde_json::json!({"tabs": {"colour": "red"}}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Lines>(serde_json::json!({"top": top, "bottom": {}}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
-67
@@ -1,3 +1,4 @@
|
||||
use crate::chrome::{ChromeLine, validate_name};
|
||||
use crate::clipboard::{ClipboardMode, SystemClipboard};
|
||||
use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget};
|
||||
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
|
||||
@@ -179,7 +180,7 @@ pub enum Effect {
|
||||
Insert(String),
|
||||
Replace(String),
|
||||
Message(String),
|
||||
TablineLabel(String),
|
||||
Label { name: String, text: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -1350,7 +1351,9 @@ pub struct Editor {
|
||||
owned_system_clipboard: Option<(String, Vec<String>, bool)>,
|
||||
last_edit: Option<EditRecipe>,
|
||||
message: String,
|
||||
tabline_label: String,
|
||||
tabline: ChromeLine,
|
||||
statusline: ChromeLine,
|
||||
labels: BTreeMap<String, String>,
|
||||
quit: bool,
|
||||
capabilities: Vec<String>,
|
||||
features: HashMap<String, Feature>,
|
||||
@@ -1436,7 +1439,9 @@ impl Editor {
|
||||
owned_system_clipboard: None,
|
||||
last_edit: None,
|
||||
message: String::new(),
|
||||
tabline_label: String::new(),
|
||||
tabline: ChromeLine::tabline(),
|
||||
statusline: ChromeLine::statusline(),
|
||||
labels: BTreeMap::new(),
|
||||
quit: false,
|
||||
capabilities,
|
||||
features: HashMap::new(),
|
||||
@@ -3826,8 +3831,14 @@ impl Editor {
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
pub fn tabline_label(&self) -> &str {
|
||||
&self.tabline_label
|
||||
pub fn tabline_config(&self) -> &ChromeLine {
|
||||
&self.tabline
|
||||
}
|
||||
pub fn statusline_config(&self) -> &ChromeLine {
|
||||
&self.statusline
|
||||
}
|
||||
pub fn label(&self, name: &str) -> &str {
|
||||
self.labels.get(name).map_or("", String::as_str)
|
||||
}
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.active.path.as_deref()
|
||||
@@ -3868,19 +3879,46 @@ 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;
|
||||
pub fn set_chrome_config(
|
||||
&mut self,
|
||||
mut tabline: ChromeLine,
|
||||
mut statusline: ChromeLine,
|
||||
labels: BTreeMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
tabline.validate(true)?;
|
||||
statusline.validate(false)?;
|
||||
let mut total = 0usize;
|
||||
for (name, text) in &labels {
|
||||
validate_label(name, text)?;
|
||||
total = total
|
||||
.checked_add(name.len())
|
||||
.and_then(|total| total.checked_add(text.len()))
|
||||
.ok_or("Labels exceed 4 MiB")?;
|
||||
if total > MAX_EDIT_BYTES {
|
||||
return Err("Labels exceed 4 MiB".into());
|
||||
}
|
||||
}
|
||||
self.tabline_label = text;
|
||||
true
|
||||
self.tabline = tabline;
|
||||
self.statusline = statusline;
|
||||
self.labels = labels
|
||||
.into_iter()
|
||||
.map(|(name, text)| (name, sanitize_label(text)))
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_label(&mut self, name: String, text: String) -> Result<bool, String> {
|
||||
validate_label(&name, &text)?;
|
||||
let text = sanitize_label(text);
|
||||
if self.label(&name) == text {
|
||||
return Ok(false);
|
||||
}
|
||||
if text.is_empty() {
|
||||
self.labels.remove(&name);
|
||||
} else {
|
||||
self.labels.insert(name, text);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn register(&mut self, feature: Feature) -> Result<(), String> {
|
||||
@@ -5767,24 +5805,7 @@ impl Editor {
|
||||
{
|
||||
return Err("Diff view is read-only".into());
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for effect in &effects {
|
||||
let text = match effect {
|
||||
Effect::Insert(text)
|
||||
| Effect::Replace(text)
|
||||
| Effect::Message(text)
|
||||
| Effect::TablineLabel(text) => text,
|
||||
};
|
||||
total = total
|
||||
.checked_add(text.len())
|
||||
.ok_or("Plugin effects exceed 4 MiB")?;
|
||||
if total > MAX_EDIT_BYTES {
|
||||
return Err("Plugin effects exceed 4 MiB".into());
|
||||
}
|
||||
if !matches!(effect, Effect::TablineLabel(_)) {
|
||||
validate_text(text)?;
|
||||
}
|
||||
}
|
||||
validate_effects(&effects)?;
|
||||
if !effects
|
||||
.iter()
|
||||
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
|
||||
@@ -5792,8 +5813,8 @@ impl Editor {
|
||||
for effect in effects {
|
||||
match effect {
|
||||
Effect::Message(text) => self.set_message(text),
|
||||
Effect::TablineLabel(text) => {
|
||||
self.set_tabline_label(text);
|
||||
Effect::Label { name, text } => {
|
||||
self.set_label(name, text)?;
|
||||
}
|
||||
_ => unreachable!("presentation-only effects"),
|
||||
}
|
||||
@@ -5837,8 +5858,8 @@ impl Editor {
|
||||
self.normalize_targets();
|
||||
}
|
||||
Effect::Message(text) => self.set_message(text),
|
||||
Effect::TablineLabel(text) => {
|
||||
self.set_tabline_label(text);
|
||||
Effect::Label { name, text } => {
|
||||
self.set_label(name, text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8296,6 +8317,54 @@ fn validate_paste_text(text: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_effects(effects: &[Effect]) -> Result<(), String> {
|
||||
let mut total = 0usize;
|
||||
for effect in effects {
|
||||
let text = match effect {
|
||||
Effect::Insert(text) | Effect::Replace(text) | Effect::Message(text) => text,
|
||||
Effect::Label { name, text } => {
|
||||
validate_label(name, text)?;
|
||||
total = total
|
||||
.checked_add(name.len())
|
||||
.ok_or("Plugin effects exceed 4 MiB")?;
|
||||
text
|
||||
}
|
||||
};
|
||||
total = total
|
||||
.checked_add(text.len())
|
||||
.ok_or("Plugin effects exceed 4 MiB")?;
|
||||
if total > MAX_EDIT_BYTES {
|
||||
return Err("Plugin effects exceed 4 MiB".into());
|
||||
}
|
||||
if !matches!(effect, Effect::Label { .. }) {
|
||||
validate_text(text)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_label(name: &str, text: &str) -> Result<(), String> {
|
||||
validate_name(name)?;
|
||||
if name
|
||||
.len()
|
||||
.checked_add(text.len())
|
||||
.is_none_or(|bytes| bytes > MAX_EDIT_BYTES)
|
||||
{
|
||||
return Err("Label exceeds 4 MiB".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_label(text: String) -> String {
|
||||
if text.chars().any(char::is_control) {
|
||||
text.chars()
|
||||
.map(|ch| if ch.is_control() { '\u{fffd}' } else { ch })
|
||||
.collect()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_text(text: &str) -> Result<(), String> {
|
||||
if let Some(ch) = text
|
||||
.chars()
|
||||
@@ -16972,10 +17041,13 @@ mod tests {
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::Message("ok".into()),
|
||||
Effect::TablineLabel("ready".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "ready".into(),
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(editor.tabline_label(), "ready");
|
||||
assert_eq!(editor.label("battery"), "ready");
|
||||
assert_eq!(editor.selections(), ranges);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
editor.apply(vec![Effect::Insert("!".into())]).unwrap();
|
||||
@@ -17674,23 +17746,91 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabline_label_sanitizes_once_and_is_global_until_cleared() {
|
||||
fn chrome_installation_and_invalid_label_batches_are_atomic() {
|
||||
let mut editor = editor();
|
||||
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_label("battery".into(), "ready".into()).unwrap();
|
||||
let mut top = ChromeLine::tabline();
|
||||
top.center.push("workspace".into());
|
||||
let bottom = ChromeLine::statusline();
|
||||
editor
|
||||
.set_chrome_config(
|
||||
top.clone(),
|
||||
bottom.clone(),
|
||||
BTreeMap::from([
|
||||
("battery".into(), "ready".into()),
|
||||
("workspace".into(), "tools\n".into()),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(editor.label("workspace"), "tools�");
|
||||
assert_eq!(
|
||||
editor.tabline_config().segment("workspace"),
|
||||
&crate::chrome::SegmentConfig::default()
|
||||
);
|
||||
let installed = editor.tabline_config().clone();
|
||||
top.segments.get_mut("tabs").unwrap().title = "{missing}".into();
|
||||
assert!(
|
||||
editor
|
||||
.set_chrome_config(top, bottom, BTreeMap::new())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(editor.tabline_config(), &installed);
|
||||
assert_eq!(editor.label("battery"), "ready");
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::Message("must not leak".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "changed".into()
|
||||
},
|
||||
Effect::Insert("must not edit".into()),
|
||||
Effect::Label {
|
||||
name: "bad-name".into(),
|
||||
text: "invalid".into()
|
||||
},
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(editor.label("battery"), "ready");
|
||||
assert_eq!(editor.message(), "");
|
||||
assert_eq!(editor.context().text, "");
|
||||
assert_eq!(editor.text_revision(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_labels_sanitize_and_survive_tabs_until_independently_cleared() {
|
||||
let mut editor = editor();
|
||||
assert_eq!(editor.label("battery"), "");
|
||||
assert!(
|
||||
editor
|
||||
.set_label("battery".into(), "界 e\u{301} 👩🚀".into())
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!editor
|
||||
.set_label("battery".into(), "界 e\u{301} 👩🚀".into())
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
editor
|
||||
.set_label("battery".into(), "界\0\t\r\n\u{1b}\u{85}👩🚀".into())
|
||||
.unwrap()
|
||||
);
|
||||
editor
|
||||
.set_label("workspace".into(), "tools".into())
|
||||
.unwrap();
|
||||
assert_eq!(editor.label("battery"), "界������👩🚀");
|
||||
editor.set_message("transient");
|
||||
editor.execute("tab.new");
|
||||
assert_eq!(editor.tabline_label(), "界������👩🚀");
|
||||
assert_eq!(editor.label("battery"), "界������👩🚀");
|
||||
editor.execute("tab.previous");
|
||||
assert_eq!(editor.tabline_label(), "界������👩🚀");
|
||||
assert!(editor.set_tabline_label(String::new()));
|
||||
assert_eq!(editor.label("workspace"), "tools");
|
||||
assert!(editor.set_label("battery".into(), String::new()).unwrap());
|
||||
editor.execute("tab.next");
|
||||
assert_eq!(editor.tabline_label(), "");
|
||||
assert!(!editor.set_tabline_label(String::new()));
|
||||
assert_eq!(editor.label("battery"), "");
|
||||
assert!(!editor.set_label("battery".into(), String::new()).unwrap());
|
||||
assert_eq!(editor.label("workspace"), "tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -17710,11 +17850,14 @@ mod tests {
|
||||
let history = (editor.active.undo.len(), editor.active.redo.len());
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("界\n\u{1b}".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "界\n\u{1b}".into(),
|
||||
},
|
||||
Effect::Message("read only".into()),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(editor.tabline_label(), "界��");
|
||||
assert_eq!(editor.label("battery"), "界��");
|
||||
assert_eq!(editor.message(), "read only");
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.selections(), selection);
|
||||
@@ -17727,27 +17870,38 @@ mod tests {
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("must not leak".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "must not leak".into()
|
||||
},
|
||||
Effect::Insert("rejected".into()),
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(editor.tabline_label(), "界��");
|
||||
assert_eq!(editor.label("battery"), "界��");
|
||||
editor
|
||||
.apply(vec![Effect::TablineLabel(String::new())])
|
||||
.apply(vec![Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: String::new(),
|
||||
}])
|
||||
.unwrap();
|
||||
assert_eq!(editor.tabline_label(), "");
|
||||
assert_eq!(editor.label("battery"), "");
|
||||
}
|
||||
|
||||
#[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());
|
||||
editor
|
||||
.set_label("battery".into(), "unchanged".into())
|
||||
.unwrap();
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("must not leak".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "must not leak".into()
|
||||
},
|
||||
Effect::Insert("good".into()),
|
||||
Effect::Message("bad\u{1b}".into())
|
||||
])
|
||||
@@ -17755,31 +17909,37 @@ mod tests {
|
||||
);
|
||||
assert_eq!(editor.context().text, "");
|
||||
assert_eq!(editor.message(), "unchanged");
|
||||
assert_eq!(editor.tabline_label(), "unchanged");
|
||||
assert_eq!(editor.label("battery"), "unchanged");
|
||||
assert!(!editor.dirty());
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("x".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "x".into()
|
||||
},
|
||||
Effect::Insert("a".repeat(MAX_EDIT_BYTES))
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(editor.context().text, "");
|
||||
assert_eq!(editor.tabline_label(), "unchanged");
|
||||
assert_eq!(editor.label("battery"), "unchanged");
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::Insert("one".into()),
|
||||
Effect::Insert("\ntwo".into()),
|
||||
Effect::Message("ok".into()),
|
||||
Effect::TablineLabel("updated".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "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");
|
||||
assert_eq!(editor.label("battery"), "updated");
|
||||
editor.handle(Input::Char('U'));
|
||||
assert_eq!(editor.context().text, "one\ntwo");
|
||||
editor
|
||||
@@ -17798,7 +17958,10 @@ mod tests {
|
||||
let undo_len = editor.active.undo.len();
|
||||
editor
|
||||
.apply(vec![
|
||||
Effect::TablineLabel("typing".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "typing".into(),
|
||||
},
|
||||
Effect::Message("notice".into()),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod chrome;
|
||||
pub mod clipboard;
|
||||
pub mod diff;
|
||||
pub mod editor;
|
||||
|
||||
+2
-1
@@ -176,7 +176,8 @@ pub enum UpdateEffect {
|
||||
Message {
|
||||
text: String,
|
||||
},
|
||||
TablineLabel {
|
||||
Label {
|
||||
name: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
+131
-40
@@ -1,3 +1,4 @@
|
||||
use crate::chrome::ChromeLine;
|
||||
use crate::editor::{Context, Effect, Feature};
|
||||
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -8,7 +9,10 @@ use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{collections::HashSet, env};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
env,
|
||||
};
|
||||
|
||||
const RUNNER: &str = concat!(
|
||||
include_str!("ruby_values.rb"),
|
||||
@@ -34,7 +38,9 @@ pub struct Config {
|
||||
pub file_picker_view: String,
|
||||
pub file_explorer_view: String,
|
||||
pub clipboard_mode: String,
|
||||
pub tabline_label: String,
|
||||
pub tabline: ChromeLine,
|
||||
pub statusline: ChromeLine,
|
||||
pub labels: BTreeMap<String, String>,
|
||||
pub diff_context_lines: usize,
|
||||
pub diff_expand_lines: usize,
|
||||
}
|
||||
@@ -71,7 +77,9 @@ impl Default for Config {
|
||||
file_picker_view: "tree".into(),
|
||||
file_explorer_view: "tree".into(),
|
||||
clipboard_mode: "system".into(),
|
||||
tabline_label: String::new(),
|
||||
tabline: ChromeLine::tabline(),
|
||||
statusline: ChromeLine::statusline(),
|
||||
labels: BTreeMap::new(),
|
||||
diff_context_lines: 10,
|
||||
diff_expand_lines: 15,
|
||||
}
|
||||
@@ -94,7 +102,9 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
file_picker_view,
|
||||
file_explorer_view,
|
||||
clipboard_mode,
|
||||
tabline_label,
|
||||
tabline,
|
||||
statusline,
|
||||
labels,
|
||||
diff_context_lines,
|
||||
diff_expand_lines,
|
||||
..
|
||||
@@ -119,7 +129,9 @@ 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.tabline = tabline;
|
||||
config.statusline = statusline;
|
||||
config.labels = labels;
|
||||
config.diff_context_lines = diff_context_lines;
|
||||
config.diff_expand_lines = diff_expand_lines;
|
||||
Ok(config)
|
||||
@@ -350,7 +362,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),
|
||||
WireEffect::Label { name, text } => Effect::Label { name, text },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -438,7 +450,12 @@ enum Response {
|
||||
file_picker_view: String,
|
||||
file_explorer_view: String,
|
||||
clipboard_mode: String,
|
||||
tabline_label: String,
|
||||
#[serde(deserialize_with = "crate::chrome::tabline")]
|
||||
tabline: ChromeLine,
|
||||
#[serde(deserialize_with = "crate::chrome::statusline")]
|
||||
statusline: ChromeLine,
|
||||
#[serde(deserialize_with = "crate::chrome::labels")]
|
||||
labels: BTreeMap<String, String>,
|
||||
#[serde(deserialize_with = "diff_context_lines")]
|
||||
diff_context_lines: usize,
|
||||
#[serde(deserialize_with = "diff_expand_lines")]
|
||||
@@ -500,8 +517,8 @@ enum WireEffect {
|
||||
Replace { text: String },
|
||||
#[serde(rename = "message")]
|
||||
Message { text: String },
|
||||
#[serde(rename = "tabline_label")]
|
||||
TablineLabel { text: String },
|
||||
#[serde(rename = "label")]
|
||||
Label { name: String, text: String },
|
||||
}
|
||||
|
||||
struct OutboundFrame {
|
||||
@@ -890,8 +907,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 ""
|
||||
editor.label :battery, "界 e\u0301 👩🚀\t\n\e"
|
||||
editor.label "battery", ""
|
||||
end
|
||||
"#);
|
||||
assert_eq!(feature.name, "greeting");
|
||||
@@ -902,8 +919,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), Effect::TablineLabel(label), Effect::TablineLabel(cleared)]
|
||||
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::Label { name, text: label }, Effect::Label { name: cleared_name, text: cleared }]
|
||||
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted"
|
||||
&& name == "battery" && cleared_name == "battery"
|
||||
&& label == "界 e\u{301} 👩🚀\t\n\u{1b}" && cleared.is_empty())
|
||||
);
|
||||
}
|
||||
@@ -1163,47 +1181,109 @@ 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","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}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline":{},"statusline":{},"labels":{},"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":{},"statusline":{},"labels":{},"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":{},"statusline":{},"labels":{},"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() {
|
||||
fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() {
|
||||
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",
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.tabline = {left: [:tabs], center: ["workspace"], right: [:battery],
|
||||
tabs: {title: "T{index}:{path}{modified}", padding: 0, first_leading_edge: ""},
|
||||
battery: {leading_edge: "界", trailing_edge: ""}}
|
||||
config.statusline = {"left" => [], center: [:workspace]}
|
||||
config.labels = {battery: "bat 38%", "workspace" => "界 e\u0301 👩🚀\t\n\e"}
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let loaded = load_config(&path).unwrap();
|
||||
assert_eq!(loaded.tabline.left, ["tabs"]);
|
||||
assert_eq!(loaded.tabline.center, ["workspace"]);
|
||||
assert_eq!(loaded.tabline.right, ["battery"]);
|
||||
assert_eq!(
|
||||
loaded.tabline.segment("tabs").title,
|
||||
"T{index}:{path}{modified}"
|
||||
);
|
||||
assert_eq!(loaded.tabline.segment("tabs").padding, 0);
|
||||
assert_eq!(loaded.tabline.segment("tabs").trailing_edge, "▓▒░");
|
||||
assert_eq!(loaded.tabline.segment("battery").leading_edge, "界");
|
||||
assert_eq!(
|
||||
loaded.tabline.segment("workspace"),
|
||||
&crate::chrome::SegmentConfig::default()
|
||||
);
|
||||
assert!(loaded.statusline.left.is_empty());
|
||||
assert_eq!(loaded.statusline.right, ["position"]);
|
||||
assert_eq!(loaded.labels["battery"], "bat 38%");
|
||||
assert_eq!(loaded.labels["workspace"], "界 e\u{301} 👩🚀\t\n\u{1b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrome_configuration_rejects_invalid_types_names_and_templates() {
|
||||
let fixture = Fixture::new();
|
||||
for (setting, value, expected) in [
|
||||
("tabline", "[]", "must be a Hash"),
|
||||
("tabline", "{left: :tabs}", "must be an Array"),
|
||||
("tabline", "{left: [7]}", "must be a String"),
|
||||
("tabline", "{left: [:bad_name!]}", "must match"),
|
||||
(
|
||||
"tabline",
|
||||
"{left: [:mode]}",
|
||||
"only available in the statusline",
|
||||
),
|
||||
(
|
||||
"statusline",
|
||||
"{right: [:tabs]}",
|
||||
"only available in the tabline",
|
||||
),
|
||||
(
|
||||
"tabline",
|
||||
"{tabs: {title: '{unknown}'}}",
|
||||
"unknown title placeholder",
|
||||
),
|
||||
("tabline", r#"{tabs: {leading_edge: "\n"}}"#, "single-line"),
|
||||
("tabline", "{tabs: {padding: 65}}", "between 0 and 64"),
|
||||
("tabline", "{tabs: {padding: '1'}}", "must be an Integer"),
|
||||
("tabline", "{tabs: {title: nil}}", "must be a String"),
|
||||
(
|
||||
"tabline",
|
||||
"{tabs: {color: 'red'}}",
|
||||
"unknown chrome style option",
|
||||
),
|
||||
(
|
||||
"tabline",
|
||||
"{left: [], 'left' => []}",
|
||||
"duplicate chrome key",
|
||||
),
|
||||
("labels", "[]", "must be a Hash"),
|
||||
("labels", "{battery: 7}", "label text must be a String"),
|
||||
("labels", "{'bad-name' => ''}", "must match"),
|
||||
] {
|
||||
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} }}"),
|
||||
&format!("Vamp.configure {{ |config| config.{setting} = {value} }}"),
|
||||
);
|
||||
let error = load_config(&path).unwrap_err();
|
||||
assert!(
|
||||
error.contains("tabline label must be a String"),
|
||||
"{value}: {error}"
|
||||
);
|
||||
assert!(error.contains(expected), "{setting}={value}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabline_label_wire_effects_require_string_text() {
|
||||
fn label_wire_effects_require_name_and_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}),
|
||||
serde_json::json!({"type": "label", "text": ""}),
|
||||
serde_json::json!({"type": "label", "name": 7, "text": ""}),
|
||||
serde_json::json!({"type": "label", "name": "battery"}),
|
||||
serde_json::json!({"type": "label", "name": "battery", "text": null}),
|
||||
serde_json::json!({"type": "label", "name": "battery", "text": 7}),
|
||||
serde_json::json!({"type": "label", "name": "battery", "text": "", "extra": true}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<WireEffect>(frame.clone()).is_err());
|
||||
assert!(serde_json::from_value::<UpdateEffect>(frame).is_err());
|
||||
@@ -1230,7 +1310,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": "",
|
||||
"tabline": {}, "statusline": {}, "labels": {},
|
||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
|
||||
});
|
||||
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
||||
@@ -1643,7 +1723,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": "",
|
||||
"tabline": {}, "statusline": {}, "labels": {},
|
||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
|
||||
});
|
||||
let Response::Config { lsp, .. } =
|
||||
@@ -1726,8 +1806,19 @@ end"#,
|
||||
for mutation in [
|
||||
"return '['",
|
||||
"frame.delete('lsp')",
|
||||
"frame.delete('tabline_label')",
|
||||
"frame['tabline_label'] = 7",
|
||||
"frame.delete('tabline')",
|
||||
"frame.delete('statusline')",
|
||||
"frame.delete('labels')",
|
||||
"frame['tabline'] = 7",
|
||||
"frame['statusline'] = []",
|
||||
"frame['labels'] = {'battery' => 7}",
|
||||
"frame['tabline'] = {'tabs' => {'title' => '{unknown}'}}",
|
||||
"frame['tabline'] = {'tabs' => {'leading_edge' => \"\\n\"}}",
|
||||
"frame['tabline'] = {'left' => nil}",
|
||||
"frame['tabline'] = {'left' => ['bad-name']}",
|
||||
"frame['tabline'] = {'tabs' => {'padding' => '1'}}",
|
||||
"frame['tabline'] = {'tabs' => {'color' => 'red'}}",
|
||||
"frame['labels'] = {'bad-name' => ''}",
|
||||
"frame['leader'] = 7",
|
||||
"frame['unexpected'] = true",
|
||||
] {
|
||||
|
||||
+85
-13
@@ -1,6 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Vamp
|
||||
# @param name [String, Symbol]
|
||||
# @return [String]
|
||||
def self.chrome_name!(name)
|
||||
name = name.to_s if name.is_a?(Symbol)
|
||||
name = string!(name, "label/segment name")
|
||||
unless /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name)
|
||||
raise ArgumentError, "label/segment name must match [A-Za-z_][A-Za-z0-9_]*"
|
||||
end
|
||||
name
|
||||
end
|
||||
|
||||
# Collects and validates the Ruby startup configuration DSL.
|
||||
class Config
|
||||
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
||||
@@ -16,7 +27,9 @@ module Vamp
|
||||
@file_picker_view = "tree"
|
||||
@file_explorer_view = "tree"
|
||||
@clipboard_mode = "system"
|
||||
@tabline_label = ""
|
||||
@tabline = {}
|
||||
@statusline = {}
|
||||
@labels = {}
|
||||
@diff_context_lines = 10
|
||||
@diff_expand_lines = 15
|
||||
end
|
||||
@@ -63,10 +76,24 @@ 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")
|
||||
# @param options [Hash{String, Symbol => Object}]
|
||||
# @return [Hash]
|
||||
def tabline=(options)
|
||||
@tabline = chrome_line!(options)
|
||||
end
|
||||
|
||||
# @param options [Hash{String, Symbol => Object}]
|
||||
# @return [Hash]
|
||||
def statusline=(options)
|
||||
@statusline = chrome_line!(options)
|
||||
end
|
||||
|
||||
# @param values [Hash{String, Symbol => String}]
|
||||
# @return [Hash]
|
||||
def labels=(values)
|
||||
@labels = chrome_hash!(values).to_h do |name, text|
|
||||
[Vamp.chrome_name!(name), Vamp.string!(text, "label text")]
|
||||
end
|
||||
end
|
||||
|
||||
# @param lines [Integer] nonnegative context lines around each diff change
|
||||
@@ -111,11 +138,51 @@ 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, "tabline_label" => @tabline_label,
|
||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||
"tabline" => @tabline, "statusline" => @statusline, "labels" => @labels,
|
||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
|
||||
end
|
||||
|
||||
private
|
||||
# @param value [Hash{String, Symbol => Object}]
|
||||
# @return [Hash{String => Object}]
|
||||
def chrome_hash!(value)
|
||||
raise TypeError, "chrome options must be a Hash" unless value.is_a?(Hash)
|
||||
value.each_with_object({}) do |(key, item), result|
|
||||
key = key.to_s if key.is_a?(Symbol)
|
||||
key = Vamp.string!(key, "chrome key")
|
||||
raise ArgumentError, "duplicate chrome key: #{key}" if result.key?(key)
|
||||
result[key] = item
|
||||
end
|
||||
end
|
||||
|
||||
# @param options [Hash{String, Symbol => Object}]
|
||||
# @return [Hash{String => Object}]
|
||||
def chrome_line!(options)
|
||||
chrome_hash!(options).to_h do |name, value|
|
||||
if %w[left center right].include?(name)
|
||||
raise TypeError, "#{name} must be an Array" unless value.is_a?(Array)
|
||||
[name, value.map { |item| Vamp.chrome_name!(item) }]
|
||||
else
|
||||
Vamp.chrome_name!(name)
|
||||
style = chrome_hash!(value).to_h do |option, item|
|
||||
case option
|
||||
when "padding"
|
||||
unless item.is_a?(Integer) && item.between?(0, 64)
|
||||
raise ArgumentError, "padding must be an Integer between 0 and 64"
|
||||
end
|
||||
when "title", "leading_edge", "trailing_edge", "first_leading_edge"
|
||||
item = Vamp.string!(item, option)
|
||||
else
|
||||
raise ArgumentError, "unknown chrome style option: #{option}"
|
||||
end
|
||||
[option, item]
|
||||
end
|
||||
[name, style]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# @param value [Integer]
|
||||
# @param label [String]
|
||||
@@ -244,10 +311,12 @@ module Vamp
|
||||
queue("message", text)
|
||||
end
|
||||
|
||||
# @param text [String] plain text displayed at the right of the tab line; empty clears it
|
||||
# @param name [String, Symbol]
|
||||
# @param text [String] named session-wide plain text; empty clears it
|
||||
# @return [nil]
|
||||
def tabline_label(text)
|
||||
queue("tabline_label", text)
|
||||
def label(name, text)
|
||||
name = Vamp.chrome_name!(name)
|
||||
queue("label", text, name)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -294,15 +363,18 @@ module Vamp
|
||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||
end
|
||||
|
||||
# @param type [String] insert, replace, message, or tabline_label
|
||||
# @param type [String] insert, replace, message, or label
|
||||
# @param text [String]
|
||||
# @param name [String, nil]
|
||||
# @return [nil]
|
||||
def queue(type, text)
|
||||
def queue(type, text, name = nil)
|
||||
text = Vamp.string!(text, "effect text")
|
||||
@effect_bytes += text.bytesize
|
||||
@effect_bytes += text.bytesize + (name ? name.bytesize : 0)
|
||||
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
||||
|
||||
@effects << { "type" => type, "text" => text }
|
||||
effect = { "type" => type, "text" => text }
|
||||
effect["name"] = name if name
|
||||
@effects << effect
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
+178
-28
@@ -220,7 +220,11 @@ impl EditorSession {
|
||||
));
|
||||
}
|
||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||
editor.set_tabline_label(config.tabline_label);
|
||||
if let Err(error) =
|
||||
editor.set_chrome_config(config.tabline, config.statusline, config.labels)
|
||||
{
|
||||
notices.push(format!("chrome: {error}"));
|
||||
}
|
||||
let mut plugins = BTreeMap::new();
|
||||
for configured in config.plugins {
|
||||
let path = configured.path;
|
||||
@@ -658,7 +662,7 @@ impl EditorSession {
|
||||
&& self.editor.startup_diagnostic().is_none()
|
||||
}
|
||||
|
||||
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> {
|
||||
fn apply_update(&mut self, owner: &str, mut update: PluginUpdate) -> Result<bool, String> {
|
||||
if owner == "core"
|
||||
&& let Some(id) = update.request_id
|
||||
&& self
|
||||
@@ -668,7 +672,9 @@ impl EditorSession {
|
||||
.map(|latest| latest.0)
|
||||
!= Some(id)
|
||||
{
|
||||
return Ok(false);
|
||||
update
|
||||
.effects
|
||||
.retain(|effect| matches!(effect, UpdateEffect::Label { .. }));
|
||||
}
|
||||
for effect in &update.effects {
|
||||
if let UpdateEffect::Completion {
|
||||
@@ -697,6 +703,7 @@ impl EditorSession {
|
||||
}
|
||||
// Validate the whole applicable annotation batch before changing any buffer.
|
||||
let validation = (|| -> Result<(), String> {
|
||||
let mut label_bytes = 0usize;
|
||||
for effect in &update.effects {
|
||||
match effect {
|
||||
UpdateEffect::Diagnostics {
|
||||
@@ -757,9 +764,14 @@ impl EditorSession {
|
||||
}
|
||||
UpdateEffect::Completion { .. } => {}
|
||||
UpdateEffect::Message { .. } => {}
|
||||
UpdateEffect::TablineLabel { text } => {
|
||||
if text.len() > 4 * 1024 * 1024 {
|
||||
return Err("Tabline label exceeds 4 MiB".into());
|
||||
UpdateEffect::Label { name, text } => {
|
||||
crate::editor::validate_label(name, text)?;
|
||||
label_bytes = label_bytes
|
||||
.checked_add(name.len())
|
||||
.and_then(|bytes| bytes.checked_add(text.len()))
|
||||
.ok_or("Labels exceed 4 MiB")?;
|
||||
if label_bytes > 4 * 1024 * 1024 {
|
||||
return Err("Labels exceed 4 MiB".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,8 +869,8 @@ impl EditorSession {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
}
|
||||
UpdateEffect::TablineLabel { text } => {
|
||||
changed |= self.editor.set_tabline_label(text);
|
||||
UpdateEffect::Label { name, text } => {
|
||||
changed |= self.editor.set_label(name, text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -870,7 +882,7 @@ impl EditorSession {
|
||||
&mut self,
|
||||
owner: &str,
|
||||
id: u64,
|
||||
effects: Vec<Effect>,
|
||||
mut effects: Vec<Effect>,
|
||||
) -> Result<bool, String> {
|
||||
let source = self
|
||||
.requests
|
||||
@@ -879,16 +891,18 @@ impl EditorSession {
|
||||
let Some(source) = source else {
|
||||
return Err("plugin response has an unknown invocation ID".into());
|
||||
};
|
||||
if !self.source_is_current(source)
|
||||
|| self.editor.buffer_id() != source.buffer_id
|
||||
|| self.editor.pane_id() != source.pane_id
|
||||
{
|
||||
return Ok(false);
|
||||
// Document effects retain source freshness; labels belong to the whole session.
|
||||
let current = self.source_is_current(source)
|
||||
&& self.editor.buffer_id() == source.buffer_id
|
||||
&& self.editor.pane_id() == source.pane_id;
|
||||
if !current {
|
||||
effects.retain(|effect| matches!(effect, Effect::Label { .. }));
|
||||
}
|
||||
if effects
|
||||
.iter()
|
||||
.all(|effect| matches!(effect, Effect::Message(_) | Effect::TablineLabel(_)))
|
||||
.all(|effect| matches!(effect, Effect::Message(_) | Effect::Label { .. }))
|
||||
{
|
||||
crate::editor::validate_effects(&effects)?;
|
||||
let mut changed = false;
|
||||
for effect in effects {
|
||||
match effect {
|
||||
@@ -896,8 +910,8 @@ impl EditorSession {
|
||||
changed |= self.editor.message() != text;
|
||||
self.editor.set_message(text);
|
||||
}
|
||||
Effect::TablineLabel(text) => {
|
||||
changed |= self.editor.set_tabline_label(text);
|
||||
Effect::Label { name, text } => {
|
||||
changed |= self.editor.set_label(name, text)?;
|
||||
}
|
||||
_ => unreachable!("presentation-only effects checked above"),
|
||||
}
|
||||
@@ -2996,35 +3010,129 @@ end
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabline_updates_survive_tab_switches_and_remain_atomic_in_readonly_views() {
|
||||
fn startup_chrome_installs_named_values_without_editing_the_document() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "source");
|
||||
let session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.tabline = {right: [:battery]}
|
||||
config.statusline = {center: [:workspace]}
|
||||
config.labels = {battery: "bat 38%", workspace: "tools\n"}
|
||||
end
|
||||
"#,
|
||||
);
|
||||
assert_eq!(session.editor.label("battery"), "bat 38%");
|
||||
assert_eq!(session.editor.label("workspace"), "tools�");
|
||||
assert_eq!(session.editor.tabline_config().right, ["battery"]);
|
||||
assert_eq!(session.editor.statusline_config().center, ["workspace"]);
|
||||
assert_eq!(session.editor.lines(), ["source"]);
|
||||
assert_eq!(session.editor.text_revision(), 0);
|
||||
assert!(!session.editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asynchronous_result_labels_outlive_the_source_without_applying_stale_edits() {
|
||||
let fixture = Fixture::new();
|
||||
let target = fixture.file("other.txt", "");
|
||||
let mut session = EditorSession::open(None, vec![], None).unwrap();
|
||||
let source = InvocationSource {
|
||||
buffer_id: session.editor.buffer_id(),
|
||||
pane_id: session.editor.pane_id(),
|
||||
revision: session.editor.text_revision(),
|
||||
};
|
||||
session.editor.execute("tab.new");
|
||||
session
|
||||
.editor
|
||||
.execute(&format!("edit {}", target.display()));
|
||||
assert_ne!(session.editor.buffer_id(), source.buffer_id);
|
||||
session.editor.handle(Input::Char('i'));
|
||||
let message = session.editor.message().to_owned();
|
||||
for (id, effects) in [
|
||||
(
|
||||
1,
|
||||
vec![
|
||||
Effect::Insert("stale".into()),
|
||||
Effect::Message("stale message".into()),
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "bat 38%".into(),
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
2,
|
||||
vec![
|
||||
Effect::Label {
|
||||
name: "battery".into(),
|
||||
text: "must not leak".into(),
|
||||
},
|
||||
Effect::Label {
|
||||
name: "bad-name".into(),
|
||||
text: "invalid".into(),
|
||||
},
|
||||
],
|
||||
),
|
||||
] {
|
||||
session
|
||||
.requests
|
||||
.entry("fixture".into())
|
||||
.or_default()
|
||||
.pending
|
||||
.insert(id, source);
|
||||
let result = session.apply_response("fixture", id, effects);
|
||||
if id == 1 {
|
||||
assert!(result.unwrap());
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
assert_eq!(session.editor.label("battery"), "bat 38%");
|
||||
assert_eq!(session.editor.lines(), [""]);
|
||||
assert_eq!(session.editor.message(), message);
|
||||
assert_eq!(session.editor.mode(), Mode::Insert);
|
||||
assert_eq!(session.editor.text_revision(), 0);
|
||||
assert!(!session.editor.dirty());
|
||||
}
|
||||
session.editor.handle(Input::Char('x'));
|
||||
session.editor.handle(Input::Escape);
|
||||
session.editor.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor.lines(), [""]);
|
||||
assert_eq!(session.editor.label("battery"), "bat 38%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_labels_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%"
|
||||
"type": "label", "name": "battery", "text": "bat 37%"
|
||||
})],
|
||||
);
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor().tabline_label() == "bat 37%"
|
||||
session.editor().label("battery") == "bat 37%"
|
||||
});
|
||||
let gate = fixture.0.join("label.gate");
|
||||
hold(
|
||||
&mut session,
|
||||
&gate,
|
||||
vec![serde_json::json!({
|
||||
"type": "tabline_label", "text": "bat 38%"
|
||||
"type": "label", "name": "battery", "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%");
|
||||
assert_eq!(session.editor().label("battery"), "bat 38%");
|
||||
session.execute("tab.previous");
|
||||
assert_eq!(session.editor().tabline_label(), "bat 38%");
|
||||
assert_eq!(session.editor().label("battery"), "bat 38%");
|
||||
let update = |text: &str| PluginUpdate {
|
||||
request_id: None,
|
||||
effects: vec![UpdateEffect::TablineLabel { text: text.into() }],
|
||||
effects: vec![UpdateEffect::Label {
|
||||
name: "battery".into(),
|
||||
text: text.into(),
|
||||
}],
|
||||
};
|
||||
assert!(
|
||||
!session
|
||||
@@ -3034,7 +3142,8 @@ end
|
||||
let rejected = PluginUpdate {
|
||||
request_id: None,
|
||||
effects: vec![
|
||||
UpdateEffect::TablineLabel {
|
||||
UpdateEffect::Label {
|
||||
name: "battery".into(),
|
||||
text: "must not leak".into(),
|
||||
},
|
||||
UpdateEffect::SemanticTokens {
|
||||
@@ -3045,7 +3154,48 @@ end
|
||||
],
|
||||
};
|
||||
assert!(session.apply_update("generic_fixture", rejected).is_err());
|
||||
assert_eq!(session.editor().tabline_label(), "bat 38%");
|
||||
assert_eq!(session.editor().label("battery"), "bat 38%");
|
||||
let message = session.editor.message().to_owned();
|
||||
for effects in [
|
||||
vec![
|
||||
UpdateEffect::Message {
|
||||
text: "must not leak".into(),
|
||||
},
|
||||
UpdateEffect::Label {
|
||||
name: "battery".into(),
|
||||
text: "changed".into(),
|
||||
},
|
||||
UpdateEffect::Label {
|
||||
name: "bad-name".into(),
|
||||
text: "invalid".into(),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
UpdateEffect::Label {
|
||||
name: "battery".into(),
|
||||
text: "x".repeat(2 * 1024 * 1024),
|
||||
},
|
||||
UpdateEffect::Label {
|
||||
name: "workspace".into(),
|
||||
text: "x".repeat(2 * 1024 * 1024),
|
||||
},
|
||||
],
|
||||
] {
|
||||
assert!(
|
||||
session
|
||||
.apply_update(
|
||||
"generic_fixture",
|
||||
PluginUpdate {
|
||||
request_id: None,
|
||||
effects
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.editor.label("battery"), "bat 38%");
|
||||
assert_eq!(session.editor.label("workspace"), "");
|
||||
assert_eq!(session.editor.message(), message);
|
||||
}
|
||||
session.editor.execute("diff");
|
||||
assert!(session.editor.mutation_blocked());
|
||||
assert!(
|
||||
@@ -3053,10 +3203,10 @@ end
|
||||
.apply_update("generic_fixture", update("bat\t39%\n"))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(session.editor().tabline_label(), "bat�39%�");
|
||||
assert_eq!(session.editor().label("battery"), "bat�39%�");
|
||||
assert_eq!(session.editor().lines(), [""]);
|
||||
assert!(session.apply_update("generic_fixture", update("")).unwrap());
|
||||
assert_eq!(session.editor().tabline_label(), "");
|
||||
assert_eq!(session.editor().label("battery"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+729
-203
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user