feat: support configurable tabline status label
This commit is contained in:
@@ -44,6 +44,20 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
|||||||
|
|
||||||
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
|
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
|
||||||
|
|
||||||
|
### Tabline status label
|
||||||
|
|
||||||
|
Set `config.tabline_label` to a plain string for the right side of the top tab line:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.tabline_label = ENV.fetch("VAMP_STATUS", "")
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `""` disables the label. A nonempty value shows the tab line even with one tab, using the existing accent background and left density fade. The label yields to the active tab when both cannot fit. Unicode is supported; control characters are replaced rather than interpreted as terminal escapes. Non-string values reject configuration.
|
||||||
|
|
||||||
|
Configuration runs once at startup. For live values such as battery charge, load a [Ruby plugin](plugins.md#tabline-status-updates) that supplies updates; the editor does not collect battery data or run periodic shell commands itself.
|
||||||
|
|
||||||
## Actions and modes
|
## Actions and modes
|
||||||
|
|
||||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, canonical Ex names (such as `"quit"`), shorthand aliases (such as `"q"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, canonical Ex names (such as `"quit"`), shorthand aliases (such as `"q"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ Use `:tab.new` to open the current buffer in a new, focused tab with one pane. E
|
|||||||
|
|
||||||
Multiple tabs show a single top row with numbered filenames and dirty markers, with padding inside each tab's styling. The active tab has an accent-colored background fading into the terminal background through `░▒▓` / `▓▒░`, except the first tab has no left fade; inactive tabs remain unfilled. Narrow terminals omit the fade before clipping the active title. The status bar uses the same fade around its mode and right-aligned caret position, leaving file metadata unfilled. New/close actions have no default keybinding; for example, `config.bind "leader t", config.core.tab.new`.
|
Multiple tabs show a single top row with numbered filenames and dirty markers, with padding inside each tab's styling. The active tab has an accent-colored background fading into the terminal background through `░▒▓` / `▓▒░`, except the first tab has no left fade; inactive tabs remain unfilled. Narrow terminals omit the fade before clipping the active title. The status bar uses the same fade around its mode and right-aligned caret position, leaving file metadata unfilled. New/close actions have no default keybinding; for example, `config.bind "leader t", config.core.tab.new`.
|
||||||
|
|
||||||
|
An optional [configured status label](configuration.md#tabline-status-label) occupies the right edge of this same row, with a left fade. It is session-wide and can update through plugins without editing buffers. A nonempty label also shows the row with a single tab; narrow layouts hide the label rather than overlap the active tab.
|
||||||
|
|
||||||
Closing a tab refuses dirty buffers whose final displayed owner would disappear, including a comparison's working and return buffers. Buffers displayed in another tab remain open with their edits, even on force-close. Other hidden buffers remain loaded. Closing the last tab quits only if every open buffer is clean; force-close quits without saving.
|
Closing a tab refuses dirty buffers whose final displayed owner would disappear, including a comparison's working and return buffers. Buffers displayed in another tab remain open with their edits, even on force-close. Other hidden buffers remain loaded. Closing the last tab quits only if every open buffer is clean; force-close quits without saving.
|
||||||
|
|
||||||
## Buffers
|
## Buffers
|
||||||
|
|||||||
+21
-3
@@ -22,7 +22,7 @@ Callbacks receive an immutable primary-only context snapshot: `editor.text`, `ed
|
|||||||
|
|
||||||
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does.
|
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does.
|
||||||
|
|
||||||
`editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
|
`editor.insert(text)`, `editor.replace(text)`, `editor.message(text)`, and `editor.tabline_label(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
|
||||||
|
|
||||||
This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
|
This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
|
||||||
|
|
||||||
@@ -40,15 +40,33 @@ A feature declares all capabilities it needs. Registration is atomic: if any req
|
|||||||
|
|
||||||
`Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.
|
`Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.
|
||||||
|
|
||||||
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
|
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, `tabline_label`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
|
||||||
|
|
||||||
Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.
|
Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.
|
||||||
|
|
||||||
`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.
|
`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.
|
||||||
|
|
||||||
|
## Tabline status updates
|
||||||
|
|
||||||
|
`editor.tabline_label(text)` sets the same session-wide label as `config.tabline_label`. It is a presentation effect: it does not edit text, move carets, change undo history, or dismiss completion. It also works in read-only diff views.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Vamp.plugin "tabline_status"
|
||||||
|
|
||||||
|
# @param editor [Vamp::Editor]
|
||||||
|
# @param args [String]
|
||||||
|
Vamp.command "tablabel" do |editor, args|
|
||||||
|
editor.tabline_label(args)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Load this plugin with `config.plugin`, then use `:tablabel bat 38%`; `:tablabel` clears it. For asynchronous data, an `async: true` plugin's worker can publish `{ type: "tabline_label", text: label }` through `Vamp.publish`, without a request ID. Collect data and choose the refresh interval in the plugin, not in a render callback; stop worker threads in `Vamp.on_shutdown`. Updates redraw while idle and persist across tabs, without depending on the originating buffer or revision. The last value remains until overwritten or cleared, including after plugin retirement.
|
||||||
|
|
||||||
|
Labels are plain strings: control characters become replacement glyphs, and an empty string clears the label. Updates share the existing bounded protocol; rejected batches cannot partially change the label.
|
||||||
|
|
||||||
## Messaging
|
## Messaging
|
||||||
|
|
||||||
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed text effects (`insert`, `replace`, `message`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed effects (`insert`, `replace`, `message`, `tabline_label`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
||||||
|
|
||||||
Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`.
|
Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Vamp.configure do |config|
|
|||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
|
# config.tabline_label = ENV.fetch("VAMP_STATUS", "") # Right-aligned top-line label
|
||||||
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
||||||
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
||||||
config.plugin "plugins/greeting.rb"
|
config.plugin "plugins/greeting.rb"
|
||||||
|
|||||||
+135
-12
@@ -179,6 +179,7 @@ pub enum Effect {
|
|||||||
Insert(String),
|
Insert(String),
|
||||||
Replace(String),
|
Replace(String),
|
||||||
Message(String),
|
Message(String),
|
||||||
|
TablineLabel(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -1349,6 +1350,7 @@ pub struct Editor {
|
|||||||
owned_system_clipboard: Option<(String, Vec<String>, bool)>,
|
owned_system_clipboard: Option<(String, Vec<String>, bool)>,
|
||||||
last_edit: Option<EditRecipe>,
|
last_edit: Option<EditRecipe>,
|
||||||
message: String,
|
message: String,
|
||||||
|
tabline_label: String,
|
||||||
quit: bool,
|
quit: bool,
|
||||||
capabilities: Vec<String>,
|
capabilities: Vec<String>,
|
||||||
features: HashMap<String, Feature>,
|
features: HashMap<String, Feature>,
|
||||||
@@ -1434,6 +1436,7 @@ impl Editor {
|
|||||||
owned_system_clipboard: None,
|
owned_system_clipboard: None,
|
||||||
last_edit: None,
|
last_edit: None,
|
||||||
message: String::new(),
|
message: String::new(),
|
||||||
|
tabline_label: String::new(),
|
||||||
quit: false,
|
quit: false,
|
||||||
capabilities,
|
capabilities,
|
||||||
features: HashMap::new(),
|
features: HashMap::new(),
|
||||||
@@ -3823,6 +3826,9 @@ impl Editor {
|
|||||||
pub fn message(&self) -> &str {
|
pub fn message(&self) -> &str {
|
||||||
&self.message
|
&self.message
|
||||||
}
|
}
|
||||||
|
pub fn tabline_label(&self) -> &str {
|
||||||
|
&self.tabline_label
|
||||||
|
}
|
||||||
pub fn path(&self) -> Option<&Path> {
|
pub fn path(&self) -> Option<&Path> {
|
||||||
self.active.path.as_deref()
|
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> {
|
pub fn register(&mut self, feature: Feature) -> Result<(), String> {
|
||||||
self.install(feature, false)
|
self.install(feature, false)
|
||||||
}
|
}
|
||||||
@@ -5737,7 +5758,7 @@ impl Editor {
|
|||||||
|
|
||||||
/// Applies one validated plugin response. Successful text effects collapse
|
/// Applies one validated plugin response. Successful text effects collapse
|
||||||
/// to the primary caret: Insert runs once there, Replace replaces the buffer.
|
/// 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> {
|
pub fn apply(&mut self, effects: Vec<Effect>) -> Result<(), String> {
|
||||||
if self.mutation_blocked()
|
if self.mutation_blocked()
|
||||||
&& effects
|
&& effects
|
||||||
@@ -5749,7 +5770,10 @@ impl Editor {
|
|||||||
let mut total = 0usize;
|
let mut total = 0usize;
|
||||||
for effect in &effects {
|
for effect in &effects {
|
||||||
let text = match effect {
|
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
|
total = total
|
||||||
.checked_add(text.len())
|
.checked_add(text.len())
|
||||||
@@ -5757,17 +5781,26 @@ impl Editor {
|
|||||||
if total > MAX_EDIT_BYTES {
|
if total > MAX_EDIT_BYTES {
|
||||||
return Err("Plugin effects exceed 4 MiB".into());
|
return Err("Plugin effects exceed 4 MiB".into());
|
||||||
}
|
}
|
||||||
|
if !matches!(effect, Effect::TablineLabel(_)) {
|
||||||
validate_text(text)?;
|
validate_text(text)?;
|
||||||
}
|
}
|
||||||
if self.mutation_blocked() {
|
}
|
||||||
|
if !effects
|
||||||
|
.iter()
|
||||||
|
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
|
||||||
|
{
|
||||||
for effect in effects {
|
for effect in effects {
|
||||||
if let Effect::Message(text) = effect {
|
match effect {
|
||||||
self.set_message(text);
|
Effect::Message(text) => self.set_message(text),
|
||||||
|
Effect::TablineLabel(text) => {
|
||||||
|
self.set_tabline_label(text);
|
||||||
|
}
|
||||||
|
_ => unreachable!("presentation-only effects"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok(());
|
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 previous_mode = self.active.view.mode;
|
||||||
let inserting = self.active.view.mode == Mode::Insert;
|
let inserting = self.active.view.mode == Mode::Insert;
|
||||||
if inserting {
|
if inserting {
|
||||||
@@ -5804,6 +5837,9 @@ impl Editor {
|
|||||||
self.normalize_targets();
|
self.normalize_targets();
|
||||||
}
|
}
|
||||||
Effect::Message(text) => self.set_message(text),
|
Effect::Message(text) => self.set_message(text),
|
||||||
|
Effect::TablineLabel(text) => {
|
||||||
|
self.set_tabline_label(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if before.is_some() {
|
if before.is_some() {
|
||||||
@@ -16921,7 +16957,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut editor = editor();
|
||||||
editor
|
editor
|
||||||
.apply(vec![Effect::Replace("aa\nbb\ncc".into())])
|
.apply(vec![Effect::Replace("aa\nbb\ncc".into())])
|
||||||
@@ -16933,8 +16969,15 @@ mod tests {
|
|||||||
editor.handle(Input::Char('v'));
|
editor.handle(Input::Char('v'));
|
||||||
let ranges = editor.selections();
|
let ranges = editor.selections();
|
||||||
assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err());
|
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.selections(), ranges);
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
editor.apply(vec![Effect::Insert("!".into())]).unwrap();
|
editor.apply(vec![Effect::Insert("!".into())]).unwrap();
|
||||||
assert_eq!(editor.context().text, "aa\n!bb\ncc");
|
assert_eq!(editor.context().text, "aa\n!bb\ncc");
|
||||||
assert_eq!(editor.selection_count(), 1);
|
assert_eq!(editor.selection_count(), 1);
|
||||||
@@ -17631,12 +17674,80 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
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!(
|
assert!(
|
||||||
editor
|
editor
|
||||||
.apply(vec![
|
.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::Insert("good".into()),
|
||||||
Effect::Message("bad\u{1b}".into())
|
Effect::Message("bad\u{1b}".into())
|
||||||
])
|
])
|
||||||
@@ -17644,27 +17755,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(editor.context().text, "");
|
assert_eq!(editor.context().text, "");
|
||||||
assert_eq!(editor.message(), "unchanged");
|
assert_eq!(editor.message(), "unchanged");
|
||||||
|
assert_eq!(editor.tabline_label(), "unchanged");
|
||||||
assert!(!editor.dirty());
|
assert!(!editor.dirty());
|
||||||
assert!(
|
assert!(
|
||||||
editor
|
editor
|
||||||
.apply(vec![
|
.apply(vec![
|
||||||
Effect::Insert("a".repeat(MAX_EDIT_BYTES)),
|
Effect::TablineLabel("x".into()),
|
||||||
Effect::Insert("x".into())
|
Effect::Insert("a".repeat(MAX_EDIT_BYTES))
|
||||||
])
|
])
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert_eq!(editor.context().text, "");
|
assert_eq!(editor.context().text, "");
|
||||||
|
assert_eq!(editor.tabline_label(), "unchanged");
|
||||||
editor
|
editor
|
||||||
.apply(vec![
|
.apply(vec![
|
||||||
Effect::Insert("one".into()),
|
Effect::Insert("one".into()),
|
||||||
Effect::Insert("\ntwo".into()),
|
Effect::Insert("\ntwo".into()),
|
||||||
Effect::Message("ok".into()),
|
Effect::Message("ok".into()),
|
||||||
|
Effect::TablineLabel("updated".into()),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(editor.context().text, "one\ntwo");
|
assert_eq!(editor.context().text, "one\ntwo");
|
||||||
assert_eq!(editor.message(), "ok");
|
assert_eq!(editor.message(), "ok");
|
||||||
editor.handle(Input::Char('u'));
|
editor.handle(Input::Char('u'));
|
||||||
assert_eq!(editor.context().text, "");
|
assert_eq!(editor.context().text, "");
|
||||||
|
assert_eq!(editor.tabline_label(), "updated");
|
||||||
editor.handle(Input::Char('U'));
|
editor.handle(Input::Char('U'));
|
||||||
assert_eq!(editor.context().text, "one\ntwo");
|
assert_eq!(editor.context().text, "one\ntwo");
|
||||||
editor
|
editor
|
||||||
@@ -17680,6 +17795,14 @@ mod tests {
|
|||||||
editor.handle(Input::Char('i'));
|
editor.handle(Input::Char('i'));
|
||||||
type_text(&mut editor, "before");
|
type_text(&mut editor, "before");
|
||||||
let cursor = editor.cursor();
|
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!(
|
assert!(
|
||||||
editor
|
editor
|
||||||
.apply(vec![Effect::Replace("invalid\0".into())])
|
.apply(vec![Effect::Replace("invalid\0".into())])
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ pub enum UpdateEffect {
|
|||||||
Message {
|
Message {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
TablineLabel {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
+59
-5
@@ -34,6 +34,7 @@ pub struct Config {
|
|||||||
pub file_picker_view: String,
|
pub file_picker_view: String,
|
||||||
pub file_explorer_view: String,
|
pub file_explorer_view: String,
|
||||||
pub clipboard_mode: String,
|
pub clipboard_mode: String,
|
||||||
|
pub tabline_label: String,
|
||||||
pub diff_context_lines: usize,
|
pub diff_context_lines: usize,
|
||||||
pub diff_expand_lines: usize,
|
pub diff_expand_lines: usize,
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,7 @@ impl Default for Config {
|
|||||||
file_picker_view: "tree".into(),
|
file_picker_view: "tree".into(),
|
||||||
file_explorer_view: "tree".into(),
|
file_explorer_view: "tree".into(),
|
||||||
clipboard_mode: "system".into(),
|
clipboard_mode: "system".into(),
|
||||||
|
tabline_label: String::new(),
|
||||||
diff_context_lines: 10,
|
diff_context_lines: 10,
|
||||||
diff_expand_lines: 15,
|
diff_expand_lines: 15,
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
file_picker_view,
|
file_picker_view,
|
||||||
file_explorer_view,
|
file_explorer_view,
|
||||||
clipboard_mode,
|
clipboard_mode,
|
||||||
|
tabline_label,
|
||||||
diff_context_lines,
|
diff_context_lines,
|
||||||
diff_expand_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_picker_view = file_picker_view;
|
||||||
config.file_explorer_view = file_explorer_view;
|
config.file_explorer_view = file_explorer_view;
|
||||||
config.clipboard_mode = clipboard_mode;
|
config.clipboard_mode = clipboard_mode;
|
||||||
|
config.tabline_label = tabline_label;
|
||||||
config.diff_context_lines = diff_context_lines;
|
config.diff_context_lines = diff_context_lines;
|
||||||
config.diff_expand_lines = diff_expand_lines;
|
config.diff_expand_lines = diff_expand_lines;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -346,6 +350,7 @@ fn convert_effects(effects: Vec<WireEffect>) -> Vec<Effect> {
|
|||||||
WireEffect::Insert { text } => Effect::Insert(text),
|
WireEffect::Insert { text } => Effect::Insert(text),
|
||||||
WireEffect::Replace { text } => Effect::Replace(text),
|
WireEffect::Replace { text } => Effect::Replace(text),
|
||||||
WireEffect::Message { text } => Effect::Message(text),
|
WireEffect::Message { text } => Effect::Message(text),
|
||||||
|
WireEffect::TablineLabel { text } => Effect::TablineLabel(text),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -433,6 +438,7 @@ enum Response {
|
|||||||
file_picker_view: String,
|
file_picker_view: String,
|
||||||
file_explorer_view: String,
|
file_explorer_view: String,
|
||||||
clipboard_mode: String,
|
clipboard_mode: String,
|
||||||
|
tabline_label: String,
|
||||||
#[serde(deserialize_with = "diff_context_lines")]
|
#[serde(deserialize_with = "diff_context_lines")]
|
||||||
diff_context_lines: usize,
|
diff_context_lines: usize,
|
||||||
#[serde(deserialize_with = "diff_expand_lines")]
|
#[serde(deserialize_with = "diff_expand_lines")]
|
||||||
@@ -494,6 +500,8 @@ enum WireEffect {
|
|||||||
Replace { text: String },
|
Replace { text: String },
|
||||||
#[serde(rename = "message")]
|
#[serde(rename = "message")]
|
||||||
Message { text: String },
|
Message { text: String },
|
||||||
|
#[serde(rename = "tabline_label")]
|
||||||
|
TablineLabel { text: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OutboundFrame {
|
struct OutboundFrame {
|
||||||
@@ -882,6 +890,8 @@ Vamp.command "greet" do |editor, args|
|
|||||||
editor.insert "Hello #{args}!"
|
editor.insert "Hello #{args}!"
|
||||||
editor.replace editor.text
|
editor.replace editor.text
|
||||||
editor.message "Greeting inserted"
|
editor.message "Greeting inserted"
|
||||||
|
editor.tabline_label "界 e\u0301 👩🚀\t\n\e"
|
||||||
|
editor.tabline_label ""
|
||||||
end
|
end
|
||||||
"#);
|
"#);
|
||||||
assert_eq!(feature.name, "greeting");
|
assert_eq!(feature.name, "greeting");
|
||||||
@@ -892,8 +902,9 @@ end
|
|||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
|
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message)]
|
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")
|
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted"
|
||||||
|
&& label == "界 e\u{301} 👩🚀\t\n\u{1b}" && cleared.is_empty())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
plugin.disable();
|
plugin.disable();
|
||||||
@@ -1152,14 +1163,53 @@ end
|
|||||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||||
}
|
}
|
||||||
for frame in [
|
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":[],"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","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","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}");
|
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]
|
#[test]
|
||||||
fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
|
fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -1180,6 +1230,7 @@ end
|
|||||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
|
"tabline_label": "",
|
||||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
|
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
|
||||||
});
|
});
|
||||||
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
||||||
@@ -1592,6 +1643,7 @@ end"#,
|
|||||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
|
"tabline_label": "",
|
||||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
|
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
|
||||||
});
|
});
|
||||||
let Response::Config { lsp, .. } =
|
let Response::Config { lsp, .. } =
|
||||||
@@ -1674,6 +1726,8 @@ end"#,
|
|||||||
for mutation in [
|
for mutation in [
|
||||||
"return '['",
|
"return '['",
|
||||||
"frame.delete('lsp')",
|
"frame.delete('lsp')",
|
||||||
|
"frame.delete('tabline_label')",
|
||||||
|
"frame['tabline_label'] = 7",
|
||||||
"frame['leader'] = 7",
|
"frame['leader'] = 7",
|
||||||
"frame['unexpected'] = true",
|
"frame['unexpected'] = true",
|
||||||
] {
|
] {
|
||||||
|
|||||||
+16
-3
@@ -16,6 +16,7 @@ module Vamp
|
|||||||
@file_picker_view = "tree"
|
@file_picker_view = "tree"
|
||||||
@file_explorer_view = "tree"
|
@file_explorer_view = "tree"
|
||||||
@clipboard_mode = "system"
|
@clipboard_mode = "system"
|
||||||
|
@tabline_label = ""
|
||||||
@diff_context_lines = 10
|
@diff_context_lines = 10
|
||||||
@diff_expand_lines = 15
|
@diff_expand_lines = 15
|
||||||
end
|
end
|
||||||
@@ -62,6 +63,12 @@ module Vamp
|
|||||||
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
||||||
end
|
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
|
# @param lines [Integer] nonnegative context lines around each diff change
|
||||||
# @return [Integer]
|
# @return [Integer]
|
||||||
def diff_context_lines=(lines)
|
def diff_context_lines=(lines)
|
||||||
@@ -104,7 +111,7 @@ module Vamp
|
|||||||
def response
|
def response
|
||||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
{ "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,
|
"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 }
|
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -203,7 +210,7 @@ module Vamp
|
|||||||
attr_reader :path
|
attr_reader :path
|
||||||
# @return [Integer] positive invocation ID
|
# @return [Integer] positive invocation ID
|
||||||
attr_reader :request_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
|
attr_reader :effects
|
||||||
|
|
||||||
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
||||||
@@ -237,6 +244,12 @@ module Vamp
|
|||||||
queue("message", text)
|
queue("message", text)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
# @param context [Hash{String => String, Integer, nil}]
|
# @param context [Hash{String => String, Integer, nil}]
|
||||||
@@ -281,7 +294,7 @@ module Vamp
|
|||||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||||
end
|
end
|
||||||
|
|
||||||
# @param type [String] insert, replace, or message
|
# @param type [String] insert, replace, message, or tabline_label
|
||||||
# @param text [String]
|
# @param text [String]
|
||||||
# @return [nil]
|
# @return [nil]
|
||||||
def queue(type, text)
|
def queue(type, text)
|
||||||
|
|||||||
+81
-2
@@ -220,6 +220,7 @@ impl EditorSession {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
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();
|
let mut plugins = BTreeMap::new();
|
||||||
for configured in config.plugins {
|
for configured in config.plugins {
|
||||||
let path = configured.path;
|
let path = configured.path;
|
||||||
@@ -756,6 +757,11 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
UpdateEffect::Completion { .. } => {}
|
UpdateEffect::Completion { .. } => {}
|
||||||
UpdateEffect::Message { .. } => {}
|
UpdateEffect::Message { .. } => {}
|
||||||
|
UpdateEffect::TablineLabel { text } => {
|
||||||
|
if text.len() > 4 * 1024 * 1024 {
|
||||||
|
return Err("Tabline label exceeds 4 MiB".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -851,6 +857,9 @@ impl EditorSession {
|
|||||||
changed |= self.editor.message() != text;
|
changed |= self.editor.message() != text;
|
||||||
self.editor.set_message(text);
|
self.editor.set_message(text);
|
||||||
}
|
}
|
||||||
|
UpdateEffect::TablineLabel { text } => {
|
||||||
|
changed |= self.editor.set_tabline_label(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.editor.refresh_live_diff();
|
self.editor.refresh_live_diff();
|
||||||
@@ -878,14 +887,20 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
if effects
|
if effects
|
||||||
.iter()
|
.iter()
|
||||||
.all(|effect| matches!(effect, Effect::Message(_)))
|
.all(|effect| matches!(effect, Effect::Message(_) | Effect::TablineLabel(_)))
|
||||||
{
|
{
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for effect in effects {
|
for effect in effects {
|
||||||
if let Effect::Message(text) = effect {
|
match effect {
|
||||||
|
Effect::Message(text) => {
|
||||||
changed |= self.editor.message() != text;
|
changed |= self.editor.message() != text;
|
||||||
self.editor.set_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);
|
return Ok(changed);
|
||||||
}
|
}
|
||||||
@@ -2980,6 +2995,70 @@ end
|
|||||||
drain_until(session, |session| session.editor().message() == delivered);
|
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]
|
#[test]
|
||||||
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
|
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
|
||||||
let fixture = Fixture::new();
|
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 {
|
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;
|
return area;
|
||||||
}
|
}
|
||||||
let bar = Rect::new(area.x, area.y, area.width, 1);
|
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_width = if active == 0 { 3 } else { 6 };
|
||||||
let fade = usize::from(bar.width) >= labels[active].width() + fade_width;
|
let fade = usize::from(bar.width) >= labels[active].width() + fade_width;
|
||||||
let mut width = labels[active].width() + if fade { fade_width } else { 0 };
|
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;
|
first -= 1;
|
||||||
width += labels[first].width();
|
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) {
|
for (index, label) in labels.iter().enumerate().skip(first) {
|
||||||
let decorated = index == active && fade;
|
let decorated = index == active && fade;
|
||||||
let width = (label.width() + if decorated { fade_width } else { 0 })
|
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()
|
let highlight = Style::default()
|
||||||
.fg(Color::Black)
|
.fg(Color::Black)
|
||||||
.bg(Color::Cyan)
|
.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));
|
frame.render_widget(Paragraph::new(line), Rect::new(x, bar.y, width, 1));
|
||||||
x += width;
|
x += width;
|
||||||
if x == bar.right() {
|
if x == tabs_right {
|
||||||
break;
|
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
|
workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5249,6 +5276,7 @@ mod tests {
|
|||||||
"edit {}",
|
"edit {}",
|
||||||
directory.file("active.txt", "ACTIVE").display()
|
directory.file("active.txt", "ACTIVE").display()
|
||||||
));
|
));
|
||||||
|
editor.set_tabline_label("status".into());
|
||||||
let mut views = BufferViewports::default();
|
let mut views = BufferViewports::default();
|
||||||
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
|
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
|
||||||
let mut terminal =
|
let mut terminal =
|
||||||
@@ -5259,6 +5287,7 @@ mod tests {
|
|||||||
if width > 0 && height > 0 {
|
if width > 0 && height > 0 {
|
||||||
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
|
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
|
||||||
assert!(title.trim_start().starts_with('3'), "{title}");
|
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}");
|
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]
|
#[test]
|
||||||
fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() {
|
fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user