Layout manager (#4601)

* ad plugin override_layout command

* watch available layouts and report to plugin thread

* Event::AvailableLayoutInfo

* save layout command

* refactor save_layout

* fix api and permissions

* delete_layout API command

* edit layout in place API

* edit layout response through api

* propagate layout parsing errors

* generate random name plugin api

* dump stringified layout API

* synchronicity dump_session_layout and include LayoutMetadata in sync response

* parse_layout plugin api

* fix: default to default layout dir in plugin api

* rename_layout plugin command API

* initial implementation of multi-tab override

* apply to multiple tabs

* allow applying to only the active tab

* add tab/pane names to layout parsing metadata

* indicate is_builtin_plugin in PaneMetadata

* get_layout_dir plugin API command

* consolidate (and fix) is_builtin_plugin logic

* fix bad ret

* allow dumping the layout for just a specific tab in the session

* get focused pane and tab sync api

* color last substring variants

* add plugin

* update assets

* fix tests

* add e2e tests for new plugin apis

* update ui with new shortcut

* rustfmt

* docs(changelog): add PR
This commit is contained in:
Aram Drevekenin
2026-01-13 11:27:07 +01:00
committed by GitHub
parent 8aba80ebcc
commit 4a27ceea0d
109 changed files with 16218 additions and 678 deletions
+374 -6
View File
@@ -10,7 +10,12 @@ use zellij_utils::input::actions::Action;
pub use zellij_utils::plugin_api;
use zellij_utils::plugin_api::event::ProtobufPaneScrollbackResponse;
use zellij_utils::plugin_api::plugin_command::{
CreateTokenResponse, ListTokensResponse, ProtobufGetPanePidResponse, ProtobufPluginCommand,
dump_layout_response, dump_session_layout_response, get_focused_pane_info_response,
parse_layout_response, CreateTokenResponse, ListTokensResponse, ProtobufDeleteLayoutResponse,
ProtobufDumpLayoutResponse, ProtobufDumpSessionLayoutResponse, ProtobufEditLayoutResponse,
ProtobufGenerateRandomNameResponse, ProtobufGetFocusedPaneInfoResponse,
ProtobufGetLayoutDirResponse, ProtobufGetPanePidResponse, ProtobufParseLayoutResponse,
ProtobufPluginCommand, ProtobufRenameLayoutResponse, ProtobufSaveLayoutResponse,
RenameWebTokenResponse, RevokeAllWebTokensResponse, RevokeTokenResponse,
};
use zellij_utils::plugin_api::plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion};
@@ -89,6 +94,98 @@ pub fn get_zellij_version() -> String {
protobuf_zellij_version.version
}
/// Generates a random human-readable name using Zellij's curated word lists.
/// Returns a name in the format AdjectiveNoun (e.g., "BraveRustacean", "ZippyWeasel").
///
/// This uses the same word lists as session name generation, providing
/// approximately 4,096 unique combinations.
pub fn generate_random_name() -> String {
let plugin_command = PluginCommand::GenerateRandomName;
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response =
ProtobufGenerateRandomNameResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
response.name
}
/// Dumps a layout by name and returns its KDL content as a String
///
/// Supports both built-in layouts (eg. "default", "compact", "welcome")
/// and custom layouts from the plugin's layout directory.
pub fn dump_layout(layout_name: &str) -> Result<String, String> {
// Create the plugin command with the layout name
let plugin_command = PluginCommand::DumpLayout(layout_name.to_string());
// Convert to protobuf and encode
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
// Call the host function (blocks until response)
unsafe { host_run_plugin_command() };
// Read and decode the response
let response_bytes =
bytes_from_stdin().map_err(|e| format!("Failed to read response from stdin: {:?}", e))?;
let protobuf_response = ProtobufDumpLayoutResponse::decode(response_bytes.as_slice())
.map_err(|e| format!("Failed to decode protobuf response: {}", e))?;
// Extract result from the oneof field
match protobuf_response.result {
Some(dump_layout_response::Result::LayoutContent(content)) => Ok(content),
Some(dump_layout_response::Result::Error(error)) => Err(error),
None => Err("Server returned empty response".to_string()),
}
}
/// Returns the path to the layout directory.
///
/// This is the directory where Zellij looks for layout files. It can be:
/// - The directory specified via CLI `--layout-dir` flag
/// - The directory specified in the config file
/// - The directory specified via ZELLIJ_LAYOUT_DIR env var
/// - The default: `~/.config/zellij/layouts`
///
/// # Returns
/// A String containing the absolute path to the layout directory.
/// Returns an empty string if the layout directory cannot be determined (rare edge case).
pub fn get_layout_dir() -> String {
let plugin_command = PluginCommand::GetLayoutDir;
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response =
ProtobufGetLayoutDirResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
response.layout_dir
}
/// Returns the focused pane ID and tab index for the client associated with this plugin.
pub fn get_focused_pane_info() -> Result<(usize, PaneId), String> {
let plugin_command = PluginCommand::GetFocusedPaneInfo;
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let protobuf_response =
ProtobufGetFocusedPaneInfoResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
match protobuf_response.result {
Some(get_focused_pane_info_response::Result::FocusedPaneInfo(info)) => {
let tab_index = info.focused_tab_index as usize;
match info.focused_pane_id {
Some(pb_pane_id) => match pb_pane_id.try_into() {
Ok(pane_id) => Ok((tab_index, pane_id)),
Err(_) => Err("Invalid pane_id in response".to_string()),
},
None => Err("Missing pane_id in response".to_string()),
}
},
Some(get_focused_pane_info_response::Result::Error(err)) => Err(err),
None => Err("Empty response from host".to_string()),
}
}
// Host Functions
/// Open a file in the user's default `$EDITOR` in a new pane
@@ -439,8 +536,8 @@ pub fn new_tabs_with_layout(layout: &str) {
}
/// Provide a LayoutInfo to be applied to the current session in a new tab. If the layout has multiple tabs, they will all be opened.
pub fn new_tabs_with_layout_info(layout_info: LayoutInfo) {
let plugin_command = PluginCommand::NewTabsWithLayoutInfo(layout_info);
pub fn new_tabs_with_layout_info<L: AsRef<LayoutInfo>>(layout_info: L) {
let plugin_command = PluginCommand::NewTabsWithLayoutInfo(layout_info.as_ref().clone());
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
@@ -1009,12 +1106,70 @@ pub fn watch_filesystem() {
unsafe { host_run_plugin_command() };
}
/// Get the serialized session layout in KDL format as a CustomMessage Event
pub fn dump_session_layout() {
let plugin_command = PluginCommand::DumpSessionLayout;
/// Get the serialized session layout in KDL format synchronously
/// note: this removes the requesting plugin from the dumped layout
pub fn dump_session_layout() -> Result<(String, Option<LayoutMetadata>), String> {
dump_session_layout_impl(None)
}
/// Get the serialized layout for a specific tab in KDL format synchronously
/// note: this removes the requesting plugin from the dumped layout
pub fn dump_session_layout_for_tab(
tab_index: usize,
) -> Result<(String, Option<LayoutMetadata>), String> {
dump_session_layout_impl(Some(tab_index))
}
fn dump_session_layout_impl(
tab_index: Option<usize>,
) -> Result<(String, Option<LayoutMetadata>), String> {
let plugin_command = PluginCommand::DumpSessionLayout { tab_index };
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response_bytes =
bytes_from_stdin().map_err(|e| format!("Failed to read response from stdin: {:?}", e))?;
let protobuf_response = ProtobufDumpSessionLayoutResponse::decode(response_bytes.as_slice())
.map_err(|e| format!("Failed to decode protobuf response: {}", e))?;
// Extract metadata if present
let metadata = protobuf_response
.metadata
.and_then(|pb_metadata| pb_metadata.try_into().ok());
match protobuf_response.result {
Some(dump_session_layout_response::Result::LayoutContent(content)) => {
Ok((content, metadata))
},
Some(dump_session_layout_response::Result::Error(error)) => Err(error),
None => Err("Server returned empty response".to_string()),
}
}
/// Parses a KDL layout string and returns LayoutMetadata
pub fn parse_layout(layout_string: &str) -> Result<LayoutMetadata, LayoutParsingError> {
let plugin_command = PluginCommand::ParseLayout(layout_string.to_string());
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
let response_bytes = bytes_from_stdin().map_err(|_| LayoutParsingError::SyntaxError)?;
let protobuf_response = ProtobufParseLayoutResponse::decode(response_bytes.as_slice())
.map_err(|_| LayoutParsingError::SyntaxError)?;
match protobuf_response.result {
Some(parse_layout_response::Result::Metadata(metadata)) => metadata
.try_into()
.map_err(|_| LayoutParsingError::SyntaxError),
Some(parse_layout_response::Result::Error(error)) => Err(error
.try_into()
.map_err(|_| LayoutParsingError::SyntaxError)?),
None => Err(LayoutParsingError::SyntaxError),
}
}
/// Get a list of clients, their focused pane and running command or focused plugin back as an
@@ -1191,6 +1346,200 @@ pub fn get_pane_pid(pane_id: PaneId) -> Result<i32, String> {
}
}
/// Save a layout to the user's layout directory
///
/// # Arguments
/// * `layout_name` - Name of the layout file (without .kdl extension)
/// * `layout_kdl` - KDL string representing the layout
/// * `overwrite` - Whether to overwrite if the file already exists
///
/// # Returns
/// * `Ok(())` - Layout was successfully validated and saved
/// * `Err(String)` - Error message (parse error, I/O error, file exists, etc.)
pub fn save_layout<S: AsRef<str>>(
layout_name: S,
layout_kdl: S,
overwrite: bool,
) -> Result<(), String> {
let plugin_command = PluginCommand::SaveLayout {
layout_name: layout_name.as_ref().to_owned(),
layout_kdl: layout_kdl.as_ref().to_owned(),
overwrite,
};
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
// Read response from stdin
let response_bytes =
bytes_from_stdin().map_err(|e| format!("Failed to read response from stdin: {:?}", e))?;
// Decode protobuf response
let protobuf_response = ProtobufSaveLayoutResponse::decode(response_bytes.as_slice())
.map_err(|e| format!("Failed to decode protobuf response: {}", e))?;
// Convert to Rust type
let response = SaveLayoutResponse::try_from(protobuf_response)
.map_err(|e| format!("Failed to convert protobuf response: {}", e))?;
// Convert Result enum to actual Result type
match response {
SaveLayoutResponse::Ok(_) => Ok(()),
SaveLayoutResponse::Err(error_msg) => Err(error_msg),
}
}
/// Delete a layout from the user's layout directory.
///
/// # Arguments
///
/// * `layout_name` - The name of the layout file to delete (without the `.kdl` extension).
/// Layout names are sanitized server-side to prevent directory traversal.
///
/// # Returns
///
/// * `Ok(())` - The layout was successfully deleted
/// * `Err(String)` - An error occurred with a descriptive message (e.g., file not found, permission denied)
///
/// # Permissions
///
/// Requires the `ChangeApplicationState` permission.
///
/// # Examples
///
/// ```no_run
/// use zellij_tile::prelude::*;
///
/// // Delete a layout named "my-layout"
/// match delete_layout("my-layout") {
/// Ok(_) => eprintln!("Layout deleted successfully"),
/// Err(e) => eprintln!("Failed to delete layout: {}", e),
/// }
/// ```
pub fn delete_layout<S: AsRef<str>>(layout_name: S) -> Result<(), String> {
let plugin_command = PluginCommand::DeleteLayout {
layout_name: layout_name.as_ref().to_owned(),
};
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
// Read response from stdin
let response_bytes =
bytes_from_stdin().map_err(|e| format!("Failed to read response from stdin: {:?}", e))?;
// Decode protobuf response
let protobuf_response = ProtobufDeleteLayoutResponse::decode(response_bytes.as_slice())
.map_err(|e| format!("Failed to decode protobuf response: {}", e))?;
// Convert to Rust type
let response = DeleteLayoutResponse::try_from(protobuf_response)
.map_err(|e| format!("Failed to convert protobuf response: {}", e))?;
// Convert Result enum to actual Result type
match response {
DeleteLayoutResponse::Ok(_) => Ok(()),
DeleteLayoutResponse::Err(error_msg) => Err(error_msg),
}
}
/// Rename a layout file in the user's layout directory
///
/// # Arguments
/// * `old_layout_name` - Current name of the layout (without .kdl extension)
/// * `new_layout_name` - New name for the layout (without .kdl extension)
///
/// # Returns
/// * `Ok(())` - Layout was successfully renamed
/// * `Err(String)` - Error message describing what went wrong
///
/// # Error Cases
/// * Old layout name is invalid (empty, contains path separators, etc.)
/// * New layout name is invalid
/// * Source layout file doesn't exist
/// * Target layout file already exists (no overwrite)
/// * Layout directory not found
/// * File system error during rename operation
///
/// # Permissions
///
/// Requires the `ChangeApplicationState` permission.
///
/// # Examples
///
/// ```no_run
/// use zellij_tile::prelude::*;
///
/// // Rename a layout from "old-name" to "new-name"
/// match rename_layout("old-name", "new-name") {
/// Ok(_) => eprintln!("Layout renamed successfully"),
/// Err(e) => eprintln!("Failed to rename layout: {}", e),
/// }
/// ```
pub fn rename_layout(
old_layout_name: impl Into<String>,
new_layout_name: impl Into<String>,
) -> Result<(), String> {
let plugin_command = PluginCommand::RenameLayout {
old_layout_name: old_layout_name.into(),
new_layout_name: new_layout_name.into(),
};
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
// Read response from stdin
let response_bytes =
bytes_from_stdin().map_err(|e| format!("Failed to read response from stdin: {:?}", e))?;
// Decode protobuf response
let protobuf_response = ProtobufRenameLayoutResponse::decode(response_bytes.as_slice())
.map_err(|e| format!("Failed to decode protobuf response: {}", e))?;
// Convert to native response type
let response: RenameLayoutResponse = protobuf_response
.try_into()
.map_err(|e| format!("Failed to convert protobuf response: {}", e))?;
match response {
RenameLayoutResponse::Ok(_) => Ok(()),
RenameLayoutResponse::Err(error_msg) => Err(error_msg),
}
}
/// Opens a layout file in the user's default `$EDITOR`
pub fn edit_layout<S: AsRef<str>>(
layout_name: S,
context: BTreeMap<String, String>,
) -> Result<(), String> {
let layout_name = layout_name.as_ref().to_owned();
let plugin_command = PluginCommand::EditLayout {
layout_name,
context,
};
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
// Read response from stdin
let response_bytes =
bytes_from_stdin().map_err(|e| format!("Failed to read response from stdin: {:?}", e))?;
// Decode protobuf response
let protobuf_response = ProtobufEditLayoutResponse::decode(response_bytes.as_slice())
.map_err(|e| format!("Failed to decode protobuf response: {}", e))?;
// Convert to Rust type
let response = EditLayoutResponse::try_from(protobuf_response)
.map_err(|e| format!("Failed to convert protobuf response: {}", e))?;
// Convert Result enum to actual Result type
match response {
EditLayoutResponse::Ok(_) => Ok(()),
EditLayoutResponse::Err(error_msg) => Err(error_msg),
}
}
/// Switch the position of the pane with this id with a different pane
pub fn move_pane_with_pane_id(pane_id: PaneId) {
let plugin_command = PluginCommand::MovePaneWithPaneId(pane_id);
@@ -1641,6 +1990,25 @@ pub fn get_focused_pane(tab_position: usize, pane_manifest: &PaneManifest) -> Op
None
}
pub fn override_layout<L: AsRef<LayoutInfo>>(
layout_info: L,
retain_existing_terminal_panes: bool,
retain_existing_plugin_panes: bool,
apply_only_to_active_tab: bool,
context: BTreeMap<String, String>,
) {
let plugin_command = PluginCommand::OverrideLayout(
layout_info.as_ref().clone(),
retain_existing_terminal_panes,
retain_existing_plugin_panes,
apply_only_to_active_tab,
context,
);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
// Internal Functions
#[doc(hidden)]
+53
View File
@@ -175,6 +175,24 @@ impl Text {
self
}
pub fn error_color_last_substring<S: AsRef<str>>(self, substr: S) -> Self {
const ERROR_COLOR_LEVEL: usize = 6;
let substr = substr.as_ref();
let mut start = 0;
let mut last_pos = None;
while let Some(pos) = self.text[start..].find(substr) {
last_pos = Some(start + pos);
start = start + pos + substr.len();
}
if let Some(abs_pos) = last_pos {
return self.color_range(ERROR_COLOR_LEVEL, abs_pos..abs_pos + substr.len());
}
self
}
pub fn error_color_all(self) -> Self {
const ERROR_COLOR_LEVEL: usize = 6;
self.color_range(ERROR_COLOR_LEVEL, ..)
@@ -239,6 +257,24 @@ impl Text {
self
}
pub fn success_color_last_substring<S: AsRef<str>>(self, substr: S) -> Self {
const SUCCESS_COLOR_LEVEL: usize = 7;
let substr = substr.as_ref();
let mut start = 0;
let mut last_pos = None;
while let Some(pos) = self.text[start..].find(substr) {
last_pos = Some(start + pos);
start = start + pos + substr.len();
}
if let Some(abs_pos) = last_pos {
return self.color_range(SUCCESS_COLOR_LEVEL, abs_pos..abs_pos + substr.len());
}
self
}
pub fn success_color_all(self) -> Self {
const SUCCESS_COLOR_LEVEL: usize = 7;
self.color_range(SUCCESS_COLOR_LEVEL, ..)
@@ -307,6 +343,23 @@ impl Text {
self
}
pub fn color_last_substring<S: AsRef<str>>(self, index_level: usize, substr: S) -> Self {
let substr = substr.as_ref();
let mut start = 0;
let mut last_pos = None;
while let Some(pos) = self.text[start..].find(substr) {
last_pos = Some(start + pos);
start = start + pos + substr.len();
}
if let Some(abs_pos) = last_pos {
return self.color_range(index_level, abs_pos..abs_pos + substr.len());
}
self
}
pub fn content(&self) -> &str {
&self.text
}