diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-01-13 11:27:07 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-13 11:27:07 +0100 |
| commit | 4a27ceea0d99b036ca607e05714cef15689d7c26 (patch) | |
| tree | 22c0148718a82082bdb196c5e867697f514cc541 | |
| parent | docs(CONTRIBUTING): Describe rust toolchain versioning policy (#4585) (diff) | |
| download | sl0ppij-4a27ceea0d99b036ca607e05714cef15689d7c26.tar.gz sl0ppij-4a27ceea0d99b036ca607e05714cef15689d7c26.tar.xz sl0ppij-4a27ceea0d99b036ca607e05714cef15689d7c26.zip | |
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
109 files changed, 16209 insertions, 669 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aca1dc7..66a0fd6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * feat: allow overriding layouts at runtime (https://github.com/zellij-org/zellij/pull/4566) * build: Update Rust toolchain to 1.92.0 (https://github.com/zellij-org/zellij/pull/4579) * docs: Explain Rust toolchain update strategy in CONTRIBUTING (https://github.com/zellij-org/zellij/pull/4585) +* feat: new `layout-manager` interface and plugin API commands (https://github.com/zellij-org/zellij/pull/4601) ## [0.43.1] - 2025-08-08 * fix: pane rename backspace regression (https://github.com/zellij-org/zellij/pull/4346) @@ -2084,6 +2084,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] +name = "layout-manager" +version = "0.1.0" +dependencies = [ + "fuzzy-matcher", + "miette", + "strip-ansi-escapes 0.2.1", + "unicode-width 0.1.10", + "zellij-tile", +] + +[[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2285,9 +2296,9 @@ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmem" @@ -3691,7 +3702,7 @@ dependencies = [ "pretty-bytes", "serde", "serde_json", - "strip-ansi-escapes", + "strip-ansi-escapes 0.1.1", "unicode-width 0.1.10", "zellij-tile", ] @@ -3716,6 +3727,15 @@ dependencies = [ ] [[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte 0.14.1", +] + +[[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4469,6 +4489,15 @@ dependencies = [ ] [[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] name = "vte_generate_state_changes" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5223,6 +5252,7 @@ dependencies = [ "insta", "interprocess", "isahc", + "kdl", "lazy_static", "libc", "log", @@ -5308,7 +5338,7 @@ dependencies = [ "serde_json", "sha2", "shellexpand", - "strip-ansi-escapes", + "strip-ansi-escapes 0.1.1", "strum", "strum_macros", "suggest", @@ -51,6 +51,7 @@ members = [ "default-plugins/share", "default-plugins/multiple-select", "default-plugins/sequence", + "default-plugins/layout-manager", "zellij-client", "zellij-server", "zellij-utils", @@ -79,6 +80,7 @@ dialoguer = { version = "0.10.4", default-features = false, features = ["passwor humantime = { version = "2.1.0", default-features = false } interprocess = { version = "1.2.1", default-features = false } isahc = { version = "1.7.2", default-features = false, features = ["http2", "text-decoding"] } +kdl = { version = "4.5.0", default-features = false, features = ["span"] } lazy_static = { version = "1.4.0", default-features = false } libc = { version = "0.2", default-features = false, features = ["std"] } log = { version = "0.4.17", default-features = false } diff --git a/default-plugins/configuration/src/presets.rs b/default-plugins/configuration/src/presets.rs index 7e875073..03d05171 100644 --- a/default-plugins/configuration/src/presets.rs +++ b/default-plugins/configuration/src/presets.rs @@ -161,6 +161,13 @@ keybinds clear-defaults=true {{ }}; SwitchToMode "Locked" }} + bind "l" {{ + LaunchOrFocusPlugin "zellij:layout-manager" {{ + floating true + move_to_focused_tab true + }}; + SwitchToMode "Locked" + }} }} shared_except "locked" "renametab" "renamepane" {{ bind "{primary_modifier} g" {{ SwitchToMode "Locked"; }} @@ -383,6 +390,13 @@ keybinds clear-defaults=true {{ }}; SwitchToMode "Normal" }} + bind "l" {{ + LaunchOrFocusPlugin "zellij:layout-manager" {{ + floating true + move_to_focused_tab true + }}; + SwitchToMode "Normal" + }} }} tmux {{ bind "[" {{ SwitchToMode "Scroll"; }} @@ -605,6 +619,13 @@ keybinds clear-defaults=true {{ }}; SwitchToMode "Normal" }} + bind "l" {{ + LaunchOrFocusPlugin "zellij:layout-manager" {{ + floating true + move_to_focused_tab true + }}; + SwitchToMode "Normal" + }} }} tmux {{ bind "[" {{ SwitchToMode "Scroll"; }} @@ -808,6 +829,13 @@ keybinds clear-defaults=true {{ }}; SwitchToMode "Normal" }} + bind "l" {{ + LaunchOrFocusPlugin "zellij:layout-manager" {{ + floating true + move_to_focused_tab true + }}; + SwitchToMode "Normal" + }} }} tmux {{ bind "[" {{ SwitchToMode "Scroll"; }} @@ -1011,6 +1039,13 @@ keybinds clear-defaults=true {{ }}; SwitchToMode "Normal" }} + bind "l" {{ + LaunchOrFocusPlugin "zellij:layout-manager" {{ + floating true + move_to_focused_tab true + }}; + SwitchToMode "Normal" + }} }} tmux {{ bind "[" {{ SwitchToMode "Scroll"; }} @@ -1209,6 +1244,13 @@ keybinds clear-defaults=true {{ }}; SwitchToMode "Normal" }} + bind "l" {{ + LaunchOrFocusPlugin "zellij:layout-manager" {{ + floating true + move_to_focused_tab true + }}; + SwitchToMode "Normal" + }} }} tmux {{ bind "[" {{ SwitchToMode "Scroll"; }} diff --git a/default-plugins/fixture-plugin-for-tests/src/main.rs b/default-plugins/fixture-plugin-for-tests/src/main.rs index 9f5b7e93..be8efe83 100644 --- a/default-plugins/fixture-plugin-for-tests/src/main.rs +++ b/default-plugins/fixture-plugin-for-tests/src/main.rs @@ -16,6 +16,7 @@ struct State { received_payload: Option<String>, configuration: BTreeMap<String, String>, message_to_plugin_payload: Option<String>, + explicit_string_to_render: Option<String>, } #[derive(Default, Serialize, Deserialize)] @@ -153,27 +154,29 @@ impl ZellijPlugin for State { BareKey::Char('6') if key.has_no_modifiers() => close_focused_tab(), BareKey::Char('7') if key.has_no_modifiers() => undo_rename_tab(), BareKey::Char('8') if key.has_no_modifiers() => quit_zellij(), - BareKey::Char('a') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('a') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { previous_swap_layout() }, - BareKey::Char('b') if key.has_modifiers(&[KeyModifier::Ctrl]) => next_swap_layout(), - BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('b') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { + next_swap_layout() + }, + BareKey::Char('c') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let tab_name = "my tab name"; go_to_tab_name(tab_name) }, - BareKey::Char('d') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('d') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let tab_name = "my tab name"; focus_or_create_tab(tab_name) }, - BareKey::Char('e') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('e') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let tab_index = 2; go_to_tab(tab_index) }, - BareKey::Char('f') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('f') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let plugin_url = "file:/path/to/my/plugin.wasm"; start_or_reload_plugin(plugin_url) }, - BareKey::Char('g') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('g') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { open_file( FileToOpen { path: std::path::PathBuf::from("/path/to/my/file.rs"), @@ -182,7 +185,7 @@ impl ZellijPlugin for State { BTreeMap::new(), ); }, - BareKey::Char('h') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('h') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { open_file_floating( FileToOpen { path: std::path::PathBuf::from("/path/to/my/file.rs"), @@ -192,7 +195,7 @@ impl ZellijPlugin for State { BTreeMap::new(), ); }, - BareKey::Char('i') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('i') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { open_file( FileToOpen { path: std::path::PathBuf::from("/path/to/my/file.rs"), @@ -202,7 +205,7 @@ impl ZellijPlugin for State { BTreeMap::new(), ); }, - BareKey::Char('j') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('j') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { open_file_floating( FileToOpen { path: std::path::PathBuf::from("/path/to/my/file.rs"), @@ -213,16 +216,16 @@ impl ZellijPlugin for State { BTreeMap::new(), ); }, - BareKey::Char('k') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('k') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { open_terminal(std::path::PathBuf::from("/path/to/my/file.rs").as_path()); }, - BareKey::Char('l') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('l') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { open_terminal_floating( std::path::PathBuf::from("/path/to/my/file.rs").as_path(), None, ); }, - BareKey::Char('m') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('m') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { open_command_pane( CommandToRun { path: std::path::PathBuf::from("/path/to/my/file.rs"), @@ -232,7 +235,7 @@ impl ZellijPlugin for State { BTreeMap::new(), ); }, - BareKey::Char('n') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('n') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { open_command_pane_floating( CommandToRun { path: std::path::PathBuf::from("/path/to/my/file.rs"), @@ -243,53 +246,53 @@ impl ZellijPlugin for State { BTreeMap::new(), ); }, - BareKey::Char('o') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('o') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { switch_tab_to(1); }, - BareKey::Char('p') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('p') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { hide_self(); }, - BareKey::Char('q') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('q') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let should_float_if_hidden = false; show_self(should_float_if_hidden); }, - BareKey::Char('r') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('r') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { close_terminal_pane(1); }, - BareKey::Char('s') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('s') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { close_plugin_pane(1); }, - BareKey::Char('t') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('t') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let should_float_if_hidden = false; let should_be_in_place_if_hidden = false; focus_terminal_pane(1, should_float_if_hidden, should_be_in_place_if_hidden); }, - BareKey::Char('u') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('u') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let should_float_if_hidden = false; let should_be_in_place_if_hidden = false; focus_plugin_pane(1, should_float_if_hidden, should_be_in_place_if_hidden); }, - BareKey::Char('v') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('v') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { rename_terminal_pane(1, "new terminal_pane_name"); }, - BareKey::Char('w') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('w') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { rename_plugin_pane(1, "new plugin_pane_name"); }, - BareKey::Char('x') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('x') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { rename_tab(1, "new tab name"); }, - BareKey::Char('z') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('z') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { go_to_tab_name(&format!("{:?}", self.configuration)); }, - BareKey::Char('1') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('1') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { request_permission(&[PermissionType::ReadApplicationState]); }, - BareKey::Char('2') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('2') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let mut context = BTreeMap::new(); context.insert("user_key_1".to_owned(), "user_value_1".to_owned()); run_command(&["ls", "-l"], context); }, - BareKey::Char('3') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('3') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let mut context = BTreeMap::new(); context.insert("user_key_2".to_owned(), "user_value_2".to_owned()); let mut env_vars = BTreeMap::new(); @@ -301,7 +304,7 @@ impl ZellijPlugin for State { context, ); }, - BareKey::Char('4') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('4') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let mut headers = BTreeMap::new(); let mut context = BTreeMap::new(); let body = vec![1, 2, 3]; @@ -317,31 +320,31 @@ impl ZellijPlugin for State { context, ); }, - BareKey::Char('5') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('5') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { switch_session(Some("my_new_session")); }, - BareKey::Char('6') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('6') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { disconnect_other_clients() }, - BareKey::Char('7') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('7') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { switch_session_with_layout( Some("my_other_new_session"), LayoutInfo::BuiltIn("compact".to_owned()), None, ); }, - BareKey::Char('8') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('8') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let mut file = std::fs::File::create("/host/hi-from-plugin.txt").unwrap(); file.write_all(b"Hi there!").unwrap(); }, - BareKey::Char('9') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('9') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { switch_session_with_layout( Some("my_other_new_session_with_cwd"), LayoutInfo::BuiltIn("compact".to_owned()), Some(std::path::PathBuf::from("/tmp")), ); }, - BareKey::Char('0') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + BareKey::Char('0') if key.has_only_modifiers(&[KeyModifier::Ctrl]) => { let write_to_disk = true; reconfigure( " @@ -355,13 +358,13 @@ impl ZellijPlugin for State { write_to_disk, ); }, - BareKey::Char('a') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('a') if key.has_only_modifiers(&[KeyModifier::Alt]) => { hide_pane_with_id(PaneId::Terminal(1)); }, - BareKey::Char('b') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('b') if key.has_only_modifiers(&[KeyModifier::Alt]) => { show_pane_with_id(PaneId::Terminal(1), true, true); }, - BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('c') if key.has_only_modifiers(&[KeyModifier::Alt]) => { open_command_pane_background( CommandToRun { path: std::path::PathBuf::from("/path/to/my/file.rs"), @@ -371,61 +374,61 @@ impl ZellijPlugin for State { BTreeMap::new(), ); }, - BareKey::Char('d') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('d') if key.has_only_modifiers(&[KeyModifier::Alt]) => { rerun_command_pane(1); }, - BareKey::Char('e') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('e') if key.has_only_modifiers(&[KeyModifier::Alt]) => { resize_pane_with_id( ResizeStrategy::new(Resize::Increase, Some(Direction::Left)), PaneId::Terminal(2), ); }, - BareKey::Char('f') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('f') if key.has_only_modifiers(&[KeyModifier::Alt]) => { edit_scrollback_for_pane_with_id(PaneId::Terminal(2)); }, - BareKey::Char('g') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('g') if key.has_only_modifiers(&[KeyModifier::Alt]) => { write_to_pane_id(vec![102, 111, 111], PaneId::Terminal(2)); }, - BareKey::Char('h') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('h') if key.has_only_modifiers(&[KeyModifier::Alt]) => { write_chars_to_pane_id("foo\n", PaneId::Terminal(2)); }, - BareKey::Char('i') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('i') if key.has_only_modifiers(&[KeyModifier::Alt]) => { move_pane_with_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('j') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('j') if key.has_only_modifiers(&[KeyModifier::Alt]) => { move_pane_with_pane_id_in_direction(PaneId::Terminal(2), Direction::Left); }, - BareKey::Char('k') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('k') if key.has_only_modifiers(&[KeyModifier::Alt]) => { clear_screen_for_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('l') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('l') if key.has_only_modifiers(&[KeyModifier::Alt]) => { scroll_up_in_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('m') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('m') if key.has_only_modifiers(&[KeyModifier::Alt]) => { scroll_down_in_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('n') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('n') if key.has_only_modifiers(&[KeyModifier::Alt]) => { scroll_to_top_in_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('o') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('o') if key.has_only_modifiers(&[KeyModifier::Alt]) => { scroll_to_bottom_in_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('p') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('p') if key.has_only_modifiers(&[KeyModifier::Alt]) => { page_scroll_up_in_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('q') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('q') if key.has_only_modifiers(&[KeyModifier::Alt]) => { page_scroll_down_in_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('r') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('r') if key.has_only_modifiers(&[KeyModifier::Alt]) => { toggle_pane_id_fullscreen(PaneId::Terminal(2)); }, - BareKey::Char('s') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('s') if key.has_only_modifiers(&[KeyModifier::Alt]) => { toggle_pane_embed_or_eject_for_pane_id(PaneId::Terminal(2)); }, - BareKey::Char('t') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('t') if key.has_only_modifiers(&[KeyModifier::Alt]) => { close_tab_with_index(2); }, - BareKey::Char('u') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('u') if key.has_only_modifiers(&[KeyModifier::Alt]) => { let should_change_focus_to_new_tab = true; break_panes_to_new_tab( &[PaneId::Terminal(1), PaneId::Plugin(2)], @@ -433,7 +436,7 @@ impl ZellijPlugin for State { should_change_focus_to_new_tab, ); }, - BareKey::Char('v') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('v') if key.has_only_modifiers(&[KeyModifier::Alt]) => { let should_change_focus_to_target_tab = true; break_panes_to_tab_with_index( &[PaneId::Terminal(1), PaneId::Plugin(2)], @@ -441,10 +444,10 @@ impl ZellijPlugin for State { should_change_focus_to_target_tab, ); }, - BareKey::Char('w') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('w') if key.has_only_modifiers(&[KeyModifier::Alt]) => { reload_plugin_with_id(0); }, - BareKey::Char('x') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('x') if key.has_only_modifiers(&[KeyModifier::Alt]) => { let config = BTreeMap::new(); let load_in_background = true; let skip_plugin_cache = true; @@ -455,7 +458,7 @@ impl ZellijPlugin for State { skip_plugin_cache, ) }, - BareKey::Char('y') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('y') if key.has_only_modifiers(&[KeyModifier::Alt]) => { let write_to_disk = true; let keys_to_unbind = vec![ ( @@ -551,22 +554,22 @@ impl ZellijPlugin for State { ]; rebind_keys(keys_to_unbind, keys_to_rebind, write_to_disk); }, - BareKey::Char('z') if key.has_modifiers(&[KeyModifier::Alt]) => { + BareKey::Char('z') if key.has_only_modifiers(&[KeyModifier::Alt]) => { list_clients(); }, - BareKey::Char('a') if key.has_modifiers(&[KeyModifier::Super]) => { + BareKey::Char('a') if key.has_only_modifiers(&[KeyModifier::Super]) => { // Test show_cursor with coordinates show_cursor(Some((5, 10))); }, - BareKey::Char('b') if key.has_modifiers(&[KeyModifier::Super]) => { + BareKey::Char('b') if key.has_only_modifiers(&[KeyModifier::Super]) => { // Test hide_cursor show_cursor(None); }, - BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Super]) => { + BareKey::Char('c') if key.has_only_modifiers(&[KeyModifier::Super]) => { // Test copy_to_clipboard copy_to_clipboard("test clipboard text"); }, - BareKey::Char('d') if key.has_modifiers(&[KeyModifier::Super]) => { + BareKey::Char('d') if key.has_only_modifiers(&[KeyModifier::Super]) => { // Test run_action with MoveFocus let mut context = BTreeMap::new(); context.insert("test_key".to_string(), "test_value".to_string()); @@ -577,14 +580,312 @@ impl ZellijPlugin for State { context, ); }, - BareKey::Char('e') if key.has_modifiers(&[KeyModifier::Super]) => { + BareKey::Char('e') if key.has_only_modifiers(&[KeyModifier::Super]) => { // Test send_sigint_to_pane_id send_sigint_to_pane_id(PaneId::Terminal(1)); }, - BareKey::Char('f') if key.has_modifiers(&[KeyModifier::Super]) => { + BareKey::Char('f') if key.has_only_modifiers(&[KeyModifier::Super]) => { // Test send_sigkill_to_pane_id send_sigkill_to_pane_id(PaneId::Terminal(1)); }, + BareKey::Char('a') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + let name = generate_random_name(); + if !name.is_empty() { + self.explicit_string_to_render = Some(format!("Generated name")); + } else { + self.explicit_string_to_render = Some(format!("Error: got empty name")); + } + }, + BareKey::Char('b') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + match dump_layout("default") { + Ok(kdl) => { + self.explicit_string_to_render = + Some(format!("Layout dump success: {}", kdl)) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Layout dump error: {}", e)) + }, + } + }, + BareKey::Char('c') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + // Test dump_layout() - not found case + match dump_layout("nonexistent_layout_xyz") { + Ok(kdl) => { + self.explicit_string_to_render = + Some(format!("Layout dump success: {}", kdl)) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Layout dump error: {}", e)) + }, + } + }, + BareKey::Char('d') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + // Test get_layout_dir() + let dir = get_layout_dir(); + if !dir.is_empty() { + self.explicit_string_to_render = Some(format!("Got layout folder")); + } else { + self.explicit_string_to_render = + Some(format!("Error: Got empty layout folder!")); + } + }, + BareKey::Char('e') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + // Test get_focused_pane_info() + match get_focused_pane_info() { + Ok((tab_index, pane_id)) => { + println!("Focused pane: tab={}, pane={:?}", tab_index, pane_id) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Focused pane error: {}", e)) + }, + } + }, + BareKey::Char('f') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + // Test dump_session_layout() + match dump_session_layout() { + Ok((kdl, metadata)) => { + self.explicit_string_to_render = Some(format!( + "Session layout: {} tabs, metadata: {:?}", + kdl.len(), + metadata + )) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Session layout error: {}", e)) + }, + } + }, + BareKey::Char('g') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + // Test dump_session_layout_for_tab() + match dump_session_layout_for_tab(0) { + Ok((kdl, metadata)) => { + self.explicit_string_to_render = Some(format!( + "Tab 0 layout: {} bytes, metadata: {:?}", + kdl.len(), + metadata + )) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Tab layout error: {}", e)) + }, + } + }, + BareKey::Char('h') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + // Test parse_layout() - valid KDL + let valid_kdl = "layout { tab { pane; }; }"; + match parse_layout(valid_kdl) { + Ok(metadata) => { + if metadata.tabs.len() == 1 { + self.explicit_string_to_render = Some(format!("Parse success")); + } else { + self.explicit_string_to_render = + Some(format!("Parse failure: got wrong metadata")); + } + }, + Err(e) => { + self.explicit_string_to_render = Some(format!("Parse error: {:?}", e)) + }, + } + }, + BareKey::Char('i') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) => + { + // Test parse_layout() - invalid KDL + let invalid_kdl = "layout { this is not valid kdl }"; + match parse_layout(invalid_kdl) { + Ok(metadata) => { + self.explicit_string_to_render = + Some(format!("Parse success: {:?}", metadata)) + }, + Err(e) => { + self.explicit_string_to_render = Some(format!("Parse error: {:?}", e)) + }, + } + }, + BareKey::Char('a') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + let test_kdl = "layout { tab { pane; }; }"; + match save_layout("test_layout", test_kdl, false) { + Ok(_) => { + self.explicit_string_to_render = Some(format!("Save layout success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Save layout error: {}", e)) + }, + } + }, + BareKey::Char('b') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + let test_kdl = "layout { tab { pane; }; }"; + match save_layout("test_layout", test_kdl, false) { + Ok(_) => { + self.explicit_string_to_render = Some(format!("Save layout success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Save layout error: {}", e)) + }, + } + }, + BareKey::Char('c') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + let test_kdl = "layout { tab { pane; pane; }; }"; + match save_layout("test_layout2", test_kdl, true) { + Ok(_) => { + self.explicit_string_to_render = + Some(format!("Save layout with overwrite success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Save layout with overwrite error: {}", e)) + }, + } + }, + BareKey::Char('d') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + // Test save_layout() - invalid KDL + let invalid_kdl = "not valid kdl at all"; + match save_layout("invalid_layout", invalid_kdl, false) { + Ok(_) => { + self.explicit_string_to_render = + Some(format!("Save invalid layout success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Save invalid layout error: {}", e)) + }, + } + }, + BareKey::Char('e') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + match rename_layout("test_layout", "renamed_layout") { + Ok(_) => { + self.explicit_string_to_render = Some(format!("Rename layout success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Rename layout error: {}", e)) + }, + } + }, + BareKey::Char('f') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + // Test rename_layout() - not found case + match rename_layout("nonexistent_layout", "new_name") { + Ok(_) => { + self.explicit_string_to_render = + Some(format!("Rename nonexistent layout success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Rename nonexistent layout error: {}", e)) + }, + } + }, + BareKey::Char('g') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + // Test delete_layout() - success case + match delete_layout("renamed_layout") { + Ok(_) => { + self.explicit_string_to_render = Some(format!("Delete layout success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Delete layout error: {}", e)) + }, + } + }, + BareKey::Char('h') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + // Test delete_layout() - not found case + match delete_layout("nonexistent_layout") { + Ok(_) => { + self.explicit_string_to_render = + Some(format!("Delete nonexistent layout success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Delete nonexistent layout error: {}", e)) + }, + } + }, + BareKey::Char('i') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + let test_kdl = "layout { tab { pane; } }"; + match save_layout("../evil_layout", test_kdl, false) { + Ok(_) => { + self.explicit_string_to_render = + Some(format!("Path traversal save success")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Path traversal save error: {}", e)) + }, + } + }, + BareKey::Char('j') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + // Test edit_layout() + let mut context = BTreeMap::new(); + context.insert("test_key".to_owned(), "test_value".to_owned()); + match edit_layout("test_layout2", context) { + Ok(_) => { + self.explicit_string_to_render = + Some(format!("Edit layout command sent")) + }, + Err(e) => { + self.explicit_string_to_render = + Some(format!("Edit layout error: {}", e)) + }, + } + }, + BareKey::Char('k') + if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt]) => + { + // Test override_layout() + let mut context = BTreeMap::new(); + context.insert("override_test".to_owned(), "value".to_owned()); + override_layout( + &LayoutInfo::BuiltIn("compact".to_owned()), + false, // retain_existing_terminal_panes + false, // retain_existing_plugin_panes + false, // apply_only_to_active_tab + context, + ); + self.explicit_string_to_render = Some(format!("Override layout command sent")); + }, _ => {}, }, Event::CustomMessage(message, payload) => { @@ -640,6 +941,8 @@ impl ZellijPlugin for State { println!("Payload from worker: {:?}", payload); } else if let Some(payload) = self.message_to_plugin_payload.take() { println!("Payload from self: {:?}", payload); + } else if let Some(explicit_string_to_render) = self.explicit_string_to_render.take() { + println!("{}", explicit_string_to_render.trim()); } else { println!( "Rows: {:?}, Cols: {:?}, Received events: {:?}", diff --git a/default-plugins/layout-manager/.cargo/config.toml b/default-plugins/layout-manager/.cargo/config.toml new file mode 100644 index 00000000..6b509f5b --- /dev/null +++ b/default-plugins/layout-manager/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/default-plugins/layout-manager/.gitignore b/default-plugins/layout-manager/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/default-plugins/layout-manager/.gitignore @@ -0,0 +1 @@ +/target diff --git a/default-plugins/layout-manager/Cargo.lock b/default-plugins/layout-manager/Cargo.lock new file mode 100644 index 00000000..73fed400 --- /dev/null +++ b/default-plugins/layout-manager/Cargo.lock @@ -0,0 +1,3020 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom 0.2.10", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" +dependencies = [ + "backtrace", +] + +[[package]] +name = "arc-swap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf46fee83e5ccffc220104713af3292ff9bc7c64c7de289f66dae8e38d826833" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-executor" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa3dc5f2a8564f07759c008b9109dc0d39de92a88d5588b8a5036d286383afb" +dependencies = [ + "async-lock", + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b6f5d7df27bd294849f8eec66ecfc63d11814df7a4f5d74168a2394467b776" +dependencies = [ + "async-channel", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite", + "log", + "parking", + "polling", + "rustix", + "slab", + "socket2 0.4.9", + "waker-fn", +] + +[[package]] +name = "async-lock" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa24f727524730b077666307f2734b4a1a1c57acb79193127dcc8914d5242dd7" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-process" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9d28b1d97e08915212e2e45310d47854eafa69600756fc735fb788f75199c9" +dependencies = [ + "async-io", + "async-lock", + "autocfg", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", + "signal-hook", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-std" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d" +dependencies = [ + "async-attributes", + "async-channel", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1181e1e0d1fce796a03db1ae795d67167da795f9cf4a39c37589e85ef57f26d3" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "backtrace" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77231a1c8f801696fc0123ec6150ce92cffb8e164a02afb9c8ddee0e9b65ad65" +dependencies = [ + "async-channel", + "async-lock", + "async-task", + "atomic-waker", + "fastrand", + "futures-lite", + "log", +] + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "bytemuck" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "castaway" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags 1.3.2", + "clap_derive", + "clap_lex", + "indexmap", + "once_cell", + "strsim 0.10.0", + "termcolor", + "textwrap 0.16.0", +] + +[[package]] +name = "clap_complete" +version = "3.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7a2e0a962c45ce25afce14220bc24f9dade0a1787f185cecf96bfba7847cd8" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "colored" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" +dependencies = [ + "atty", + "lazy_static", + "winapi", +] + +[[package]] +name = "colorsys" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54261aba646433cb567ec89844be4c4825ca92a4f8afba52fc4dd88436e31bbd" + +[[package]] +name = "concurrent-queue" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ec6771ecfa0762d24683ee5a32ad78487a3d3afdc0fb8cae19d2c5deb50b7c" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c" +dependencies = [ + "cfg-if", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset 0.9.0", + "scopeguard", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "curl" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e2161dd6eba090ff1594084e95fd67aeccf04382ffea77999ea94ed42ec67b6" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2 0.5.10", + "windows-sys 0.52.0", +] + +[[package]] +name = "curl-sys" +version = "0.4.72+curl-8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29cbdc8314c447d11e8fd156dcdd031d9e02a7a976163e396b548c03153bc9ea" +dependencies = [ + "cc", + "libc", + "libnghttp2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.52.0", +] + +[[package]] +name = "deltae" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef311e2c0a388b9ba56c839ac68d33b92d18ce7104d0acc4375cb479e2b9a53" + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "euclid" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52c2ef4a78da0ba68fbe1fd920627411096d2ac478f7f4c9f3a54ba6705bade" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.40", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0a93d233ebf96623465aad4046a8d3aa4da22d4f4beba5388838c8a434bbb4" + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.11", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "include_dir" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18762faeff7122e89e0857b02f7ce6fcc0d101d5e9ad2ad7846cc01d61b7f19e" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b139284b5cf57ecfa712bcc66950bb635b31aff41c188e8a4cfc758eca374a3f" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f2533f3be42fffe3b5e63b71aeca416c1c3bc33e4e27be018521e76b1f38fb" +dependencies = [ + "cfg-if", + "libc", + "rustc_version", + "to_method", + "winapi", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.1", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "is-terminal" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +dependencies = [ + "hermit-abi 0.3.1", + "io-lifetimes", + "rustix", + "windows-sys 0.48.0", +] + +[[package]] +name = "is_ci" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616cde7c720bb2bb5824a224687d8f77bfd38922027f01d825cd7453be5099fb" + +[[package]] +name = "isahc" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" +dependencies = [ + "async-channel", + "castaway", + "crossbeam-utils", + "curl", + "curl-sys", + "encoding_rs", + "event-listener", + "futures-lite", + "http", + "log", + "mime", + "once_cell", + "polling", + "slab", + "sluice", + "tracing", + "tracing-futures", + "url", + "waker-fn", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kdl" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062c875482ccb676fd40c804a40e3824d4464c18c364547456d1c8e8e951ae47" +dependencies = [ + "miette", + "nom", + "thiserror 1.0.40", +] + +[[package]] +name = "kqueue" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8fc60ba15bf51257aa9807a48a61013db043fcf3a78cb0d916e8e396dcad98" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8367585489f01bc55dd27404dcf56b95e6da061a256a666ab23be9ba96a2e587" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "layout-manager" +version = "0.1.0" +dependencies = [ + "fuzzy-matcher", + "miette", + "strip-ansi-escapes 0.2.1", + "unicode-width", + "zellij-tile", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lev_distance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d9d1bd215936bc8647ad92986bb56f3f216550b53c44ab785e3217ae33625e" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libnghttp2-sys" +version = "0.1.10+1.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c25552127d2e1fa72f0e52548ec04fc386e827ba71a7bd01db46a447dc135" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", + "redox_syscall 0.5.13", +] + +[[package]] +name = "libz-sys" +version = "1.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "lock_api" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" +dependencies = [ + "value-bag", +] + +[[package]] +name = "log-mdc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" + +[[package]] +name = "log4rs" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36ca1786d9e79b8193a68d480a0907b612f109537115c6ff655a3a1967533fd" +dependencies = [ + "anyhow", + "arc-swap", + "chrono", + "derivative", + "fnv", + "log", + "log-mdc", + "parking_lot", + "thiserror 1.0.40", + "thread-id", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "5.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a236ff270093b0b67451bc50a509bd1bad302cb1d3c7d37d5efe931238581fa9" +dependencies = [ + "backtrace", + "backtrace-ext", + "is-terminal", + "miette-derive", + "once_cell", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap 0.15.2", + "thiserror 1.0.40", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "5.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4901771e1d44ddb37964565c654a3223ba41a594d02b8da471cc4464912b5cfa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "names" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bddcd3bf5144b6392de80e04c347cd7fab2508f6df16a85fc496ecd5cec39bc" +dependencies = [ + "rand", +] + +[[package]] +name = "nix" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.0", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.6.0", + "filetime", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.8", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.30.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b4680b86d9cfafba8fc491dc9b6df26b68cf40e9e6cd73909194759a63c385" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c65ee1f9701bf938026630b455d5315f490640234259037edb259798b3bcf85e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_str_bytes" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "parking" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f2252c834a40ed9bb5422029649578e63aa341ac401f74e719dd1afda8394e" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.3.5", + "smallvec", + "windows-targets 0.48.0", +] + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "pest" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +dependencies = [ + "memchr", + "thiserror 2.0.12", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "pest_meta" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928c6535de93548188ef63bb7c4036bd415cd8f36ad25af44b9789b2ee72a48c" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56ac890c5e3ca598bbdeaa99964edb5b0258a583a9eb6ef4e89fc85d9224770" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1181c94580fa345f50f19d738aaa39c0ed30a600d95cb2d3e23f94266f14fbf" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92aacdc5f16768709a569e913f7451034034178b05bdc8acda226659a3dccc66" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_shared" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fb5f6f826b772a8d4c0394209441e7d37cbbb967ae9c7e0e8134365c9ee676" +dependencies = [ + "siphasher 0.3.10", +] + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.10", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom 0.2.10", + "redox_syscall 0.2.16", + "thiserror 1.0.40", +] + +[[package]] +name = "regex" +version = "1.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.37.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96e891d04aa506a6d1f318d2771bcb1c7dfda84e126660ace067c9b474bb2c0" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "semver" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" + +[[package]] +name = "serde" +version = "1.0.164" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.164" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "serde_json" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +dependencies = [ + "itoa 0.4.7", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shellexpand" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b" +dependencies = [ + "dirs", +] + +[[package]] +name = "signal-hook" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "sluice" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" +dependencies = [ + "async-channel", + "futures-core", + "futures-io", +] + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "smawk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011cbb39cf7c1f62871aea3cc46e5817b0937b49e9447370c93cacbe93a766d8" +dependencies = [ + "vte 0.10.1", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte 0.14.1", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "suggest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2558d0d6327d908558f161bc862d365c0d33aa796bdc81c13c0f954868576659" +dependencies = [ + "clap", + "lev_distance", +] + +[[package]] +name = "supports-color" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4950e7174bffabe99455511c39707310e7e9b440364a2fcb1cc21521be57b354" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "supports-unicode" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6c2cb240ab5dd21ed4906895ee23fe5a48acdbd15a3ce388e7b62a9b66baf7" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6" +dependencies = [ + "autocfg", + "cfg-if", + "fastrand", + "redox_syscall 0.3.5", + "rustix", + "windows-sys 0.48.0", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.6.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher 1.0.1", + "terminfo", + "termios", + "thiserror 1.0.40", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "textwrap" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" + +[[package]] +name = "thiserror" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +dependencies = [ + "thiserror-impl 1.0.40", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "thread-id" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee93aa2b8331c0fec9091548843f2c90019571814057da3b783f9de09349d73" +dependencies = [ + "libc", + "redox_syscall 0.2.16", + "winapi", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "to_method" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio 1.0.4", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.10", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "ucd-trie" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" + +[[package]] +name = "unicode-linebreak" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" +dependencies = [ + "hashbrown", + "regex", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] +name = "url" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "atomic", + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d330786735ea358f3bc09eea4caa098569c1c93f342d9aca0514915022fe7e" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vte" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbce692ab4ca2f1f3047fcf732430249c0e971bfdd2b234cf2c47ad93af5983" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d257817081c7dffcdbab24b9e62d2def62e2ff7d00b1c20062551e6cccc145ff" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "waker-fn" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" + +[[package]] +name = "walkdir" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.104", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.3", + "mac_address", + "sha2", + "thiserror 1.0.40", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim 0.11.1", + "thiserror 1.0.40", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "zellij-tile" +version = "0.44.0" +dependencies = [ + "clap", + "prost", + "serde", + "serde_json", + "strum", + "strum_macros", + "zellij-utils", +] + +[[package]] +name = "zellij-utils" +version = "0.44.0" +dependencies = [ + "anyhow", + "async-std", + "backtrace", + "bitflags 2.6.0", + "clap", + "clap_complete", + "colored", + "colorsys", + "crossbeam", + "directories", + "humantime", + "include_dir", + "interprocess", + "isahc", + "kdl", + "lazy_static", + "log", + "log4rs", + "miette", + "names", + "nix 0.23.2", + "notify", + "percent-encoding", + "prost", + "serde", + "serde_json", + "sha2", + "shellexpand", + "strip-ansi-escapes 0.1.1", + "strum", + "strum_macros", + "suggest", + "tempfile", + "termwiz", + "thiserror 1.0.40", + "tokio", + "unicode-width", + "url", + "uuid", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] diff --git a/default-plugins/layout-manager/Cargo.toml b/default-plugins/layout-manager/Cargo.toml new file mode 100644 index 00000000..992626ac --- /dev/null +++ b/default-plugins/layout-manager/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "layout-manager" +version = "0.1.0" +authors = ["Aram Drevekenin <aram@poor.dev>"] +edition = "2018" + +[dependencies] +zellij-tile = { path = "../../zellij-tile" } +miette = { version = "5.7.0", features = ["fancy"] } +strip-ansi-escapes = "0.2" +unicode-width = "0.1" +fuzzy-matcher = "0.3" diff --git a/default-plugins/layout-manager/LICENSE.md b/default-plugins/layout-manager/LICENSE.md new file mode 120000 index 00000000..f0608a63 --- /dev/null +++ b/default-plugins/layout-manager/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md
\ No newline at end of file diff --git a/default-plugins/layout-manager/README.md b/default-plugins/layout-manager/README.md new file mode 100644 index 00000000..daa5e816 --- /dev/null +++ b/default-plugins/layout-manager/README.md @@ -0,0 +1,27 @@ +## About + +This is an example [Zellij][zellij] plugin in Rust. It can be used as a template to start developing your own plugins. + +More about Zellij plugins: [Zellij Documentation][docs] + +[zellij]: https://github.com/zellij-org/zellij +[docs]: https://zellij.dev/documentation/plugins.html + +## Development + +*Note*: you will need to have `wasm32-wasi` added to rust as a target to build the plugin. This can be done with `rustup target add wasm32-wasi`. + +### With the Provided Layout + + + + +Run `zellij -l zellij.kdl` at the root of this repository. This will open a development environment that will help you develop the plugin inside Zellij. + +It can also be used if you prefer developing outside of the terminal - in this case you should ignore the `$EDITOR` pane and use your IDE instead. + +### Otherwise + +1. Build the project: `cargo build` +2. Load it inside a running Zellij session: `zellij action start-or-reload-plugin file:target/wasm32-wasi/debug/rust-plugin-example.wasm` +3. Repeat on changes (perhaps with a `watchexec` or similar command to run on fs changes). diff --git a/default-plugins/layout-manager/src/errors.rs b/default-plugins/layout-manager/src/errors.rs new file mode 100644 index 00000000..a86af8bf --- /dev/null +++ b/default-plugins/layout-manager/src/errors.rs @@ -0,0 +1,155 @@ +use crate::screens::{KeyResponse, Screen}; +use crate::ui::{truncate_line_with_ansi, wrap_text_to_width, ErrorMessage, MultiLineErrorMessage}; +use zellij_tile::prelude::*; + +/// Format a layout parsing error into a detailed error string +pub fn format_kdl_error(error: LayoutParsingError) -> String { + match error { + LayoutParsingError::KdlError { + mut kdl_error, + file_name, + source_code, + } => { + use miette::{GraphicalReportHandler, NamedSource, Report}; + + kdl_error.help_message = + Some("https://zellij.dev/documentation/creating-a-layout.html".to_owned()); + let report: Report = kdl_error.into(); + let report = report.with_source_code(NamedSource::new(file_name, source_code)); + + let handler = GraphicalReportHandler::new(); + let mut output = String::new(); + handler.render_report(&mut output, report.as_ref()).unwrap(); + output + }, + LayoutParsingError::SyntaxError => { + format!("Failed to deserialize KDL node. \nPossible reasons:\n{}\n{}\n{}\n{}", + "- Missing `;` after a node name, eg. { node; another_node; }", + "- Missing quotations (\") around an argument node eg. { first_node \"argument_node\"; }", + "- Missing an equal sign (=) between node arguments on a title line. eg. argument=\"value\"", + "- Found an extraneous equal sign (=) between node child arguments and their values. eg. { argument=\"value\" }") + }, + } +} + +#[derive(Clone)] +pub struct ErrorScreen { + pub message: String, + pub return_to_screen: Box<Screen>, +} + +impl ErrorScreen { + pub fn handle_key(&mut self, _key: KeyWithModifier) -> KeyResponse { + KeyResponse::new_screen((*self.return_to_screen).clone()) + } + + pub fn render(&self, rows: usize, cols: usize) { + if self.message.chars().count() > cols.saturating_sub(4) { + let max_width = cols.saturating_sub(4); + let max_rows = rows.saturating_sub(4); + let lines = wrap_text_to_width(&self.message, max_width); + let base_x = 2; + let base_y = rows.saturating_sub(4 + lines.len()) / 2; + MultiLineErrorMessage::new(lines).render(base_x, base_y, max_rows); + } else { + let desired_width = self.message.chars().count(); + let base_y = rows.saturating_sub(5) / 2; + let base_x = cols.saturating_sub(desired_width) / 2; + ErrorMessage::new(&self.message).render(base_x, base_y); + } + } +} + +#[derive(Clone)] +pub struct ErrorDetailScreen { + pub layout_name: String, + pub detailed_error: String, + pub return_to_screen: Box<Screen>, +} + +impl ErrorDetailScreen { + pub fn new(layout_name: String, detailed_error: String, return_to_screen: Box<Screen>) -> Self { + Self { + layout_name, + detailed_error, + return_to_screen, + } + } + + pub fn handle_key(&mut self, _key: KeyWithModifier) -> KeyResponse { + KeyResponse::new_screen((*self.return_to_screen).clone()) + } + + pub fn render(&self, rows: usize, cols: usize) { + // Header: show layout name + let header = format!("Error in layout: {}", self.layout_name); + let header_text = Text::new(&header).error_color_all(); + print_text_with_coordinates(header_text, 1, 0, None, None); + + // Calculate available space for error content + let header_height = 2; // Header + gap + let available_rows = rows.saturating_sub(header_height); + let available_cols = cols.saturating_sub(2); // Padding on sides + + // Render error lines with middle truncation + let error_lines: Vec<&str> = self.detailed_error.lines().collect(); + let total_lines = error_lines.len(); + + if total_lines <= available_rows { + // All lines fit, show them all + for (i, line) in error_lines.iter().enumerate() { + let truncated = truncate_line_with_ansi(line, available_cols); + print!("\u{1b}[{};{}H{}", header_height + i + 1, 2, truncated); + } + } else { + // Need to truncate middle - show beginning, indicator, and end + let omitted_indicator_lines = 1; // Reserve 1 line for "... X lines omitted ..." + let lines_for_content = available_rows.saturating_sub(omitted_indicator_lines); + + // Split content space: 60% for beginning, 40% for end + let beginning_lines = (lines_for_content as f32 * 0.6).ceil() as usize; + let end_lines = lines_for_content.saturating_sub(beginning_lines); + + let omitted_count = total_lines.saturating_sub(beginning_lines + end_lines); + + let mut current_row = 0; + + // Render beginning lines + for line in error_lines.iter().take(beginning_lines) { + let truncated = truncate_line_with_ansi(line, available_cols); + print!( + "\u{1b}[{};{}H{}", + header_height + current_row + 1, + 2, + truncated + ); + current_row += 1; + } + + // Render omission indicator + let indicator = format!("... {} lines omitted ...", omitted_count); + let indicator_text = Text::new(&indicator).color_range(0, ..); + print_text_with_coordinates( + indicator_text, + (cols.saturating_sub(indicator.chars().count())) / 2, + header_height + current_row, + None, + None, + ); + current_row += 1; + + // Render end lines + let start_index = total_lines.saturating_sub(end_lines); + for line in error_lines.iter().skip(start_index) { + let truncated = truncate_line_with_ansi(line, available_cols); + print!( + "\u{1b}[{};{}H{}", + header_height + current_row + 1, + 2, + truncated + ); + current_row += 1; + } + } + } +} diff --git a/default-plugins/layout-manager/src/main.rs b/default-plugins/layout-manager/src/main.rs new file mode 100644 index 00000000..0966f4ec --- /dev/null +++ b/default-plugins/layout-manager/src/main.rs @@ -0,0 +1,295 @@ +mod errors; +mod screens; +mod text_input; +mod ui; + +use errors::format_kdl_error; +use screens::{KeyResponse, OptimisticUpdate, Screen}; +use std::collections::BTreeMap; +use ui::{get_last_modified_string, get_layout_display_info}; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub enum DisplayLayout { + Valid(LayoutInfo), + Error { + name: String, + error: String, + error_message: String, + }, +} + +impl DisplayLayout { + fn name(&self) -> String { + match self { + DisplayLayout::Valid(info) => match info { + LayoutInfo::BuiltIn(name) => name.clone(), + LayoutInfo::File(path, _) => path.split('/').last().unwrap_or(path).to_string(), + LayoutInfo::Url(url) => url.split('/').last().unwrap_or(url).to_string(), + LayoutInfo::Stringified(_) => "raw".to_string(), + }, + DisplayLayout::Error { name, .. } => name.clone(), + } + } + fn file_name(&self) -> Option<String> { + match self { + DisplayLayout::Valid(LayoutInfo::File(file_name, _)) => Some(file_name.clone()), + DisplayLayout::Error { name, .. } => Some(name.clone()), + _ => None, + } + } + fn is_error(&self) -> bool { + match self { + DisplayLayout::Error { .. } => true, + _ => false, + } + } + fn is_builtin(&self) -> bool { + matches!(self, DisplayLayout::Valid(LayoutInfo::BuiltIn(..))) + } + + fn builtin_sort_priority(&self) -> usize { + let name = self.name().to_lowercase(); + match name.as_str() { + "default" => 0, + "compact" => 1, + _ => 2, + } + } + + fn get_update_time(&self) -> Option<i64> { + match self { + DisplayLayout::Valid(LayoutInfo::File(_, metadata)) => { + metadata.update_time.parse::<i64>().ok() + }, + _ => None, + } + } + + fn compare_builtin_layouts(&self, other: &DisplayLayout) -> std::cmp::Ordering { + match self + .builtin_sort_priority() + .cmp(&other.builtin_sort_priority()) + { + std::cmp::Ordering::Equal => { + self.name().to_lowercase().cmp(&other.name().to_lowercase()) + }, + priority_order => priority_order, + } + } + + fn compare_for_display(&self, other: &DisplayLayout) -> std::cmp::Ordering { + match (self.is_builtin(), other.is_builtin()) { + (false, false) => { + // Sort by update_time (newest first) + match (self.get_update_time(), other.get_update_time()) { + (Some(self_time), Some(other_time)) => { + // Reverse comparison for descending order + other_time.cmp(&self_time) + }, + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => { + // Fallback to alphabetical + self.name().to_lowercase().cmp(&other.name().to_lowercase()) + }, + } + }, + (false, true) => std::cmp::Ordering::Less, + (true, false) => std::cmp::Ordering::Greater, + (true, true) => self.compare_builtin_layouts(other), + } + } + + fn name_width(&self) -> usize { + self.name().chars().count() + } + fn last_modified_width(&self) -> usize { + let display_info = get_layout_display_info(&self); + get_last_modified_string(display_info.1, self.is_builtin()) + .chars() + .count() + } +} + +#[derive(Default)] +struct State { + display_layouts: Vec<DisplayLayout>, + screen: Screen, +} + +impl State { + fn handle_key_event(&mut self, key: KeyWithModifier) -> KeyResponse { + match &mut self.screen { + Screen::LayoutList(layout_list_screen) => { + layout_list_screen.handle_key(key, &self.display_layouts) + }, + Screen::NewLayoutFromSession(new_layout_from_session_screen) => { + new_layout_from_session_screen.handle_key(key) + }, + Screen::ImportLayout(import_layout_screen) => import_layout_screen.handle_key(key), + Screen::RenameLayout(rename_layout_screen) => rename_layout_screen.handle_key(key), + Screen::Error(ref mut error_screen) => error_screen.handle_key(key), + Screen::ErrorDetail(ref mut error_detail_screen) => error_detail_screen.handle_key(key), + } + } + + fn apply_key_response(&mut self, response: KeyResponse) -> bool { + let mut should_render = false; + + if let Some(update) = response.optimistic_update { + self.apply_optimistic_update(update); + should_render = true; + } + + if let Some(new_screen) = response.new_screen { + self.screen = new_screen; + } + + should_render || response.should_render + } + + fn apply_optimistic_update(&mut self, update: OptimisticUpdate) { + match update { + OptimisticUpdate::Delete(file_name) => { + self.optimistic_delete_layout(&file_name); + }, + OptimisticUpdate::Rename { old_name, new_name } => { + self.optimistic_rename_layout(&old_name, &new_name); + }, + OptimisticUpdate::Add { name, metadata } => { + self.optimistic_add_layout(name, metadata); + }, + } + } + + fn optimistic_add_layout(&mut self, name: String, metadata: LayoutMetadata) { + let placeholder = DisplayLayout::Valid(LayoutInfo::File(name, metadata)); + + self.display_layouts.push(placeholder); + self.display_layouts + .sort_by(|a, b| a.compare_for_display(b)); + + if let Screen::LayoutList(ref mut screen) = self.screen { + screen.update_selected_index(&self.display_layouts); + } + } + + fn optimistic_delete_layout(&mut self, file_name: &str) { + self.display_layouts + .retain(|layout| layout.file_name() != Some(file_name.to_string())); + + if let Screen::LayoutList(ref mut screen) = self.screen { + screen.update_selected_index(&self.display_layouts); + } + } + + fn optimistic_rename_layout(&mut self, old_name: &str, new_name: &str) { + for layout in &mut self.display_layouts { + match layout { + DisplayLayout::Valid(LayoutInfo::File(file_name, _)) => { + if file_name == old_name { + let _ = std::mem::replace(file_name, new_name.to_string()); + break; + } + }, + DisplayLayout::Error { ref mut name, .. } => { + if name == old_name { + let _ = std::mem::replace(name, new_name.to_string()); + break; + } + }, + _ => {}, + } + } + + self.display_layouts + .sort_by(|a, b| a.compare_for_display(b)); + + if let Screen::LayoutList(ref mut screen) = self.screen { + screen.update_selected_index(&self.display_layouts); + } + } +} + +register_plugin!(State); + +impl ZellijPlugin for State { + fn load(&mut self, _configuration: BTreeMap<String, String>) { + subscribe(&[ + EventType::PastedText, + EventType::SessionUpdate, + EventType::Key, + EventType::AvailableLayoutInfo, + EventType::PermissionRequestResult, + ]); + rename_plugin_pane(get_plugin_ids().plugin_id, "Layout Manager"); + } + + fn update(&mut self, event: Event) -> bool { + let mut should_render = false; + + match event { + Event::PastedText(text) => { + if let Screen::ImportLayout(ref mut import_screen) = self.screen { + import_screen.handle_pasted_text(text); + should_render = true; + } + }, + Event::AvailableLayoutInfo(available_layouts, layouts_with_errors) => { + // Convert valid layouts to DisplayLayout::Valid + let mut display_layouts: Vec<DisplayLayout> = available_layouts + .into_iter() + .map(DisplayLayout::Valid) + .collect(); + + // Convert error layouts to DisplayLayout::Error + for layout_error in &layouts_with_errors { + let stringified_error = format_kdl_error(layout_error.error.clone()); + let error_message = match &layout_error.error { + LayoutParsingError::KdlError { kdl_error, .. } => { + format!("Layout Error: {}", kdl_error.error_message) + }, + LayoutParsingError::SyntaxError => "KDL parsing error".to_owned(), + }; + + display_layouts.push(DisplayLayout::Error { + name: layout_error.layout_name.clone(), + error_message, + error: stringified_error, + }); + } + + display_layouts.sort_by(|a, b| a.compare_for_display(b)); + + self.display_layouts = display_layouts; + if let Screen::LayoutList(ref mut screen) = self.screen { + screen.update_selected_index(&self.display_layouts); + } + should_render = true; + }, + Event::Key(key) => { + let response = self.handle_key_event(key); + should_render = self.apply_key_response(response); + }, + _ => {}, + } + + should_render + } + + fn render(&mut self, rows: usize, cols: usize) { + match &mut self.screen { + Screen::LayoutList(layout_list_screen) => { + layout_list_screen.render(&self.display_layouts, rows, cols) + }, + Screen::NewLayoutFromSession(new_layout_from_session_screen) => { + new_layout_from_session_screen.render(rows, cols) + }, + Screen::ImportLayout(import_layout_screen) => import_layout_screen.render(rows, cols), + Screen::RenameLayout(rename_layout_screen) => rename_layout_screen.render(rows, cols), + Screen::Error(ref error_screen) => error_screen.render(rows, cols), + Screen::ErrorDetail(ref error_detail_screen) => error_detail_screen.render(rows, cols), + } + } +} diff --git a/default-plugins/layout-manager/src/screens/import_layout.rs b/default-plugins/layout-manager/src/screens/import_layout.rs new file mode 100644 index 00000000..8bf1a3a1 --- /dev/null +++ b/default-plugins/layout-manager/src/screens/import_layout.rs @@ -0,0 +1,331 @@ +use super::{KeyResponse, LayoutListScreen, OptimisticUpdate, Screen}; +use crate::errors::{format_kdl_error, ErrorScreen}; +use crate::text_input::{InputAction, TextInput}; +use crate::ui::{truncate_with_ellipsis_start, LayoutDetail, Title}; +use crate::{DisplayLayout, LayoutInfo}; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct ImportLayoutScreen { + pub name_input: TextInput, + pub pasted_text: Option<String>, + pub parsed_metadata: Option<LayoutMetadata>, + pub parse_error: Option<String>, + pub editing_name: bool, +} + +impl ImportLayoutScreen { + pub fn new() -> Self { + Self { + name_input: TextInput::empty(), + pasted_text: None, + parsed_metadata: None, + parse_error: None, + editing_name: false, + } + } + + pub fn handle_pasted_text(&mut self, text: String) { + self.pasted_text = Some(text.clone()); + + match parse_layout(&text) { + Ok(parsed_metadata) => { + self.parsed_metadata = Some(parsed_metadata); + self.parse_error = None; + }, + Err(e) => { + self.parsed_metadata = None; + self.parse_error = Some(format_kdl_error(e)); + }, + } + } + + pub fn handle_key(&mut self, key: KeyWithModifier) -> KeyResponse { + // Special Esc handling based on edit state + if key.bare_key == BareKey::Esc && key.has_no_modifiers() { + return self.handle_escape_key(); + } + + if self.editing_name { + self.handle_editing_mode_key(key) + } else { + self.handle_non_editing_mode_key(key) + } + } + + fn handle_escape_key(&mut self) -> KeyResponse { + if self.editing_name { + self.editing_name = false; + KeyResponse::render() + } else { + KeyResponse::new_screen(self.cancel_import()) + } + } + + fn handle_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse { + let action = self.name_input.handle_key(key); + + match action { + InputAction::Continue => KeyResponse::render(), + InputAction::Submit => self.exit_editing_mode(), + InputAction::Cancel => self.exit_editing_mode(), + InputAction::Complete => KeyResponse::none(), + InputAction::NoAction => KeyResponse::none(), + } + } + + fn handle_non_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse { + match key.bare_key { + BareKey::Enter if key.has_no_modifiers() => self.attempt_save_layout(), + BareKey::Char('r') if key.has_no_modifiers() => self.enter_editing_mode(), + _ => KeyResponse::none(), + } + } + + fn exit_editing_mode(&mut self) -> KeyResponse { + self.editing_name = false; + KeyResponse::render() + } + + fn enter_editing_mode(&mut self) -> KeyResponse { + self.editing_name = true; + KeyResponse::render() + } + + fn cancel_import(&self) -> Screen { + Screen::LayoutList(LayoutListScreen::default()) + } + + fn attempt_save_layout(&self) -> KeyResponse { + // Check if we have a pasted layout + let Some(pasted_text) = &self.pasted_text else { + return KeyResponse::new_screen( + self.create_error_screen("Please paste a layout first"), + ); + }; + + // Check if the layout is valid + if self.parse_error.is_some() { + return KeyResponse::new_screen( + self.create_error_screen("Cannot save an invalid layout"), + ); + } + + // Get the parsed metadata for optimistic update + let Some(metadata) = &self.parsed_metadata else { + return KeyResponse::new_screen( + self.create_error_screen("Cannot save layout without metadata"), + ); + }; + + let layout_name = self.determine_layout_name(); + + // Capture optimistic update BEFORE the API call + let optimistic = OptimisticUpdate::Add { + name: layout_name.clone(), + metadata: metadata.clone(), + }; + + match self.save_layout(&layout_name, pasted_text) { + Ok(()) => KeyResponse::new_screen(Screen::LayoutList(LayoutListScreen::default())) + .with_optimistic(optimistic), + Err(error_msg) => KeyResponse::new_screen(self.create_error_screen(&error_msg)), + } + } + + fn create_error_screen(&self, message: &str) -> Screen { + Screen::Error(ErrorScreen { + message: message.to_string(), + return_to_screen: Box::new(Screen::ImportLayout(self.clone())), + }) + } + + fn determine_layout_name(&self) -> String { + let input_text = self.name_input.get_text(); + if input_text.is_empty() { + generate_random_name() + } else { + input_text.to_string() + } + } + + fn save_layout(&self, layout_name: &str, layout_content: &str) -> Result<(), String> { + save_layout(layout_name.to_owned(), layout_content.to_owned(), false) + .map_err(|err| format!("Failed to save layout '{}': {}", layout_name, err))?; + + eprintln!("Successfully imported layout: {}", layout_name); + Ok(()) + } + + fn description_text(&self) -> &str { + "Paste your layout here." + } + + fn max_description_width(&self) -> usize { + self.description_text().chars().count() + } + + fn render_description(&self, x: usize, y: usize, _width: usize) { + let text = Text::new(self.description_text()); + print_text_with_coordinates(text, x, y, None, None); + } + + fn save_as_line_text(&self, max_width: Option<usize>) -> (String, usize) { + // Returns (text, cursor_position_in_line) + let input_text = self.name_input.get_text(); + let cursor_pos = self.name_input.get_cursor_position(); + + let display_name = if input_text.is_empty() && !self.editing_name { + "RANDOM" + } else { + input_text + }; + + let text_suffix = if self.editing_name { + "" // No hint when editing + } else { + " (<r> Rename)" // Show rename hint when not editing + }; + + let mut text = format!("Save as: {}{}", display_name, text_suffix); + let mut cursor_position_in_line = 9 + cursor_pos; + + if let Some(max_width) = max_width { + if text.chars().count() > max_width { + let truncated_display_name = truncate_with_ellipsis_start( + display_name, + max_width.saturating_sub(9 + text_suffix.chars().count()), + ); + text = format!("Save as: {}{}", truncated_display_name, text_suffix); + let truncated_len = truncated_display_name.chars().count(); + cursor_position_in_line = 9 + cursor_pos.min(truncated_len); + } + } + + (text, cursor_position_in_line) + } + + fn render_save_as_line(&self, x: usize, y: usize, width: usize) { + let (text, _) = self.save_as_line_text(Some(width)); + let colored = Text::new(&text).color_substring(3, "<r>"); + print_text_with_coordinates(colored, x, y, None, None); + } + + fn help_text(&self) -> (&str, &[&str]) { + // Only Enter and Esc - NO <r> here (it's in the save-as line) + ("<Enter> - Save, <Esc> - Back", &["<Enter>", "<Esc>"]) + } + + fn render_help_text(&self, x: usize, y: usize, _width: usize) { + let (text, items_to_color) = self.help_text(); + let mut text_obj = Text::new(text); + for item in items_to_color { + text_obj = text_obj.color_substring(3, item); + } + print_text_with_coordinates(text_obj, x, y, None, None); + } + + fn render_title(&self, x: usize, y: usize, width: usize) { + let title = Text::new("Import Layout").color_all(2); + print_text_with_coordinates(title, x, y, Some(width), None); + } + + pub fn render(&self, rows: usize, cols: usize) { + // Only render modal if we have a valid pasted layout + if let Some(metadata) = &self.parsed_metadata { + let display_layout = + DisplayLayout::Valid(LayoutInfo::File("imported".to_string(), metadata.clone())); + + let layout_detail = LayoutDetail::new(&display_layout); + + let desired_ui_width = std::cmp::max( + self.help_text().0.chars().count(), + self.save_as_line_text(None).0.chars().count(), + ); + + let actual_ui_width = std::cmp::min(desired_ui_width, cols); + + // Calculate layout detail dimensions + let available_cols_for_details = actual_ui_width.saturating_sub(2); + let desired_details_height = + layout_detail.calculate_required_height(available_cols_for_details); + + let desired_ui_height = 6 + desired_details_height; + let base_y = rows.saturating_sub(desired_ui_height) / 2; + let base_x = cols.saturating_sub(actual_ui_width) / 2; + + // Update cursor BEFORE rendering + let save_as_line_y = base_y + 2; + self.update_cursor_position(base_x, save_as_line_y, actual_ui_width); + + // Render components + self.render_title(base_x, base_y, actual_ui_width); + self.render_save_as_line(base_x, save_as_line_y, actual_ui_width); + + let details_y = save_as_line_y + 2; + + let actual_details_height = if rows < desired_ui_height { + rows.saturating_sub(details_y + 2) + } else { + desired_details_height + }; + + layout_detail.render( + base_x + 2, + details_y, + actual_details_height, + available_cols_for_details, + ); + + // Render help text + let help_y = details_y + actual_details_height + 1; + self.render_help_text(base_x, help_y, actual_ui_width); + } else if let Some(error) = &self.parse_error { + // Show error if parse failed + Title::new("Import Layout").render(0, 0); + self.render_parse_error(error, 2); + + let help_y = rows.saturating_sub(2); + self.render_esc_cancel_help(0, help_y); + } else { + // Calculate desired width + let desired_ui_width = std::cmp::max( + self.help_text().0.chars().count(), + self.max_description_width(), + ); + + let actual_ui_width = std::cmp::min(desired_ui_width, cols); + let actual_ui_height = std::cmp::min(6, rows); // 6 - content rows plus padding + + let base_y = rows.saturating_sub(actual_ui_height) / 2; + let base_x = cols.saturating_sub(actual_ui_width) / 2; + + Title::new("Import Layout").render(base_x, base_y); + self.render_description(base_x, base_y + 2, cols); + + let help_y = base_y + 4; + self.render_esc_cancel_help(base_x, help_y); + } + } + fn render_esc_cancel_help(&self, x: usize, y: usize) { + let help_text = "<Esc> - Cancel"; + let help_text = Text::new(help_text).color_substring(3, "<Esc>"); + print_text_with_coordinates(help_text, x, y, None, None); + } + + fn render_parse_error(&self, error: &str, y: usize) { + // Render error message using ANSI escape sequences + for (i, line) in error.lines().enumerate() { + print!("\u{1b}[{};{}H{}", y + i + 1, 1, line); + } + } + + pub fn update_cursor_position(&self, base_x: usize, cursor_y: usize, max_width: usize) { + if self.editing_name { + let (_, cursor_position_in_line) = self.save_as_line_text(Some(max_width)); + show_cursor(Some((base_x + cursor_position_in_line, cursor_y))) + } else { + show_cursor(None) + } + } +} diff --git a/default-plugins/layout-manager/src/screens/layout_list/mod.rs b/default-plugins/layout-manager/src/screens/layout_list/mod.rs new file mode 100644 index 00000000..e643657b --- /dev/null +++ b/default-plugins/layout-manager/src/screens/layout_list/mod.rs @@ -0,0 +1,689 @@ +mod search; + +use super::{KeyResponse, OptimisticUpdate, Screen}; +use crate::text_input::InputAction; +use crate::ui::{Controls, LayoutDetail, LayoutsTable}; +use crate::DisplayLayout; +use search::SearchState; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct LayoutListScreen { + pub selected_layout_index: usize, + retain_terminal_panes: bool, + retain_plugin_panes: bool, + apply_only_to_active_tab: bool, + show_more_override_options: bool, + search_state: SearchState, + last_rows: usize, + last_cols: usize, +} + +impl Default for LayoutListScreen { + fn default() -> Self { + Self { + selected_layout_index: 0, + retain_terminal_panes: false, + retain_plugin_panes: false, + apply_only_to_active_tab: false, + show_more_override_options: false, + search_state: SearchState::new(), + last_rows: 0, + last_cols: 0, + } + } +} + +impl LayoutListScreen { + pub fn with_selected_index(selected_index: usize) -> Self { + Self { + selected_layout_index: selected_index, + retain_terminal_panes: true, + retain_plugin_panes: false, + apply_only_to_active_tab: false, + show_more_override_options: false, + search_state: SearchState::new(), + last_rows: 0, + last_cols: 0, + } + } + + pub fn handle_key( + &mut self, + key: KeyWithModifier, + display_layouts: &[DisplayLayout], + ) -> KeyResponse { + if self.search_state.is_typing() { + self.handle_typing_filter_mode(key, display_layouts) + } else { + match key.bare_key { + BareKey::Char('/') if key.has_no_modifiers() => { + self.search_state.start_typing(); + self.update_filter(display_layouts); + KeyResponse::render() + }, + BareKey::Delete if key.has_no_modifiers() => { + if let Some(file_name) = self.get_selected_file_name(display_layouts) { + let _ = delete_layout(file_name.clone()); + KeyResponse::none().with_optimistic(OptimisticUpdate::Delete(file_name)) + } else { + KeyResponse::none() + } + }, + BareKey::Char('e') if key.has_no_modifiers() => { + self.edit_selected_layout(display_layouts); + KeyResponse::none() + }, + BareKey::Char('i') if key.has_no_modifiers() => { + let new_screen = self.start_import_layout(); + KeyResponse::new_screen(new_screen) + }, + BareKey::Char('n') if key.has_no_modifiers() => { + let new_screen = self.start_new_layout_creation_from_session(); + KeyResponse::new_screen(new_screen) + }, + BareKey::Char('r') if key.has_no_modifiers() => { + if let Some(new_screen) = self.start_rename_layout(display_layouts) { + KeyResponse::new_screen(new_screen) + } else { + KeyResponse::render() + } + }, + BareKey::Up if key.has_no_modifiers() => { + self.navigate_up(display_layouts); + KeyResponse::render() + }, + BareKey::Down if key.has_no_modifiers() => { + self.navigate_down(display_layouts); + KeyResponse::render() + }, + BareKey::Enter if key.has_no_modifiers() => { + self.open_selected_layout(display_layouts); + KeyResponse::none() + }, + BareKey::Tab if key.has_no_modifiers() => { + self.apply_selected_layout(display_layouts); + KeyResponse::none() + }, + BareKey::Char('t') if key.has_no_modifiers() => { + self.toggle_retain_options(); + KeyResponse::render() + }, + BareKey::Char('a') if key.has_no_modifiers() => { + self.toggle_target_option(); + KeyResponse::render() + }, + BareKey::Char('m') if key.has_no_modifiers() => { + if let Some(new_screen) = self.show_error_detail(display_layouts) { + KeyResponse::new_screen(new_screen) + } else { + KeyResponse::render() + } + }, + BareKey::Char('?') if key.has_no_modifiers() => { + self.show_more_override_options = !self.show_more_override_options; + KeyResponse::render() + }, + BareKey::Esc if key.has_no_modifiers() => { + self.clear_filter(); + KeyResponse::render() + }, + _ => KeyResponse::none(), + } + } + } + + fn handle_typing_filter_mode( + &mut self, + key: KeyWithModifier, + display_layouts: &[DisplayLayout], + ) -> KeyResponse { + // Special handling for Enter and Esc + match key.bare_key { + BareKey::Esc if key.has_no_modifiers() => { + self.clear_filter(); + return KeyResponse::render(); + }, + BareKey::Enter if key.has_no_modifiers() => { + if self.search_state.get_filter_input().is_empty() + || self.search_state.get_search_results().is_empty() + { + self.clear_filter(); + } else { + self.search_state.stop_typing(); + show_cursor(None); + } + return KeyResponse::render(); + }, + _ => {}, + } + + // Pass all keys to TextInput + let action = self.search_state.get_filter_input_mut().handle_key(key); + + match action { + InputAction::Continue => { + self.update_filter(display_layouts); + KeyResponse::render() + }, + InputAction::Cancel => { + // Ctrl-C or Esc - clear filter + self.clear_filter(); + KeyResponse::render() + }, + InputAction::Submit => { + // Enter handled above + KeyResponse::none() + }, + InputAction::Complete => KeyResponse::none(), + InputAction::NoAction => KeyResponse::none(), + } + } + + fn edit_selected_layout(&self, display_layouts: &[DisplayLayout]) { + if let Some(file_name) = self.get_selected_file_name(display_layouts) { + let _ = edit_layout(file_name, Default::default()); + } + } + + fn get_selected_file_name(&self, display_layouts: &[DisplayLayout]) -> Option<String> { + display_layouts + .get(self.selected_layout_index) + .and_then(|layout| layout.file_name()) + } + + fn start_new_layout_creation_from_session(&self) -> Screen { + match dump_session_layout() { + Ok((session_layout, Some(session_layout_metadata))) => { + Screen::NewLayoutFromSession(super::NewLayoutFromCurrentSessionScreen { + name_input: crate::text_input::TextInput::empty(), + session_layout, + current_layout_metadata: session_layout_metadata, + editing_name: false, + save_current_tab_only: false, + }) + }, + Ok((_, None)) => Screen::Error(super::ErrorScreen { + message: "Failed to retrieve session layout metadata".to_string(), + return_to_screen: Box::new(Screen::LayoutList(self.clone())), + }), + Err(error_msg) => Screen::Error(super::ErrorScreen { + message: format!("Failed to dump session layout: {}", error_msg), + return_to_screen: Box::new(Screen::LayoutList(self.clone())), + }), + } + } + + fn start_import_layout(&self) -> Screen { + Screen::ImportLayout(super::ImportLayoutScreen::new()) + } + + fn start_rename_layout(&self, display_layouts: &[DisplayLayout]) -> Option<Screen> { + if let Some(layout_file_name) = self.get_selected_file_name(display_layouts) { + Some(Screen::RenameLayout(super::RenameLayoutScreen::new( + layout_file_name, + self.selected_layout_index, + ))) + } else { + None + } + } + + fn navigate_up(&mut self, display_layouts: &[DisplayLayout]) { + if self.is_searching() { + // Navigate in search results + if self.search_state.get_selected_search_index() == 0 { + self.search_state.set_selected_search_index( + self.search_state + .get_search_results() + .len() + .saturating_sub(1), + ); + } else { + let new_index = self + .search_state + .get_selected_search_index() + .saturating_sub(1); + self.search_state.set_selected_search_index(new_index); + } + // Keep selected_layout_index synchronized with the actual position + self.selected_layout_index = self + .search_state + .get_search_results() + .get(self.search_state.get_selected_search_index()) + .map(|r| r.original_index) + .unwrap_or(0); + } else { + // Normal navigation + if self.selected_layout_index == 0 { + self.selected_layout_index = display_layouts.len().saturating_sub(1); + } else { + self.selected_layout_index -= 1; + } + } + self.apply_only_to_active_tab = self.should_default_to_current_tab(display_layouts); + } + + fn navigate_down(&mut self, display_layouts: &[DisplayLayout]) { + if self.is_searching() { + // Navigate in search results + if self.search_state.get_selected_search_index() + 1 + >= self.search_state.get_search_results().len() + { + self.search_state.set_selected_search_index(0); + } else { + let new_index = self.search_state.get_selected_search_index() + 1; + self.search_state.set_selected_search_index(new_index); + } + // Keep selected_layout_index synchronized with the actual position + self.selected_layout_index = self + .search_state + .get_search_results() + .get(self.search_state.get_selected_search_index()) + .map(|r| r.original_index) + .unwrap_or(0); + } else { + // Normal navigation + if self.selected_layout_index + 1 >= display_layouts.len() { + self.selected_layout_index = 0; + } else { + self.selected_layout_index += 1; + } + } + self.apply_only_to_active_tab = self.should_default_to_current_tab(display_layouts); + } + + fn apply_selected_layout(&self, display_layouts: &[DisplayLayout]) { + if let Some(DisplayLayout::Valid(chosen_layout)) = + display_layouts.get(self.selected_layout_index) + { + override_layout( + chosen_layout, + self.retain_terminal_panes, + self.retain_plugin_panes, + self.apply_only_to_active_tab, + Default::default(), + ); + } + } + + fn toggle_retain_options(&mut self) { + // Cycle through: Terminals -> Plugins -> Both -> None -> (back to Terminals) + match (self.retain_terminal_panes, self.retain_plugin_panes) { + (true, false) => { + // Terminals -> Plugins + self.retain_terminal_panes = false; + self.retain_plugin_panes = true; + }, + (false, true) => { + // Plugins -> Both + self.retain_terminal_panes = true; + self.retain_plugin_panes = true; + }, + (true, true) => { + // Both -> None + self.retain_terminal_panes = false; + self.retain_plugin_panes = false; + }, + (false, false) => { + // None -> Terminals + self.retain_terminal_panes = true; + self.retain_plugin_panes = false; + }, + } + } + + fn toggle_target_option(&mut self) { + // Toggle between: All Tabs (false) <-> Current (true) + self.apply_only_to_active_tab = !self.apply_only_to_active_tab; + } + + fn show_error_detail(&self, display_layouts: &[DisplayLayout]) -> Option<Screen> { + if let Some(DisplayLayout::Error { + name, + error, + error_message: _, + }) = display_layouts.get(self.selected_layout_index) + { + Some(Screen::ErrorDetail(super::ErrorDetailScreen::new( + name.clone(), + error.clone(), + Box::new(Screen::LayoutList(self.clone())), + ))) + } else { + None + } + } + + fn should_default_to_current_tab(&self, display_layouts: &[DisplayLayout]) -> bool { + match display_layouts.get(self.selected_layout_index) { + Some(DisplayLayout::Valid(LayoutInfo::BuiltIn(_))) => true, + Some(DisplayLayout::Valid(LayoutInfo::File(_, metadata))) => metadata.tabs.len() == 1, + _ => false, + } + } + + fn open_selected_layout(&self, display_layouts: &[DisplayLayout]) { + if let Some(DisplayLayout::Valid(chosen_layout)) = + display_layouts.get(self.selected_layout_index) + { + new_tabs_with_layout_info(chosen_layout); + } + } + + pub fn render(&mut self, display_layouts: &[DisplayLayout], rows: usize, cols: usize) { + show_cursor(None); + // Store terminal dimensions for cursor positioning + self.last_rows = rows; + self.last_cols = cols; + + self.update_filter(display_layouts); + + // Calculate base coordinates using the full display_layouts (NOT filtered results) + let (total_width, total_height) = + self.calculate_content_dimensions(rows, cols, display_layouts); + let (base_x, base_y) = + self.calculate_base_coordinates(rows, cols, total_width, total_height); + + let layouts_to_render = self.effective_layouts(display_layouts); + let (content_height, controls_y) = self.calculate_layout(rows, &display_layouts); + + let controls = Controls::new( + self.retain_terminal_panes, + self.retain_plugin_panes, + self.apply_only_to_active_tab, + self.show_more_override_options, + self.search_state.is_typing(), + self.search_state.is_active(), + ); + let controls_width = controls.calculate_width(cols); + + let (table_width, detail_x) = + self.calculate_horizontal_split(cols, display_layouts, controls_width); + + let table_y = if self.search_state.is_active() || self.search_state.is_typing() { + 1 + base_y + } else { + base_y + }; + + if self.search_state.is_active() || self.search_state.is_typing() { + self.search_state.render_filter_line(base_x, base_y); + } + + let selected_index = self.effective_selected_index(); + + let (visible_layouts, selected_in_window, hidden_above, hidden_below) = + calculate_visible_window( + &layouts_to_render, + selected_index, + content_height.saturating_sub(1), // account for title row + ); + + let matched_indices = self + .search_state + .get_matched_indices_for_visible(hidden_above, visible_layouts.len()); + + let has_visible_layouts = !visible_layouts.is_empty(); + LayoutsTable::new( + visible_layouts, + selected_in_window, + hidden_above, + hidden_below, + ) + .with_matched_indices(matched_indices) + .render( + base_x, + table_y, + content_height.saturating_sub(1), + table_width, + ); + + if has_visible_layouts { + if let Some(selected_layout) = display_layouts.get(self.selected_layout_index) { + LayoutDetail::new(selected_layout).render( + detail_x + base_x, + table_y + 1, + content_height.saturating_sub(1), + table_width, + ); + } else { + // Render "No layout selected" message (moved from LayoutDetail) + let msg = Text::new("No layout selected").color_all(2); + print_text_with_coordinates(msg, detail_x + base_x, table_y, None, None); + } + } + + controls.render(base_x, controls_y + base_y, cols); + } + + fn calculate_layout(&self, rows: usize, display_layouts: &[DisplayLayout]) -> (usize, usize) { + let rows_in_table = display_layouts.len() + 1; // 1 for the title row + let controls_height = self.get_controls_height(); + let filter_row_height = if self.is_searching() { 1 } else { 0 }; + let padding = 1; + let mut content_height = std::cmp::max(rows_in_table, 5); + if content_height + controls_height + padding + filter_row_height >= rows { + content_height = rows + .saturating_sub(controls_height) + .saturating_sub(padding) + .saturating_sub(filter_row_height) + } + let controls_y = content_height + padding + filter_row_height; + + (content_height, controls_y) + } + + fn get_controls_height(&self) -> usize { + if self.show_more_override_options { + 5 + } else { + 3 + } + } + + fn calculate_horizontal_split( + &self, + cols: usize, + display_layouts: &[DisplayLayout], + controls_width: usize, + ) -> (usize, usize) { + let padding = 2; + let widest_layout_name = display_layouts + .iter() + .map(|layout| layout.name_width()) + .max() + .unwrap_or(0); + let widest_last_modified = display_layouts + .iter() + .map(|layout| layout.last_modified_width()) + .max() + .unwrap_or(0); + + let content_based_table_width = std::cmp::min( + (cols / 2).saturating_sub(padding), + widest_layout_name + widest_last_modified + padding, + ); + + let controls_based_table_width = controls_width.saturating_sub(padding) / 2; + + let table_width = content_based_table_width.max(controls_based_table_width); + let detail_x = table_width.saturating_add(padding); + + (table_width, detail_x) + } + + fn calculate_content_dimensions( + &self, + rows: usize, + cols: usize, + display_layouts: &[DisplayLayout], + ) -> (usize, usize) { + let filter_row_height = if self.is_searching() { 1 } else { 0 }; + let (content_height, _) = self.calculate_layout(rows, display_layouts); + let padding = 1; + let controls_height = self.get_controls_height(); + let total_height = filter_row_height + content_height + padding + controls_height; + + let controls = Controls::new( + self.retain_terminal_panes, + self.retain_plugin_panes, + self.apply_only_to_active_tab, + self.show_more_override_options, + self.search_state.is_typing(), + self.search_state.is_active(), + ); + let controls_width = controls.calculate_width(cols); + + let (table_width, detail_x) = + self.calculate_horizontal_split(cols, display_layouts, controls_width); + + let layout_width = detail_x.saturating_add(table_width); + let total_width = layout_width.max(controls_width); + + (total_width, total_height) + } + + fn calculate_base_coordinates( + &self, + rows: usize, + cols: usize, + total_width: usize, + total_height: usize, + ) -> (usize, usize) { + let base_x = if total_width < cols { + (cols.saturating_sub(total_width)) / 2 + } else { + 0 + }; + let base_y = if total_height < rows { + (rows.saturating_sub(total_height)) / 2 + } else { + 0 + }; + (base_x, base_y) + } + + pub fn update_selected_index(&mut self, display_layouts: &[DisplayLayout]) { + if self.selected_layout_index >= display_layouts.len() { + self.selected_layout_index = display_layouts.len().saturating_sub(1); + } + + // Refresh filter results if filtering is active + if self.search_state.is_active() && !self.search_state.get_filter_input().is_empty() { + self.update_filter(display_layouts); + } + + self.apply_only_to_active_tab = self.should_default_to_current_tab(display_layouts); + } + + fn is_searching(&self) -> bool { + (self.search_state.is_active() && !self.search_state.get_search_results().is_empty()) + || self.search_state.is_typing() + } + + fn effective_selected_index(&self) -> usize { + if self.is_searching() { + self.search_state.get_selected_search_index() + } else { + self.selected_layout_index + } + } + + fn effective_layouts(&self, display_layouts: &[DisplayLayout]) -> Vec<DisplayLayout> { + if self.is_searching() { + self.search_state + .get_search_results() + .iter() + .map(|r| r.layout.clone()) + .collect() + } else { + display_layouts.to_vec() + } + } + + fn update_filter(&mut self, display_layouts: &[DisplayLayout]) { + let (total_width, total_height) = + self.calculate_content_dimensions(self.last_rows, self.last_cols, display_layouts); + let (base_x, base_y) = self.calculate_base_coordinates( + self.last_rows, + self.last_cols, + total_width, + total_height, + ); + + self.search_state + .update_filter(display_layouts, base_x, base_y); + + // Update selected_layout_index to match the selected search result + if let Some(original_index) = self.search_state.get_current_selected_original_index() { + self.selected_layout_index = original_index; + } + } + + fn clear_filter(&mut self) { + self.search_state.clear_filter(); + self.selected_layout_index = 0; + } +} + +fn calculate_visible_window( + layouts: &[DisplayLayout], + selected_index: usize, + max_visible: usize, +) -> (Vec<DisplayLayout>, usize, usize, usize) { + // returns: (index_in_rendered, hidden_items_above, hidden_items_below) + if layouts.is_empty() || max_visible == 0 { + return (Vec::new(), 0, 0, 0); + } + + if layouts.len() <= max_visible { + return (layouts.to_vec(), selected_index, 0, 0); + } + + let Some(selected_layout) = layouts.get(selected_index) else { + return (Vec::new(), 0, 0, 0); + }; + + let mut visible = Vec::with_capacity(max_visible); + visible.push(selected_layout.clone()); + + let mut before = selected_index; + let mut after = selected_index; + let mut take_before = true; + + while visible.len() < max_visible { + if take_before && before > 0 { + before = before.saturating_sub(1); + if let Some(layout) = layouts.get(before) { + visible.insert(0, layout.clone()); + } + } else if !take_before && after + 1 < layouts.len() { + after += 1; + if let Some(layout) = layouts.get(after) { + visible.push(layout.clone()); + } + } else if before > 0 { + before = before.saturating_sub(1); + if let Some(layout) = layouts.get(before) { + visible.insert(0, layout.clone()); + } + } else if after + 1 < layouts.len() { + after += 1; + if let Some(layout) = layouts.get(after) { + visible.push(layout.clone()); + } + } else { + break; + } + take_before = !take_before; + } + + let adjusted_index = selected_index.saturating_sub(before); + ( + visible, + adjusted_index, + before, + layouts.len().saturating_sub(after + 1), + ) +} diff --git a/default-plugins/layout-manager/src/screens/layout_list/search.rs b/default-plugins/layout-manager/src/screens/layout_list/search.rs new file mode 100644 index 00000000..1414d6a3 --- /dev/null +++ b/default-plugins/layout-manager/src/screens/layout_list/search.rs @@ -0,0 +1,187 @@ +use crate::text_input::TextInput; +use crate::DisplayLayout; +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct SearchResult { + pub layout: DisplayLayout, + pub original_index: usize, + pub score: i64, + pub matched_indices: Vec<usize>, +} + +#[derive(Clone)] +pub struct SearchState { + filter_active: bool, + filter_input: TextInput, + typing_filter: bool, + search_results: Vec<SearchResult>, + selected_search_index: usize, +} + +impl Default for SearchState { + fn default() -> Self { + Self { + filter_active: false, + filter_input: TextInput::empty(), + typing_filter: false, + search_results: Vec::new(), + selected_search_index: 0, + } + } +} + +impl SearchState { + pub fn new() -> Self { + Self::default() + } + + pub fn is_active(&self) -> bool { + self.filter_active + } + + pub fn is_typing(&self) -> bool { + self.typing_filter + } + + pub fn start_typing(&mut self) { + self.typing_filter = true; + } + + pub fn stop_typing(&mut self) { + self.typing_filter = false; + } + + pub fn get_filter_input(&self) -> &TextInput { + &self.filter_input + } + + pub fn get_filter_input_mut(&mut self) -> &mut TextInput { + &mut self.filter_input + } + + pub fn get_search_results(&self) -> &[SearchResult] { + &self.search_results + } + + pub fn get_selected_search_index(&self) -> usize { + self.selected_search_index + } + + pub fn set_selected_search_index(&mut self, index: usize) { + self.selected_search_index = index; + } + + pub fn update_filter( + &mut self, + display_layouts: &[DisplayLayout], + base_x: usize, + base_y: usize, + ) { + let filter_prompt = "Filter:"; + let filter_text = self.filter_input.get_text(); + + // Clear results if filter is empty + if filter_text.is_empty() && !self.typing_filter { + self.search_results.clear(); + self.selected_search_index = 0; + return; + } + + // Perform fuzzy matching + let matcher = SkimMatcherV2::default(); + + let mut results: Vec<SearchResult> = display_layouts + .iter() + .enumerate() + .filter_map(|(index, layout)| { + let name = layout.name(); + matcher + .fuzzy_indices(&name, filter_text) + .map(|(score, indices)| SearchResult { + layout: layout.clone(), + original_index: index, + score, + matched_indices: indices, + }) + }) + .collect(); + + // Sort by score descending (best matches first) + results.sort_by(|a, b| b.score.cmp(&a.score)); + self.search_results = results; + + if self.search_results.is_empty() { + self.filter_active = false; + } else { + self.filter_active = true; + } + + // Keep selection in bounds + if self.selected_search_index >= self.search_results.len() { + self.selected_search_index = self.search_results.len().saturating_sub(1); + } + + // Show cursor when typing + if self.typing_filter { + let cursor_pos = self.filter_input.get_cursor_position(); + show_cursor(Some(( + filter_prompt.chars().count() + 1 + cursor_pos + base_x, + base_y, + ))); + } else { + show_cursor(None); + } + } + + pub fn clear_filter(&mut self) { + self.filter_active = false; + self.typing_filter = false; + self.filter_input.clear(); + self.search_results.clear(); + self.selected_search_index = 0; + show_cursor(None); + } + + pub fn get_current_selected_original_index(&self) -> Option<usize> { + self.search_results + .get(self.selected_search_index) + .map(|result| result.original_index) + } + + pub fn render_filter_line(&self, base_x: usize, base_y: usize) { + let filter_text_str = self.filter_input.get_text(); + let filter_text = if self.typing_filter { + let mut filter_line = + Text::new(format!("Filter: {}", filter_text_str)).color_substring(2, "Filter:"); + if !filter_text_str.is_empty() { + filter_line = filter_line.color_last_substring(3, filter_text_str) + } + filter_line + } else { + Text::new(format!("Filter: {} (<Esc> - clear)", filter_text_str)) + .color_substring(3, "<Esc>") + .color_substring(2, "Filter:") + }; + print_text_with_coordinates(filter_text, base_x, base_y, None, None); + } + + pub fn get_matched_indices_for_visible( + &self, + hidden_above: usize, + visible_count: usize, + ) -> Vec<Option<Vec<usize>>> { + if !self.filter_active || self.search_results.is_empty() { + return vec![None; visible_count]; + } + + self.search_results + .iter() + .skip(hidden_above) + .take(visible_count) + .map(|result| Some(result.matched_indices.clone())) + .collect() + } +} diff --git a/default-plugins/layout-manager/src/screens/mod.rs b/default-plugins/layout-manager/src/screens/mod.rs new file mode 100644 index 00000000..d9d617be --- /dev/null +++ b/default-plugins/layout-manager/src/screens/mod.rs @@ -0,0 +1,79 @@ +mod import_layout; +mod layout_list; +mod new_layout_from_session; +mod rename_layout; + +use zellij_tile::prelude::LayoutMetadata; + +pub use import_layout::ImportLayoutScreen; +pub use layout_list::LayoutListScreen; +pub use new_layout_from_session::NewLayoutFromCurrentSessionScreen; +pub use rename_layout::RenameLayoutScreen; + +// Re-export error types from errors module for convenience +pub use crate::errors::{ErrorDetailScreen, ErrorScreen}; + +#[derive(Clone)] +pub enum Screen { + LayoutList(LayoutListScreen), + NewLayoutFromSession(NewLayoutFromCurrentSessionScreen), + ImportLayout(ImportLayoutScreen), + RenameLayout(RenameLayoutScreen), + Error(ErrorScreen), + ErrorDetail(ErrorDetailScreen), +} + +impl Default for Screen { + fn default() -> Self { + Screen::LayoutList(LayoutListScreen::default()) + } +} + +/// Optimistic state updates to apply before Zellij confirms +#[derive(Clone, Debug)] +pub enum OptimisticUpdate { + Delete(String), // file_name + Rename { + old_name: String, + new_name: String, + }, + Add { + name: String, + metadata: LayoutMetadata, + }, +} + +/// Response from screen key handlers +#[derive(Default)] +pub struct KeyResponse { + pub should_render: bool, + pub new_screen: Option<Screen>, + pub optimistic_update: Option<OptimisticUpdate>, +} + +impl KeyResponse { + pub fn render() -> Self { + KeyResponse { + should_render: true, + new_screen: None, + optimistic_update: None, + } + } + + pub fn new_screen(screen: Screen) -> Self { + KeyResponse { + should_render: true, + new_screen: Some(screen), + optimistic_update: None, + } + } + + pub fn with_optimistic(mut self, update: OptimisticUpdate) -> Self { + self.optimistic_update = Some(update); + self + } + + pub fn none() -> Self { + KeyResponse::default() + } +} diff --git a/default-plugins/layout-manager/src/screens/new_layout_from_session.rs b/default-plugins/layout-manager/src/screens/new_layout_from_session.rs new file mode 100644 index 00000000..fbf6d63b --- /dev/null +++ b/default-plugins/layout-manager/src/screens/new_layout_from_session.rs @@ -0,0 +1,352 @@ +use super::{ErrorScreen, KeyResponse, LayoutListScreen, OptimisticUpdate, Screen}; +use crate::text_input::{InputAction, TextInput}; +use crate::ui::{truncate_with_ellipsis_start, LayoutDetail}; +use crate::DisplayLayout; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct NewLayoutFromCurrentSessionScreen { + pub name_input: TextInput, + pub session_layout: String, + pub current_layout_metadata: LayoutMetadata, + pub editing_name: bool, + pub save_current_tab_only: bool, +} + +impl NewLayoutFromCurrentSessionScreen { + pub fn handle_key(&mut self, key: KeyWithModifier) -> KeyResponse { + // Special Esc handling based on edit state + if key.bare_key == BareKey::Esc && key.has_no_modifiers() { + return self.handle_escape_key(); + } + + if self.editing_name { + self.handle_editing_mode_key(key) + } else { + self.handle_non_editing_mode_key(key) + } + } + + fn handle_escape_key(&mut self) -> KeyResponse { + if self.editing_name { + self.editing_name = false; + KeyResponse::render() + } else { + KeyResponse::new_screen(self.cancel_save()) + } + } + + fn handle_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse { + let action = self.name_input.handle_key(key); + + match action { + InputAction::Continue => KeyResponse::render(), + InputAction::Submit => self.exit_editing_mode(), + InputAction::Cancel => self.exit_editing_mode(), + InputAction::Complete => KeyResponse::none(), + InputAction::NoAction => KeyResponse::none(), + } + } + + fn handle_non_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse { + match key.bare_key { + BareKey::Enter if key.has_no_modifiers() => self.attempt_save_current_session(), + BareKey::Tab if key.has_no_modifiers() => self.handle_tab_toggle(), + BareKey::Char('u') if key.has_no_modifiers() => self.handle_update_layout(), + BareKey::Char('r') if key.has_no_modifiers() => self.enter_editing_mode(), + _ => KeyResponse::none(), + } + } + + fn exit_editing_mode(&mut self) -> KeyResponse { + self.editing_name = false; + KeyResponse::render() + } + + fn enter_editing_mode(&mut self) -> KeyResponse { + self.editing_name = true; + KeyResponse::render() + } + + fn handle_tab_toggle(&mut self) -> KeyResponse { + self.toggle_tab_target(); + if let Some(new_screen) = self.update_layout_from_session() { + KeyResponse::new_screen(new_screen) + } else { + KeyResponse::render() + } + } + + fn handle_update_layout(&mut self) -> KeyResponse { + if let Some(new_screen) = self.update_layout_from_session() { + KeyResponse::new_screen(new_screen) + } else { + KeyResponse::render() + } + } + + fn update_layout_from_session(&mut self) -> Option<Screen> { + let Ok(focused_pane_info) = get_focused_pane_info() else { + eprintln!("Cannot retrieve focused tab info"); + return None; + }; + let focused_tab_index = focused_pane_info.0; + if self.save_current_tab_only { + match dump_session_layout_for_tab(focused_tab_index) { + Ok((session_layout, Some(session_layout_metadata))) => { + self.session_layout = session_layout; + self.current_layout_metadata = session_layout_metadata; + None + }, + Ok((_, None)) => Some(Screen::Error(super::ErrorScreen { + message: "Failed to retrieve session layout metadata".to_string(), + return_to_screen: Box::new(Screen::LayoutList(Default::default())), + })), + Err(error_msg) => Some(Screen::Error(super::ErrorScreen { + message: format!("Failed to dump session layout: {}", error_msg), + return_to_screen: Box::new(Default::default()), + })), + } + } else { + match dump_session_layout() { + Ok((session_layout, Some(session_layout_metadata))) => { + self.session_layout = session_layout; + self.current_layout_metadata = session_layout_metadata; + None + }, + Ok((_, None)) => Some(Screen::Error(super::ErrorScreen { + message: "Failed to retrieve session layout metadata".to_string(), + return_to_screen: Box::new(Screen::LayoutList(Default::default())), + })), + Err(error_msg) => Some(Screen::Error(super::ErrorScreen { + message: format!("Failed to dump session layout: {}", error_msg), + return_to_screen: Box::new(Default::default()), + })), + } + } + } + + fn cancel_save(&self) -> Screen { + Screen::LayoutList(LayoutListScreen::default()) + } + + fn toggle_tab_target(&mut self) { + self.save_current_tab_only = !self.save_current_tab_only; + } + + fn attempt_save_current_session(&self) -> KeyResponse { + let layout_name = self.determine_layout_name(); + + // Capture optimistic update BEFORE the API call + let optimistic = OptimisticUpdate::Add { + name: layout_name.clone(), + metadata: self.current_layout_metadata.clone(), + }; + + match self.save_session_layout(&layout_name) { + Ok(()) => KeyResponse::new_screen(Screen::LayoutList(LayoutListScreen::default())) + .with_optimistic(optimistic), + Err(error_msg) => KeyResponse::new_screen(self.create_error_screen(&error_msg)), + } + } + + fn create_error_screen(&self, message: &str) -> Screen { + Screen::Error(ErrorScreen { + message: message.to_string(), + return_to_screen: Box::new(Screen::NewLayoutFromSession(self.clone())), + }) + } + + fn determine_layout_name(&self) -> String { + let input_text = self.name_input.get_text(); + if input_text.is_empty() { + generate_random_name() + } else { + input_text.to_string() + } + } + + fn save_session_layout(&self, layout_name: &str) -> Result<(), String> { + save_layout(layout_name.to_owned(), self.session_layout.clone(), false) + .map_err(|err| format!("Failed to save layout '{}': {}", layout_name, err))?; + + eprintln!( + "Successfully saved current session as layout: {}", + layout_name + ); + Ok(()) + } + + pub fn render(&self, rows: usize, cols: usize) { + let display_layout = DisplayLayout::Valid(LayoutInfo::File( + "current".to_string(), + self.current_layout_metadata.clone(), + )); + + let layout_detail = LayoutDetail::new(&display_layout); + + let desired_ui_width = std::cmp::max( + self.help_text_full().0.chars().count(), + self.max_description_width(), + ); + + let actual_ui_width = std::cmp::min(desired_ui_width, cols); + + let available_cols_for_details = actual_ui_width.saturating_sub(2); // Account for indentation + let desired_details_height = + layout_detail.calculate_required_height(available_cols_for_details); + + let desired_ui_height = 12 + desired_details_height; + let base_y = rows.saturating_sub(desired_ui_height) / 2; + let base_x = cols.saturating_sub(actual_ui_width) / 2; + + let save_as_line_y = base_y + 5; + self.update_cursor_position(base_x, save_as_line_y, actual_ui_width); // NOTE: must be before any render + // happens + + self.render_title(base_x, base_y, actual_ui_width); + self.render_description(base_x, base_y + 2, actual_ui_width); + self.render_save_as_line(base_x, save_as_line_y, actual_ui_width); + self.render_tab_toggle(base_x, base_y + 7, actual_ui_width); + + let details_y = base_y + 9; + + let actual_details_height = if rows < desired_ui_height { + rows.saturating_sub(details_y + 2) + } else { + desired_details_height + }; + + layout_detail.render( + base_x + 2, + details_y, + actual_details_height, + available_cols_for_details, + ); + + let help_y = details_y + actual_details_height + 1; + self.render_help_text(base_x, help_y, actual_ui_width); + } + + fn render_title(&self, x: usize, y: usize, width: usize) { + let title = Text::new("Save Layout of Current Session").color_all(2); + print_text_with_coordinates(title, x, y, Some(width), None); + } + + fn description_text_full(&self) -> (&str, &str) { + ( + "This layout was created from the current session.", + "Save it to recreate the session later or share it with others.", + ) + } + fn description_text_short(&self) -> (&str, &str) { + ( + "Layout from current session.", + "Save it to recreate or share the later.", + ) + } + fn render_description(&self, x: usize, y: usize, width: usize) { + let (line1, line2) = if width >= self.max_description_width() { + self.description_text_full() + } else { + self.description_text_short() + }; + print_text_with_coordinates(Text::new(line1), x, y, None, None); + print_text_with_coordinates(Text::new(line2), x, y + 1, None, None); + } + fn max_description_width(&self) -> usize { + let (line1, line2) = self.description_text_full(); + std::cmp::max(line1.chars().count(), line2.chars().count()) + } + + fn save_as_line_text(&self, width: usize) -> (String, usize) { + // (text, cursor_position_in_line) + let input_text = self.name_input.get_text(); + let cursor_pos = self.name_input.get_cursor_position(); + + let display_name = if input_text.is_empty() && !self.editing_name { + "RANDOM" + } else { + input_text + }; + let mut text = format!("Save as: {} (<r> Rename)", display_name); + let mut cursor_position_in_line = 9 + cursor_pos; + if text.chars().count() > width { + let truncated_display_name = truncate_with_ellipsis_start( + display_name, + width.saturating_sub(22), // size of text without the display name + ); + text = format!("Save as: {} (<r> Rename)", truncated_display_name); + let truncated_len = truncated_display_name.chars().count(); + cursor_position_in_line = 9 + cursor_pos.min(truncated_len); + } + (text, cursor_position_in_line) + } + fn render_save_as_line(&self, x: usize, y: usize, width: usize) { + let (text, _) = self.save_as_line_text(width); + let colored = Text::new(&text).color_substring(3, "<r>"); + + print_text_with_coordinates(colored, x, y, None, None); + } + + fn render_tab_toggle(&self, x: usize, y: usize, width: usize) { + let text = if self.save_current_tab_only { + "<Tab> All Tabs | [Current Tab Only]" + } else { + "<Tab> [All Tabs] | Current Tab Only" + }; + + let short_text = if self.save_current_tab_only { + "<Tab> All | [Current Tab]" + } else { + "<Tab> [All] | Current Tab" + }; + let text = if text.chars().count() > width { + short_text + } else { + text + }; + let colored = Text::new(text) + .color_substring(3, "<Tab>") + .color_substring(0, "[All Tabs]") + .color_substring(0, "[All]") + .color_substring(0, "[Current Tab Only]") + .color_substring(0, "[Current Tab]"); + + print_text_with_coordinates(colored, x, y, None, None); + } + + fn help_text_full(&self) -> (&str, &[&str]) { + ( + "<Enter> - Save, <u> - Update Layout, <Esc> - Back", + &["<Enter>", "<u>", "<Esc>"], + ) + } + fn help_text_short(&self) -> (&str, &[&str]) { + ( + "<Enter> - Save, <u> - Update, <Esc> - Back", + &["<Enter>", "<u>", "<Esc>"], + ) + } + fn render_help_text(&self, x: usize, y: usize, width: usize) { + let (text, items_to_color) = if width < self.help_text_full().0.chars().count() { + self.help_text_short() + } else { + self.help_text_full() + }; + let mut text = Text::new(text); + for item in items_to_color { + text = text.color_substring(3, item) + } + print_text_with_coordinates(text, x, y, None, None); + } + + pub fn update_cursor_position(&self, base_x: usize, cursor_y: usize, max_width: usize) { + if self.editing_name { + let (_, cursor_position_in_line) = self.save_as_line_text(max_width); + show_cursor(Some((base_x + cursor_position_in_line, cursor_y))) + } else { + show_cursor(None) + } + } +} diff --git a/default-plugins/layout-manager/src/screens/rename_layout.rs b/default-plugins/layout-manager/src/screens/rename_layout.rs new file mode 100644 index 00000000..5ca48bb5 --- /dev/null +++ b/default-plugins/layout-manager/src/screens/rename_layout.rs @@ -0,0 +1,156 @@ +use super::{ErrorScreen, KeyResponse, LayoutListScreen, OptimisticUpdate, Screen}; +use crate::text_input::{InputAction, TextInput}; +use crate::ui::truncate_with_ellipsis_start; +use zellij_tile::prelude::*; + +#[derive(Clone)] +pub struct RenameLayoutScreen { + pub name_input: TextInput, + pub old_file_name: String, + pub selected_layout_index: usize, +} + +impl RenameLayoutScreen { + pub fn new(old_file_name: String, selected_layout_index: usize) -> Self { + Self { + name_input: TextInput::new(old_file_name.clone()), + old_file_name, + selected_layout_index, + } + } + + pub fn handle_key(&mut self, key: KeyWithModifier) -> KeyResponse { + let action = self.name_input.handle_key(key); + + match action { + InputAction::Continue => KeyResponse::render(), + InputAction::Submit => self.attempt_rename(), + InputAction::Cancel => KeyResponse::new_screen(self.cancel_rename()), + InputAction::Complete => KeyResponse::none(), + InputAction::NoAction => KeyResponse::none(), + } + } + + fn cancel_rename(&self) -> Screen { + Screen::LayoutList(LayoutListScreen::with_selected_index( + self.selected_layout_index, + )) + } + + fn attempt_rename(&self) -> KeyResponse { + let name_input_text = self.name_input.get_text(); + + if name_input_text.is_empty() { + show_cursor(None); + return KeyResponse::new_screen( + self.create_error_screen("Layout name cannot be empty"), + ); + } + + let optimistic = OptimisticUpdate::Rename { + old_name: self.old_file_name.clone(), + new_name: name_input_text.to_string(), + }; + + match rename_layout(&self.old_file_name, name_input_text) { + Ok(_) => KeyResponse::new_screen(Screen::LayoutList( + LayoutListScreen::with_selected_index(self.selected_layout_index), + )) + .with_optimistic(optimistic), + Err(error_msg) => KeyResponse::new_screen(self.create_error_screen(&error_msg)), + } + } + + fn create_error_screen(&self, message: &str) -> Screen { + show_cursor(None); + Screen::Error(ErrorScreen { + message: message.to_string(), + return_to_screen: Box::new(Screen::RenameLayout(self.clone())), + }) + } + + fn rename_line_text(&self, max_width: Option<usize>) -> (String, usize) { + // Returns (text, cursor_position_in_line) + let prompt = "Rename Layout: "; + let prompt_len = prompt.chars().count(); + + let input_text = self.name_input.get_text(); + let cursor_pos = self.name_input.get_cursor_position(); + + let mut text = format!("{}{}", prompt, input_text); + let mut cursor_position_in_line = prompt_len + cursor_pos; + + if let Some(max_width) = max_width { + if text.chars().count() > max_width { + let truncated_name = + truncate_with_ellipsis_start(input_text, max_width.saturating_sub(prompt_len)); + text = format!("{}{}", prompt, truncated_name); + let truncated_len = truncated_name.chars().count(); + cursor_position_in_line = prompt_len + cursor_pos.min(truncated_len); + } + } + + (text, cursor_position_in_line) + } + + fn render_rename_line(&self, x: usize, y: usize, width: usize) { + let (text, _) = self.rename_line_text(Some(width)); + let colored = Text::new(&text); + print_text_with_coordinates(colored, x, y, None, None); + } + + fn help_text(&self) -> (&str, &[&str]) { + ("<Enter> - Save, <Esc> - Cancel", &["<Enter>", "<Esc>"]) + } + + fn render_help_text(&self, x: usize, y: usize, _width: usize) { + let (text, items_to_color) = self.help_text(); + let mut text_obj = Text::new(text); + for item in items_to_color { + text_obj = text_obj.color_substring(3, item); + } + print_text_with_coordinates(text_obj, x, y, None, None); + } + + fn render_title(&self, x: usize, y: usize, width: usize) { + let title = Text::new("Rename Layout").color_all(2); + print_text_with_coordinates(title, x, y, Some(width), None); + } + + fn update_cursor_position(&self, base_x: usize, cursor_y: usize, max_width: usize) { + // Always show cursor since rename is always in editing mode + let (_, cursor_position_in_line) = self.rename_line_text(Some(max_width)); + show_cursor(Some((base_x + cursor_position_in_line, cursor_y))) + } + + pub fn render(&self, rows: usize, cols: usize) { + // Calculate desired width based on rename line and help text + let desired_ui_width = std::cmp::max( + self.help_text().0.chars().count(), + self.rename_line_text(None).0.chars().count(), + ); + + // Leave at least 4 columns margin (2 on each side) to prevent text from reaching screen edge + let max_allowed_width = cols.saturating_sub(4); + let actual_ui_width = std::cmp::min(desired_ui_width, max_allowed_width); + + // Calculate total height: title(1) + spacing(1) + rename_line(1) + spacing(1) + help(1) = 5 + let desired_ui_height = 5; + let actual_ui_height = std::cmp::min(desired_ui_height, rows); + + let base_y = rows.saturating_sub(actual_ui_height) / 2; + let base_x = cols.saturating_sub(actual_ui_width) / 2; + + // Update cursor BEFORE rendering (critical!) + let rename_line_y = base_y + 2; + self.update_cursor_position(base_x, rename_line_y, actual_ui_width); + + // Render components + self.render_title(base_x, base_y, actual_ui_width); + self.render_rename_line(base_x, rename_line_y, actual_ui_width); + + // Help text at bottom + let help_y = base_y + 4; + self.render_help_text(base_x, help_y, actual_ui_width); + } +} diff --git a/default-plugins/layout-manager/src/text_input.rs b/default-plugins/layout-manager/src/text_input.rs new file mode 100644 index 00000000..4e3a0b48 --- /dev/null +++ b/default-plugins/layout-manager/src/text_input.rs @@ -0,0 +1,1007 @@ +// This is a duplicate of the same file in the sequence (and possibly other) plugins +// Right now there's no good place to put them for shared logic except the zellij-tile-utils crate +// (which I'd rather eliminate), hence the duplication. +use zellij_tile::prelude::*; + +const MAX_UNDO_STACK_SIZE: usize = 100; + +/// Action returned by TextInput after handling a key event +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(Eq))] +pub enum InputAction { + /// Continue editing + Continue, + /// User pressed Enter to submit + Submit, + /// User pressed Esc to cancel + Cancel, + /// User pressed Tab to request completion + Complete, + /// Key was not handled by the input + NoAction, +} + +/// A reusable text input component with cursor support and standard editing keybindings +#[derive(Debug, Clone)] +pub struct TextInput { + buffer: String, + cursor_position: usize, // Character position (0-based), NOT byte position + undo_stack: Vec<(String, usize)>, // (buffer, cursor) snapshots + redo_stack: Vec<(String, usize)>, + last_edit_was_insert: bool, // For coalescing consecutive inserts +} + +impl TextInput { + /// Create a new TextInput with the given initial text + /// Cursor is positioned at the end of the text + pub fn new(initial_text: String) -> Self { + let cursor_position = initial_text.chars().count(); + Self { + buffer: initial_text, + cursor_position, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + last_edit_was_insert: false, + } + } + + /// Create an empty TextInput + pub fn empty() -> Self { + Self::new(String::new()) + } + + /// Get the current text + pub fn get_text(&self) -> &str { + &self.buffer + } + + /// Get the cursor position (in characters, not bytes) + pub fn get_cursor_position(&self) -> usize { + self.cursor_position + } + + /// Check if the input is empty + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + /// Get a shorthand for cursor_position + #[allow(unused)] + pub fn cursor_position(&self) -> usize { + self.cursor_position + } + + /// Get mutable access to the underlying buffer for direct manipulation + #[allow(unused)] + pub fn get_text_mut(&mut self) -> &mut String { + &mut self.buffer + } + + /// Set the text and move cursor to the end + #[allow(unused)] + pub fn set_text(&mut self, text: String) { + self.break_coalescing(); + self.save_undo_state(); + self.cursor_position = text.chars().count(); + self.buffer = text; + } + + /// Set cursor position (clamped to text length) + #[allow(unused)] + pub fn set_cursor_position(&mut self, pos: usize) { + let text_len = self.buffer.chars().count(); + self.cursor_position = pos.min(text_len); + } + + /// Clear all text and reset cursor + pub fn clear(&mut self) { + self.break_coalescing(); + self.save_undo_state(); + self.buffer.clear(); + self.cursor_position = 0; + } + + /// Insert a character at the current cursor position + pub fn insert_char(&mut self, c: char) { + self.save_undo_state_unless_coalescing(); + // Convert cursor position (char index) to byte index + let byte_index = self.char_index_to_byte_index(self.cursor_position); + self.buffer.insert(byte_index, c); + self.cursor_position += 1; + } + + /// Delete the character before the cursor (backspace) + pub fn backspace(&mut self) { + if self.cursor_position > 0 { + self.break_coalescing(); + self.save_undo_state(); + self.cursor_position -= 1; + let byte_index = self.char_index_to_byte_index(self.cursor_position); + self.buffer.remove(byte_index); + } + } + + /// Delete the character at the cursor position (delete key) + pub fn delete(&mut self) { + let len = self.buffer.chars().count(); + if self.cursor_position < len { + self.break_coalescing(); + self.save_undo_state(); + let byte_index = self.char_index_to_byte_index(self.cursor_position); + self.buffer.remove(byte_index); + } + } + + /// Delete the word before the cursor (Ctrl/Alt + Backspace) + pub fn delete_word_backward(&mut self) { + if self.cursor_position == 0 { + return; + } + + self.break_coalescing(); + self.save_undo_state(); + + let old_position = self.cursor_position; + self.move_word_left(); + let new_position = self.cursor_position; + + // Delete from new position to old position + let start_byte = self.char_index_to_byte_index(new_position); + let end_byte = self.char_index_to_byte_index(old_position); + self.buffer.drain(start_byte..end_byte); + } + + /// Delete the word after the cursor (Ctrl/Alt + Delete) + pub fn delete_word_forward(&mut self) { + let chars: Vec<char> = self.buffer.chars().collect(); + let len = chars.len(); + + if self.cursor_position >= len { + return; + } + + self.break_coalescing(); + self.save_undo_state(); + + let start_position = self.cursor_position; + let mut end_position = start_position; + + // Skip the current word + while end_position < len && !chars[end_position].is_whitespace() { + end_position += 1; + } + + // Skip any whitespace after the word + while end_position < len && chars[end_position].is_whitespace() { + end_position += 1; + } + + // Delete from start to end position + let start_byte = self.char_index_to_byte_index(start_position); + let end_byte = self.char_index_to_byte_index(end_position); + self.buffer.drain(start_byte..end_byte); + } + + /// Move cursor one position to the left + pub fn move_left(&mut self) { + if self.cursor_position > 0 { + self.break_coalescing(); + self.cursor_position -= 1; + } + } + + /// Move cursor one position to the right + pub fn move_right(&mut self) { + let len = self.buffer.chars().count(); + if self.cursor_position < len { + self.break_coalescing(); + self.cursor_position += 1; + } + } + + /// Move cursor to the start of the text (Ctrl-A / Home) + pub fn move_to_start(&mut self) { + self.break_coalescing(); + self.cursor_position = 0; + } + + /// Move cursor to the end of the text (Ctrl-E / End) + pub fn move_to_end(&mut self) { + self.break_coalescing(); + self.cursor_position = self.buffer.chars().count(); + } + + /// Move cursor to the start of the previous word (Ctrl/Alt + Left) + pub fn move_word_left(&mut self) { + if self.cursor_position == 0 { + return; + } + + self.break_coalescing(); + + let chars: Vec<char> = self.buffer.chars().collect(); + let mut pos = self.cursor_position; + + // Skip any whitespace immediately to the left + while pos > 0 && chars[pos - 1].is_whitespace() { + pos -= 1; + } + + // Skip the word characters + while pos > 0 && !chars[pos - 1].is_whitespace() { + pos -= 1; + } + + self.cursor_position = pos; + } + + /// Move cursor to the start of the next word (Ctrl/Alt + Right) + pub fn move_word_right(&mut self) { + let chars: Vec<char> = self.buffer.chars().collect(); + let len = chars.len(); + + if self.cursor_position >= len { + return; + } + + self.break_coalescing(); + + let mut pos = self.cursor_position; + + // Skip the current word + while pos < len && !chars[pos].is_whitespace() { + pos += 1; + } + + // Skip any whitespace + while pos < len && chars[pos].is_whitespace() { + pos += 1; + } + + self.cursor_position = pos; + } + + /// Handle a key event and return the appropriate action + /// This is the main entry point for key handling + pub fn handle_key(&mut self, key: KeyWithModifier) -> InputAction { + // Check for Ctrl modifiers + if key.has_modifiers(&[KeyModifier::Ctrl]) { + match key.bare_key { + BareKey::Char('a') => { + self.move_to_start(); + return InputAction::Continue; + }, + BareKey::Char('e') => { + self.move_to_end(); + return InputAction::Continue; + }, + BareKey::Char('c') => { + // Ctrl-C clears the prompt + return InputAction::Cancel; + }, + BareKey::Char('z') => { + // Ctrl-Z: Undo + self.undo(); + return InputAction::Continue; + }, + BareKey::Char('y') => { + // Ctrl-Y: Redo + self.redo(); + return InputAction::Continue; + }, + BareKey::Left => { + self.move_word_left(); + return InputAction::Continue; + }, + BareKey::Right => { + self.move_word_right(); + return InputAction::Continue; + }, + BareKey::Backspace => { + self.delete_word_backward(); + return InputAction::Continue; + }, + BareKey::Delete => { + self.delete_word_forward(); + return InputAction::Continue; + }, + _ => {}, + } + } + + // Check for Ctrl+Shift modifiers (alternative redo: Ctrl+Shift+Z) + if key.has_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) { + match key.bare_key { + BareKey::Char('Z') => { + // Ctrl-Shift-Z: Redo (alternative) + self.redo(); + return InputAction::Continue; + }, + _ => {}, + } + } + + // Check for Alt modifiers + if key.has_modifiers(&[KeyModifier::Alt]) { + match key.bare_key { + BareKey::Left => { + self.move_word_left(); + return InputAction::Continue; + }, + BareKey::Right => { + self.move_word_right(); + return InputAction::Continue; + }, + BareKey::Backspace => { + self.delete_word_backward(); + return InputAction::Continue; + }, + BareKey::Delete => { + self.delete_word_forward(); + return InputAction::Continue; + }, + _ => {}, + } + } + + // Handle bare keys (no modifiers) + match key.bare_key { + BareKey::Enter => InputAction::Submit, + BareKey::Esc => InputAction::Cancel, + BareKey::Tab => InputAction::Complete, + BareKey::Backspace => { + self.backspace(); + InputAction::Continue + }, + BareKey::Delete => { + self.delete(); + InputAction::Continue + }, + BareKey::Left => { + self.move_left(); + InputAction::Continue + }, + BareKey::Right => { + self.move_right(); + InputAction::Continue + }, + BareKey::Home => { + self.move_to_start(); + InputAction::Continue + }, + BareKey::End => { + self.move_to_end(); + InputAction::Continue + }, + BareKey::Char(c) => { + self.insert_char(c); + InputAction::Continue + }, + _ => InputAction::NoAction, + } + } + + /// Helper: Convert character index to byte index + fn char_index_to_byte_index(&self, char_index: usize) -> usize { + self.buffer + .char_indices() + .nth(char_index) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(self.buffer.len()) + } + + /// Save current state to undo stack before making changes + fn save_undo_state(&mut self) { + if self.undo_stack.len() >= MAX_UNDO_STACK_SIZE { + self.undo_stack.remove(0); + } + self.undo_stack + .push((self.buffer.clone(), self.cursor_position)); + self.redo_stack.clear(); + } + + /// Save state only if not coalescing with previous insert + fn save_undo_state_unless_coalescing(&mut self) { + if !self.last_edit_was_insert { + self.save_undo_state(); + } + self.last_edit_was_insert = true; + } + + /// Mark that a non-insert edit occurred (breaks coalescing) + fn break_coalescing(&mut self) { + self.last_edit_was_insert = false; + } + + /// Undo last change + pub fn undo(&mut self) -> bool { + if let Some((buffer, cursor)) = self.undo_stack.pop() { + self.redo_stack + .push((self.buffer.clone(), self.cursor_position)); + self.buffer = buffer; + self.cursor_position = cursor; + self.break_coalescing(); + true + } else { + false + } + } + + /// Redo last undone change + pub fn redo(&mut self) -> bool { + if let Some((buffer, cursor)) = self.redo_stack.pop() { + self.undo_stack + .push((self.buffer.clone(), self.cursor_position)); + self.buffer = buffer; + self.cursor_position = cursor; + self.break_coalescing(); + true + } else { + false + } + } + + /// Check if undo is available + #[allow(unused)] + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + /// Check if redo is available + #[allow(unused)] + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + #[allow(unused)] + pub fn drain_text(&mut self) -> String { + self.cursor_position = 0; + self.buffer.drain(..).collect() + } +} + +// run with: +// cargo test --lib --target x86_64-unknown-linux-gnu +// +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_and_empty() { + let input = TextInput::new("hello".to_string()); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 5); + + let empty = TextInput::empty(); + assert_eq!(empty.get_text(), ""); + assert_eq!(empty.get_cursor_position(), 0); + } + + #[test] + fn test_insert_char() { + let mut input = TextInput::new("helo".to_string()); + input.cursor_position = 3; // Position after "hel" + input.insert_char('l'); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 4); + } + + #[test] + fn test_backspace() { + let mut input = TextInput::new("hello".to_string()); + input.backspace(); + assert_eq!(input.get_text(), "hell"); + assert_eq!(input.get_cursor_position(), 4); + + // Backspace at start does nothing + input.cursor_position = 0; + input.backspace(); + assert_eq!(input.get_text(), "hell"); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_delete() { + let mut input = TextInput::new("hello".to_string()); + input.cursor_position = 0; + input.delete(); + assert_eq!(input.get_text(), "ello"); + assert_eq!(input.get_cursor_position(), 0); + + // Delete at end does nothing + input.move_to_end(); + input.delete(); + assert_eq!(input.get_text(), "ello"); + } + + #[test] + fn test_cursor_movement() { + let mut input = TextInput::new("hello".to_string()); + assert_eq!(input.get_cursor_position(), 5); + + input.move_left(); + assert_eq!(input.get_cursor_position(), 4); + + input.move_right(); + assert_eq!(input.get_cursor_position(), 5); + + input.move_to_start(); + assert_eq!(input.get_cursor_position(), 0); + + input.move_to_end(); + assert_eq!(input.get_cursor_position(), 5); + } + + #[test] + fn test_unicode_support() { + let mut input = TextInput::new("hello 🦀 world".to_string()); + assert_eq!(input.get_cursor_position(), 13); // 13 characters + + input.cursor_position = 6; // After "hello " + input.insert_char('🐱'); + assert_eq!(input.get_text(), "hello 🐱🦀 world"); + } + + #[test] + fn test_word_jump_right() { + let mut input = TextInput::new("hello world foo bar".to_string()); + input.cursor_position = 0; + + // Jump from start to "world" + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 6); // After "hello " + + // Jump to "foo" + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 12); // After "world " + + // Jump to "bar" + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 16); // After "foo " + + // Jump to end + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 19); // At end + } + + #[test] + fn test_word_jump_left() { + let mut input = TextInput::new("hello world foo bar".to_string()); + input.move_to_end(); + assert_eq!(input.get_cursor_position(), 19); + + // Jump back to "bar" + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 16); // Start of "bar" + + // Jump back to "foo" + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 12); // Start of "foo" + + // Jump back to "world" + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 6); // Start of "world" + + // Jump back to "hello" + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 0); // Start of "hello" + } + + #[test] + fn test_word_jump_with_multiple_spaces() { + let mut input = TextInput::new("hello world".to_string()); + input.cursor_position = 0; + + // Jump over multiple spaces + input.move_word_right(); + assert_eq!(input.get_cursor_position(), 8); // After "hello ", at start of "world" + + // Jump back should skip spaces + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 0); // Back to start of "hello" + } + + #[test] + fn test_word_jump_boundaries() { + let mut input = TextInput::new("test".to_string()); + + // At start - word left does nothing + input.cursor_position = 0; + input.move_word_left(); + assert_eq!(input.get_cursor_position(), 0); + + // At end - word right does nothing + input.move_to_end(); + let end_pos = input.get_cursor_position(); + input.move_word_right(); + assert_eq!(input.get_cursor_position(), end_pos); + } + + #[test] + fn test_up_down_arrows() { + let mut input = TextInput::new("hello world".to_string()); + + // Start in the middle + input.cursor_position = 5; + assert_eq!(input.get_cursor_position(), 5); + + // Up arrow should go to start + input.move_to_start(); + assert_eq!(input.get_cursor_position(), 0); + + // Move back to middle + input.cursor_position = 5; + + // Down arrow should go to end + input.move_to_end(); + assert_eq!(input.get_cursor_position(), 11); + } + + #[test] + fn test_delete_word_backward() { + let mut input = TextInput::new("hello world foo".to_string()); + + // Delete "foo" from end + input.move_to_end(); + input.delete_word_backward(); + assert_eq!(input.get_text(), "hello world "); + assert_eq!(input.get_cursor_position(), 12); + + // Delete "world " + input.delete_word_backward(); + assert_eq!(input.get_text(), "hello "); + assert_eq!(input.get_cursor_position(), 6); + + // Delete "hello " + input.delete_word_backward(); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + + // Delete on empty buffer does nothing + input.delete_word_backward(); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_delete_word_backward_middle() { + let mut input = TextInput::new("hello world foo".to_string()); + + // Position in middle of "world" + input.cursor_position = 8; // After "hello wo" + input.delete_word_backward(); + assert_eq!(input.get_text(), "hello rld foo"); + assert_eq!(input.get_cursor_position(), 6); // After "hello " + } + + #[test] + fn test_delete_word_forward() { + let mut input = TextInput::new("hello world foo".to_string()); + + // Delete "hello " from start + input.cursor_position = 0; + input.delete_word_forward(); + assert_eq!(input.get_text(), "world foo"); + assert_eq!(input.get_cursor_position(), 0); + + // Delete "world " + input.delete_word_forward(); + assert_eq!(input.get_text(), "foo"); + assert_eq!(input.get_cursor_position(), 0); + + // Delete "foo" + input.delete_word_forward(); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + + // Delete on empty buffer does nothing + input.delete_word_forward(); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_delete_word_forward_middle() { + let mut input = TextInput::new("hello world foo".to_string()); + + // Position in middle of "world" + input.cursor_position = 8; // After "hello wo" + input.delete_word_forward(); + assert_eq!(input.get_text(), "hello wofoo"); + assert_eq!(input.get_cursor_position(), 8); // Same position, text deleted forward + } + + #[test] + fn test_delete_word_with_multiple_spaces() { + let mut input = TextInput::new("hello world".to_string()); + + // Delete forward includes trailing spaces + input.cursor_position = 0; + input.delete_word_forward(); + assert_eq!(input.get_text(), "world"); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_undo_redo_basic() { + let mut input = TextInput::empty(); + + // Type "hello" + input.insert_char('h'); + input.insert_char('e'); + input.insert_char('l'); + input.insert_char('l'); + input.insert_char('o'); + assert_eq!(input.get_text(), "hello"); + + // Undo should remove all characters (coalesced into one undo entry) + assert!(input.can_undo()); + assert!(input.undo()); + assert_eq!(input.get_text(), ""); + assert_eq!(input.get_cursor_position(), 0); + + // Redo should restore "hello" + assert!(input.can_redo()); + assert!(input.redo()); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 5); + } + + #[test] + fn test_undo_coalescing_breaks_on_cursor_move() { + let mut input = TextInput::empty(); + + // Type "he" + input.insert_char('h'); + input.insert_char('e'); + + // Move cursor to start (breaks coalescing) + input.move_to_start(); + + // Type "llo" + input.insert_char('l'); + input.insert_char('l'); + input.insert_char('o'); + + assert_eq!(input.get_text(), "llohe"); + + // First undo removes "llo" (second coalesced group) + input.undo(); + assert_eq!(input.get_text(), "he"); + + // Second undo removes "he" (first coalesced group) + input.undo(); + assert_eq!(input.get_text(), ""); + } + + #[test] + fn test_undo_backspace() { + let mut input = TextInput::new("hello".to_string()); + + // Backspace once + input.backspace(); + assert_eq!(input.get_text(), "hell"); + + // Undo should restore "hello" + input.undo(); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 5); + } + + #[test] + fn test_undo_delete() { + let mut input = TextInput::new("hello".to_string()); + input.cursor_position = 0; + + // Delete first character + input.delete(); + assert_eq!(input.get_text(), "ello"); + + // Undo should restore "hello" + input.undo(); + assert_eq!(input.get_text(), "hello"); + assert_eq!(input.get_cursor_position(), 0); + } + + #[test] + fn test_undo_word_delete() { + let mut input = TextInput::new("hello world".to_string()); + + // Delete "world" backward + input.delete_word_backward(); + assert_eq!(input.get_text(), "hello "); + + // Undo should restore "hello world" + input.undo(); + assert_eq!(input.get_text(), "hello world"); + assert_eq!(input.get_cursor_position(), 11); + } + + #[test] + fn test_undo_clear() { + let mut input = TextInput::new("hello world".to_string()); + + // Clear the buffer + input.clear(); + assert_eq!(input.get_text(), ""); + + // Undo should restore the text + input.undo(); + assert_eq!(input.get_text(), "hello world"); + } + + #[test] + fn test_undo_set_text() { + let mut input = TextInput::new("hello".to_string()); + + // Replace with new text + input.set_text("goodbye".to_string()); + assert_eq!(input.get_text(), "goodbye"); + + // Undo should restore "hello" + input.undo(); + assert_eq!(input.get_text(), "hello"); + } + + #[test] + fn test_redo_clears_on_new_edit() { + let mut input = TextInput::empty(); + + // Type "hello" + input.insert_char('h'); + input.insert_char('e'); + input.insert_char('l'); + input.insert_char('l'); + input.insert_char('o'); + + // Undo + input.undo(); + assert_eq!(input.get_text(), ""); + assert!(input.can_redo()); + + // Make a new edit (should clear redo stack) + input.insert_char('x'); + assert!(!input.can_redo()); + } + + #[test] + fn test_multiple_undo_redo() { + let mut input = TextInput::empty(); + + // First edit: type "hello" + for c in "hello".chars() { + input.insert_char(c); + } + + // Break coalescing + input.move_left(); + + // Second edit: type "world" + for c in "world".chars() { + input.insert_char(c); + } + + assert_eq!(input.get_text(), "hellworldo"); + + // Undo "world" + input.undo(); + assert_eq!(input.get_text(), "hello"); + + // Undo "hello" + input.undo(); + assert_eq!(input.get_text(), ""); + + // Redo "hello" + input.redo(); + assert_eq!(input.get_text(), "hello"); + + // Redo "world" + input.redo(); + assert_eq!(input.get_text(), "hellworldo"); + } + + #[test] + fn test_undo_stack_limit() { + let mut input = TextInput::empty(); + + // Perform 102 separate edits (breaking coalescing each time) + for _i in 0..102 { + input.backspace(); // Break coalescing + input.insert_char('x'); + } + + // Should have at most 100 undo entries + let mut undo_count = 0; + while input.undo() { + undo_count += 1; + } + + // We should have 100 undo entries (the stack limit) + // Plus the final state change from the last coalescing break + assert!( + undo_count <= 100, + "Undo count should be at most 100, got {}", + undo_count + ); + } + + #[test] + fn test_undo_redo_empty_stack() { + let mut input = TextInput::empty(); + + // Undo on empty stack should return false + assert!(!input.can_undo()); + assert!(!input.undo()); + + // Redo on empty stack should return false + assert!(!input.can_redo()); + assert!(!input.redo()); + } + + #[test] + fn test_undo_restores_cursor_position() { + let mut input = TextInput::new("hello world".to_string()); + + // Move cursor to position 5 (before "world") + input.cursor_position = 5; + + // Insert a space + input.insert_char(' '); + assert_eq!(input.get_text(), "hello world"); + assert_eq!(input.get_cursor_position(), 6); + + // Undo should restore both text and cursor position + input.undo(); + assert_eq!(input.get_text(), "hello world"); + assert_eq!(input.get_cursor_position(), 5); + } + + #[test] + fn test_coalescing_consecutive_inserts() { + let mut input = TextInput::empty(); + + // Type several characters + input.insert_char('a'); + input.insert_char('b'); + input.insert_char('c'); + + assert_eq!(input.get_text(), "abc"); + + // Single undo should remove all three (they were coalesced) + input.undo(); + assert_eq!(input.get_text(), ""); + + // No more undo available + assert!(!input.can_undo()); + } + + #[test] + fn test_backspace_breaks_coalescing() { + let mut input = TextInput::empty(); + + // Type "ab" + input.insert_char('a'); + input.insert_char('b'); + + // Backspace + input.backspace(); + assert_eq!(input.get_text(), "a"); + + // Type "c" + input.insert_char('c'); + assert_eq!(input.get_text(), "ac"); + + // Undo should remove just "c" + input.undo(); + assert_eq!(input.get_text(), "a"); + + // Undo should remove backspace operation + input.undo(); + assert_eq!(input.get_text(), "ab"); + + // Undo should remove "ab" + input.undo(); + assert_eq!(input.get_text(), ""); + } +} diff --git a/default-plugins/layout-manager/src/ui/layout_detail_view.rs b/default-plugins/layout-manager/src/ui/layout_detail_view.rs new file mode 100644 index 00000000..2095d38f --- /dev/null +++ b/default-plugins/layout-manager/src/ui/layout_detail_view.rs @@ -0,0 +1,345 @@ +use super::text_utils::{get_layout_display_info, truncate_with_ellipsis, wrap_text_to_width}; +use crate::DisplayLayout; +use zellij_tile::prelude::*; + +pub struct LayoutDetail<'a> { + layout: &'a DisplayLayout, +} + +impl<'a> LayoutDetail<'a> { + pub fn new(layout: &'a DisplayLayout) -> Self { + Self { layout } + } + + pub fn render(&self, x: usize, y: usize, max_rows: usize, max_cols: usize) { + match self.layout { + DisplayLayout::Valid(_) => { + self.render_valid_layout(self.layout, x, y, max_rows, max_cols) + }, + DisplayLayout::Error { + name: _, + error: _, + error_message, + } => self.render_error(error_message, x, y, max_rows, max_cols), + } + } + + pub fn calculate_required_height(&self, max_cols: usize) -> usize { + match self.layout { + DisplayLayout::Valid(_) => { + let (_, metadata_opt) = get_layout_display_info(self.layout); + let Some(metadata) = metadata_opt else { + return 1; // No metadata available + }; + self.calculate_content_lines(metadata, max_cols) + }, + DisplayLayout::Error { .. } => { + 3 // Error message + hint line + }, + } + } + + fn calculate_content_lines(&self, metadata: &LayoutMetadata, max_cols: usize) -> usize { + if !self.should_show_tabs_section(metadata) { + // Panes only + let panes_lines = self.prepare_panes_content(metadata, max_cols); + panes_lines.len() + } else { + // Tabs and panes side-by-side + let (left_width, right_width, _) = self.calculate_column_layout(max_cols); + let tabs_lines = self.prepare_tabs_content(metadata, left_width); + let panes_lines = self.prepare_panes_content(metadata, right_width); + tabs_lines.len().max(panes_lines.len()) + } + } + + fn render_valid_layout( + &self, + layout: &DisplayLayout, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + let (name, metadata_opt) = get_layout_display_info(layout); + let Some(metadata) = metadata_opt else { + if layout.is_builtin() { + self.render_built_in_indication(&name, x, y, max_rows, max_cols); + } else { + self.render_no_metadata(x, y); + } + return; + }; + + self.render_tabs_and_panes(metadata, x, y, max_rows, max_cols); + } + + fn render_no_metadata(&self, x: usize, y: usize) { + let msg = Text::new("No metadata available").color_all(1); + print_text_with_coordinates(msg, x, y + 1, None, None); + } + fn render_built_in_indication( + &self, + name: &str, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + if max_rows == 0 { + return; + } + + let full_text = format!("{} is a built-in layout. Create your own layouts to automate or share workspace setup.", name); + let wrapped_lines = wrap_text_to_width(&full_text, max_cols); + + let mut current_y = y; + for line in wrapped_lines.iter().take(max_rows) { + let text = if line.contains(name) { + Text::new(line).color_substring(1, name) + } else { + Text::new(line) + }; + print_text_with_coordinates(text, x, current_y, None, None); + current_y += 1; + } + } + + fn render_error( + &self, + error_message: &str, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + if max_rows == 0 { + return; + } + + let wrapped_lines = wrap_text_to_width(error_message, max_cols); + let available_rows = max_rows.saturating_sub(3); + + let mut current_y = y + 1; // + 1 (and saturating_sub(3) above) to be aligned witht he + // table on the left + for line in wrapped_lines.iter().take(available_rows) { + let text = Text::new(line).error_color_all(); + print_text_with_coordinates(text, x, current_y, None, None); + current_y += 1; + } + + let hint = Text::new("<m> - Show detailed error").color_substring(3, "<m>"); + print_text_with_coordinates(hint, x, current_y + 1, None, None); // 1 for gap + } + + fn render_tabs_and_panes( + &self, + metadata: &LayoutMetadata, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + // let max_rows = max_rows.saturating_sub(1); + + if !self.should_show_tabs_section(metadata) { + self.render_panes_only(metadata, x, y, max_rows, max_cols); + return; + } + + let (left_width, right_width, padding) = self.calculate_column_layout(max_cols); + let right_x = x + left_width + padding; + + let tabs_lines = self.prepare_tabs_content(metadata, left_width); + let panes_lines = self.prepare_panes_content(metadata, right_width); + + let (tabs_truncated, panes_truncated) = + self.render_side_by_side_columns(&panes_lines, &tabs_lines, x, right_x, y, max_rows); + + if tabs_truncated { + self.render_truncation_ellipsis(x, y + max_rows); + } + if panes_truncated { + self.render_truncation_ellipsis(right_x, y + max_rows); + } + } + + fn render_side_by_side_columns( + &self, + left_lines: &[String], + right_lines: &[String], + left_x: usize, + right_x: usize, + start_y: usize, + max_rows: usize, + ) -> (bool, bool) { + let max_lines = left_lines.len().max(right_lines.len()); + let mut left_truncated = false; + let mut right_truncated = false; + + for line_idx in 0..=max_lines { + if line_idx >= max_rows { + if line_idx < left_lines.len() { + left_truncated = true; + } + if line_idx < right_lines.len() { + right_truncated = true; + } + break; + } + + let current_y = start_y + line_idx; + + if let Some(line) = left_lines.get(line_idx) { + self.render_colored_line(line, left_x, current_y, line_idx == 0, 2); + } + + if let Some(line) = right_lines.get(line_idx) { + self.render_colored_line(line, right_x, current_y, line_idx == 0, 2); + } + } + + (left_truncated, right_truncated) + } + + fn should_show_tabs_section(&self, metadata: &LayoutMetadata) -> bool { + metadata.tabs.len() > 1 + } + + fn prepare_tabs_content(&self, metadata: &LayoutMetadata, max_width: usize) -> Vec<String> { + let mut lines = Vec::new(); + + // Add title + let title = "Tabs:"; + lines.push(truncate_with_ellipsis(title, max_width)); + + // Add tabs + for (i, tab) in metadata.tabs.iter().enumerate() { + let tab_label = if let Some(name) = &tab.name { + name.clone() + } else { + format!("Tab {}", i + 1) + }; + + // Account for " - " prefix (4 characters) + let available_width = max_width.saturating_sub(4); + let truncated_label = truncate_with_ellipsis(&tab_label, available_width); + lines.push(format!(" - {}", truncated_label)); + } + + lines + } + + fn prepare_panes_content(&self, metadata: &LayoutMetadata, max_width: usize) -> Vec<String> { + let mut lines = Vec::new(); + + let (named_panes, terminal_count) = self.collect_pane_info(metadata); + + // Early return if nothing to show + if !self.has_panes_to_show(&named_panes, terminal_count) { + return lines; + } + + // Add title + let title = "Panes:"; + lines.push(truncate_with_ellipsis(title, max_width)); + + // Add named panes + for pane_name in &named_panes { + let available_width = max_width.saturating_sub(4); + let truncated_name = truncate_with_ellipsis(pane_name, available_width); + lines.push(format!(" - {}", truncated_name)); + } + + // Add terminal count if any + if terminal_count > 0 { + let text = if named_panes.is_empty() { + format!(" {} Terminals", terminal_count) + } else { + format!(" +{} Terminals", terminal_count) + }; + let truncated_text = truncate_with_ellipsis(&text, max_width); + lines.push(truncated_text); + } + + lines + } + + fn calculate_column_layout(&self, max_cols: usize) -> (usize, usize, usize) { + let left_width = (max_cols - 1) / 2; + let padding = 1; + let right_width = max_cols - left_width - padding; + (left_width, right_width, padding) + } + + fn render_colored_line( + &self, + line: &str, + x: usize, + y: usize, + is_title: bool, + title_color: usize, + ) { + let text = if is_title { + Text::new(line).color_all(title_color) + } else { + Text::new(line) + }; + print_text_with_coordinates(text, x, y, None, None); + } + + fn render_truncation_ellipsis(&self, x: usize, y: usize) { + let ellipsis = Text::new(" [...]").color_all(1); + print_text_with_coordinates(ellipsis, x, y, None, None); + } + + fn render_panes_only( + &self, + metadata: &LayoutMetadata, + x: usize, + y: usize, + max_rows: usize, + max_cols: usize, + ) { + let panes_lines = self.prepare_panes_content(metadata, max_cols); + + let mut was_truncated = false; + for (line_idx, pane_line) in panes_lines.iter().enumerate() { + if line_idx >= max_rows { + was_truncated = true; + break; + } + + let current_y = y + line_idx; + self.render_colored_line(pane_line, x, current_y, line_idx == 0, 2); + } + + if was_truncated { + self.render_truncation_ellipsis(x, y + max_rows); + } + } + + fn has_panes_to_show(&self, named_panes: &[String], terminal_count: usize) -> bool { + !named_panes.is_empty() || terminal_count > 0 + } + + fn collect_pane_info(&self, metadata: &LayoutMetadata) -> (Vec<String>, usize) { + let mut named_panes = Vec::new(); + let mut terminal_count = 0; + + for tab in &metadata.tabs { + for pane in &tab.panes { + // Skip builtin plugin panes + if pane.is_builtin_plugin { + continue; + } + match &pane.name { + Some(name) => named_panes.push(name.clone()), + None => terminal_count += 1, + } + } + } + + (named_panes, terminal_count) + } +} diff --git a/default-plugins/layout-manager/src/ui/layout_table.rs b/default-plugins/layout-manager/src/ui/layout_table.rs new file mode 100644 index 00000000..2f22f06e --- /dev/null +++ b/default-plugins/layout-manager/src/ui/layout_table.rs @@ -0,0 +1,452 @@ +use super::text_utils::{ + get_last_modified_string, get_layout_display_info, truncate_with_ellipsis, +}; +use crate::DisplayLayout; +use zellij_tile::prelude::*; + +fn create_table_row( + name: &str, + metadata: Option<&LayoutMetadata>, + is_error: bool, + is_builtin: bool, + max_name_width: Option<usize>, + matched_indices: Option<&Vec<usize>>, +) -> Vec<Text> { + let display_name = if let Some(max_width) = max_name_width { + truncate_with_ellipsis(name, max_width) + } else { + name.to_string() + }; + + let name_text = if let Some(indices) = matched_indices { + // Create text with highlighted matched characters + let mut text = Text::new(&display_name); + + // Apply base color to entire text + text = if is_error { + text.error_color_all() + } else { + text.color_all(1) + }; + + text = text.color_indices(3, indices.iter().cloned().collect()); + + text + } else { + // No matches, use original coloring + if is_error { + Text::new(&display_name).error_color_all() + } else { + Text::new(&display_name).color_all(1) + } + }; + + let last_modified_string = get_last_modified_string(metadata, is_builtin); + let last_modified = if is_builtin { + Text::new(&last_modified_string).color_all(0) + } else { + Text::new(&last_modified_string) + }; + + vec![name_text, last_modified] +} + +fn apply_selection(mut row: Vec<Text>) -> Vec<Text> { + for cell in &mut row { + *cell = std::mem::take(cell).selected(); + } + row +} + +pub struct LayoutsTable { + display_layouts: Vec<DisplayLayout>, + selected_layout_index: usize, + hidden_above: usize, + hidden_below: usize, + matched_indices: Vec<Option<Vec<usize>>>, +} + +impl LayoutsTable { + pub fn new( + display_layouts: Vec<DisplayLayout>, + selected_layout_index: usize, + hidden_above: usize, + hidden_below: usize, + ) -> Self { + Self { + display_layouts, + selected_layout_index, + hidden_above, + hidden_below, + matched_indices: Vec::new(), + } + } + + pub fn with_matched_indices(mut self, matched_indices: Vec<Option<Vec<usize>>>) -> Self { + self.matched_indices = matched_indices; + self + } + + pub fn render(&self, x: usize, y: usize, _max_rows: usize, max_cols: usize) { + let mut table = match self.overflow_indicator(self.hidden_above) { + Some(overflow_indicator) => { + Table::new().add_styled_row(vec![Text::new(" "), overflow_indicator]) + }, + None => Table::new().add_row(vec![" ", " "]), + }; + + // Calculate the actual width needed for the "last modified" column + let max_last_modified_width = self + .display_layouts + .iter() + .map(|layout| { + let (_, metadata_opt) = get_layout_display_info(layout); + get_last_modified_string(metadata_opt, layout.is_builtin()).len() + }) + .max() + .unwrap_or(1); + + let table_padding = 2; + let max_name_width = if max_cols > max_last_modified_width + table_padding { + Some(max_cols.saturating_sub(max_last_modified_width + table_padding)) + } else { + Some(max_cols.saturating_div(2)) + }; + + for (i, layout) in self.display_layouts.iter().enumerate() { + let (name, metadata_opt) = get_layout_display_info(layout); + let matched_indices = self.matched_indices.get(i).and_then(|opt| opt.as_ref()); + let mut row = create_table_row( + &name, + metadata_opt, + layout.is_error(), + layout.is_builtin(), + max_name_width, + matched_indices, + ); + + if i == self.selected_layout_index { + row = apply_selection(row); + } + + table = table.add_styled_row(row); + } + + if let Some(overflow_indicator) = self.overflow_indicator(self.hidden_below) { + table = table.add_styled_row(vec![Text::new(" "), overflow_indicator]); + } + + print_table_with_coordinates(table, x, y, Some(max_cols), None); + } + fn overflow_indicator(&self, count: usize) -> Option<Text> { + if count > 0 { + Some(Text::new(format!("[+{}]", count)).color_all(2)) + } else { + None + } + } +} + +fn color_control_text(text: &str, keys: &[&str]) -> Text { + let mut colored_text = Text::new(text); + for key in keys { + colored_text = colored_text.color_substring(3, key) + } + colored_text +} + +pub struct Controls { + retain_terminal_panes: bool, + retain_plugin_panes: bool, + apply_only_to_active_tab: bool, + show_more_options: bool, + typing_filter: bool, + filter_active: bool, +} + +impl Controls { + pub fn new( + retain_terminal_panes: bool, + retain_plugin_panes: bool, + apply_only_to_active_tab: bool, + show_more_options: bool, + typing_filter: bool, + filter_active: bool, + ) -> Self { + Self { + retain_terminal_panes, + retain_plugin_panes, + apply_only_to_active_tab, + show_more_options, + typing_filter, + filter_active, + } + } + + fn get_typing_filter_controls(&self, max_cols: usize) -> (&str, &[&str]) { + let long_text = "- <Enter> - accept filter"; + let short_text = "<Enter> - accept filter"; + let minimum_text = "<Enter> ..."; + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<Enter>"]) + } + + fn get_filter_active_controls(&self, max_cols: usize) -> (&str, &[&str]) { + let long_text = "- <Enter> Open, <↓↑> Nav, <e> Edit, <r> Rename, <Del> - Delete"; + let short_text = "<Enter>/<↓↑>/<e>/<r>/<Del> Open/Nav/Edit/Rename/Del"; + let minimum_text = "<Enter>/<↓↑>/<e>/<r>/<Del> ..."; + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<Enter>", "<↓↑>", "<e>", "<r>", "<Del>"]) + } + + fn get_default_controls(&self, max_cols: usize) -> (&str, &[&str]) { + let long_text = "- <Enter> Open, <↓↑> Nav, </> Filter, <e> Edit, <r> Rename, <Del> - Del"; + let short_text = "<Enter>/<↓↑>/</>/<e>/<r>/<Del> Open/Nav/Filter/Edit/Rename/Del"; + let minimum_text = "<Enter>/<↓↑>/</>/<e>/<r>/<Del> ..."; + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<Enter>", "<↓↑>", "</>", "<e>", "<r>", "<Del>"]) + } + + fn get_basic_controls_text_and_keys(&self, max_cols: usize) -> (&str, &[&str]) { + if self.filter_active { + self.get_filter_active_controls(max_cols) + } else { + self.get_default_controls(max_cols) + } + } + + fn get_override_text_and_keys(&self, max_cols: usize) -> (String, &[&str]) { + let toggle_word = if self.show_more_options { + "less" + } else { + "more" + }; + let long_text = format!( + "- <Tab> Override Session Layout, <?> {} options", + toggle_word + ); + let short_text = format!("<Tab> Override, <?> {} options", toggle_word); + let minimum_text = format!("<Tab>/<?> ..."); + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<Tab>", "<?>"]) + } + + fn get_new_layout_text_and_keys(&self, max_cols: usize) -> (&str, &[&str]) { + let long_text = "- New Layout: <n> from current session, <i> import"; + let short_text = "New: <n> current, <i> import"; + let minimum_text = "New: <n>/<N>/<i> ..."; + let text = if max_cols >= long_text.chars().count() { + long_text + } else if max_cols >= short_text.chars().count() { + short_text + } else { + minimum_text + }; + (text, &["<n>", "<i>"]) + } + + pub fn calculate_width(&self, max_cols: usize) -> usize { + let mut width = 0; + + // Line 1: Basic controls + let (basic_text, _) = self.get_default_controls(max_cols); + width = width.max(basic_text.chars().count()); + + // Line 2: Override and toggle + let (override_text, _) = self.get_override_text_and_keys(max_cols); + width = width.max(override_text.chars().count()); + + // Line 3 (or 5 if show_more_options): New layout controls + let (new_layout_text, _) = self.get_new_layout_text_and_keys(max_cols); + width = width.max(new_layout_text.chars().count()); + + // Lines 4-5: Advanced options (only if show_more_options) + if self.show_more_options { + let (retain_text, _) = self.get_retain_text_and_highlight(max_cols); + width = width.max(retain_text.chars().count()); + + let (target_text, _) = self.get_target_text_and_highlight(max_cols); + width = width.max(target_text.chars().count()); + } + + width + } + + pub fn render(&self, x: usize, y: usize, max_cols: usize) { + if self.typing_filter { + self.render_typing_filter_controls(x, y, max_cols); + } else { + self.render_basic_controls(x, y, max_cols); + self.render_override_and_toggle(x, y + 1, max_cols); + + let new_layout_y = if self.show_more_options { y + 4 } else { y + 2 }; + self.render_new_layout_controls(x, new_layout_y, max_cols); + + if self.show_more_options { + self.render_advanced_options(x, y + 2, max_cols); + } + } + } + + fn render_basic_controls(&self, x: usize, y: usize, max_width: usize) { + let (text, keys) = self.get_basic_controls_text_and_keys(max_width); + let controls_line = color_control_text(text, keys); + print_text_with_coordinates(controls_line, x, y, None, None); + } + + fn render_override_and_toggle(&self, x: usize, y: usize, max_cols: usize) { + let (text, keys) = self.get_override_text_and_keys(max_cols); + let override_line = color_control_text(&text, keys); + print_text_with_coordinates(override_line, x, y, None, None); + } + + fn render_new_layout_controls(&self, x: usize, y: usize, max_cols: usize) { + let (text, keys) = self.get_new_layout_text_and_keys(max_cols); + let new_layout_line = color_control_text(text, keys).color_substring(2, "New Layout:"); + print_text_with_coordinates(new_layout_line, x, y, None, None); + } + + fn render_typing_filter_controls(&self, x: usize, y: usize, max_cols: usize) { + let (text, keys) = self.get_typing_filter_controls(max_cols); + let controls_line = color_control_text(text, keys); + print_text_with_coordinates(controls_line, x, y, None, None); + } + + fn render_advanced_options(&self, x: usize, y: usize, max_cols: usize) { + self.render_retain_option(x, y, max_cols); + self.render_target_option(x, y + 1, max_cols); + } + + fn render_retain_option(&self, x: usize, y: usize, max_cols: usize) { + let (retain_text, retain_substring) = self.get_retain_text_and_highlight(max_cols); + let retain_line = + color_control_text(retain_text, &["<t>"]).color_substring(0, retain_substring); + print_text_with_coordinates(retain_line, x, y, None, None); + } + + fn get_retain_text_and_highlight(&self, max_cols: usize) -> (&str, &str) { + let long_text = " <t> Retain: Terminals | Plugins | Both | None "; + let short_text = " <t> Retain: Term | Pl | Both | None "; + if max_cols >= long_text.chars().count() { + match (self.retain_terminal_panes, self.retain_plugin_panes) { + (true, false) => ( + " <t> Retain: [Terminals] | Plugins | Both | None", + "[Terminals]", + ), + (false, true) => ( + " <t> Retain: Terminals | [Plugins] | Both | None", + "[Plugins]", + ), + (true, true) => ( + " <t> Retain: Terminals | Plugins | [Both] | None", + "[Both]", + ), + (false, false) => ( + " <t> Retain: Terminals | Plugins | Both | [None]", + "[None]", + ), + } + } else if max_cols >= short_text.chars().count() { + match (self.retain_terminal_panes, self.retain_plugin_panes) { + (true, false) => (" <t> Retain: [Term]| Pl | Both | None", "[Term]"), + (false, true) => (" <t> Retain: Term |[Pl]| Both | None", "[Pl]"), + (true, true) => (" <t> Retain: Term | Pl |[Both]| None", "[Both]"), + (false, false) => (" <t> Retain: Term | Pl | Both |[None]", "[None]"), + } + } else { + match (self.retain_terminal_panes, self.retain_plugin_panes) { + (true, false) => (" <t> R: [T] | P | B | N ...", "[T]"), + (false, true) => (" <t> R: T |[P]| B | N ...", "[P]"), + (true, true) => (" <t> R: T | P |[B]| N ...", "[B]"), + (false, false) => (" <t> R: T | P | B |[N]...", "[N]"), + } + } + } + + fn render_target_option(&self, x: usize, y: usize, max_cols: usize) { + let (target_text, target_substring) = self.get_target_text_and_highlight(max_cols); + let target_line = + color_control_text(target_text, &["<a>"]).color_substring(2, target_substring); + print_text_with_coordinates(target_line, x, y, None, None); + } + + fn get_target_text_and_highlight(&self, max_cols: usize) -> (&str, &str) { + if self.apply_only_to_active_tab { + let long_text = " <a> Target: All Tabs | [Current]"; + let short_text = " <a> Target: All |[Current]"; + if max_cols >= long_text.chars().count() { + (long_text, "[Current]") + } else { + (short_text, "[Current]") + } + } else { + let long_text = " <a> Target: [All Tabs] | Current "; + let short_text = " <a> Target: [All]| Current "; + if max_cols >= long_text.chars().count() { + (long_text, "[All Tabs]") + } else { + (short_text, "[All]") + } + } + } +} + +pub struct Title<'a> { + text: &'a str, +} + +impl<'a> Title<'a> { + pub fn new(text: &'a str) -> Self { + Self { text } + } + + pub fn render(&self, x: usize, y: usize) { + let title = Text::new(self.text).color_all(2); + print_text_with_coordinates(title, x, y, None, None); + } +} + +pub struct ErrorMessage<'a> { + message: &'a str, +} + +impl<'a> ErrorMessage<'a> { + pub fn new(message: &'a str) -> Self { + Self { message } + } + + pub fn render(&self, x: usize, y: usize) { + let title = Text::new("Error").error_color_all(); + print_text_with_coordinates(title, x, y, None, None); + + let message = Text::new(self.message).error_color_all(); + print_text_with_coordinates(message, x, y + 2, None, None); + + let help = Text::new("Press any key to continue"); + print_text_with_coordinates(help, x, y + 4, None, None); + } +} diff --git a/default-plugins/layout-manager/src/ui/mod.rs b/default-plugins/layout-manager/src/ui/mod.rs new file mode 100644 index 00000000..f5a98e00 --- /dev/null +++ b/default-plugins/layout-manager/src/ui/mod.rs @@ -0,0 +1,9 @@ +pub mod layout_detail_view; +pub mod layout_table; +pub mod multi_line_error_message; +pub mod text_utils; + +pub use layout_detail_view::LayoutDetail; +pub use layout_table::{Controls, ErrorMessage, LayoutsTable, Title}; +pub use multi_line_error_message::MultiLineErrorMessage; +pub use text_utils::*; diff --git a/default-plugins/layout-manager/src/ui/multi_line_error_message.rs b/default-plugins/layout-manager/src/ui/multi_line_error_message.rs new file mode 100644 index 00000000..223d7de3 --- /dev/null +++ b/default-plugins/layout-manager/src/ui/multi_line_error_message.rs @@ -0,0 +1,26 @@ +use zellij_tile::prelude::*; + +pub struct MultiLineErrorMessage { + message: Vec<String>, +} + +impl MultiLineErrorMessage { + pub fn new(message: Vec<String>) -> Self { + Self { message } + } + + pub fn render(&self, x: usize, y: usize, max_rows: usize) { + let title = Text::new("Error").error_color_all(); + print_text_with_coordinates(title, x, y, None, None); + + let mut current_y = y + 2; + for line in self.message.iter().take(max_rows) { + let text = Text::new(line).error_color_all(); + print_text_with_coordinates(text, x, current_y, None, None); + current_y += 1; + } + + let help = Text::new("Press any key to continue"); + print_text_with_coordinates(help, x, current_y + 2, None, None); + } +} diff --git a/default-plugins/layout-manager/src/ui/text_utils.rs b/default-plugins/layout-manager/src/ui/text_utils.rs new file mode 100644 index 00000000..3a4623d9 --- /dev/null +++ b/default-plugins/layout-manager/src/ui/text_utils.rs @@ -0,0 +1,195 @@ +use crate::DisplayLayout; +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +pub fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if text.chars().count() <= max_width { + return text.to_string(); + } + if max_width <= 3 { + return text.chars().take(max_width).collect(); + } + // Reserve 3 characters for "..." + let truncate_at = max_width.saturating_sub(3); + format!("{}...", text.chars().take(truncate_at).collect::<String>()) +} + +pub fn truncate_with_ellipsis_start(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if text.chars().count() <= max_width { + return text.to_string(); + } + if max_width <= 3 { + return text + .chars() + .rev() + .take(max_width) + .collect::<Vec<_>>() + .into_iter() + .rev() + .collect(); + } + let truncate_at = max_width.saturating_sub(3); + let suffix: String = text + .chars() + .rev() + .take(truncate_at) + .collect::<Vec<_>>() + .into_iter() + .rev() + .collect(); + format!("...{}", suffix) +} + +struct AnsiString<'a> { + content: &'a str, +} + +impl<'a> AnsiString<'a> { + fn new(content: &'a str) -> Self { + Self { content } + } + + fn stripped_content(&self) -> String { + strip_ansi_escapes::strip_str(self.content) + } + + fn display_width(&self) -> usize { + let stripped = self.stripped_content(); + UnicodeWidthStr::width(stripped.as_str()) + } + + fn fits_within(&self, max_width: usize) -> bool { + self.display_width() <= max_width + } + + fn truncate_to_width(&self, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + + if self.fits_within(max_width) { + return self.content.to_string(); + } + + let mut truncated = String::new(); + for ch in self.content.chars() { + let test_string = format!("{}{}", truncated, ch); + let test_ansi = AnsiString::new(&test_string); + + if test_ansi.display_width() > max_width { + break; + } + + truncated.push(ch); + } + + truncated + } + + fn with_reset_code(content: String) -> String { + format!("{}\u{1b}[0m", content) + } +} + +pub fn truncate_line_with_ansi(line: &str, max_width: usize) -> String { + let ansi_line = AnsiString::new(line); + let truncated = ansi_line.truncate_to_width(max_width); + AnsiString::with_reset_code(truncated) +} + +pub fn wrap_text_to_width(text: &str, max_width: usize) -> Vec<String> { + if max_width == 0 { + return Vec::new(); + } + if max_width < 3 { + return text.chars().map(|c| c.to_string()).collect(); + } + + let words: Vec<&str> = text.split_whitespace().collect(); + let mut lines = Vec::new(); + let mut current_line = String::new(); + + for word in words.iter() { + let word_to_add = if word.len() > max_width { + truncate_with_ellipsis(word, max_width) + } else { + word.to_string() + }; + + if current_line.is_empty() { + current_line = word_to_add; + } else { + let test_line = format!("{} {}", current_line, word_to_add); + if test_line.len() <= max_width { + current_line = test_line; + } else { + lines.push(current_line); + current_line = word_to_add; + } + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines +} + +pub fn get_layout_display_info(layout: &DisplayLayout) -> (String, Option<&LayoutMetadata>) { + match layout { + DisplayLayout::Valid(info) => match info { + LayoutInfo::BuiltIn(name) => (name.clone(), None), + LayoutInfo::File(path, metadata) => { + let name = path.split('/').last().unwrap_or(path).to_string(); + (name, Some(metadata)) + }, + LayoutInfo::Url(url) => { + let name = url.split('/').last().unwrap_or(url).to_string(); + (name, None) + }, + LayoutInfo::Stringified(_) => ("raw".to_string(), None), + }, + DisplayLayout::Error { name, .. } => (name.clone(), None), + } +} + +fn format_elapsed(unix_epoch_str: &str) -> String { + let Ok(timestamp) = unix_epoch_str.parse::<i64>() else { + return " ".to_string(); + }; + + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let diff = now - timestamp; + + if diff < 60 { + "now".to_string() + } else if diff < 3600 { + format!("{}m ago", diff / 60) + } else if diff < 86400 { + format!("{}h ago", diff / 3600) + } else { + format!("{}d ago", diff / 86400) + } +} + +pub fn get_last_modified_string(metadata: Option<&LayoutMetadata>, is_builtin: bool) -> String { + if let Some(metadata) = metadata { + format_elapsed(&metadata.update_time) + } else if is_builtin { + "Built-in".to_string() + } else { + " ".to_string() + } +} diff --git a/default-plugins/status-bar/src/one_line_ui.rs b/default-plugins/status-bar/src/one_line_ui.rs index a73c7b80..928d0d46 100644 --- a/default-plugins/status-bar/src/one_line_ui.rs +++ b/default-plugins/status-bar/src/one_line_ui.rs @@ -1491,6 +1491,7 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec<KeyWithModifier (s("Session Manager"), s("Manager"), session_manager_key(&km)), (s("Share"), s("Share"), share_key(&km)), (s("Configure"), s("Config"), configuration_key(&km)), + (s("Layout Manager"), s("Layouts"), layout_manager_key(&km)), (s("Plugin Manager"), s("Plugins"), plugin_manager_key(&km)), (s("About"), s("About"), about_key(&km)), (s("Select pane"), s("Select"), to_basemode_key), @@ -1631,6 +1632,25 @@ fn plugin_manager_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithM } } +fn layout_manager_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithModifier> { + let mut matching = keymap.iter().find_map(|(key, acvec)| { + let has_match = acvec + .iter() + .find(|a| a.launches_plugin("zellij:layout-manager")) + .is_some(); + if has_match { + Some(key.clone()) + } else { + None + } + }); + if let Some(matching) = matching.take() { + vec![matching] + } else { + vec![] + } +} + fn about_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithModifier> { let mut matching = keymap.iter().find_map(|(key, acvec)| { let has_match = acvec diff --git a/example/default.kdl b/example/default.kdl index e11be799..f82030be 100644 --- a/example/default.kdl +++ b/example/default.kdl @@ -150,6 +150,13 @@ keybinds { }; SwitchToMode "Normal" } + bind "l" { + LaunchOrFocusPlugin "zellij:layout-manager" { + floating true + move_to_focused_tab true + }; + SwitchToMode "Normal" + } } tmux { bind "[" { SwitchToMode "Scroll"; } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 54c2e068..96a50e3f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -83,6 +83,10 @@ fn workspace_members() -> &'static Vec<WorkspaceMember> { build: true, }, WorkspaceMember { + crate_name: "default-plugins/layout-manager", + build: true, + }, + WorkspaceMember { crate_name: "zellij-utils", build: false, }, diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs index ce11e874..5fcf68be 100644 --- a/zellij-client/src/lib.rs +++ b/zellij-client/src/lib.rs @@ -85,7 +85,7 @@ use zellij_utils::cli::CliArgs; use zellij_utils::{ channels::{self, ChannelWithContext, SenderWithContext}, consts::{set_permissions, ZELLIJ_SOCK_DIR}, - data::{ClientId, ConnectToSession, KeyWithModifier, LayoutInfo}, + data::{ClientId, ConnectToSession, KeyWithModifier, LayoutInfo, LayoutMetadata}, envs, errors::{ClientContext, ContextType, ErrorInstruction}, input::{cli_assets::CliAssets, config::Config, options::Options}, @@ -696,7 +696,10 @@ pub fn start_client( config_dir: cli_args.config_dir.clone(), should_ignore_config: cli_args.is_setup_clean(), configuration_options: Some(config_options.clone()), - layout: Some(LayoutInfo::File(path_to_layout.display().to_string())), + layout: Some(LayoutInfo::File( + path_to_layout.display().to_string(), + LayoutMetadata::default(), + )), terminal_window_size: full_screen_ws, data_dir: cli_args.data_dir.clone(), is_debug: cli_args.debug, @@ -1128,7 +1131,10 @@ pub fn start_server_detached( config_dir: cli_args.config_dir.clone(), should_ignore_config: cli_args.is_setup_clean(), configuration_options: Some(config_options.clone()), - layout: Some(LayoutInfo::File(path_to_layout.display().to_string())), + layout: Some(LayoutInfo::File( + path_to_layout.display().to_string(), + LayoutMetadata::default(), + )), terminal_window_size: Size { cols: 50, rows: 50 }, // static number until a // client connects data_dir: cli_args.data_dir.clone(), diff --git a/zellij-client/src/web_client/session_management.rs b/zellij-client/src/web_client/session_management.rs index d3518b53..9530ba94 100644 --- a/zellij-client/src/web_client/session_management.rs +++ b/zellij-client/src/web_client/session_management.rs @@ -4,7 +4,7 @@ use crate::spawn_server; use std::{fs, path::PathBuf}; use zellij_utils::{ consts::session_layout_cache_file_name, - data::{ConnectToSession, LayoutInfo, WebSharing}, + data::{ConnectToSession, LayoutInfo, LayoutMetadata, WebSharing}, envs, input::{cli_assets::CliAssets, config::Config, options::Options}, ipc::{ClientAttributes, ClientToServerMsg}, @@ -70,6 +70,7 @@ pub fn create_first_message( session_layout_cache_file_name(&session_name) .display() .to_string(), + LayoutMetadata::default(), )) } else { None diff --git a/zellij-server/Cargo.toml b/zellij-server/Cargo.toml index 263e66b1..e513feb6 100644 --- a/zellij-server/Cargo.toml +++ b/zellij-server/Cargo.toml @@ -26,6 +26,7 @@ daemonize = { workspace = true } highway = { version = "0.6.4", default-features = false, features = ["std"] } interprocess = { workspace = true } isahc = { workspace = true } +kdl = { workspace = true } lazy_static = { workspace = true } libc = { workspace = true } log = { workspace = true } diff --git a/zellij-server/src/background_jobs.rs b/zellij-server/src/background_jobs.rs index 62907048..d91a80a4 100644 --- a/zellij-server/src/background_jobs.rs +++ b/zellij-server/src/background_jobs.rs @@ -204,6 +204,7 @@ pub(crate) fn background_jobs_main( let current_session_name = current_session_name.lock().unwrap().to_string(); let current_session_info = current_session_info.lock().unwrap().clone(); + let available_layouts = current_session_info.available_layouts.clone(); let current_session_layout = current_session_layout.lock().unwrap().clone(); if !disable_session_metadata { @@ -221,6 +222,8 @@ pub(crate) fn background_jobs_main( let current_session_plugin_list = current_session_plugin_list.lock().unwrap().clone(); session_info.populate_plugin_list(current_session_plugin_list); + // these are not serialized, so must be explicitly added + session_info.available_layouts = available_layouts.clone(); } } let resurrectable_sessions = diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs index 810ff27f..2ccb9d24 100644 --- a/zellij-server/src/lib.rs +++ b/zellij-server/src/lib.rs @@ -51,15 +51,15 @@ use zellij_utils::{ DEFAULT_SCROLL_BUFFER_SIZE, SCROLL_BUFFER_SIZE, ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE, }, data::{ - ConnectToSession, Event, InputMode, KeyWithModifier, LayoutInfo, PluginCapabilities, Style, - WebSharing, + ConnectToSession, Event, InputMode, KeyWithModifier, LayoutInfo, LayoutWithError, + PluginCapabilities, Style, WebSharing, }, errors::{prelude::*, ContextType, ErrorInstruction, FatalError, ServerContext}, home::{default_layout_dir, get_default_data_dir}, input::{ actions::Action, command::{RunCommand, TerminalAction}, - config::{watch_config_file_changes, Config}, + config::{watch_config_file_changes, watch_layout_dir_changes, Config}, get_mode_info, keybinds::Keybinds, layout::{FloatingPaneLayout, Layout, PluginAlias, Run, RunPluginOrAlias}, @@ -401,6 +401,7 @@ impl SessionMetaData { keybinds: Some(new_config.keybinds), default_mode: new_config.options.default_mode, default_shell: self.default_shell.clone(), + layout_dir: new_config.options.layout_dir, was_written_to_disk: config_was_written_to_disk, }) .unwrap(); @@ -1781,6 +1782,9 @@ fn init_session( .unwrap(); let zellij_cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + + let (available_layouts, available_layout_errors) = get_available_layouts(&config_options); + let plugin_thread = thread::Builder::new() .name("wasm".to_string()) .spawn({ @@ -1800,7 +1804,10 @@ fn init_session( let client_attributes = client_attributes.clone(); let default_shell = default_shell.clone(); let capabilities = capabilities.clone(); - let layout_dir = config_options.layout_dir.clone(); + let layout_dir = config_options + .layout_dir + .clone() + .or_else(|| default_layout_dir()); let background_plugins = config.background_plugins.clone(); move || { plugin_thread_main( @@ -1809,6 +1816,8 @@ fn init_session( data_dir, layout, layout_dir, + available_layouts, + available_layout_errors, path_to_default_shell, zellij_cwd, capabilities, @@ -1873,7 +1882,19 @@ fn init_session( }) .unwrap(); if let Some(config_file_path) = cli_assets.config_file_path.clone() { + let layout_dir = config_options + .layout_dir + .clone() + .or_else(|| default_layout_dir()); + let default_layout_name = config_options + .default_layout + .map(|l| format!("{}", l.display())); report_changes_in_config_file(config_file_path, to_server.clone()); + + // Watch layout directory for changes + if let Some(layout_dir_path) = layout_dir { + report_changes_in_layout_dir(layout_dir_path, default_layout_name, to_plugin.clone()); + } } SessionMetaData { @@ -1997,6 +2018,33 @@ fn report_changes_in_config_file( }); } +fn report_changes_in_layout_dir( + layout_dir: PathBuf, + default_layout_name: Option<String>, + to_plugin: SenderWithContext<PluginInstruction>, +) { + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async move { + let to_plugin = to_plugin.clone(); + watch_layout_dir_changes( + layout_dir, + default_layout_name, + move |new_layouts, layout_errors| { + let to_plugin = to_plugin.clone(); + async move { + let _ = to_plugin.send(PluginInstruction::LayoutListUpdate( + new_layouts, + layout_errors, + )); + } + }, + ) + .await; + }); + }); +} + fn update_new_saved_config( new_config: Option<Config>, write_config_to_disk: bool, @@ -2086,3 +2134,16 @@ pub fn get_engine() -> Engine { log::info!("Loading plugins using Wasmi interpreter"); Engine::default() } + +// TODO: move elsewhere +fn get_available_layouts(config_options: &Options) -> (Vec<LayoutInfo>, Vec<LayoutWithError>) { + let layout_dir = config_options + .layout_dir + .clone() + .or_else(|| default_layout_dir()); + let default_layout_name = config_options + .default_layout + .as_ref() + .map(|l| format!("{}", l.display())); + Layout::list_available_layouts(layout_dir, &default_layout_name) +} diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs index a8f60b30..c6c931c8 100644 --- a/zellij-server/src/plugins/mod.rs +++ b/zellij-server/src/plugins/mod.rs @@ -23,6 +23,7 @@ use crate::screen::ScreenInstruction; use crate::session_layout_metadata::SessionLayoutMetadata; use crate::{pty::PtyInstruction, thread_bus::Bus, ClientId, ServerInstruction}; use zellij_utils::data::PaneRenderReport; +use zellij_utils::input::layout::TabLayoutInfo; pub use wasm_bridge::PluginRenderAsset; use wasm_bridge::WasmBridge; @@ -31,18 +32,15 @@ use async_std::{channel, future::timeout, task}; use zellij_utils::{ data::{ ClientInfo, CommandOrPlugin, Event, EventType, FloatingPaneCoordinates, InputMode, - MessageToPlugin, PermissionStatus, PermissionType, PipeMessage, PipeSource, - PluginCapabilities, WebServerStatus, + LayoutInfo, LayoutWithError, MessageToPlugin, PermissionStatus, PermissionType, + PipeMessage, PipeSource, PluginCapabilities, WebServerStatus, }, errors::{prelude::*, ContextType, PluginContext}, input::{ actions::Action, command::TerminalAction, keybinds::Keybinds, - layout::{ - FloatingPaneLayout, Layout, Run, RunPlugin, RunPluginOrAlias, SwapFloatingLayout, - SwapTiledLayout, TiledPaneLayout, - }, + layout::{FloatingPaneLayout, Layout, Run, RunPlugin, RunPluginOrAlias, TiledPaneLayout}, plugins::PluginAliases, }, ipc::ClientAttributes, @@ -53,6 +51,12 @@ use zellij_utils::{ pub type PluginId = u32; #[derive(Clone, Debug)] +pub struct DumpSessionLayoutResponse { + pub layout_result: Result<String, String>, + pub metadata: Option<zellij_utils::data::LayoutMetadata>, +} + +#[derive(Clone, Debug)] pub enum PluginInstruction { Load( Option<bool>, // should float @@ -100,13 +104,9 @@ pub enum PluginInstruction { OverrideLayout( Option<PathBuf>, // cwd Option<TerminalAction>, // default_shell - TiledPaneLayout, - Vec<FloatingPaneLayout>, - Option<Vec<SwapTiledLayout>>, - Option<Vec<SwapFloatingLayout>>, - bool, // retain_existing_terminal_panes - bool, // retain_existing_plugin_panes - usize, // tab_index + Vec<TabLayoutInfo>, // layouts for each tab + bool, // retain_existing_terminal_panes + bool, // retain_existing_plugin_panes ClientId, Option<NotificationEnd>, ), @@ -140,7 +140,11 @@ pub enum PluginInstruction { ), DumpLayout(SessionLayoutMetadata, ClientId, Option<NotificationEnd>), ListClientsMetadata(SessionLayoutMetadata, ClientId, Option<NotificationEnd>), - DumpLayoutToPlugin(SessionLayoutMetadata, PluginId), + DumpLayoutToPlugin { + session_layout_metadata: SessionLayoutMetadata, + plugin_id: PluginId, + response_channel: crossbeam::channel::Sender<DumpSessionLayoutResponse>, + }, LogLayoutToHd(SessionLayoutMetadata), CliPipe { pipe_id: String, @@ -183,6 +187,7 @@ pub enum PluginInstruction { keybinds: Option<Keybinds>, default_mode: Option<InputMode>, default_shell: Option<TerminalAction>, + layout_dir: Option<PathBuf>, was_written_to_disk: bool, }, FailedToWriteConfigToDisk { @@ -200,6 +205,8 @@ pub enum PluginInstruction { terminal_id: Option<u32>, cli_client_id: Option<ClientId>, }, + LayoutListUpdate(Vec<LayoutInfo>, Vec<LayoutWithError>), + RequestStateUpdateForPlugin(PluginId), Exit, } @@ -241,7 +248,7 @@ impl From<&PluginInstruction> for PluginContext { PluginInstruction::UnblockCliPipes { .. } => PluginContext::UnblockCliPipes, PluginInstruction::WatchFilesystem => PluginContext::WatchFilesystem, PluginInstruction::KeybindPipe { .. } => PluginContext::KeybindPipe, - PluginInstruction::DumpLayoutToPlugin(..) => PluginContext::DumpLayoutToPlugin, + PluginInstruction::DumpLayoutToPlugin { .. } => PluginContext::DumpLayoutToPlugin, PluginInstruction::Reconfigure { .. } => PluginContext::Reconfigure, PluginInstruction::FailedToWriteConfigToDisk { .. } => { PluginContext::FailedToWriteConfigToDisk @@ -252,6 +259,10 @@ impl From<&PluginInstruction> for PluginContext { PluginInstruction::FailedToStartWebServer(..) => PluginContext::FailedToStartWebServer, PluginInstruction::PaneRenderReport(..) => PluginContext::PaneRenderReport, PluginInstruction::UserInput { .. } => PluginContext::UserInput, + PluginInstruction::LayoutListUpdate(..) => PluginContext::LayoutListUpdate, + PluginInstruction::RequestStateUpdateForPlugin(..) => { + PluginContext::RequestStateUpdateForPlugin + }, } } } @@ -262,6 +273,8 @@ pub(crate) fn plugin_thread_main( data_dir: PathBuf, mut layout: Box<Layout>, layout_dir: Option<PathBuf>, + available_layouts: Vec<LayoutInfo>, + available_layout_errors: Vec<LayoutWithError>, path_to_default_shell: PathBuf, zellij_cwd: PathBuf, capabilities: PluginCapabilities, @@ -297,6 +310,8 @@ pub(crate) fn plugin_thread_main( default_shell, layout.clone(), layout_dir, + available_layouts, + available_layout_errors, default_mode, default_keybinds, ); @@ -592,17 +607,13 @@ pub(crate) fn plugin_thread_main( PluginInstruction::OverrideLayout( cwd, default_shell, - mut tiled_layout, - mut floating_layouts, - swap_tiled_layouts, - swap_floating_layouts, + tab_layouts, retain_existing_terminal_panes, retain_existing_plugin_panes, - tab_index, client_id, completion_tx, ) => { - // Prefer connected clients to avoid opening plugins in background for CLI clients + // 1. Prefer connected clients over CLI clients let client_id = if wasm_bridge.client_is_connected(&client_id) { client_id } else if let Some(first_client_id) = wasm_bridge.get_first_client_id() { @@ -611,70 +622,78 @@ pub(crate) fn plugin_thread_main( client_id }; - let mut plugin_ids: HashMap<RunPluginOrAlias, Vec<PluginId>> = HashMap::new(); + // 2. Process each tab layout + let mut tab_layouts_with_plugin_ids = Vec::new(); - // Populate plugin aliases in layouts - tiled_layout.populate_plugin_aliases_in_layout(&plugin_aliases); - floating_layouts.iter_mut().for_each(|f| { - f.run - .as_mut() - .map(|f| f.populate_run_plugin_if_needed(&plugin_aliases)); - }); + for mut tab_layout_info in tab_layouts { + // Populate plugin aliases in layouts + tab_layout_info + .tiled_layout + .populate_plugin_aliases_in_layout(&plugin_aliases); + tab_layout_info.floating_layouts.iter_mut().for_each(|f| { + f.run + .as_mut() + .map(|r| r.populate_run_plugin_if_needed(&plugin_aliases)); + }); - // Extract run instructions from tiled layout - let extracted_run_instructions = tiled_layout.extract_run_instructions(); + // Extract run instructions from tiled layout + let extracted_run_instructions = + tab_layout_info.tiled_layout.extract_run_instructions(); - // Extract run instructions from floating layouts (excluding already_running) - let extracted_floating_plugins: Vec<Option<Run>> = floating_layouts - .iter() - .filter(|f| !f.already_running) - .map(|f| f.run.clone()) - .collect(); + // Extract run instructions from floating layouts (excluding already_running) + let extracted_floating_plugins: Vec<Option<Run>> = tab_layout_info + .floating_layouts + .iter() + .filter(|f| !f.already_running) + .map(|f| f.run.clone()) + .collect(); - // Combine all run instructions - let mut all_run_instructions = extracted_run_instructions; - all_run_instructions.extend(extracted_floating_plugins); + // Combine all run instructions + let mut all_run_instructions = extracted_run_instructions; + all_run_instructions.extend(extracted_floating_plugins); - // Load plugins for all Run::Plugin instructions - let size = Size::default(); - for run_instruction in all_run_instructions { - if let Some(Run::Plugin(run_plugin_or_alias)) = run_instruction { - let run_plugin = run_plugin_or_alias.get_run_plugin(); - let cwd = run_plugin_or_alias.get_initial_cwd(); - let skip_cache = false; - match wasm_bridge.load_plugin( - &run_plugin, - Some(tab_index), - size, - cwd, - skip_cache, - Some(client_id), - ) { - Ok((plugin_id, _client_id)) => { - plugin_ids - .entry(run_plugin_or_alias.clone()) - .or_default() - .push(plugin_id); - }, - Err(e) => { - log::error!("Failed to load plugin: {}", e); - }, + // Load plugins for all Run::Plugin instructions + let mut plugin_ids: HashMap<RunPluginOrAlias, Vec<PluginId>> = HashMap::new(); + let size = Size::default(); + + for run_instruction in all_run_instructions { + if let Some(Run::Plugin(run_plugin_or_alias)) = run_instruction { + let run_plugin = run_plugin_or_alias.get_run_plugin(); + let cwd = run_plugin_or_alias.get_initial_cwd(); + let skip_cache = false; + + match wasm_bridge.load_plugin( + &run_plugin, + Some(tab_layout_info.tab_index), + size, + cwd, + skip_cache, + Some(client_id), + ) { + Ok((plugin_id, _client_id)) => { + plugin_ids + .entry(run_plugin_or_alias.clone()) + .or_default() + .push(plugin_id); + }, + Err(e) => { + log::error!("Failed to load plugin: {}", e); + }, + } } } + + // Pair this tab's layout with its plugin IDs + tab_layouts_with_plugin_ids.push((tab_layout_info, plugin_ids)); } - // Send to pty thread + // 3. Send to pty thread with all tab layouts and their plugin IDs drop(bus.senders.send_to_pty(PtyInstruction::OverrideLayout( cwd, default_shell, - tiled_layout, - floating_layouts, - swap_tiled_layouts, - swap_floating_layouts, + tab_layouts_with_plugin_ids, retain_existing_terminal_panes, retain_existing_plugin_panes, - tab_index, - plugin_ids, client_id, completion_tx, ))); @@ -756,6 +775,7 @@ pub(crate) fn plugin_thread_main( &mut session_layout_metadata, &wasm_bridge, &plugin_aliases, + None, ); drop(bus.senders.send_to_pty(PtyInstruction::DumpLayout( session_layout_metadata, @@ -772,6 +792,7 @@ pub(crate) fn plugin_thread_main( &mut session_layout_metadata, &wasm_bridge, &plugin_aliases, + None, ); drop(bus.senders.send_to_pty(PtyInstruction::ListClientsMetadata( session_layout_metadata, @@ -779,16 +800,33 @@ pub(crate) fn plugin_thread_main( completion_tx, ))); }, - PluginInstruction::DumpLayoutToPlugin(mut session_layout_metadata, plugin_id) => { + PluginInstruction::DumpLayoutToPlugin { + mut session_layout_metadata, + plugin_id, + response_channel, + } => { populate_session_layout_metadata( &mut session_layout_metadata, &wasm_bridge, &plugin_aliases, + Some(plugin_id), ); + + let layout_metadata = session_layout_metadata.to_layout_metadata(); + match session_serialization::serialize_session_layout( session_layout_metadata.into(), ) { Ok((layout, _pane_contents)) => { + // send synchronous response + let response = DumpSessionLayoutResponse { + layout_result: Ok(layout.clone()), + metadata: Some(layout_metadata), + }; + let _ = response_channel.send(response); + + // send CustomMessage to plugin (backwards compatibility, should get rid of + // this on API version upgrade) let updates = vec![( Some(plugin_id), None, @@ -797,13 +835,17 @@ pub(crate) fn plugin_thread_main( wasm_bridge.update_plugins(updates, shutdown_send.clone())?; }, Err(e) => { + let error_msg = format!("{}", e); + let response = DumpSessionLayoutResponse { + layout_result: Err(error_msg.clone()), + metadata: None, + }; + let _ = response_channel.send(response); + let updates = vec![( Some(plugin_id), None, - Event::CustomMessage( - "session_layout_error".to_owned(), - format!("{}", e), - ), + Event::CustomMessage("session_layout_error".to_owned(), error_msg), )]; wasm_bridge.update_plugins(updates, shutdown_send.clone())?; }, @@ -818,6 +860,7 @@ pub(crate) fn plugin_thread_main( &mut session_layout_metadata, &wasm_bridge, &plugin_aliases, + Some(plugin_id), ); let mut clients_metadata = session_layout_metadata.all_clients_metadata(); let mut client_list_for_plugin = vec![]; @@ -843,6 +886,7 @@ pub(crate) fn plugin_thread_main( &mut session_layout_metadata, &wasm_bridge, &plugin_aliases, + None, ); drop( bus.senders @@ -1076,10 +1120,11 @@ pub(crate) fn plugin_thread_main( keybinds, default_mode, default_shell, + layout_dir, was_written_to_disk, } => { wasm_bridge - .reconfigure(client_id, keybinds, default_mode, default_shell) + .reconfigure(client_id, keybinds, default_mode, default_shell, layout_dir) .non_fatal(); // TODO: notify plugins that this happened so that they can eg. rebind temporary keys that // were lost @@ -1152,6 +1197,12 @@ pub(crate) fn plugin_thread_main( )]; wasm_bridge.update_plugins(updates, shutdown_send.clone())?; }, + PluginInstruction::LayoutListUpdate(layouts, errors) => { + wasm_bridge.update_available_layouts(layouts, errors); + }, + PluginInstruction::RequestStateUpdateForPlugin(plugin_id) => { + wasm_bridge.state_update_for_plugin(plugin_id); + }, PluginInstruction::Exit => { break; }, @@ -1188,7 +1239,13 @@ fn populate_session_layout_metadata( session_layout_metadata: &mut SessionLayoutMetadata, wasm_bridge: &WasmBridge, plugin_aliases: &PluginAliases, + exclude_plugin_id: Option<u32>, ) { + // Remove the requesting plugin from the layout to prevent deadlock + if let Some(plugin_id) = exclude_plugin_id { + session_layout_metadata.remove_plugin_from_layout(plugin_id); + } + let plugin_ids = session_layout_metadata.all_plugin_ids(); let mut plugin_ids_to_cmds: HashMap<u32, RunPlugin> = HashMap::new(); for plugin_id in plugin_ids { diff --git a/zellij-server/src/plugins/plugin_map.rs b/zellij-server/src/plugins/plugin_map.rs index 7520114f..6f57deff 100644 --- a/zellij-server/src/plugins/plugin_map.rs +++ b/zellij-server/src/plugins/plugin_map.rs @@ -406,6 +406,9 @@ impl RunningPlugin { pub fn update_default_shell(&mut self, default_shell: Option<TerminalAction>) { self.store.data_mut().default_shell = default_shell; } + pub fn update_layout_dir(&mut self, layout_dir: Option<PathBuf>) { + self.store.data_mut().layout_dir = layout_dir; + } pub fn intercepting_key_presses(&self) -> bool { self.store.data().intercepting_key_presses } diff --git a/zellij-server/src/plugins/unit/plugin_tests.rs b/zellij-server/src/plugins/unit/plugin_tests.rs index c0368fbc..3dfa288f 100644 --- a/zellij-server/src/plugins/unit/plugin_tests.rs +++ b/zellij-server/src/plugins/unit/plugin_tests.rs @@ -338,6 +338,7 @@ fn create_plugin_thread( config.set_max_recursion_depth(1000); let engine = Engine::new(&config); let data_dir = PathBuf::from(tempdir().unwrap().path()); + let layout_dir = PathBuf::from(tempdir().unwrap().path()); let default_shell = PathBuf::from("."); let plugin_capabilities = PluginCapabilities::default(); let client_attributes = ClientAttributes::default(); @@ -362,7 +363,9 @@ fn create_plugin_thread( engine, data_dir, Box::new(Layout::default()), - None, + Some(layout_dir), + vec![], + vec![], default_shell, zellij_cwd, plugin_capabilities, @@ -450,6 +453,8 @@ fn create_plugin_thread_with_server_receiver( data_dir, Box::new(Layout::default()), None, + vec![], + vec![], default_shell, zellij_cwd, plugin_capabilities, @@ -485,6 +490,7 @@ fn create_plugin_thread_with_server_receiver( fn create_plugin_thread_with_pty_receiver( zellij_cwd: Option<PathBuf>, + layout_dir: Option<PathBuf>, ) -> ( SenderWithContext<PluginInstruction>, Receiver<(PtyInstruction, ErrorContext)>, @@ -528,6 +534,7 @@ fn create_plugin_thread_with_pty_receiver( config.set_max_recursion_depth(1000); let engine = Engine::new(&config); let data_dir = PathBuf::from(tempdir().unwrap().path()); + let layout_dir = layout_dir.unwrap_or_else(|| PathBuf::from(tempdir().unwrap().path())); let default_shell = PathBuf::from("."); let plugin_capabilities = PluginCapabilities::default(); let client_attributes = ClientAttributes::default(); @@ -542,7 +549,9 @@ fn create_plugin_thread_with_pty_receiver( engine, data_dir, Box::new(Layout::default()), - None, + Some(layout_dir), + vec![], + vec![], default_shell, zellij_cwd, plugin_capabilities, @@ -631,6 +640,8 @@ fn create_plugin_thread_with_background_jobs_receiver( data_dir, Box::new(Layout::default()), None, + vec![], + vec![], default_shell, zellij_cwd, plugin_capabilities, @@ -4220,7 +4231,7 @@ pub fn open_file_floating_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -4306,7 +4317,7 @@ pub fn open_file_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -4392,7 +4403,7 @@ pub fn open_file_with_line_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -4479,7 +4490,7 @@ pub fn open_file_with_line_floating_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -4565,7 +4576,7 @@ pub fn open_terminal_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -4647,7 +4658,7 @@ pub fn open_terminal_floating_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -4729,7 +4740,7 @@ pub fn open_command_pane_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -4811,7 +4822,7 @@ pub fn open_command_pane_floating_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -7250,7 +7261,7 @@ pub fn open_command_pane_background_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let plugin_should_float = Some(false); let plugin_title = Some("test_plugin".to_owned()); let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { @@ -9462,7 +9473,7 @@ pub fn send_sigint_to_pane_id_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let received_pty_instructions = Arc::new(Mutex::new(vec![])); let client_id = 1; @@ -9551,7 +9562,7 @@ pub fn send_sigkill_to_pane_id_plugin_command() { let plugin_host_folder = PathBuf::from(temp_folder.path()); let cache_path = plugin_host_folder.join("permissions_test.kdl"); let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = - create_plugin_thread_with_pty_receiver(Some(plugin_host_folder)); + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), None); let received_pty_instructions = Arc::new(Mutex::new(vec![])); let client_id = 1; @@ -9804,3 +9815,2018 @@ pub fn run_action_without_permission() { // Should be None because permission was denied assert_snapshot!(format!("{:#?}", move_focus_instruction)); } + +#[test] +#[ignore] +pub fn generate_random_name_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+A to trigger generate_random_name() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('a')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Generated name") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn dump_layout_success_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+B to trigger dump_layout("default") + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('b')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Layout dump") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn dump_layout_not_found_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+C to trigger dump_layout("nonexistent_layout_xyz") + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('c')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Layout dump error") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn get_layout_dir_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+D to trigger get_layout_dir() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('d')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Got layout folder") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn get_focused_pane_info_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread_struct_variant!( + received_screen_instructions, + ScreenInstruction::GetFocusedPaneInfo, + screen_receiver, + 1, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+E to trigger get_focused_pane_info() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('e')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let focused_pane_info_query = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::GetFocusedPaneInfo { .. } = i { + return Some(i.clone()); + } else { + None + } + }); + assert_snapshot!(format!("{:#?}", focused_pane_info_query)); +} + +#[test] +#[ignore] +pub fn dump_session_layout_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+F to trigger dump_session_layout() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('f')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let dump_layout_instruction = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::DumpLayoutToPlugin { .. } = i { + return Some(i.clone()); + } + None + }); + assert_snapshot!(format!("{:#?}", dump_layout_instruction)); +} + +#[test] +#[ignore] +pub fn dump_session_layout_for_tab_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+G to trigger dump_session_layout_for_tab(0) + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('g')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let dump_layout_instruction = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::DumpLayoutToPlugin { .. } = i { + return Some(i.clone()); + } + None + }); + assert_snapshot!(format!("{:#?}", dump_layout_instruction)); +} + +#[test] +#[ignore] +pub fn parse_layout_success_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+H to trigger parse_layout() with valid KDL + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('h')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Parse success") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn parse_layout_error_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+I to trigger parse_layout() with invalid KDL + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('i')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Parse error:") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn save_layout_success_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+A to trigger save_layout() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('a')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Save layout") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn save_layout_already_exists_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 3, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+A to save the layout first time + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('a')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Send Ctrl+Alt+B to try saving without overwrite + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('b')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .rev() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("error") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn save_layout_with_overwrite_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+C to save with overwrite=true + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('c')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Save layout") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn save_layout_invalid_kdl_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+D to save invalid KDL + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('d')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("invalid layout error") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn rename_layout_success_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 3, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+A to save layout first + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('a')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Send Ctrl+Alt+E to rename layout + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('e')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .rev() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Rename layout") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn rename_layout_not_found_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+F to rename nonexistent layout + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('f')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Rename") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn delete_layout_success_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 3, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+A to save layout first + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('a')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Send Ctrl+Alt+E to rename layout + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('e')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Send Ctrl+Alt+G to delete renamed layout + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('g')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .rev() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Delete layout") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn delete_layout_not_found_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+H to delete nonexistent layout + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('h')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Delete") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn save_layout_path_traversal_blocked_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+I to test path traversal blocking + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('i')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_event = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Path traversal") { + return Some(plugin_bytes); + } + } + } + None + }); + assert_snapshot!(format!("{:#?}", plugin_bytes_event)); +} + +#[test] +#[ignore] +pub fn edit_layout_plugin_command() { + let temp_host_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_host_folder.path()); + let temp_layout_folder = tempdir().unwrap(); + let layout_folder_path = PathBuf::from(temp_layout_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, pty_receiver, screen_receiver, teardown) = + create_plugin_thread_with_pty_receiver(Some(plugin_host_folder), Some(layout_folder_path)); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let received_pty_instructions = Arc::new(Mutex::new(vec![])); + let pty_thread = log_actions_in_thread!( + received_pty_instructions, + PtyInstruction::SpawnInPlaceTerminal, + pty_receiver, + 1 + ); + let _screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 3, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+J to trigger edit_layout() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('j')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + pty_thread.join().unwrap(); + teardown(); + + // this is the editor pane for the layout being spawned + let spawn_terminal_instruction = + received_pty_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let PtyInstruction::SpawnInPlaceTerminal(..) = i { + Some(i.clone()) + } else { + None + } + }); + + assert_snapshot!(format!("{:#?}", spawn_terminal_instruction).replace( + &format!("{}", temp_layout_folder.path().display().to_string()), + "LAYOUT_FOLDER_PATH" + )) +} + +#[test] +#[ignore] +pub fn override_layout_plugin_command() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let screen_thread = grant_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::OverrideLayout, + screen_receiver, + 1, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + 1 + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let client_id = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+K to trigger override_layout() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('k')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + screen_thread.join().unwrap(); + teardown(); + + let override_layout_instruction = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::OverrideLayout(..) = i { + Some(i.clone()) + } else { + None + } + }); + assert_snapshot!(format!("{:#?}", override_layout_instruction)); +} + +#[test] +#[ignore] +pub fn generate_random_name_permission_denied() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let client_id = 1; + + let screen_thread = deny_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ReadApplicationState, + cache_path, + plugin_thread_sender, + client_id + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Shift+A to trigger generate_random_name() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('a')) + .with_ctrl_modifier() + .with_shift_modifier(), + ), + )])); + + // Give it time to potentially (incorrectly) send the output + std::thread::sleep(std::time::Duration::from_millis(1000)); + + let _ = plugin_thread_sender.send(PluginInstruction::Exit); + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_with_name = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Generated name:") { + return Some(plugin_bytes); + } + } + } + None + }); + + // Should be None because permission was denied + assert_snapshot!(format!("{:#?}", plugin_bytes_with_name)); +} + +#[test] +#[ignore] +pub fn save_layout_permission_denied() { + let temp_folder = tempdir().unwrap(); + let plugin_host_folder = PathBuf::from(temp_folder.path()); + let cache_path = plugin_host_folder.join("permissions_test.kdl"); + let (plugin_thread_sender, screen_receiver, teardown) = + create_plugin_thread(Some(plugin_host_folder.clone())); + + let received_screen_instructions = Arc::new(Mutex::new(vec![])); + let client_id = 1; + + let screen_thread = deny_permissions_and_log_actions_in_thread!( + received_screen_instructions, + ScreenInstruction::PluginBytes, + screen_receiver, + 2, + &PermissionType::ChangeApplicationState, + cache_path, + plugin_thread_sender, + client_id + ); + + let plugin_should_float = Some(false); + let plugin_title = Some("test_plugin".to_owned()); + let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin { + _allow_exec_host_cmd: false, + location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)), + configuration: Default::default(), + ..Default::default() + }); + let tab_index = 1; + let size = Size { + cols: 121, + rows: 20, + }; + + let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id)); + let _ = plugin_thread_sender.send(PluginInstruction::Load( + plugin_should_float, + false, + plugin_title, + run_plugin, + Some(tab_index), + None, + client_id, + size, + None, + None, + false, + None, + None, + None, + )); + + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Send Ctrl+Alt+A to trigger save_layout() + let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![( + None, + Some(client_id), + Event::Key( + KeyWithModifier::new(BareKey::Char('a')) + .with_ctrl_modifier() + .with_alt_modifier(), + ), + )])); + + // Give it time to potentially (incorrectly) send the output + std::thread::sleep(std::time::Duration::from_millis(1000)); + + let _ = plugin_thread_sender.send(PluginInstruction::Exit); + screen_thread.join().unwrap(); + teardown(); + + let plugin_bytes_with_save = received_screen_instructions + .lock() + .unwrap() + .iter() + .find_map(|i| { + if let ScreenInstruction::PluginBytes(plugin_render_assets) = i { + for plugin_render_asset in plugin_render_assets { + let plugin_bytes = plugin_render_asset.bytes.clone(); + let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string(); + if plugin_bytes.contains("Save layout") { + return Some(plugin_bytes); + } + } + } + None + }); + + // Should be None because permission was denied + assert_snapshot!(format!("{:#?}", plugin_bytes_with_save)); +} diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__delete_layout_not_found_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__delete_layout_not_found_plugin_command.snap new file mode 100644 index 00000000..470461a5 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__delete_layout_not_found_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Delete nonexistent layout error: Layout 'nonexistent_layout' not found\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__delete_layout_success_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__delete_layout_success_plugin_command.snap new file mode 100644 index 00000000..1af0a4de --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__delete_layout_success_plugin_command.snap @@ -0,0 +1,5 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +None diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_layout_not_found_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_layout_not_found_plugin_command.snap new file mode 100644 index 00000000..aac26703 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_layout_not_found_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Layout dump error: IoError: The layout was not found, File: nonexistent_layout_xyz\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_layout_success_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_layout_success_plugin_command.snap new file mode 100644 index 00000000..df2ce697 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_layout_success_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Layout dump success: layout {\n\r pane size=1 borderless=true {\n\r plugin location=\"tab-bar\"\n\r }\n\r pane\n\r pane size=1 borderless=true {\n\r plugin location=\"status-bar\"\n\r }\n\r}\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_session_layout_for_tab_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_session_layout_for_tab_plugin_command.snap new file mode 100644 index 00000000..2299bca7 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_session_layout_for_tab_plugin_command.snap @@ -0,0 +1,13 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", dump_layout_instruction)" +--- +Some( + DumpLayoutToPlugin { + plugin_id: 0, + tab_index: Some( + 0, + ), + response_channel: Sender { .. }, + }, +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_session_layout_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_session_layout_plugin_command.snap new file mode 100644 index 00000000..95b6b784 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_session_layout_plugin_command.snap @@ -0,0 +1,11 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", dump_layout_instruction)" +--- +Some( + DumpLayoutToPlugin { + plugin_id: 0, + tab_index: None, + response_channel: Sender { .. }, + }, +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__edit_layout_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__edit_layout_plugin_command.snap new file mode 100644 index 00000000..611f2015 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__edit_layout_plugin_command.snap @@ -0,0 +1,45 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\",\nspawn_terminal_instruction).replace(&format!(\"{}\",\ntemp_layout_folder.path().display().to_string()), \"LAYOUT_FOLDER_PATH\")" +--- +Some( + SpawnInPlaceTerminal( + Some( + OpenFile( + OpenFilePayload { + path: "LAYOUT_FOLDER_PATH/test_layout2.kdl", + line_number: None, + cwd: Some( + "LAYOUT_FOLDER_PATH", + ), + originating_plugin: Some( + OriginatingPlugin { + plugin_id: 0, + client_id: 1, + context: { + "test_key": "test_value", + }, + }, + ), + }, + ), + ), + Some( + "Editing: LAYOUT_FOLDER_PATH/test_layout2.kdl", + ), + false, + PaneId( + Plugin( + 0, + ), + ), + Some( + NotificationEnd { + channel: None, + exit_status: None, + unblock_condition: None, + affected_pane_id: None, + }, + ), + ), +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__generate_random_name_permission_denied.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__generate_random_name_permission_denied.snap new file mode 100644 index 00000000..1383e484 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__generate_random_name_permission_denied.snap @@ -0,0 +1,5 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_with_name)" +--- +None diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__generate_random_name_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__generate_random_name_plugin_command.snap new file mode 100644 index 00000000..78ff0528 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__generate_random_name_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Generated name\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__get_focused_pane_info_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__get_focused_pane_info_plugin_command.snap new file mode 100644 index 00000000..bd6d78eb --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__get_focused_pane_info_plugin_command.snap @@ -0,0 +1,10 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", focused_pane_info_query)" +--- +Some( + GetFocusedPaneInfo { + client_id: 1, + response_channel: Sender { .. }, + }, +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__get_layout_dir_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__get_layout_dir_plugin_command.snap new file mode 100644 index 00000000..8d421c3f --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__get_layout_dir_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Got layout folder\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap new file mode 100644 index 00000000..24de4279 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap @@ -0,0 +1,1977 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", override_layout_instruction)" +--- +Some( + OverrideLayout( + None, + None, + [ + TabLayoutInfo { + tab_index: 0, + tab_name: None, + tiled_layout: TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: true, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + floating_layouts: [], + swap_tiled_layouts: Some( + [ + ( + { + MaxPanes( + 4, + ): TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: Some( + 0, + ), + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: true, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + MaxPanes( + 7, + ): TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: Some( + 0, + ), + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: true, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + MaxPanes( + 11, + ): TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: Some( + 0, + ), + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: true, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + }, + Some( + "vertical", + ), + ), + ( + { + MaxPanes( + 3, + ): TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: true, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + MaxPanes( + 7, + ): TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: Some( + 0, + ), + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: true, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + MaxPanes( + 11, + ): TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: Some( + 0, + ), + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: true, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + }, + Some( + "horizontal", + ), + ), + ( + { + MinPanes( + 4, + ): TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Vertical, + name: None, + children: [ + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: Some( + 0, + ), + children_are_stacked: true, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + TiledPaneLayout { + children_split_direction: Horizontal, + name: None, + children: [], + split_size: Some( + Fixed( + 1, + ), + ), + run: Some( + Plugin( + Alias( + PluginAlias { + name: "compact-bar", + configuration: Some( + PluginUserConfiguration( + {}, + ), + ), + initial_cwd: None, + run_plugin: None, + }, + ), + ), + ), + borderless: true, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + ], + split_size: None, + run: None, + borderless: false, + focus: None, + external_children_index: None, + children_are_stacked: false, + is_expanded_in_stack: false, + exclude_from_sync: None, + run_instructions_to_ignore: [], + hide_floating_panes: false, + pane_initial_contents: None, + }, + }, + Some( + "stacked", + ), + ), + ], + ), + swap_floating_layouts: Some( + [ + ( + { + NoConstraint: [], + }, + Some( + "staggered", + ), + ), + ( + { + MaxPanes( + 10, + ): [ + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 1, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 2, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 3, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 4, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 5, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 6, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 7, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 8, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Percent( + 5, + ), + ), + y: Some( + Fixed( + 9, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 90, + ), + ), + width: Some( + Percent( + 90, + ), + ), + x: Some( + Fixed( + 10, + ), + ), + y: Some( + Fixed( + 10, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + ], + }, + Some( + "enlarged", + ), + ), + ( + { + MaxPanes( + 1, + ): [ + FloatingPaneLayout { + name: None, + height: None, + width: None, + x: Some( + Percent( + 50, + ), + ), + y: Some( + Percent( + 50, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + ], + MaxPanes( + 2, + ): [ + FloatingPaneLayout { + name: None, + height: None, + width: Some( + Percent( + 45, + ), + ), + x: Some( + Percent( + 1, + ), + ), + y: Some( + Percent( + 25, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: None, + width: Some( + Percent( + 45, + ), + ), + x: Some( + Percent( + 50, + ), + ), + y: Some( + Percent( + 25, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + ], + MaxPanes( + 3, + ): [ + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 45, + ), + ), + width: Some( + Percent( + 45, + ), + ), + x: None, + y: Some( + Percent( + 55, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: None, + width: Some( + Percent( + 45, + ), + ), + x: Some( + Percent( + 1, + ), + ), + y: Some( + Percent( + 1, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: None, + width: Some( + Percent( + 45, + ), + ), + x: Some( + Percent( + 50, + ), + ), + y: Some( + Percent( + 1, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + ], + MaxPanes( + 4, + ): [ + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 45, + ), + ), + width: Some( + Percent( + 45, + ), + ), + x: Some( + Percent( + 1, + ), + ), + y: Some( + Percent( + 55, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 45, + ), + ), + width: Some( + Percent( + 45, + ), + ), + x: Some( + Percent( + 50, + ), + ), + y: Some( + Percent( + 55, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 45, + ), + ), + width: Some( + Percent( + 45, + ), + ), + x: Some( + Percent( + 1, + ), + ), + y: Some( + Percent( + 1, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + FloatingPaneLayout { + name: None, + height: Some( + Percent( + 45, + ), + ), + width: Some( + Percent( + 45, + ), + ), + x: Some( + Percent( + 50, + ), + ), + y: Some( + Percent( + 1, + ), + ), + pinned: None, + run: None, + focus: None, + already_running: false, + pane_initial_contents: None, + logical_position: None, + }, + ], + }, + Some( + "spread", + ), + ), + ], + ), + }, + ], + false, + false, + false, + 1, + Some( + NotificationEnd { + channel: None, + exit_status: None, + unblock_condition: None, + affected_pane_id: None, + }, + ), + ), +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__parse_layout_error_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__parse_layout_error_plugin_command.snap new file mode 100644 index 00000000..8e665541 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__parse_layout_error_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Parse error: KdlError { kdl_error: KdlError { error_message: \"Failed to deserialize KDL node. \\nPossible reasons:\\n- Missing `;` after a node name, eg. { node; another_node; }\\n- Missing quotations (\\\") around an argument node eg. { first_node \\\"argument_node\\\"; }\\n- Missing an equal sign (=) between node arguments on a title line. eg. argument=\\\"value\\\"\\n- Found an extraneous equal sign (=) between node child arguments and their values. eg. { argument=\\\"value\\\" }\", src: None, offset: Some(9), len: Some(5), help_message: None }, file_name: \"parse_layout_api\", source_code: \"layout { this is not valid kdl }\" }\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__parse_layout_success_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__parse_layout_success_plugin_command.snap new file mode 100644 index 00000000..73583531 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__parse_layout_success_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Parse success\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__rename_layout_not_found_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__rename_layout_not_found_plugin_command.snap new file mode 100644 index 00000000..59ff10a3 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__rename_layout_not_found_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Rename nonexistent layout error: Layout 'nonexistent_layout' not found\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__rename_layout_success_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__rename_layout_success_plugin_command.snap new file mode 100644 index 00000000..8a4810a0 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__rename_layout_success_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Rename layout success\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_already_exists_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_already_exists_plugin_command.snap new file mode 100644 index 00000000..a4487819 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_already_exists_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Save layout error: Layout file 'test_layout' already exists. Use overwrite flag to replace it.\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_invalid_kdl_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_invalid_kdl_plugin_command.snap new file mode 100644 index 00000000..0e02eefd --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_invalid_kdl_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Save invalid layout error: Invalid layout KDL: KdlError(KdlError { error_message: \"Failed to deserialize KDL node. \\nPossible reasons:\\n- Missing `;` after a node name, eg. { node; another_node; }\\n- Missing quotations (\\\") around an argument node eg. { first_node \\\"argument_node\\\"; }\\n- Missing an equal sign (=) between node arguments on a title line. eg. argument=\\\"value\\\"\\n- Found an extraneous equal sign (=) between node child arguments and their values. eg. { argument=\\\"value\\\" }\", src: Some(NamedSource { name: \"invalid_layout.kdl\", source: \"<redacted>\"), offset: Some(0), len: Some(4), help_message: None })\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_path_traversal_blocked_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_path_traversal_blocked_plugin_command.snap new file mode 100644 index 00000000..8c950759 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_path_traversal_blocked_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Path traversal save error: Layout name cannot contain path separators\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_permission_denied.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_permission_denied.snap new file mode 100644 index 00000000..e8605f1e --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_permission_denied.snap @@ -0,0 +1,5 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_with_save)" +--- +None diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_success_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_success_plugin_command.snap new file mode 100644 index 00000000..ba23455c --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_success_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Save layout success\n\r", +) diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_with_overwrite_plugin_command.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_with_overwrite_plugin_command.snap new file mode 100644 index 00000000..11f09285 --- /dev/null +++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__save_layout_with_overwrite_plugin_command.snap @@ -0,0 +1,7 @@ +--- +source: zellij-server/src/plugins/./unit/plugin_tests.rs +expression: "format!(\"{:#?}\", plugin_bytes_event)" +--- +Some( + "Save layout with overwrite success\n\r", +) diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs index 2453a0a1..bdbe25f3 100644 --- a/zellij-server/src/plugins/wasm_bridge.rs +++ b/zellij-server/src/plugins/wasm_bridge.rs @@ -23,8 +23,8 @@ use url::Url; use wasmi::{Engine, Module}; use zellij_utils::consts::{ZELLIJ_CACHE_DIR, ZELLIJ_SESSION_CACHE_DIR, ZELLIJ_TMP_DIR}; use zellij_utils::data::{ - FloatingPaneCoordinates, InputMode, PaneContents, PaneRenderReport, PermissionStatus, - PermissionType, PipeMessage, PipeSource, + FloatingPaneCoordinates, InputMode, LayoutInfo, LayoutWithError, PaneContents, + PaneRenderReport, PermissionStatus, PermissionType, PipeMessage, PipeSource, }; use zellij_utils::downloader::Downloader; use zellij_utils::input::keybinds::Keybinds; @@ -179,6 +179,8 @@ pub struct WasmBridge { HashMap<RunPluginLocation, HashMap<PluginUserConfiguration, Vec<(PluginId, ClientId)>>>, pending_pipes: PendingPipes, layout_dir: Option<PathBuf>, + available_layouts: Vec<LayoutInfo>, + available_layout_errors: Vec<LayoutWithError>, default_mode: InputMode, default_keybinds: Keybinds, keybinds: HashMap<ClientId, Keybinds>, @@ -199,6 +201,8 @@ impl WasmBridge { default_shell: Option<TerminalAction>, default_layout: Box<Layout>, layout_dir: Option<PathBuf>, + available_layouts: Vec<LayoutInfo>, + available_layout_errors: Vec<LayoutWithError>, default_mode: InputMode, default_keybinds: Keybinds, ) -> Self { @@ -240,6 +244,8 @@ impl WasmBridge { cached_plugin_map: HashMap::new(), pending_pipes: Default::default(), layout_dir, + available_layouts, + available_layout_errors, default_mode, default_keybinds, keybinds: HashMap::new(), @@ -1324,6 +1330,7 @@ impl WasmBridge { keybinds: Option<Keybinds>, default_mode: Option<InputMode>, default_shell: Option<TerminalAction>, + layout_dir: Option<PathBuf>, ) -> Result<()> { let plugins_to_reconfigure: Vec<(PluginId, Arc<Mutex<RunningPlugin>>)> = self .plugin_map @@ -1347,11 +1354,13 @@ impl WasmBridge { self.keybinds.insert(client_id, keybinds.clone()); } self.default_shell = default_shell.clone(); + self.layout_dir = layout_dir.clone(); for (plugin_id, running_plugin) in plugins_to_reconfigure { self.plugin_executor.execute_for_plugin(plugin_id, { let running_plugin = running_plugin.clone(); let keybinds = keybinds.clone(); let default_shell = default_shell.clone(); + let layout_dir = layout_dir.clone(); move |_senders, _plugin_map, _connected_clients, @@ -1366,6 +1375,7 @@ impl WasmBridge { running_plugin.update_default_mode(default_mode); } running_plugin.update_default_shell(default_shell); + running_plugin.update_layout_dir(layout_dir); } }); } @@ -1842,6 +1852,35 @@ impl WasmBridge { .next() .copied() } + pub fn update_available_layouts( + &mut self, + layouts: Vec<LayoutInfo>, + errors: Vec<LayoutWithError>, + ) { + // Diff with existing layouts + if self.available_layouts != layouts || self.available_layout_errors != errors { + // Update the stored layouts + self.available_layouts = layouts.clone(); + self.available_layout_errors = errors.clone(); + + // Notify all plugins of the change + let _ = self.senders.send_to_plugin(PluginInstruction::Update(vec![( + None, // Broadcast to all plugins + None, // Broadcast to all clients + Event::AvailableLayoutInfo(layouts, errors), + )])); + } + } + pub fn state_update_for_plugin(&self, plugin_id: PluginId) { + let _ = self.senders.send_to_plugin(PluginInstruction::Update(vec![( + Some(plugin_id), + None, + Event::AvailableLayoutInfo( + self.available_layouts.clone(), + self.available_layout_errors.clone(), + ), + )])); + } } fn handle_plugin_successful_loading( @@ -1852,6 +1891,7 @@ fn handle_plugin_successful_loading( let _ = senders.send_to_background_jobs(BackgroundJob::StopPluginLoadingAnimation(plugin_id)); let _ = senders.send_to_screen(ScreenInstruction::RequestStateUpdateForPlugins); let _ = senders.send_to_background_jobs(BackgroundJob::ReportPluginList(plugin_list)); + let _ = senders.send_to_plugin(PluginInstruction::RequestStateUpdateForPlugin(plugin_id)); } fn handle_plugin_loading_failure( @@ -1902,6 +1942,7 @@ fn check_event_permission( | Event::FailedToWriteConfigToDisk(..) | Event::CommandPaneReRun(..) | Event::CwdChanged(..) + | Event::AvailableLayoutInfo(..) | Event::InputReceived => PermissionType::ReadApplicationState, Event::WebServerStatus(..) => PermissionType::StartWebServer, Event::PaneRenderReport(..) => PermissionType::ReadPaneContents, diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index 035c278b..82e4dabf 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -20,12 +20,16 @@ use std::{ }; use wasmi::{Caller, Linker}; use zellij_utils::data::{ - CommandType, ConnectToSession, Event, FloatingPaneCoordinates, GetPanePidResponse, HttpVerb, - KeyWithModifier, LayoutInfo, MessageToPlugin, NewPanePlacement, OriginatingPlugin, - PaneScrollbackResponse, PermissionStatus, PermissionType, PluginPermission, + CommandType, ConnectToSession, DeleteLayoutResponse, EditLayoutResponse, Event, + FloatingPaneCoordinates, GetFocusedPaneInfoResponse, GetPanePidResponse, HttpVerb, + KeyWithModifier, LayoutInfo, LayoutMetadata, LayoutParsingError, MessageToPlugin, + NewPanePlacement, OriginatingPlugin, PaneScrollbackResponse, PermissionStatus, PermissionType, + PluginPermission, RenameLayoutResponse, SaveLayoutResponse, TabMetadata, }; +use zellij_utils::home::default_layout_dir; use zellij_utils::input::permission::PermissionCache; use zellij_utils::ipc::{ClientToServerMsg, IpcSenderWithContext}; +use zellij_utils::sessions::generate_random_name as generate_random_name_impl; #[cfg(feature = "web_server_capability")] use zellij_utils::web_authentication_tokens::{ create_token, list_tokens, rename_token, revoke_all_tokens, revoke_token, @@ -34,6 +38,7 @@ use zellij_utils::web_authentication_tokens::{ use zellij_utils::web_server_commands::shutdown_all_webserver_instances; use crate::{panes::PaneId, screen::ScreenInstruction}; +use kdl::KdlDocument; use prost::Message; use zellij_utils::{ @@ -46,11 +51,22 @@ use zellij_utils::{ input::{ actions::Action, command::{OpenFilePayload, RunCommand, RunCommandAction, TerminalAction}, - layout::{Layout, RunPluginOrAlias}, + config::ConfigError, + layout::{Layout, RunPluginOrAlias, TabLayoutInfo}, }, plugin_api::{ - event::ProtobufPaneScrollbackResponse, - plugin_command::{ProtobufGetPanePidResponse, ProtobufPluginCommand}, + event::{ + layout_parsing_error::ErrorType as ProtobufLayoutParsingErrorType, + ProtobufLayoutParsingError, ProtobufPaneScrollbackResponse, ProtobufSyntaxError, + }, + plugin_command::{ + dump_layout_response, dump_session_layout_response, parse_layout_response, + ProtobufDeleteLayoutResponse, ProtobufDumpLayoutResponse, + ProtobufDumpSessionLayoutResponse, ProtobufEditLayoutResponse, + ProtobufGenerateRandomNameResponse, ProtobufGetFocusedPaneInfoResponse, + ProtobufGetLayoutDirResponse, ProtobufGetPanePidResponse, ProtobufParseLayoutResponse, + ProtobufPluginCommand, ProtobufRenameLayoutResponse, ProtobufSaveLayoutResponse, + }, plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion}, }, }; @@ -107,6 +123,11 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { PluginCommand::ShowCursor(cursor_position) => show_cursor(env, cursor_position), PluginCommand::GetPluginIds => get_plugin_ids(env), PluginCommand::GetZellijVersion => get_zellij_version(env), + PluginCommand::GenerateRandomName => generate_random_name(env), + PluginCommand::DumpLayout(layout_name) => dump_layout(env, layout_name), + PluginCommand::ParseLayout(layout_string) => parse_layout(env, layout_string), + PluginCommand::GetLayoutDir => get_layout_dir(env), + PluginCommand::GetFocusedPaneInfo => get_focused_pane_info(env), PluginCommand::OpenFile(file_to_open, context) => { open_file(env, file_to_open, context) }, @@ -184,6 +205,34 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { PluginCommand::NewTabsWithLayoutInfo(layout_info) => { new_tabs_with_layout_info(env, layout_info)? }, + PluginCommand::OverrideLayout( + layout_info, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + apply_only_to_active_tab, + context, + ) => override_layout( + env, + layout_info, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + apply_only_to_active_tab, + context, + )?, + PluginCommand::SaveLayout { + layout_name, + layout_kdl, + overwrite, + } => save_layout(env, layout_name, layout_kdl, overwrite), + PluginCommand::DeleteLayout { layout_name } => delete_layout(env, layout_name), + PluginCommand::RenameLayout { + old_layout_name, + new_layout_name, + } => rename_layout(env, old_layout_name, new_layout_name), + PluginCommand::EditLayout { + layout_name, + context, + } => edit_layout(env, layout_name, context), PluginCommand::NewTab { name, cwd } => new_tab(env, name, cwd), PluginCommand::GoToNextTab => go_to_next_tab(env), PluginCommand::GoToPreviousTab => go_to_previous_tab(env), @@ -325,7 +374,9 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { scan_host_folder(env, folder_to_scan) }, PluginCommand::WatchFilesystem => watch_filesystem(env), - PluginCommand::DumpSessionLayout => dump_session_layout(env), + PluginCommand::DumpSessionLayout { tab_index } => { + dump_session_layout(env, tab_index) + }, PluginCommand::CloseSelf => close_self(env), PluginCommand::Reconfigure(new_config, write_config_to_disk) => { reconfigure(env, new_config, write_config_to_disk)? @@ -715,6 +766,218 @@ fn get_zellij_version(env: &PluginEnv) { .non_fatal(); } +fn generate_random_name(env: &PluginEnv) { + let name = generate_random_name_impl(); + let response = ProtobufGenerateRandomNameResponse { name }; + wasi_write_object(env, &response.encode_to_vec()) + .with_context(|| { + format!( + "failed to send generated random name to plugin {}", + env.name() + ) + }) + .non_fatal(); +} + +fn dump_layout(env: &PluginEnv, layout_name: String) { + let layout_path = PathBuf::from(&layout_name); + + let layout_dir = env.layout_dir.clone().or_else(default_layout_dir); + let response = match Layout::stringified_from_dir(&layout_path, layout_dir.as_ref()) { + Ok((_, layout_content, _)) => ProtobufDumpLayoutResponse { + result: Some(dump_layout_response::Result::LayoutContent(layout_content)), + }, + Err(e) => ProtobufDumpLayoutResponse { + result: Some(dump_layout_response::Result::Error(e.to_string())), + }, + }; + + wasi_write_object(env, &response.encode_to_vec()) + .with_context(|| format!("failed to send layout dump to plugin {}", env.name())) + .non_fatal(); +} + +fn get_layout_dir(env: &PluginEnv) { + // Get layout dir from env or fall back to default + let layout_dir = env.layout_dir.clone().or_else(default_layout_dir); + + // Convert PathBuf to String + let layout_dir_string = layout_dir + .and_then(|path| path.to_str().map(|s| s.to_string())) + .unwrap_or_else(|| String::from("")); + + let response = ProtobufGetLayoutDirResponse { + layout_dir: layout_dir_string, + }; + + wasi_write_object(env, &response.encode_to_vec()) + .with_context(|| format!("failed to send layout dir to plugin {}", env.name())) + .non_fatal(); +} + +fn get_focused_pane_info(env: &PluginEnv) { + use crossbeam::channel::RecvTimeoutError; + use std::time::Duration; + + let err_context = || { + format!( + "failed to get focused pane info for client {:?} from plugin {}", + env.client_id, + env.name() + ) + }; + + // Create oneshot channel for response + let (response_sender, response_receiver) = crossbeam::channel::bounded(1); + + // Send request to screen thread + env.senders + .send_to_screen(ScreenInstruction::GetFocusedPaneInfo { + client_id: env.client_id, + response_channel: response_sender, + }) + .with_context(err_context) + .non_fatal(); + + // Block waiting for response with 100ms timeout + let response = match response_receiver.recv_timeout(Duration::from_millis(100)) { + Ok(response) => response, + Err(RecvTimeoutError::Timeout) => { + log::error!( + "GetFocusedPaneInfo timed out for plugin {} for client {:?}", + env.plugin_id, + env.client_id + ); + GetFocusedPaneInfoResponse::Err("Timeout retrieving focused pane info".to_string()) + }, + Err(RecvTimeoutError::Disconnected) => { + log::error!( + "GetFocusedPaneInfo channel disconnected for plugin {}", + env.plugin_id + ); + GetFocusedPaneInfoResponse::Err( + "Channel disconnected while retrieving focused pane info".to_string(), + ) + }, + }; + + // Convert to protobuf and write response back to plugin + let protobuf_response = ProtobufGetFocusedPaneInfoResponse::from(response); + wasi_write_object(env, &protobuf_response.encode_to_vec()) + .with_context(err_context) + .non_fatal(); +} + +fn parse_layout(env: &PluginEnv, layout_string: String) { + use std::time::{SystemTime, UNIX_EPOCH}; + + // Parse the KDL layout - same parameters as list_available_layouts in layout.rs:1260 + let parse_result = Layout::from_kdl( + &layout_string, + Some("parse_layout_api".to_string()), // file_name for error reporting + None, // no swap layouts + None, // no cwd + ); + + let response = match parse_result { + Ok(layout) => { + // Extract tabs from layout + // This logic matches LayoutMetadata::from in data.rs:1762-1772 + let layout_tabs = layout.tabs(); + let tabs = if layout_tabs.is_empty() { + // Use default template if no explicit tabs defined + let (tiled_pane_layout, floating_pane_layout) = layout.new_tab(); + vec![TabMetadata::from(&( + None, + tiled_pane_layout, + floating_pane_layout, + ))] + } else { + layout + .tabs() + .into_iter() + .map(|tab| TabMetadata::from(&tab)) + .collect() + }; + + // Get current time as Unix epoch timestamp + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + + let layout_metadata = LayoutMetadata { + tabs, + creation_time: current_time.clone(), + update_time: current_time, + }; + + // Convert LayoutMetadata to protobuf + match layout_metadata.try_into() { + Ok(protobuf_metadata) => ProtobufParseLayoutResponse { + result: Some(parse_layout_response::Result::Metadata(protobuf_metadata)), + }, + Err(e) => { + log::error!("Failed to convert metadata to protobuf: {}", e); + // Fallback to SyntaxError if conversion fails + let error = LayoutParsingError::SyntaxError; + let protobuf_error = + error + .try_into() + .unwrap_or_else(|_| ProtobufLayoutParsingError { + error_type: Some(ProtobufLayoutParsingErrorType::SyntaxError( + ProtobufSyntaxError {}, + )), + }); + ProtobufParseLayoutResponse { + result: Some(parse_layout_response::Result::Error(protobuf_error)), + } + }, + } + }, + Err(config_error) => { + // Build LayoutParsingError following list_available_layouts pattern + // See zellij-utils/src/input/layout.rs:1238-1245 + let file_name = "parse_layout_api".to_string(); + let source_code = layout_string; + + let error = match config_error { + ConfigError::KdlError(kdl_err) => LayoutParsingError::KdlError { + kdl_error: kdl_err, + file_name, + source_code, + }, + _ => LayoutParsingError::SyntaxError, + }; + + // Convert LayoutParsingError to protobuf + // TryFrom is implemented in zellij-utils/src/plugin_api/event.rs:1293-1314 + let protobuf_error = error.try_into().unwrap_or_else(|e| { + log::error!("Failed to convert error to protobuf: {}", e); + ProtobufLayoutParsingError { + error_type: Some(ProtobufLayoutParsingErrorType::SyntaxError( + ProtobufSyntaxError {}, + )), + } + }); + + ProtobufParseLayoutResponse { + result: Some(parse_layout_response::Result::Error(protobuf_error)), + } + }, + }; + + wasi_write_object(env, &response.encode_to_vec()) + .with_context(|| { + format!( + "failed to send parse layout response to plugin {}", + env.name() + ) + }) + .non_fatal(); +} + fn open_file(env: &PluginEnv, file_to_open: FileToOpen, context: BTreeMap<String, String>) { let error_msg = || format!("failed to open file in plugin {}", env.name()); let floating = false; @@ -2195,12 +2458,79 @@ fn watch_filesystem(env: &PluginEnv) { .map(|sender| sender.send(PluginInstruction::WatchFilesystem)); } -fn dump_session_layout(env: &PluginEnv) { - let _ = env +// note this removes the requesting plugin +fn dump_session_layout(env: &PluginEnv, tab_index: Option<usize>) { + use crate::plugins::DumpSessionLayoutResponse; + + // Create oneshot channel for response + let (response_sender, response_receiver) = crossbeam::channel::bounded(1); + + // Send request to screen thread + let send_result = env .senders - .to_screen - .as_ref() - .map(|sender| sender.send(ScreenInstruction::DumpLayoutToPlugin(env.plugin_id))); + .send_to_screen(ScreenInstruction::DumpLayoutToPlugin { + plugin_id: env.plugin_id, + tab_index, + response_channel: response_sender, + }); + + if let Err(e) = send_result { + let error_response = ProtobufDumpSessionLayoutResponse { + result: Some(dump_session_layout_response::Result::Error(format!( + "Failed to send dump layout request: {}", + e + ))), + metadata: None, + }; + let _ = wasi_write_object(env, &error_response.encode_to_vec()); + return; + } + + // Wait for response with 1 second timeout + let response: DumpSessionLayoutResponse = + match response_receiver.recv_timeout(Duration::from_secs(1)) { + Ok(resp) => resp, + Err(e) => { + let error_response = ProtobufDumpSessionLayoutResponse { + result: Some(dump_session_layout_response::Result::Error(format!( + "Timeout waiting for session layout: {}", + e + ))), + metadata: None, + }; + let _ = wasi_write_object(env, &error_response.encode_to_vec()); + return; + }, + }; + + // Convert LayoutMetadata to protobuf + let protobuf_metadata = response + .metadata + .and_then(|metadata| match metadata.try_into() { + Ok(pb) => Some(pb), + Err(e) => { + log::error!("Failed to convert LayoutMetadata to protobuf: {}", e); + None + }, + }); + + // Build protobuf response + let protobuf_response = match response.layout_result { + Ok(layout_content) => ProtobufDumpSessionLayoutResponse { + result: Some(dump_session_layout_response::Result::LayoutContent( + layout_content, + )), + metadata: protobuf_metadata, + }, + Err(error) => ProtobufDumpSessionLayoutResponse { + result: Some(dump_session_layout_response::Result::Error(error)), + metadata: None, + }, + }; + + // Write response back to plugin via WASI pipe + let _ = wasi_write_object(env, &protobuf_response.encode_to_vec()) + .with_context(|| format!("failed to send session layout to plugin {}", env.name())); } fn list_clients(env: &PluginEnv) { @@ -2493,6 +2823,298 @@ fn get_pane_pid(env: &PluginEnv, pane_id: PaneId) { .non_fatal(); } +fn save_layout(env: &PluginEnv, layout_name: String, layout_kdl: String, overwrite: bool) { + let err_context = || format!("Failed to save layout '{}'", layout_name); + + let response = try_save_layout(env, &layout_name, &layout_kdl, overwrite) + .map(|_| SaveLayoutResponse::Ok(())) + .unwrap_or_else(SaveLayoutResponse::Err); + + // Serialize and send response back to plugin + let protobuf_response = ProtobufSaveLayoutResponse::from(response); + let serialized = protobuf_response.encode_to_vec(); + wasi_write_object(env, &serialized) + .with_context(err_context) + .non_fatal(); +} + +fn try_save_layout( + env: &PluginEnv, + layout_name: &str, + layout_kdl: &str, + overwrite: bool, +) -> Result<(), String> { + // Step 1: Sanitize layout name for path traversal and invalid characters + let safe_name = sanitize_layout_name(layout_name)?; + + // Step 2: Validate the layout by parsing it + Layout::from_kdl( + layout_kdl, + Some(format!("{}.kdl", safe_name)), + None, // No swap layouts + None, // No cwd override + ) + .map_err(|config_error| format!("Invalid layout KDL: {:?}", config_error))?; + + // Step 3: Get layout_dir from PluginEnv + let layout_dir = env + .layout_dir + .clone() + .or_else(default_layout_dir) + .ok_or_else(|| "Layout directory not found".to_string())?; + + // Step 4: Create file path + let file_path = layout_dir.join(format!("{}.kdl", safe_name)); + + // Step 5: Check if file exists when overwrite=false + if file_path.exists() && !overwrite { + return Err(format!( + "Layout file '{}' already exists. Use overwrite flag to replace it.", + safe_name + )); + } + + // Step 6: Ensure layout directory exists + std::fs::create_dir_all(layout_dir) + .map_err(|e| format!("Failed to create layout directory: {}", e))?; + + // Step 7: Write to disk + let mut parsed_layout: KdlDocument = layout_kdl.parse().unwrap(); // unwrap + // should + // be + // safe, + // but + // let's + // do + // it + // nicer + parsed_layout.fmt(); + std::fs::write(&file_path, &parsed_layout.to_string()) + .map_err(|io_error| format!("Failed to write layout file: {}", io_error))?; + + Ok(()) +} + +fn delete_layout(env: &PluginEnv, layout_name: String) { + let err_context = || format!("Failed to delete layout '{}'", layout_name); + + let response = try_delete_layout(env, &layout_name) + .map(|_| DeleteLayoutResponse::Ok(())) + .unwrap_or_else(|e| DeleteLayoutResponse::Err(e)); + + let protobuf_response: ProtobufDeleteLayoutResponse = response.into(); + let serialized = protobuf_response.encode_to_vec(); + wasi_write_object(env, &serialized) + .with_context(err_context) + .non_fatal(); +} + +fn rename_layout(env: &PluginEnv, old_layout_name: String, new_layout_name: String) { + let err_context = || { + format!( + "Failed to rename layout '{}' to '{}'", + old_layout_name, new_layout_name + ) + }; + + let response = try_rename_layout(env, &old_layout_name, &new_layout_name) + .map(|_| RenameLayoutResponse::Ok(())) + .unwrap_or_else(|e| RenameLayoutResponse::Err(e)); + + let protobuf_response: ProtobufRenameLayoutResponse = response.into(); + let serialized = protobuf_response.encode_to_vec(); + wasi_write_object(env, &serialized) + .with_context(err_context) + .non_fatal(); +} + +fn try_delete_layout(env: &PluginEnv, layout_name: &str) -> Result<(), String> { + // Sanitize the layout name to prevent directory traversal + let safe_name = + sanitize_layout_name(layout_name).map_err(|e| format!("Invalid layout name: {}", e))?; + + // Get the layout directory from PluginEnv + let layout_dir = env + .layout_dir + .clone() + .or_else(default_layout_dir) + .ok_or_else(|| "Layout directory not found".to_string())?; + + // Construct the full file path + let file_path = layout_dir.join(format!("{}.kdl", safe_name)); + + // Check if the file exists + if !file_path.exists() { + return Err(format!("Layout '{}' not found", safe_name)); + } + + // Delete the file + std::fs::remove_file(&file_path).map_err(|e| format!("Failed to delete layout file: {}", e))?; + + Ok(()) +} + +fn try_rename_layout( + env: &PluginEnv, + old_layout_name: &str, + new_layout_name: &str, +) -> Result<(), String> { + // Step 1: Sanitize both layout names + let safe_old_name = sanitize_layout_name(old_layout_name) + .map_err(|e| format!("Invalid old layout name: {}", e))?; + + let safe_new_name = sanitize_layout_name(new_layout_name) + .map_err(|e| format!("Invalid new layout name: {}", e))?; + + // Step 2: Get layout directory from PluginEnv + let layout_dir = env + .layout_dir + .clone() + .or_else(default_layout_dir) + .ok_or_else(|| "Layout directory not found".to_string())?; + + // Step 3: Construct file paths + let old_file_path = layout_dir.join(format!("{}.kdl", safe_old_name)); + let new_file_path = layout_dir.join(format!("{}.kdl", safe_new_name)); + + // Step 4: Check if source file exists + if !old_file_path.exists() { + return Err(format!("Layout '{}' not found", safe_old_name)); + } + + // Step 5: Check if target file already exists (fail if it does - no overwrite) + if new_file_path.exists() { + return Err(format!( + "Layout '{}' already exists. Cannot rename '{}' to existing layout name.", + safe_new_name, safe_old_name + )); + } + + // Step 6: Rename the file + std::fs::rename(&old_file_path, &new_file_path) + .map_err(|e| format!("Failed to rename layout file: {}", e))?; + + Ok(()) +} + +fn edit_layout(env: &PluginEnv, layout_name: String, context: BTreeMap<String, String>) { + let err_context = || format!("Failed to edit layout '{}'", layout_name); + + let response = try_edit_layout(env, &layout_name, context) + .map(|_| EditLayoutResponse::Ok(())) + .unwrap_or_else(|e| EditLayoutResponse::Err(e)); + + let protobuf_response: ProtobufEditLayoutResponse = response.into(); + let serialized = protobuf_response.encode_to_vec(); + wasi_write_object(env, &serialized) + .with_context(err_context) + .non_fatal(); +} + +fn try_edit_layout( + env: &PluginEnv, + layout_name: &str, + context: BTreeMap<String, String>, +) -> Result<(), String> { + // Sanitize the layout name to prevent directory traversal + let safe_name = + sanitize_layout_name(layout_name).map_err(|e| format!("Invalid layout name: {}", e))?; + + // Get the layout directory from PluginEnv + let layout_dir = env + .layout_dir + .clone() + .or_else(default_layout_dir) + .ok_or_else(|| "Layout directory not found".to_string())?; + + // Construct the full file path + let file_path = layout_dir.join(format!("{}.kdl", safe_name)); + + // Create FileToOpen for the layout file + let file_to_open = FileToOpen { + path: file_path, + line_number: None, + cwd: Some(layout_dir.clone()), + }; + + // Create an Action::EditFile + let action = Action::EditFile { + payload: OpenFilePayload::new( + file_to_open.path, + file_to_open.line_number, + file_to_open.cwd, + ) + .with_originating_plugin(OriginatingPlugin::new( + env.plugin_id, + env.client_id, + context, + )), + direction: None, + floating: false, + in_place: true, + start_suppressed: false, + coordinates: None, + near_current_pane: true, + }; + + // Route the action - this is fallible + route_action( + action, + env.client_id, + None, + Some(PaneId::Plugin(env.plugin_id)), + env.senders.clone(), + env.capabilities.clone(), + env.client_attributes.clone(), + env.default_shell.clone(), + env.default_layout.clone(), + None, + env.keybinds.clone(), + env.default_mode.clone(), + None, + ) + .map(|_| ()) + .map_err(|e| format!("Failed to route edit action: {:?}", e)) +} + +/// Sanitize layout name to prevent path traversal and invalid filenames +/// Returns the sanitized name or an error message +fn sanitize_layout_name(name: &str) -> Result<String, String> { + // Check for empty name + if name.is_empty() { + return Err("Layout name cannot be empty".to_string()); + } + + // Check for path separators (prevent directory traversal) + if name.contains('/') || name.contains('\\') { + return Err("Layout name cannot contain path separators".to_string()); + } + + // Check for parent directory references + if name.contains("..") { + return Err("Layout name cannot contain '..'".to_string()); + } + + // Check length (most filesystems have 255 char limit, leave room for .kdl) + if name.len() > 250 { + return Err("Layout name too long (max 250 characters)".to_string()); + } + + // Only allow alphanumeric, hyphens, underscores, and spaces + let valid_chars = name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ' '); + + if !valid_chars { + return Err( + "Layout name can only contain letters, numbers, hyphens, underscores, and spaces" + .to_string(), + ); + } + + Ok(name.to_string()) +} + fn move_pane_with_pane_id(env: &PluginEnv, pane_id: PaneId) { let _ = env .senders @@ -2935,6 +3557,57 @@ fn replace_pane_with_existing_pane( )); } +fn override_layout( + env: &mut PluginEnv, + layout_info: LayoutInfo, + retain_existing_terminal_panes: bool, + retain_existing_plugin_panes: bool, + apply_only_to_active_tab: bool, + context: BTreeMap<String, String>, +) -> Result<()> { + let layout = Layout::from_layout_info(&env.layout_dir, layout_info) + .map_err(|e| anyhow!("Failed to parse layout: {:?}", e))?; + + // Convert all tabs to Vec<TabLayoutInfo> + let tabs: Vec<TabLayoutInfo> = layout + .tabs + .iter() + .enumerate() + .map(|(index, (tab_name, tiled, floating))| TabLayoutInfo { + tab_index: index, + tab_name: tab_name.clone(), + tiled_layout: tiled.clone(), + floating_layouts: floating.clone(), + swap_tiled_layouts: Some(layout.swap_tiled_layouts.clone()), + swap_floating_layouts: Some(layout.swap_floating_layouts.clone()), + }) + .collect(); + + // If no tabs, create default tab + let tabs = if tabs.is_empty() { + let (tiled, floating) = layout.new_tab(); + vec![TabLayoutInfo { + tab_index: 0, + tab_name: None, + tiled_layout: tiled, + floating_layouts: floating, + swap_tiled_layouts: Some(layout.swap_tiled_layouts), + swap_floating_layouts: Some(layout.swap_floating_layouts), + }] + } else { + tabs + }; + + let action = Action::OverrideLayout { + tabs, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + apply_only_to_active_tab, + }; + run_action(env, action, context); + Ok(()) +} + // Custom panic handler for plugins. // // This is called when a panic occurs in a plugin. Since most panics will likely originate in the @@ -3105,14 +3778,24 @@ fn check_command_permission( | PluginCommand::ReplacePaneWithExistingPane(..) | PluginCommand::KillSessions(..) | PluginCommand::SendSigintToPaneId(..) - | PluginCommand::SendSigkillToPaneId(..) => PermissionType::ChangeApplicationState, + | PluginCommand::SendSigkillToPaneId(..) + | PluginCommand::OverrideLayout(..) + | PluginCommand::SaveLayout { .. } + | PluginCommand::DeleteLayout { .. } + | PluginCommand::RenameLayout { .. } + | PluginCommand::EditLayout { .. } => PermissionType::ChangeApplicationState, PluginCommand::UnblockCliPipeInput(..) | PluginCommand::BlockCliPipeInput(..) | PluginCommand::CliPipeOutput(..) => PermissionType::ReadCliPipes, PluginCommand::MessageToPlugin(..) => PermissionType::MessageAndLaunchOtherPlugins, PluginCommand::ListClients - | PluginCommand::DumpSessionLayout - | PluginCommand::GetPanePid { .. } => PermissionType::ReadApplicationState, + | PluginCommand::DumpSessionLayout { .. } + | PluginCommand::GetPanePid { .. } + | PluginCommand::GenerateRandomName + | PluginCommand::GetLayoutDir + | PluginCommand::GetFocusedPaneInfo + | PluginCommand::DumpLayout(..) + | PluginCommand::ParseLayout(..) => PermissionType::ReadApplicationState, PluginCommand::RebindKeys { .. } | PluginCommand::Reconfigure(..) => { PermissionType::Reconfigure }, diff --git a/zellij-server/src/pty.rs b/zellij-server/src/pty.rs index 544830af..82a24b55 100644 --- a/zellij-server/src/pty.rs +++ b/zellij-server/src/pty.rs @@ -3,8 +3,8 @@ use crate::route::NotificationEnd; use crate::terminal_bytes::TerminalBytes; use crate::{ panes::PaneId, - plugins::{PluginId, PluginInstruction}, - screen::ScreenInstruction, + plugins::{DumpSessionLayoutResponse, PluginId, PluginInstruction}, + screen::{ScreenInstruction, TabOverrideResult}, session_layout_metadata::SessionLayoutMetadata, thread_bus::{Bus, ThreadSenders}, ClientId, ServerInstruction, @@ -27,7 +27,7 @@ use zellij_utils::{ command::{OpenFilePayload, RunCommand, TerminalAction}, layout::{ FloatingPaneLayout, Layout, Run, RunPluginOrAlias, SwapFloatingLayout, SwapTiledLayout, - TiledPaneLayout, + TabLayoutInfo, TiledPaneLayout, }, }, pane_size::Size, @@ -79,16 +79,11 @@ pub enum PtyInstruction { Option<NotificationEnd>, // completion signal ), // the String is the tab name OverrideLayout( - Option<PathBuf>, // CWD - Option<TerminalAction>, // Default Shell - TiledPaneLayout, - Vec<FloatingPaneLayout>, - Option<Vec<SwapTiledLayout>>, - Option<Vec<SwapFloatingLayout>>, - bool, // retain_existing_terminal_panes - bool, // retain_existing_plugin_panes - usize, // tab_index - HashMap<RunPluginOrAlias, Vec<u32>>, // plugin_ids + Option<PathBuf>, // CWD + Option<TerminalAction>, // Default Shell + Vec<(TabLayoutInfo, HashMap<RunPluginOrAlias, Vec<u32>>)>, // (layout, plugin_ids) per tab + bool, // retain_existing_terminal_panes + bool, // retain_existing_plugin_panes ClientId, Option<NotificationEnd>, ), @@ -109,7 +104,11 @@ pub enum PtyInstruction { Option<NotificationEnd>, // completion signal ), // String is an optional pane name DumpLayout(SessionLayoutMetadata, ClientId, Option<NotificationEnd>), - DumpLayoutToPlugin(SessionLayoutMetadata, PluginId), + DumpLayoutToPlugin { + session_layout_metadata: SessionLayoutMetadata, + plugin_id: PluginId, + response_channel: crossbeam::channel::Sender<DumpSessionLayoutResponse>, + }, LogLayoutToHd(SessionLayoutMetadata), FillPluginCwd( Option<bool>, // should float @@ -159,7 +158,7 @@ impl From<&PtyInstruction> for PtyContext { PtyInstruction::DropToShellInPane { .. } => PtyContext::DropToShellInPane, PtyInstruction::SpawnInPlaceTerminal(..) => PtyContext::SpawnInPlaceTerminal, PtyInstruction::DumpLayout(..) => PtyContext::DumpLayout, - PtyInstruction::DumpLayoutToPlugin(..) => PtyContext::DumpLayoutToPlugin, + PtyInstruction::DumpLayoutToPlugin { .. } => PtyContext::DumpLayoutToPlugin, PtyInstruction::LogLayoutToHd(..) => PtyContext::LogLayoutToHd, PtyInstruction::FillPluginCwd(..) => PtyContext::FillPluginCwd, PtyInstruction::ListClientsMetadata(..) => PtyContext::ListClientsMetadata, @@ -496,34 +495,55 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> { PtyInstruction::OverrideLayout( cwd, default_shell, - tiled_layout, - floating_panes_layout, - swap_tiled_layouts, - swap_floating_layouts, + tab_layouts_with_plugin_ids, retain_existing_terminal_panes, retain_existing_plugin_panes, - tab_index, - plugin_ids, client_id, completion_tx, ) => { let err_context = || "failed to override layout"; - pty.spawn_terminals_for_layout_override( - cwd, - tiled_layout, - floating_panes_layout, - default_shell, - plugin_ids, - tab_index, - client_id, - swap_tiled_layouts, - swap_floating_layouts, - retain_existing_terminal_panes, - retain_existing_plugin_panes, - completion_tx, - ) - .with_context(err_context)?; + let mut all_tab_results = Vec::new(); + + // Process each tab + for (tab_layout_info, plugin_ids) in tab_layouts_with_plugin_ids { + match pty.spawn_terminals_for_layout_override( + cwd.clone(), + tab_layout_info.tiled_layout, + tab_layout_info.floating_layouts, + default_shell.clone(), + plugin_ids, + tab_layout_info.tab_index, + tab_layout_info.tab_name, + client_id, + tab_layout_info.swap_tiled_layouts, + tab_layout_info.swap_floating_layouts, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + ) { + Ok(tab_result) => all_tab_results.push(tab_result), + Err(e) => { + log::error!( + "Failed to spawn terminals for tab {}: {:?}", + tab_layout_info.tab_index, + e + ); + // Continue with other tabs (best-effort approach) + }, + } + } + + // Send all results back to screen in one message + pty.bus + .senders + .send_to_screen(ScreenInstruction::OverrideLayoutComplete( + all_tab_results, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + client_id, + completion_tx, + )) + .with_context(err_context)?; }, PtyInstruction::ClosePane(id, _completion_tx) => { pty.close_pane(id) @@ -682,15 +702,20 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> { .with_context(err_context) .non_fatal(); }, - PtyInstruction::DumpLayoutToPlugin(mut session_layout_metadata, plugin_id) => { + PtyInstruction::DumpLayoutToPlugin { + mut session_layout_metadata, + plugin_id, + response_channel, + } => { let err_context = || format!("Failed to dump layout"); pty.populate_session_layout_metadata(&mut session_layout_metadata); pty.bus .senders - .send_to_plugin(PluginInstruction::DumpLayoutToPlugin( + .send_to_plugin(PluginInstruction::DumpLayoutToPlugin { session_layout_metadata, plugin_id, - )) + response_channel, + }) .with_context(err_context) .non_fatal(); }, @@ -1258,13 +1283,13 @@ impl Pty { default_shell: Option<TerminalAction>, plugin_ids: HashMap<RunPluginOrAlias, Vec<u32>>, tab_index: usize, + tab_name: Option<String>, client_id: ClientId, swap_tiled_layouts: Option<Vec<SwapTiledLayout>>, swap_floating_layouts: Option<Vec<SwapFloatingLayout>>, - retain_existing_terminal_panes: bool, - retain_existing_plugin_panes: bool, - completion_tx: Option<NotificationEnd>, - ) -> Result<()> { + _retain_existing_terminal_panes: bool, + _retain_existing_plugin_panes: bool, + ) -> Result<TabOverrideResult> { let err_context = || format!("failed to spawn terminals for layout for"); let mut default_shell = @@ -1351,23 +1376,17 @@ impl Pty { }) .collect(); - self.bus - .senders - .send_to_screen(ScreenInstruction::OverrideLayoutComplete( - layout, - floating_panes_layout, - swap_tiled_layouts, - swap_floating_layouts, - new_tab_pane_ids, - new_tab_floating_pane_ids, - plugin_ids, - retain_existing_terminal_panes, - retain_existing_plugin_panes, - tab_index, - client_id, - completion_tx, - )) - .with_context(err_context)?; + let tab_result = TabOverrideResult { + tab_index, + tab_name, + tiled_layout: layout, + floating_layouts: floating_panes_layout, + swap_tiled_layouts, + swap_floating_layouts, + new_terminal_pids: new_tab_pane_ids, + new_floating_pane_pids: new_tab_floating_pane_ids, + plugin_ids, + }; let mut terminals_to_start = vec![]; @@ -1429,7 +1448,7 @@ impl Pty { for (terminal_id, originating_plugin) in originating_plugins_to_inform { self.inform_originating_plugin_of_open(terminal_id, originating_plugin); } - Ok(()) + Ok(tab_result) } fn inform_originating_plugin_of_open( &mut self, diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs index 57b499e1..f1c47dfa 100644 --- a/zellij-server/src/route.rs +++ b/zellij-server/src/route.rs @@ -28,7 +28,7 @@ use zellij_utils::{ command::TerminalAction, get_mode_info, keybinds::Keybinds, - layout::{Layout, TiledPaneLayout}, + layout::Layout, }, ipc::{ ClientAttributes, ClientToServerMsg, ExitReason, IpcReceiverWithContext, ServerToClientMsg, @@ -1115,33 +1115,22 @@ pub(crate) fn route_action( .with_context(err_context)?; }, Action::OverrideLayout { - tiled_layout, - floating_layouts, - swap_tiled_layouts, - swap_floating_layouts, - tab_name, + tabs, retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_active_tab, } => { - // Extract required layout fields, use defaults if None let cwd = None; let shell = default_shell.clone(); - let tiled = tiled_layout.unwrap_or_else(|| TiledPaneLayout::default()); - let floating = floating_layouts; - let swap_tiled = swap_tiled_layouts; - let swap_floating = swap_floating_layouts; senders .send_to_screen(ScreenInstruction::OverrideLayout( cwd, shell, - tab_name, - tiled, - floating, - swap_tiled, - swap_floating, + tabs, retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_active_tab, client_id, Some(NotificationEnd::new(completion_tx)), )) diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index a93ad27e..cc7bff40 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -12,9 +12,9 @@ use crate::route::NotificationEnd; use log::{debug, warn}; use zellij_utils::data::{ - CommandOrPlugin, Direction, FloatingPaneCoordinates, KeyWithModifier, NewPanePlacement, - PaneContents, PaneManifest, PaneScrollbackResponse, PluginPermission, Resize, ResizeStrategy, - SessionInfo, Styling, WebSharing, + CommandOrPlugin, Direction, FloatingPaneCoordinates, GetFocusedPaneInfoResponse, + KeyWithModifier, NewPanePlacement, PaneContents, PaneManifest, PaneScrollbackResponse, + PluginPermission, Resize, ResizeStrategy, SessionInfo, Styling, WebSharing, }; use zellij_utils::errors::prelude::*; use zellij_utils::input::command::RunCommand; @@ -30,7 +30,7 @@ use zellij_utils::{ input::command::TerminalAction, input::layout::{ FloatingPaneLayout, Layout, Run, RunPluginOrAlias, SplitSize, SwapFloatingLayout, - SwapTiledLayout, TiledPaneLayout, + SwapTiledLayout, TabLayoutInfo, TiledPaneLayout, }, position::Position, }; @@ -47,7 +47,7 @@ use crate::{ output::Output, panes::sixel::SixelImageStore, panes::PaneId, - plugins::{PluginId, PluginInstruction, PluginRenderAsset}, + plugins::{DumpSessionLayoutResponse, PluginId, PluginInstruction, PluginRenderAsset}, pty::{get_default_shell, ClientTabIndexOrPaneId, PtyInstruction, VteBytes}, tab::{SuppressedPanes, Tab}, thread_bus::Bus, @@ -142,6 +142,20 @@ macro_rules! active_tab_and_connected_client_id { type InitialTitle = String; type HoldForCommand = Option<RunCommand>; +/// Result of overriding a single tab's layout +#[derive(Debug, Clone)] +pub struct TabOverrideResult { + pub tab_index: usize, + pub tab_name: Option<String>, + pub tiled_layout: TiledPaneLayout, + pub floating_layouts: Vec<FloatingPaneLayout>, + pub swap_tiled_layouts: Option<Vec<SwapTiledLayout>>, + pub swap_floating_layouts: Option<Vec<SwapFloatingLayout>>, + pub new_terminal_pids: Vec<(u32, HoldForCommand)>, + pub new_floating_pane_pids: Vec<(u32, HoldForCommand)>, + pub plugin_ids: HashMap<RunPluginOrAlias, Vec<u32>>, +} + /// Instructions that can be sent to the [`Screen`]. #[derive(Debug, Clone)] pub enum ScreenInstruction { @@ -192,7 +206,15 @@ pub enum ScreenInstruction { DumpScreen(String, ClientId, bool, Option<NotificationEnd>), DumpLayout(Option<PathBuf>, ClientId, Option<NotificationEnd>), // PathBuf is the default configured // shell - DumpLayoutToPlugin(PluginId), + DumpLayoutToPlugin { + plugin_id: PluginId, + tab_index: Option<usize>, + response_channel: crossbeam::channel::Sender<DumpSessionLayoutResponse>, + }, + GetFocusedPaneInfo { + client_id: ClientId, + response_channel: crossbeam::channel::Sender<GetFocusedPaneInfoResponse>, + }, EditScrollback(ClientId, Option<NotificationEnd>), GetPaneScrollback { pane_id: PaneId, @@ -296,29 +318,19 @@ pub enum ScreenInstruction { PreviousSwapLayout(ClientId, Option<NotificationEnd>), NextSwapLayout(ClientId, Option<NotificationEnd>), OverrideLayout( - Option<PathBuf>, // cwd - Option<TerminalAction>, // default_shell - Option<String>, // new name for tab - TiledPaneLayout, - Vec<FloatingPaneLayout>, - Option<Vec<SwapTiledLayout>>, - Option<Vec<SwapFloatingLayout>>, - bool, // retain_existing_terminal_panes - bool, // retain_existing_plugin_panes + Option<PathBuf>, // cwd (applies to all tabs) + Option<TerminalAction>, // default_shell (applies to all tabs) + Vec<TabLayoutInfo>, // layouts for each tab to override + bool, // retain_existing_terminal_panes + bool, // retain_existing_plugin_panes + bool, // apply_only_to_focused_tab ClientId, Option<NotificationEnd>, ), OverrideLayoutComplete( - TiledPaneLayout, - Vec<FloatingPaneLayout>, - Option<Vec<SwapTiledLayout>>, - Option<Vec<SwapFloatingLayout>>, - Vec<(u32, HoldForCommand)>, // new terminal pids - Vec<(u32, HoldForCommand)>, // new floating pane pids - HashMap<RunPluginOrAlias, Vec<u32>>, - bool, // retain_existing_terminal_panes - bool, // retain_existing_plugin_panes - usize, // tab_index + Vec<TabOverrideResult>, // results for each tab + bool, // retain_existing_terminal_panes + bool, // retain_existing_plugin_panes ClientId, Option<NotificationEnd>, ), @@ -564,7 +576,8 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::ClearScreen(..) => ScreenContext::ClearScreen, ScreenInstruction::DumpScreen(..) => ScreenContext::DumpScreen, ScreenInstruction::DumpLayout(..) => ScreenContext::DumpLayout, - ScreenInstruction::DumpLayoutToPlugin(..) => ScreenContext::DumpLayoutToPlugin, + ScreenInstruction::DumpLayoutToPlugin { .. } => ScreenContext::DumpLayoutToPlugin, + ScreenInstruction::GetFocusedPaneInfo { .. } => ScreenContext::GetFocusedPaneInfo, ScreenInstruction::EditScrollback(..) => ScreenContext::EditScrollback, ScreenInstruction::GetPaneScrollback { .. } => ScreenContext::GetPaneScrollback, ScreenInstruction::ScrollUp(..) => ScreenContext::ScrollUp, @@ -2087,7 +2100,7 @@ impl Screen { // because this is mostly about HD access - it does however throw off the timing in the // tests and causes them to flake, which is why we skip it here #[cfg(not(test))] - let available_layouts = + let (available_layouts, _layout_errors) = Layout::list_available_layouts(self.layout_dir.clone(), &self.default_layout_name); #[cfg(test)] let available_layouts = vec![]; @@ -2135,7 +2148,8 @@ impl Screen { } fn dump_layout_to_hd(&mut self) -> Result<()> { let err_context = || format!("Failed to log and report session state"); - let session_layout_metadata = self.get_layout_metadata(Some(self.default_shell.clone())); + let session_layout_metadata = + self.get_layout_metadata(Some(self.default_shell.clone()), None); self.bus .senders .send_to_plugin(PluginInstruction::LogLayoutToHd(session_layout_metadata)) @@ -3296,7 +3310,11 @@ impl Screen { } Ok(()) } - fn get_layout_metadata(&self, default_shell: Option<PathBuf>) -> SessionLayoutMetadata { + fn get_layout_metadata( + &self, + default_shell: Option<PathBuf>, + tab_index: Option<usize>, + ) -> SessionLayoutMetadata { let mut session_layout_metadata = SessionLayoutMetadata::new(self.default_layout.clone()); if let Some(default_shell) = default_shell { session_layout_metadata.update_default_shell(default_shell); @@ -3305,7 +3323,14 @@ impl Screen { let active_tab_index = first_client_id.and_then(|client_id| self.active_tab_indices.get(&client_id)); - for (tab_index, tab) in self.tabs.iter() { + // Filter tabs based on optional tab_index parameter + let tabs_to_process: Vec<_> = self + .tabs + .iter() + .filter(|(idx, _)| tab_index.map_or(true, |target| **idx == target)) + .collect(); + + for (tab_index, tab) in tabs_to_process { let tab_is_focused = active_tab_index == Some(&tab_index); let hide_floating_panes = !tab.are_floating_panes_visible(); let mut suppressed_panes = HashMap::new(); @@ -4188,7 +4213,7 @@ pub(crate) fn screen_thread_main( }, ScreenInstruction::DumpLayout(default_shell, client_id, completion_tx) => { let err_context = || format!("Failed to dump layout"); - let session_layout_metadata = screen.get_layout_metadata(default_shell); + let session_layout_metadata = screen.get_layout_metadata(default_shell, None); screen .bus .senders @@ -4201,7 +4226,7 @@ pub(crate) fn screen_thread_main( }, ScreenInstruction::ListClientsMetadata(default_shell, client_id, completion_tx) => { let err_context = || format!("Failed to dump layout"); - let session_layout_metadata = screen.get_layout_metadata(default_shell); + let session_layout_metadata = screen.get_layout_metadata(default_shell, None); screen .bus .senders @@ -4212,24 +4237,51 @@ pub(crate) fn screen_thread_main( )) .with_context(err_context)?; }, - ScreenInstruction::DumpLayoutToPlugin(plugin_id) => { + ScreenInstruction::DumpLayoutToPlugin { + plugin_id, + tab_index, + response_channel, + } => { let err_context = || format!("Failed to dump layout"); let session_layout_metadata = - screen.get_layout_metadata(Some(screen.default_shell.clone())); + screen.get_layout_metadata(Some(screen.default_shell.clone()), tab_index); screen .bus .senders - .send_to_pty(PtyInstruction::DumpLayoutToPlugin( + .send_to_pty(PtyInstruction::DumpLayoutToPlugin { session_layout_metadata, plugin_id, - )) + response_channel, + }) .with_context(err_context) .non_fatal(); }, + ScreenInstruction::GetFocusedPaneInfo { + client_id, + response_channel, + } => { + let response = match screen.active_tab_indices.get(&client_id) { + Some(&focused_tab_index) => match screen.get_active_pane_id(&client_id) { + Some(focused_pane_id) => GetFocusedPaneInfoResponse::Ok { + tab_index: focused_tab_index, + pane_id: focused_pane_id.into(), + }, + None => GetFocusedPaneInfoResponse::Err(format!( + "No active pane found for client {:?}", + client_id + )), + }, + None => GetFocusedPaneInfoResponse::Err(format!( + "Client {:?} not found in active_tab_indices", + client_id + )), + }; + let _ = response_channel.send(response); + }, ScreenInstruction::ListClientsToPlugin(plugin_id, client_id) => { let err_context = || format!("Failed to dump layout"); let session_layout_metadata = - screen.get_layout_metadata(Some(screen.default_shell.clone())); + screen.get_layout_metadata(Some(screen.default_shell.clone()), None); screen .bus .senders @@ -5193,90 +5245,212 @@ pub(crate) fn screen_thread_main( ScreenInstruction::OverrideLayout( cwd, default_shell, - mut new_tab_name, - mut tiled_layout, - mut floating_layouts, - swap_tiled_layouts, - swap_floating_layouts, + mut tab_layouts, retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_focused_tab, client_id, - _completion_tx, + completion_tx, ) => { - let active_tab = match screen.get_active_tab_mut(client_id) { - Ok(tab) => tab, - Err(_) => { - log::error!( - "OverrideLayout: No active tab for client_id {:?}", - client_id + // 1. Determine which tabs to close (exist but not in layout) + let existing_tab_indices: HashSet<usize> = screen.tabs.keys().copied().collect(); + let layout_tab_indices: HashSet<usize> = + tab_layouts.iter().map(|tl| tl.tab_index).collect(); + let tabs_to_close: Vec<usize> = existing_tab_indices + .difference(&layout_tab_indices) + .copied() + .collect(); + + // 2. Process each tab layout + let mut processed_tab_layouts = Vec::new(); + + if apply_only_to_focused_tab { + match screen.get_active_tab_mut(client_id) { + Ok(active_tab) => { + if tab_layouts.is_empty() { + log::error!("No tab layouts found, cannot override."); + continue; + } + let mut tab_layout_info = tab_layouts.remove(0); + tab_layout_info.tab_index = active_tab.index; + // Set the tab name if provided + if let Some(name) = tab_layout_info.tab_name.take() { + active_tab.name = name; + } + + // Find already-running panes for this tab + let (tiled_to_ignore, floating_indices) = find_already_running_panes( + &tab_layout_info.tiled_layout, + &tab_layout_info.floating_layouts, + active_tab, + ); + + // Mark run instructions to ignore (prevents re-spawning) + for run_instruction in tiled_to_ignore { + tab_layout_info + .tiled_layout + .ignore_run_instruction(run_instruction); + } + + for idx in floating_indices { + tab_layout_info + .floating_layouts + .get_mut(idx) + .map(|f| f.already_running = true); + } + + processed_tab_layouts.push(tab_layout_info); + }, + Err(e) => { + log::error!("Failed to override layout of active tab: {}", e); + }, + } + } else { + for mut tab_layout_info in tab_layouts { + // Get the tab by index + let tab = match screen.tabs.get_mut(&tab_layout_info.tab_index) { + Some(t) => t, + None => { + // no corresponding tab exists, we'll create it + processed_tab_layouts.push(tab_layout_info); + continue; + }, + }; + + // Set the tab name if provided + if let Some(name) = tab_layout_info.tab_name.take() { + tab.name = name; + } + + // Find already-running panes for this tab + let (tiled_to_ignore, floating_indices) = find_already_running_panes( + &tab_layout_info.tiled_layout, + &tab_layout_info.floating_layouts, + tab, ); - continue; - }, - }; - if let Some(name) = new_tab_name.take() { - active_tab.name = name; - } - let (tiled_to_ignore, floating_indices) = - find_already_running_panes(&tiled_layout, &floating_layouts, active_tab); + // Mark run instructions to ignore (prevents re-spawning) + for run_instruction in tiled_to_ignore { + tab_layout_info + .tiled_layout + .ignore_run_instruction(run_instruction); + } - for run_instruction in tiled_to_ignore { - tiled_layout.ignore_run_instruction(run_instruction); - } + for idx in floating_indices { + tab_layout_info + .floating_layouts + .get_mut(idx) + .map(|f| f.already_running = true); + } - for idx in floating_indices { - floating_layouts - .get_mut(idx) - .map(|f| f.already_running = true); + processed_tab_layouts.push(tab_layout_info); + } } - let tab_index = active_tab.index; + // 3. Send to plugin thread with all tab layouts screen .bus .senders .send_to_plugin(PluginInstruction::OverrideLayout( cwd, default_shell, - tiled_layout, - floating_layouts, - swap_tiled_layouts, - swap_floating_layouts, + processed_tab_layouts, retain_existing_terminal_panes, retain_existing_plugin_panes, - tab_index, client_id, - _completion_tx, + completion_tx, ))?; + + // 4. Close tabs that aren't in the layout + if !apply_only_to_focused_tab { + for tab_index in tabs_to_close { + screen.close_tab_at_index(tab_index)?; + } + } }, ScreenInstruction::OverrideLayoutComplete( - tiled_layout, - floating_layouts, - new_swap_tiled_layouts, - new_swap_floating_layouts, - new_terminal_pids, - new_floating_pane_pids, - plugin_ids, + tab_results, retain_existing_terminal_panes, retain_existing_plugin_panes, - tab_index, client_id, completion_tx, ) => { - screen.tabs.get_mut(&tab_index).map(|t| { - t.override_layout( - tiled_layout, - floating_layouts, - new_swap_tiled_layouts, - new_swap_floating_layouts, - new_terminal_pids, - new_floating_pane_pids, - plugin_ids, - retain_existing_terminal_panes, - retain_existing_plugin_panes, - client_id, - None, - ) - }); + // Process each tab result + for tab_result in tab_results { + if let Some(tab) = screen.tabs.get_mut(&tab_result.tab_index) { + if let Err(e) = tab.override_layout( + tab_result.tiled_layout, + tab_result.floating_layouts, + tab_result.swap_tiled_layouts.clone(), + tab_result.swap_floating_layouts.clone(), + tab_result.new_terminal_pids, + tab_result.new_floating_pane_pids, + tab_result.plugin_ids, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + client_id, + None, + ) { + log::error!( + "Failed to override layout for tab {}: {:?}", + tab_result.tab_index, + e + ); + } + } else { + // Tab doesn't exist - create it + let swap_layouts = ( + tab_result.swap_tiled_layouts.clone().unwrap_or_default(), + tab_result.swap_floating_layouts.clone().unwrap_or_default(), + ); + if let Err(e) = screen.new_tab( + tab_result.tab_index, + swap_layouts, + tab_result.tab_name.clone(), + None, + ) { + log::error!( + "Failed to create new tab {} during override completion: {:?}", + tab_result.tab_index, + e + ); + continue; + } + + // Now override the newly created tab's layout + if let Some(tab) = screen.tabs.get_mut(&tab_result.tab_index) { + if let Err(e) = tab.override_layout( + tab_result.tiled_layout, + tab_result.floating_layouts, + tab_result.swap_tiled_layouts, + tab_result.swap_floating_layouts, + tab_result.new_terminal_pids, + tab_result.new_floating_pane_pids, + tab_result.plugin_ids, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + client_id, + None, + ) { + log::error!( + "Failed to override layout for new tab {}: {:?}", + tab_result.tab_index, + e + ); + } + } + } + } + + for event in pending_events_waiting_for_client.drain(..) { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + + for event in pending_events_waiting_for_tab.drain(..) { + screen.bus.senders.send_to_screen(event).non_fatal(); + } + + // Single render and log after all tabs are updated (performance optimization) screen.log_and_report_session_state()?; let _ = screen.render(None); drop(completion_tx); // action ends here, notify the action initiator diff --git a/zellij-server/src/session_layout_metadata.rs b/zellij-server/src/session_layout_metadata.rs index 238d476a..96e5246d 100644 --- a/zellij-server/src/session_layout_metadata.rs +++ b/zellij-server/src/session_layout_metadata.rs @@ -2,9 +2,11 @@ use crate::panes::PaneId; use crate::ClientId; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; use zellij_utils::common_path::common_path_all; use zellij_utils::pane_size::PaneGeom; use zellij_utils::{ + data::{LayoutMetadata, PaneMetadata, TabMetadata}, input::command::RunCommand, input::layout::{Layout, Run, RunPlugin, RunPluginOrAlias}, input::plugins::PluginAliases, @@ -241,6 +243,27 @@ impl SessionLayoutMetadata { } plugin_ids } + pub fn remove_plugin_from_layout(&mut self, plugin_id_to_remove: u32) { + for tab in &mut self.tabs { + // Filter tiled panes + tab.tiled_panes.retain(|pane| { + if let PaneId::Plugin(id) = pane.id { + id != plugin_id_to_remove + } else { + true + } + }); + + // Filter floating panes + tab.floating_panes.retain(|pane| { + if let PaneId::Plugin(id) = pane.id { + id != plugin_id_to_remove + } else { + true + } + }); + } + } pub fn update_terminal_commands( &mut self, mut terminal_ids_to_commands: HashMap<u32, Vec<String>>, @@ -330,6 +353,22 @@ impl SessionLayoutMetadata { self.default_layout .populate_plugin_aliases_in_layout(&plugin_aliases); } + pub fn to_layout_metadata(&self) -> LayoutMetadata { + // Get current timestamp for both creation and update time + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_default(); + + // Convert all tabs + let tabs = self.tabs.iter().map(|tab| tab.to_tab_metadata()).collect(); + + LayoutMetadata { + tabs, + creation_time: current_time.clone(), + update_time: current_time, + } + } } impl Into<GlobalLayoutManifest> for SessionLayoutMetadata { @@ -358,6 +397,27 @@ impl Into<TabLayoutManifest> for TabLayoutMetadata { } } +impl TabLayoutMetadata { + fn to_tab_metadata(&self) -> TabMetadata { + let mut panes = Vec::new(); + + // Extract pane metadata from tiled panes + for pane in &self.tiled_panes { + panes.push(pane.to_pane_metadata()); + } + + // Extract pane metadata from floating panes + for pane in &self.floating_panes { + panes.push(pane.to_pane_metadata()); + } + + TabMetadata { + panes, + name: self.name.clone(), + } + } +} + impl Into<PaneLayoutManifest> for PaneLayoutMetadata { fn into(self) -> PaneLayoutManifest { PaneLayoutManifest { @@ -417,6 +477,40 @@ impl PaneLayoutMetadata { focused_clients, } } + fn to_pane_metadata(&self) -> PaneMetadata { + use zellij_utils::input::layout::RunPluginLocation; + + // Try to extract a meaningful name from the pane + // Priority: explicit title > command name > file name > plugin location + let name = self.title.clone().or_else(|| { + self.run.as_ref().and_then(|run| match run { + Run::Command(cmd) => Some(cmd.command.display().to_string()), + Run::EditFile(path, _, _) => { + path.file_name().map(|n| n.to_string_lossy().to_string()) + }, + Run::Plugin(plugin) => Some(plugin.location_string()), + Run::Cwd(_) => None, + }) + }); + + let is_plugin = matches!(self.id, PaneId::Plugin(_)); + + // Detect if this is a builtin plugin + let is_builtin_plugin = self + .run + .as_ref() + .map(|run| match run { + Run::Plugin(plugin) => plugin.is_builtin_plugin(), + _ => false, + }) + .unwrap_or(false); + + PaneMetadata { + name, + is_plugin, + is_builtin_plugin, + } + } } pub struct ClientMetadata { diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 7e1dce3d..25180259 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -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)] diff --git a/zellij-tile/src/ui_components/text.rs b/zellij-tile/src/ui_components/text.rs index 492e2352..fe813fb3 100644 --- a/zellij-tile/src/ui_components/text.rs +++ b/zellij-tile/src/ui_components/text.rs @@ -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 } diff --git a/zellij-utils/assets/config/default.kdl b/zellij-utils/assets/config/default.kdl index e06dbe5c..db2e4468 100644 --- a/zellij-utils/assets/config/default.kdl +++ b/zellij-utils/assets/config/default.kdl @@ -156,6 +156,13 @@ keybinds { }; SwitchToMode "Normal" } + bind "l" { + LaunchOrFocusPlugin "zellij:layout-manager" { + floating true + move_to_focused_tab true + }; + SwitchToMode "Normal" + } } tmux { bind "[" { SwitchToMode "Scroll"; } diff --git a/zellij-utils/assets/plugins/about.wasm b/zellij-utils/assets/plugins/about.wasm Binary files differindex 812659bc..aab775a5 100755 --- a/zellij-utils/assets/plugins/about.wasm +++ b/zellij-utils/assets/plugins/about.wasm diff --git a/zellij-utils/assets/plugins/compact-bar.wasm b/zellij-utils/assets/plugins/compact-bar.wasm Binary files differindex 75f94c4b..2719eaa4 100755 --- a/zellij-utils/assets/plugins/compact-bar.wasm +++ b/zellij-utils/assets/plugins/compact-bar.wasm diff --git a/zellij-utils/assets/plugins/configuration.wasm b/zellij-utils/assets/plugins/configuration.wasm Binary files differindex ffa97232..d5111f1a 100755 --- a/zellij-utils/assets/plugins/configuration.wasm +++ b/zellij-utils/assets/plugins/configuration.wasm diff --git a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm Binary files differindex cd74ed1d..1db435ba 100755 --- a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm +++ b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm diff --git a/zellij-utils/assets/plugins/layout-manager.wasm b/zellij-utils/assets/plugins/layout-manager.wasm Binary files differnew file mode 100755 index 00000000..e147d179 --- /dev/null +++ b/zellij-utils/assets/plugins/layout-manager.wasm diff --git a/zellij-utils/assets/plugins/multiple-select.wasm b/zellij-utils/assets/plugins/multiple-select.wasm Binary files differindex 4412b8df..43676c22 100755 --- a/zellij-utils/assets/plugins/multiple-select.wasm +++ b/zellij-utils/assets/plugins/multiple-select.wasm diff --git a/zellij-utils/assets/plugins/plugin-manager.wasm b/zellij-utils/assets/plugins/plugin-manager.wasm Binary files differindex cb8e2239..ef666220 100755 --- a/zellij-utils/assets/plugins/plugin-manager.wasm +++ b/zellij-utils/assets/plugins/plugin-manager.wasm diff --git a/zellij-utils/assets/plugins/sequence.wasm b/zellij-utils/assets/plugins/sequence.wasm Binary files differindex d6286cb0..b2e470a1 100755 --- a/zellij-utils/assets/plugins/sequence.wasm +++ b/zellij-utils/assets/plugins/sequence.wasm diff --git a/zellij-utils/assets/plugins/session-manager.wasm b/zellij-utils/assets/plugins/session-manager.wasm Binary files differindex ff97eb14..6f556f01 100755 --- a/zellij-utils/assets/plugins/session-manager.wasm +++ b/zellij-utils/assets/plugins/session-manager.wasm diff --git a/zellij-utils/assets/plugins/share.wasm b/zellij-utils/assets/plugins/share.wasm Binary files differindex ed580f3f..99606d71 100755 --- a/zellij-utils/assets/plugins/share.wasm +++ b/zellij-utils/assets/plugins/share.wasm diff --git a/zellij-utils/assets/plugins/status-bar.wasm b/zellij-utils/assets/plugins/status-bar.wasm Binary files differindex 98e8e647..f5db526c 100755 --- a/zellij-utils/assets/plugins/status-bar.wasm +++ b/zellij-utils/assets/plugins/status-bar.wasm diff --git a/zellij-utils/assets/plugins/strider.wasm b/zellij-utils/assets/plugins/strider.wasm Binary files differindex c33e5e2f..735f74a9 100755 --- a/zellij-utils/assets/plugins/strider.wasm +++ b/zellij-utils/assets/plugins/strider.wasm diff --git a/zellij-utils/assets/plugins/tab-bar.wasm b/zellij-utils/assets/plugins/tab-bar.wasm Binary files differindex 609fa718..fc2d6f1c 100755 --- a/zellij-utils/assets/plugins/tab-bar.wasm +++ b/zellij-utils/assets/plugins/tab-bar.wasm diff --git a/zellij-utils/assets/prost/api.action.rs b/zellij-utils/assets/prost/api.action.rs index ccf96860..52ab0ab0 100644 --- a/zellij-utils/assets/prost/api.action.rs +++ b/zellij-utils/assets/prost/api.action.rs @@ -257,21 +257,31 @@ pub struct NewTabPayload { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] -pub struct OverrideLayoutPayload { - #[prost(message, optional, tag="1")] +pub struct TabLayoutInfo { + #[prost(uint32, tag="1")] + pub tab_index: u32, + #[prost(string, optional, tag="2")] + pub tab_name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag="3")] pub tiled_layout: ::core::option::Option<TiledPaneLayout>, - #[prost(message, repeated, tag="2")] + #[prost(message, repeated, tag="4")] pub floating_layouts: ::prost::alloc::vec::Vec<FloatingPaneLayout>, - #[prost(message, repeated, tag="3")] + #[prost(message, repeated, tag="5")] pub swap_tiled_layouts: ::prost::alloc::vec::Vec<SwapTiledLayout>, - #[prost(message, repeated, tag="4")] + #[prost(message, repeated, tag="6")] pub swap_floating_layouts: ::prost::alloc::vec::Vec<SwapFloatingLayout>, - #[prost(string, optional, tag="5")] - pub tab_name: ::core::option::Option<::prost::alloc::string::String>, - #[prost(bool, tag="10")] +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OverrideLayoutPayload { + #[prost(message, repeated, tag="1")] + pub tabs: ::prost::alloc::vec::Vec<TabLayoutInfo>, + #[prost(bool, tag="2")] pub retain_existing_terminal_panes: bool, - #[prost(bool, tag="11")] + #[prost(bool, tag="3")] pub retain_existing_plugin_panes: bool, + #[prost(bool, tag="4")] + pub apply_only_to_active_tab: bool, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs index 5e1d7d1c..03a3597c 100644 --- a/zellij-utils/assets/prost/api.event.rs +++ b/zellij-utils/assets/prost/api.event.rs @@ -9,7 +9,7 @@ pub struct EventNameList { pub struct Event { #[prost(enumeration="EventType", tag="1")] pub name: i32, - #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33")] + #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34")] pub payload: ::core::option::Option<event::Payload>, } /// Nested message and enum types in `Event`. @@ -81,6 +81,8 @@ pub mod event { ActionCompletePayload(super::ActionCompletePayload), #[prost(message, tag="33")] CwdChangedPayload(super::CwdChangedPayload), + #[prost(message, tag="34")] + AvailableLayoutInfoPayload(super::AvailableLayoutInfoPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -218,6 +220,14 @@ pub struct SessionUpdatePayload { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct AvailableLayoutInfoPayload { + #[prost(message, repeated, tag="1")] + pub available_layouts: ::prost::alloc::vec::Vec<LayoutInfo>, + #[prost(message, repeated, tag="2")] + pub layouts_with_errors: ::prost::alloc::vec::Vec<LayoutWithError>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct RunCommandResultPayload { #[prost(int32, optional, tag="1")] pub exit_code: ::core::option::Option<i32>, @@ -391,6 +401,88 @@ pub struct LayoutInfo { pub name: ::prost::alloc::string::String, #[prost(string, tag="2")] pub source: ::prost::alloc::string::String, + #[prost(message, optional, tag="5")] + pub layout_metadata: ::core::option::Option<LayoutMetadata>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LayoutWithError { + #[prost(string, tag="1")] + pub layout_name: ::prost::alloc::string::String, + #[prost(message, optional, tag="2")] + pub error: ::core::option::Option<LayoutParsingError>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LayoutParsingError { + #[prost(oneof="layout_parsing_error::ErrorType", tags="1, 2")] + pub error_type: ::core::option::Option<layout_parsing_error::ErrorType>, +} +/// Nested message and enum types in `LayoutParsingError`. +pub mod layout_parsing_error { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum ErrorType { + #[prost(message, tag="1")] + KdlError(super::KdlErrorVariant), + #[prost(message, tag="2")] + SyntaxError(super::SyntaxError), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct KdlErrorVariant { + #[prost(message, optional, tag="1")] + pub kdl_error: ::core::option::Option<KdlError>, + #[prost(string, tag="2")] + pub file_name: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub source_code: ::prost::alloc::string::String, +} +/// Empty message, just a marker +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SyntaxError { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct KdlError { + #[prost(string, tag="1")] + pub error_message: ::prost::alloc::string::String, + #[prost(uint64, optional, tag="2")] + pub offset: ::core::option::Option<u64>, + #[prost(uint64, optional, tag="3")] + pub len: ::core::option::Option<u64>, + #[prost(string, optional, tag="4")] + pub help_message: ::core::option::Option<::prost::alloc::string::String>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LayoutMetadata { + #[prost(message, repeated, tag="1")] + pub tabs: ::prost::alloc::vec::Vec<TabMetadata>, + #[prost(string, tag="2")] + pub creation_time: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub update_time: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TabMetadata { + #[prost(message, repeated, tag="1")] + pub pane_metadata: ::prost::alloc::vec::Vec<PaneMetadata>, + #[prost(string, optional, tag="2")] + pub name: ::core::option::Option<::prost::alloc::string::String>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaneMetadata { + #[prost(string, optional, tag="1")] + pub name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(bool, tag="2")] + pub is_plugin: bool, + #[prost(bool, tag="3")] + pub is_builtin_plugin: bool, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -674,6 +766,7 @@ pub enum EventType { UserAction = 37, ActionComplete = 38, CwdChanged = 39, + AvailableLayoutInfo = 40, } impl EventType { /// String value of the enum field names used in the ProtoBuf definition. @@ -721,6 +814,7 @@ impl EventType { EventType::UserAction => "UserAction", EventType::ActionComplete => "ActionComplete", EventType::CwdChanged => "CwdChanged", + EventType::AvailableLayoutInfo => "AvailableLayoutInfo", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -765,6 +859,7 @@ impl EventType { "UserAction" => Some(Self::UserAction), "ActionComplete" => Some(Self::ActionComplete), "CwdChanged" => Some(Self::CwdChanged), + "AvailableLayoutInfo" => Some(Self::AvailableLayoutInfo), _ => None, } } diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index 58abbc2b..7caeb247 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -3,7 +3,7 @@ pub struct PluginCommand { #[prost(enumeration="CommandName", tag="1")] pub name: i32, - #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123")] + #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134")] pub payload: ::core::option::Option<plugin_command::Payload>, } /// Nested message and enum types in `PluginCommand`. @@ -229,6 +229,28 @@ pub mod plugin_command { SendSigkillToPaneIdPayload(super::PaneId), #[prost(message, tag="123")] GetPanePidPayload(super::GetPanePidPayload), + #[prost(message, tag="124")] + OverrideLayoutPayload(super::OverrideLayoutPayload), + #[prost(message, tag="125")] + SaveLayoutPayload(super::SaveLayoutPayload), + #[prost(message, tag="126")] + DeleteLayoutPayload(super::DeleteLayoutPayload), + #[prost(message, tag="127")] + EditLayoutPayload(super::EditLayoutPayload), + #[prost(message, tag="128")] + GenerateRandomNamePayload(super::GenerateRandomNamePayload), + #[prost(message, tag="129")] + DumpLayoutPayload(super::DumpLayoutPayload), + #[prost(message, tag="130")] + ParseLayoutPayload(super::ParseLayoutPayload), + #[prost(message, tag="131")] + RenameLayoutPayload(super::RenameLayoutPayload), + #[prost(message, tag="132")] + GetLayoutDirPayload(super::GetLayoutDirPayload), + #[prost(message, tag="133")] + DumpSessionLayoutPayload(super::DumpSessionLayoutPayload), + #[prost(message, tag="134")] + GetFocusedPaneInfoPayload(super::GetFocusedPaneInfoPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -241,6 +263,12 @@ pub struct NewTabPayload { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct DumpSessionLayoutPayload { + #[prost(uint32, optional, tag="1")] + pub tab_index: ::core::option::Option<u32>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct RunActionPayload { #[prost(message, optional, tag="1")] pub action: ::core::option::Option<super::action::Action>, @@ -970,6 +998,234 @@ pub mod get_pane_pid_response { Error(::prost::alloc::string::String), } } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SaveLayoutResponse { + #[prost(oneof="save_layout_response::Result", tags="1, 2")] + pub result: ::core::option::Option<save_layout_response::Result>, +} +/// Nested message and enum types in `SaveLayoutResponse`. +pub mod save_layout_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(bool, tag="1")] + Success(bool), + #[prost(string, tag="2")] + Error(::prost::alloc::string::String), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeleteLayoutResponse { + #[prost(oneof="delete_layout_response::Result", tags="1, 2")] + pub result: ::core::option::Option<delete_layout_response::Result>, +} +/// Nested message and enum types in `DeleteLayoutResponse`. +pub mod delete_layout_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(bool, tag="1")] + Success(bool), + #[prost(string, tag="2")] + Error(::prost::alloc::string::String), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EditLayoutResponse { + #[prost(oneof="edit_layout_response::Result", tags="1, 2")] + pub result: ::core::option::Option<edit_layout_response::Result>, +} +/// Nested message and enum types in `EditLayoutResponse`. +pub mod edit_layout_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(bool, tag="1")] + Success(bool), + #[prost(string, tag="2")] + Error(::prost::alloc::string::String), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GenerateRandomNameResponse { + #[prost(string, tag="1")] + pub name: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OverrideLayoutPayload { + #[prost(message, optional, tag="1")] + pub layout_info: ::core::option::Option<super::event::LayoutInfo>, + #[prost(message, repeated, tag="2")] + pub context: ::prost::alloc::vec::Vec<ContextItem>, + #[prost(bool, tag="3")] + pub retain_existing_terminal_panes: bool, + #[prost(bool, tag="4")] + pub retain_existing_plugin_panes: bool, + #[prost(bool, tag="5")] + pub apply_only_to_active_tab: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SaveLayoutPayload { + #[prost(string, tag="1")] + pub layout_name: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub layout_kdl: ::prost::alloc::string::String, + #[prost(bool, tag="3")] + pub overwrite: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeleteLayoutPayload { + #[prost(string, tag="1")] + pub layout_name: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EditLayoutPayload { + #[prost(string, tag="1")] + pub layout_name: ::prost::alloc::string::String, + #[prost(message, repeated, tag="2")] + pub context: ::prost::alloc::vec::Vec<ContextItem>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GenerateRandomNamePayload { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DumpLayoutPayload { + #[prost(string, tag="1")] + pub layout_name: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DumpLayoutResponse { + #[prost(oneof="dump_layout_response::Result", tags="1, 2")] + pub result: ::core::option::Option<dump_layout_response::Result>, +} +/// Nested message and enum types in `DumpLayoutResponse`. +pub mod dump_layout_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(string, tag="1")] + LayoutContent(::prost::alloc::string::String), + #[prost(string, tag="2")] + Error(::prost::alloc::string::String), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DumpSessionLayoutResponse { + #[prost(message, optional, tag="3")] + pub metadata: ::core::option::Option<super::event::LayoutMetadata>, + #[prost(oneof="dump_session_layout_response::Result", tags="1, 2")] + pub result: ::core::option::Option<dump_session_layout_response::Result>, +} +/// Nested message and enum types in `DumpSessionLayoutResponse`. +pub mod dump_session_layout_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(string, tag="1")] + LayoutContent(::prost::alloc::string::String), + #[prost(string, tag="2")] + Error(::prost::alloc::string::String), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ParseLayoutPayload { + #[prost(string, tag="1")] + pub layout_string: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ParseLayoutResponse { + #[prost(oneof="parse_layout_response::Result", tags="1, 2")] + pub result: ::core::option::Option<parse_layout_response::Result>, +} +/// Nested message and enum types in `ParseLayoutResponse`. +pub mod parse_layout_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag="1")] + Metadata(super::super::event::LayoutMetadata), + #[prost(message, tag="2")] + Error(super::super::event::LayoutParsingError), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RenameLayoutPayload { + #[prost(string, tag="1")] + pub old_layout_name: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub new_layout_name: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RenameLayoutResponse { + #[prost(oneof="rename_layout_response::Result", tags="1, 2")] + pub result: ::core::option::Option<rename_layout_response::Result>, +} +/// Nested message and enum types in `RenameLayoutResponse`. +pub mod rename_layout_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(bool, tag="1")] + Success(bool), + #[prost(string, tag="2")] + Error(::prost::alloc::string::String), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetLayoutDirPayload { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetLayoutDirResponse { + #[prost(string, tag="1")] + pub layout_dir: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetFocusedPaneInfoPayload { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetFocusedPaneInfoResponse { + #[prost(oneof="get_focused_pane_info_response::Result", tags="1, 2")] + pub result: ::core::option::Option<get_focused_pane_info_response::Result>, +} +/// Nested message and enum types in `GetFocusedPaneInfoResponse`. +pub mod get_focused_pane_info_response { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Result { + #[prost(message, tag="1")] + FocusedPaneInfo(super::FocusedPaneInfo), + #[prost(string, tag="2")] + Error(::prost::alloc::string::String), + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FocusedPaneInfo { + #[prost(uint32, tag="1")] + pub focused_tab_index: u32, + #[prost(message, optional, tag="2")] + pub focused_pane_id: ::core::option::Option<PaneId>, +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum CommandName { @@ -1126,6 +1382,16 @@ pub enum CommandName { SendSigintToPaneId = 171, SendSigkillToPaneId = 172, GetPanePid = 173, + OverrideLayout = 174, + SaveLayout = 175, + DeleteLayout = 176, + EditLayout = 177, + GenerateRandomName = 178, + DumpLayout = 179, + ParseLayout = 180, + RenameLayout = 181, + GetLayoutDir = 182, + GetFocusedPaneInfo = 183, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -1287,6 +1553,16 @@ impl CommandName { CommandName::SendSigintToPaneId => "SendSigintToPaneId", CommandName::SendSigkillToPaneId => "SendSigkillToPaneId", CommandName::GetPanePid => "GetPanePid", + CommandName::OverrideLayout => "OverrideLayout", + CommandName::SaveLayout => "SaveLayout", + CommandName::DeleteLayout => "DeleteLayout", + CommandName::EditLayout => "EditLayout", + CommandName::GenerateRandomName => "GenerateRandomName", + CommandName::DumpLayout => "DumpLayout", + CommandName::ParseLayout => "ParseLayout", + CommandName::RenameLayout => "RenameLayout", + CommandName::GetLayoutDir => "GetLayoutDir", + CommandName::GetFocusedPaneInfo => "GetFocusedPaneInfo", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -1445,6 +1721,16 @@ impl CommandName { "SendSigintToPaneId" => Some(Self::SendSigintToPaneId), "SendSigkillToPaneId" => Some(Self::SendSigkillToPaneId), "GetPanePid" => Some(Self::GetPanePid), + "OverrideLayout" => Some(Self::OverrideLayout), + "SaveLayout" => Some(Self::SaveLayout), + "DeleteLayout" => Some(Self::DeleteLayout), + "EditLayout" => Some(Self::EditLayout), + "GenerateRandomName" => Some(Self::GenerateRandomName), + "DumpLayout" => Some(Self::DumpLayout), + "ParseLayout" => Some(Self::ParseLayout), + "RenameLayout" => Some(Self::RenameLayout), + "GetLayoutDir" => Some(Self::GetLayoutDir), + "GetFocusedPaneInfo" => Some(Self::GetFocusedPaneInfo), _ => None, } } diff --git a/zellij-utils/assets/prost_ipc/client_server_contract.rs b/zellij-utils/assets/prost_ipc/client_server_contract.rs index b0a9be5b..8d3680c2 100644 --- a/zellij-utils/assets/prost_ipc/client_server_contract.rs +++ b/zellij-utils/assets/prost_ipc/client_server_contract.rs @@ -466,21 +466,31 @@ pub struct NextSwapLayoutAction { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] -pub struct OverrideLayoutAction { - #[prost(message, optional, tag="1")] +pub struct TabLayoutInfo { + #[prost(uint32, tag="1")] + pub tab_index: u32, + #[prost(string, optional, tag="2")] + pub tab_name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag="3")] pub tiled_layout: ::core::option::Option<TiledPaneLayout>, - #[prost(message, repeated, tag="2")] + #[prost(message, repeated, tag="4")] pub floating_layouts: ::prost::alloc::vec::Vec<FloatingPaneLayout>, - #[prost(message, repeated, tag="3")] + #[prost(message, repeated, tag="5")] pub swap_tiled_layouts: ::prost::alloc::vec::Vec<SwapTiledLayout>, - #[prost(message, repeated, tag="4")] + #[prost(message, repeated, tag="6")] pub swap_floating_layouts: ::prost::alloc::vec::Vec<SwapFloatingLayout>, - #[prost(string, optional, tag="5")] - pub tab_name: ::core::option::Option<::prost::alloc::string::String>, - #[prost(bool, tag="6")] +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OverrideLayoutAction { + #[prost(message, repeated, tag="1")] + pub tabs: ::prost::alloc::vec::Vec<TabLayoutInfo>, + #[prost(bool, tag="2")] pub retain_existing_terminal_panes: bool, - #[prost(bool, tag="7")] + #[prost(bool, tag="3")] pub retain_existing_plugin_panes: bool, + #[prost(bool, tag="4")] + pub apply_only_to_active_tab: bool, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -1014,6 +1024,8 @@ pub struct CliAssets { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct LayoutInfo { + #[prost(message, optional, tag="5")] + pub layout_metadata: ::core::option::Option<LayoutMetadata>, #[prost(oneof="layout_info::LayoutType", tags="1, 2, 3, 4")] pub layout_type: ::core::option::Option<layout_info::LayoutType>, } @@ -1034,6 +1046,34 @@ pub mod layout_info { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct LayoutMetadata { + #[prost(message, repeated, tag="1")] + pub tabs: ::prost::alloc::vec::Vec<TabMetadata>, + #[prost(string, tag="2")] + pub creation_time: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub update_time: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TabMetadata { + #[prost(message, repeated, tag="1")] + pub pane_metadata: ::prost::alloc::vec::Vec<PaneMetadata>, + #[prost(string, optional, tag="2")] + pub name: ::core::option::Option<::prost::alloc::string::String>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PaneMetadata { + #[prost(string, optional, tag="1")] + pub name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(bool, tag="2")] + pub is_plugin: bool, + #[prost(bool, tag="3")] + pub is_builtin_plugin: bool, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct ConnectToSession { #[prost(string, optional, tag="1")] pub name: ::core::option::Option<::prost::alloc::string::String>, diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs index 1d196df9..d5070f2f 100644 --- a/zellij-utils/src/cli.rs +++ b/zellij-utils/src/cli.rs @@ -950,6 +950,11 @@ pub enum CliAction { /// Retain existing plugin panes that do not fit with the layout default: false) #[clap(long, value_parser, takes_value(false), default_value("false"))] retain_existing_plugin_panes: bool, + + /// Only apply the layout to the active tab (uses just the first layout tab if it has + /// multiple) + #[clap(long, value_parser, takes_value(false), default_value("false"))] + apply_only_to_active_tab: bool, }, /// Query all tab names QueryTabNames, diff --git a/zellij-utils/src/client_server_contract/common_types.proto b/zellij-utils/src/client_server_contract/common_types.proto index bbaeadad..3435718f 100644 --- a/zellij-utils/src/client_server_contract/common_types.proto +++ b/zellij-utils/src/client_server_contract/common_types.proto @@ -272,14 +272,20 @@ message DenyAction {} message ToggleMouseModeAction {} message PreviousSwapLayoutAction {} message NextSwapLayoutAction {} +message TabLayoutInfo { + uint32 tab_index = 1; + optional string tab_name = 2; + TiledPaneLayout tiled_layout = 3; + repeated FloatingPaneLayout floating_layouts = 4; + repeated SwapTiledLayout swap_tiled_layouts = 5; + repeated SwapFloatingLayout swap_floating_layouts = 6; +} + message OverrideLayoutAction { - optional TiledPaneLayout tiled_layout = 1; - repeated FloatingPaneLayout floating_layouts = 2; - repeated SwapTiledLayout swap_tiled_layouts = 3; - repeated SwapFloatingLayout swap_floating_layouts = 4; - optional string tab_name = 5; - bool retain_existing_terminal_panes = 6; - bool retain_existing_plugin_panes = 7; + repeated TabLayoutInfo tabs = 1; + bool retain_existing_terminal_panes = 2; + bool retain_existing_plugin_panes = 3; + bool apply_only_to_active_tab = 4; } message QueryTabNamesAction {} message BreakPaneAction {} @@ -635,6 +641,25 @@ message LayoutInfo { string url = 3; string stringified = 4; } + optional LayoutMetadata layout_metadata = 5; +} + +message LayoutMetadata { + repeated TabMetadata tabs = 1; + string creation_time = 2; + string update_time = 3; + +} + +message TabMetadata { + repeated PaneMetadata pane_metadata = 1; + optional string name = 2; +} + +message PaneMetadata { + optional string name = 1; + bool is_plugin = 2; + bool is_builtin_plugin = 3; } message ConnectToSession { diff --git a/zellij-utils/src/consts.rs b/zellij-utils/src/consts.rs index e5b93e1a..3faab0c9 100644 --- a/zellij-utils/src/consts.rs +++ b/zellij-utils/src/consts.rs @@ -139,6 +139,7 @@ mod not_wasm { add_plugin!(assets, "share.wasm"); add_plugin!(assets, "multiple-select.wasm"); add_plugin!(assets, "sequence.wasm"); + add_plugin!(assets, "layout-manager.wasm"); assets }; } diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 4a29375a..177d8c7b 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -1,8 +1,10 @@ use crate::home::default_layout_dir; use crate::input::actions::{Action, RunCommandAction}; -use crate::input::config::ConversionError; +use crate::input::config::{ConversionError, KdlError}; use crate::input::keybinds::Keybinds; -use crate::input::layout::{RunPlugin, RunPluginOrAlias, SplitSize}; +use crate::input::layout::{ + Layout, Run, RunPlugin, RunPluginLocation, RunPluginOrAlias, SplitSize, +}; use crate::pane_size::PaneGeom; use crate::position::Position; use crate::shared::{colors as default_colors, eightbit_to_rgb}; @@ -11,6 +13,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt; use std::fs::Metadata; +use std::hash::{Hash, Hasher}; use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::str::{self, FromStr}; @@ -612,6 +615,17 @@ impl KeyWithModifier { } true } + pub fn has_only_modifiers(&self, modifiers: &[KeyModifier]) -> bool { + for modifier in modifiers { + if !self.key_modifiers.contains(modifier) { + return false; + } + } + if self.key_modifiers.len() != modifiers.len() { + return false; + } + true + } } #[derive(Eq, Clone, Copy, Debug, PartialEq, Hash, Deserialize, Serialize, PartialOrd, Ord)] @@ -996,6 +1010,7 @@ pub enum Event { PaneRenderReport(HashMap<PaneId, PaneContents>), ActionComplete(Action, Option<PaneId>, BTreeMap<String, String>), // Action, pane_id, context CwdChanged(PaneId, PathBuf, Vec<ClientId>), // pane_id, cwd, focused_client_ids + AvailableLayoutInfo(Vec<LayoutInfo>, Vec<LayoutWithError>), } #[derive(Debug, Clone, PartialEq, Eq, EnumDiscriminants, Display, Serialize, Deserialize)] @@ -1717,16 +1732,266 @@ impl From<RunPlugin> for PluginInfo { #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub enum LayoutInfo { BuiltIn(String), - File(String), + File(String, LayoutMetadata), Url(String), Stringified(String), } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct LayoutWithError { + pub layout_name: String, + pub error: LayoutParsingError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub enum LayoutParsingError { + KdlError { + kdl_error: KdlError, + file_name: String, + source_code: String, + }, + SyntaxError, +} + +impl AsRef<LayoutInfo> for LayoutInfo { + fn as_ref(&self) -> &LayoutInfo { + self + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct LayoutMetadata { + pub tabs: Vec<TabMetadata>, + pub creation_time: String, + pub update_time: String, +} + +impl From<&PathBuf> for LayoutMetadata { + fn from(path: &PathBuf) -> LayoutMetadata { + match Layout::stringified_from_path(path) { + Ok((path_str, stringified_layout, _swap_layouts)) => { + match Layout::from_kdl(&stringified_layout, Some(path_str), None, None) { + Ok(layout) => { + let layout_tabs = layout.tabs(); + let tabs = if layout_tabs.is_empty() { + let (tiled_pane_layout, floating_pane_layout) = layout.new_tab(); + vec![TabMetadata::from(&( + None, + tiled_pane_layout, + floating_pane_layout, + ))] + } else { + layout + .tabs() + .into_iter() + .map(|tab| TabMetadata::from(&tab)) + .collect() + }; + + // Get file metadata for creation and modification times as Unix epochs + let (creation_time, update_time) = + LayoutMetadata::creation_and_update_times(&path); + + LayoutMetadata { + tabs, + creation_time, + update_time, + } + }, + Err(e) => { + log::error!("Failed to parse layout: {}", e); + LayoutMetadata::default() + }, + } + }, + Err(e) => { + log::error!("Failed to read layout file: {}", e); + LayoutMetadata::default() + }, + } + } +} + +impl LayoutMetadata { + fn creation_and_update_times(path: &PathBuf) -> (String, String) { + // (creation_time, update_time) returns stringified unix epoch + match std::fs::metadata(path) { + Ok(metadata) => { + let creation_time = metadata + .created() + .ok() + .and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs().to_string()) + }) + .unwrap_or_default(); + + let update_time = metadata + .modified() + .ok() + .and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs().to_string()) + }) + .unwrap_or_default(); + + (creation_time, update_time) + }, + Err(_) => (String::new(), String::new()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TabMetadata { + pub panes: Vec<PaneMetadata>, + pub name: Option<String>, +} + +impl + From<&( + Option<String>, + crate::input::layout::TiledPaneLayout, + Vec<crate::input::layout::FloatingPaneLayout>, + )> for TabMetadata +{ + fn from( + tab: &( + Option<String>, + crate::input::layout::TiledPaneLayout, + Vec<crate::input::layout::FloatingPaneLayout>, + ), + ) -> Self { + let (tab_name, tiled_pane_layout, floating_panes) = tab; + + // Collect panes from tiled layout (only leaf nodes are real panes) + let mut panes = Vec::new(); + collect_leaf_panes(&tiled_pane_layout, &mut panes); + + // Collect panes from floating panes + for floating_pane in floating_panes { + panes.push(PaneMetadata::from(floating_pane)); + } + + TabMetadata { + panes, + name: tab_name.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PaneMetadata { + pub name: Option<String>, + pub is_plugin: bool, + pub is_builtin_plugin: bool, +} + +impl From<&crate::input::layout::TiledPaneLayout> for PaneMetadata { + fn from(pane: &crate::input::layout::TiledPaneLayout) -> Self { + let mut is_plugin = false; + let mut is_builtin_plugin = false; + + // Try to get the name from the pane's name field first + let name = if let Some(ref name) = pane.name { + Some(name.clone()) + } else if let Some(ref run) = pane.run { + // If no explicit name, glean it from the run configuration + match run { + Run::Command(cmd) => { + // Use the command name + Some(cmd.command.to_string_lossy().to_string()) + }, + Run::EditFile(path, _line, _cwd) => { + // Use the file name + path.file_name().map(|n| n.to_string_lossy().to_string()) + }, + Run::Plugin(plugin) => { + is_plugin = true; + is_builtin_plugin = plugin.is_builtin_plugin(); + Some(plugin.location_string()) + }, + Run::Cwd(_) => None, + } + } else { + None + }; + + PaneMetadata { + name, + is_plugin, + is_builtin_plugin, + } + } +} + +impl From<&crate::input::layout::FloatingPaneLayout> for PaneMetadata { + fn from(pane: &crate::input::layout::FloatingPaneLayout) -> Self { + let mut is_plugin = false; + let mut is_builtin_plugin = false; + + // Try to get the name from the pane's name field first + let name = if let Some(ref name) = pane.name { + Some(name.clone()) + } else if let Some(ref run) = pane.run { + // If no explicit name, glean it from the run configuration + match run { + Run::Command(cmd) => { + // Use the command name + Some(cmd.command.to_string_lossy().to_string()) + }, + Run::EditFile(path, _line, _cwd) => { + // Use the file name + path.file_name().map(|n| n.to_string_lossy().to_string()) + }, + Run::Plugin(plugin) => { + is_plugin = true; + is_builtin_plugin = match plugin { + crate::input::layout::RunPluginOrAlias::RunPlugin(run_plugin) => { + matches!(run_plugin.location, RunPluginLocation::Zellij(_)) + }, + crate::input::layout::RunPluginOrAlias::Alias(_) => false, + }; + // Use the plugin location string + Some(plugin.location_string()) + }, + Run::Cwd(_) => None, + } + } else { + None + }; + + PaneMetadata { + name, + is_plugin, + is_builtin_plugin, + } + } +} + +// Helper function to recursively collect leaf panes from TiledPaneLayout +fn collect_leaf_panes( + pane: &crate::input::layout::TiledPaneLayout, + result: &mut Vec<PaneMetadata>, +) { + if pane.children.is_empty() { + // This is a leaf node (actual pane) + result.push(PaneMetadata::from(pane)); + } else { + // This is a container, recurse into children + for child in &pane.children { + collect_leaf_panes(child, result); + } + } +} + impl LayoutInfo { pub fn name(&self) -> &str { match self { LayoutInfo::BuiltIn(name) => &name, - LayoutInfo::File(name) => &name, + LayoutInfo::File(name, _) => &name, LayoutInfo::Url(url) => &url, LayoutInfo::Stringified(layout) => &layout, } @@ -1734,7 +1999,7 @@ impl LayoutInfo { pub fn is_builtin(&self) -> bool { match self { LayoutInfo::BuiltIn(_name) => true, - LayoutInfo::File(_name) => false, + LayoutInfo::File(_name, _) => false, LayoutInfo::Url(_url) => false, LayoutInfo::Stringified(_stringified) => false, } @@ -1753,8 +2018,11 @@ impl LayoutInfo { else { return None; }; + let file_path = layout_dir.join(layout_path); Some(LayoutInfo::File( - layout_dir.join(layout_path).display().to_string(), + // layout_dir.join(layout_path).display().to_string(), + file_path.display().to_string(), + LayoutMetadata::from(&file_path), )) } else if layout_path.starts_with("http://") || layout_path.starts_with("https://") { @@ -1768,8 +2036,6 @@ impl LayoutInfo { } } -use std::hash::{Hash, Hasher}; - #[allow(clippy::derive_hash_xor_eq)] impl Hash for SessionInfo { fn hash<H: Hasher>(&self, state: &mut H) { @@ -2113,6 +2379,36 @@ pub enum GetPanePidResponse { Err(String), } +#[derive(Debug, Clone, PartialEq)] +pub enum GetFocusedPaneInfoResponse { + Ok { tab_index: usize, pane_id: PaneId }, + Err(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SaveLayoutResponse { + Ok(()), + Err(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum DeleteLayoutResponse { + Ok(()), + Err(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum RenameLayoutResponse { + Ok(()), + Err(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EditLayoutResponse { + Ok(()), + Err(String), +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct SelectedText { pub start: Position, @@ -2391,7 +2687,7 @@ pub struct ConnectToSession { impl ConnectToSession { pub fn apply_layout_dir(&mut self, layout_dir: &PathBuf) { - if let Some(LayoutInfo::File(file_path)) = self.layout.as_mut() { + if let Some(LayoutInfo::File(file_path, _layout_metadata)) = self.layout.as_mut() { *file_path = Path::join(layout_dir, &file_path) .to_string_lossy() .to_string(); @@ -2829,7 +3125,9 @@ pub enum PluginCommand { KillSessions(Vec<String>), // one or more session names ScanHostFolder(PathBuf), // TODO: rename to ScanHostFolder WatchFilesystem, - DumpSessionLayout, + DumpSessionLayout { + tab_index: Option<usize>, + }, CloseSelf, NewTabsWithLayoutInfo(LayoutInfo), Reconfigure(String, bool), // String -> stringified configuration, bool -> save configuration @@ -2920,4 +3218,32 @@ pub enum PluginCommand { // suppress_replaced_pane) RunAction(Action, BTreeMap<String, String>), CopyToClipboard(String), // text to copy + OverrideLayout( + LayoutInfo, + bool, // retain_existing_terminal_panes + bool, // retain_existing_plugin_panes + bool, // apply_only_to_active_tab, + BTreeMap<String, String>, // context + ), + SaveLayout { + layout_name: String, + layout_kdl: String, + overwrite: bool, + }, + DeleteLayout { + layout_name: String, + }, + RenameLayout { + old_layout_name: String, + new_layout_name: String, + }, + EditLayout { + layout_name: String, + context: Context, + }, + GenerateRandomName, + DumpLayout(String), + ParseLayout(String), // String contains raw KDL layout + GetLayoutDir, + GetFocusedPaneInfo, } diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index 78330d76..06f69e06 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -353,6 +353,7 @@ pub enum ScreenContext { SerializeLayoutForResurrection, RenameSession, DumpLayoutToPlugin, + GetFocusedPaneInfo, ListClientsMetadata, Reconfigure, RerunCommandPane, @@ -469,6 +470,8 @@ pub enum PluginContext { FailedToStartWebServer, PaneRenderReport, UserInput, + LayoutListUpdate, + RequestStateUpdateForPlugin, } /// Stack call representations corresponding to the different types of [`ClientInstruction`]s. diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs index 42ec2037..caeadf4d 100644 --- a/zellij-utils/src/input/actions.rs +++ b/zellij-utils/src/input/actions.rs @@ -3,7 +3,7 @@ pub use super::command::{OpenFilePayload, RunCommandAction}; use super::layout::{ FloatingPaneLayout, Layout, PluginAlias, RunPlugin, RunPluginLocation, RunPluginOrAlias, - SwapFloatingLayout, SwapTiledLayout, TiledPaneLayout, + SwapFloatingLayout, SwapTiledLayout, TabLayoutInfo, TiledPaneLayout, }; use crate::cli::CliAction; use crate::data::{ @@ -351,13 +351,10 @@ pub enum Action { NextSwapLayout, /// Override the layout of the active tab OverrideLayout { - tiled_layout: Option<TiledPaneLayout>, - floating_layouts: Vec<FloatingPaneLayout>, - swap_tiled_layouts: Option<Vec<SwapTiledLayout>>, - swap_floating_layouts: Option<Vec<SwapFloatingLayout>>, - tab_name: Option<String>, + tabs: Vec<TabLayoutInfo>, retain_existing_terminal_panes: bool, retain_existing_plugin_panes: bool, + apply_only_to_active_tab: bool, }, /// Query all tab names QueryTabNames, @@ -989,6 +986,7 @@ impl Action { layout_dir, retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_active_tab, } => { // Determine layout_dir: CLI arg > config > default let layout_dir = layout_dir @@ -1041,31 +1039,41 @@ impl Action { stringified_error })?; - // Extract layout from first tab or use default - let swap_tiled_layouts = Some(layout.swap_tiled_layouts.clone()); - let swap_floating_layouts = Some(layout.swap_floating_layouts.clone()); - - if layout.tabs.len() > 1 { - return Err(format!("This layout has {} tabs, overriding only supports layouts with 0 or 1 tabs.", layout.tabs.len())); - } + // Convert all tabs to Vec<TabLayoutInfo> + let tabs: Vec<TabLayoutInfo> = layout + .tabs + .iter() + .enumerate() + .map(|(index, (tab_name, tiled, floating))| TabLayoutInfo { + tab_index: index, + tab_name: tab_name.clone(), + tiled_layout: tiled.clone(), + floating_layouts: floating.clone(), + swap_tiled_layouts: Some(layout.swap_tiled_layouts.clone()), + swap_floating_layouts: Some(layout.swap_floating_layouts.clone()), + }) + .collect(); - let (name, tiled_layout, floating_panes_layout) = - if let Some(first_tab) = layout.tabs.get(0).take() { - first_tab.clone() - } else { - let name = None; - let (tiled_panes, floating_panes) = layout.new_tab(); - (name, tiled_panes, floating_panes) - }; + // If no tabs, create default tab + let tabs = if tabs.is_empty() { + let (tiled, floating) = layout.new_tab(); + vec![TabLayoutInfo { + tab_index: 0, + tab_name: None, + tiled_layout: tiled, + floating_layouts: floating, + swap_tiled_layouts: Some(layout.swap_tiled_layouts), + swap_floating_layouts: Some(layout.swap_floating_layouts), + }] + } else { + tabs + }; Ok(vec![Action::OverrideLayout { - tiled_layout: Some(tiled_layout), - floating_layouts: floating_panes_layout, - swap_tiled_layouts, - swap_floating_layouts, - tab_name: name, + tabs, retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_active_tab, }]) }, CliAction::QueryTabNames => Ok(vec![Action::QueryTabNames]), diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs index 1f407621..9882b8e6 100644 --- a/zellij-utils/src/input/config.rs +++ b/zellij-utils/src/input/config.rs @@ -1,4 +1,8 @@ use crate::data::Styling; + +#[cfg(not(target_family = "wasm"))] +use crate::data::{LayoutInfo, LayoutWithError}; + use miette::{Diagnostic, LabeledSpan, NamedSource, SourceCode}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -36,15 +40,40 @@ pub struct Config { pub web_client: WebClientConfig, } -#[derive(Error, Debug)] +#[derive(Error, Debug, Serialize, Deserialize)] pub struct KdlError { pub error_message: String, + #[serde(skip)] pub src: Option<NamedSource>, pub offset: Option<usize>, pub len: Option<usize>, pub help_message: Option<String>, } +impl Clone for KdlError { + fn clone(&self) -> Self { + KdlError { + error_message: self.error_message.clone(), + src: None, // NamedSource doesn't implement Clone, so we skip it + offset: self.offset, + len: self.len, + help_message: self.help_message.clone(), + } + } +} + +impl PartialEq for KdlError { + fn eq(&self, other: &Self) -> bool { + // Compare everything except src (which doesn't implement PartialEq) + self.error_message == other.error_message + && self.offset == other.offset + && self.len == other.len + && self.help_message == other.help_message + } +} + +impl Eq for KdlError {} + impl KdlError { pub fn add_src(mut self, src_name: String, src_input: String) -> Self { self.src = Some(NamedSource::new(src_name, src_input)); @@ -482,6 +511,72 @@ where } } +#[cfg(not(target_family = "wasm"))] +pub async fn watch_layout_dir_changes<F, Fut>( + layout_dir: PathBuf, + default_layout_name: Option<String>, + on_layout_change: F, +) where + F: Fn(Vec<LayoutInfo>, Vec<LayoutWithError>) -> Fut + Send + 'static, + Fut: std::future::Future<Output = ()> + Send, +{ + use crate::input::layout::Layout; + use notify::{self, Config as WatcherConfig, Event, PollWatcher, RecursiveMode, Watcher}; + use std::time::Duration; + use tokio::sync::mpsc; + + loop { + if layout_dir.exists() { + let (tx, mut rx) = mpsc::unbounded_channel(); + + let mut watcher = match PollWatcher::new( + move |res: Result<Event, notify::Error>| { + let _ = tx.send(res); + }, + WatcherConfig::default().with_poll_interval(Duration::from_secs(1)), + ) { + Ok(watcher) => watcher, + Err(_) => break, + }; + + if watcher + .watch(&layout_dir, RecursiveMode::Recursive) + .is_err() + { + break; + } + + while let Some(event_result) = rx.recv().await { + match event_result { + Ok(event) => { + if event.kind.is_remove() + || event.kind.is_create() + || event.kind.is_modify() + { + tokio::time::sleep(Duration::from_millis(100)).await; + + if !layout_dir.exists() { + break; + } + + let (layouts, layout_errors) = Layout::list_available_layouts( + Some(layout_dir.clone()), + &default_layout_name, + ); + on_layout_change(layouts, layout_errors).await; + } + }, + Err(_) => break, + } + } + } + + while !layout_dir.exists() { + tokio::time::sleep(Duration::from_secs(3)).await; + } + } +} + #[cfg(test)] mod config_test { use super::*; diff --git a/zellij-utils/src/input/layout.rs b/zellij-utils/src/input/layout.rs index d925f7c5..8ba81159 100644 --- a/zellij-utils/src/input/layout.rs +++ b/zellij-utils/src/input/layout.rs @@ -11,7 +11,7 @@ #[cfg(not(target_family = "wasm"))] use crate::downloader::Downloader; use crate::{ - data::{Direction, LayoutInfo}, + data::{Direction, LayoutInfo, LayoutMetadata, LayoutParsingError, LayoutWithError}, home::{default_layout_dir, find_default_config_dir}, input::{ command::RunCommand, @@ -229,6 +229,11 @@ impl RunPluginOrAlias { }, } } + pub fn is_builtin_plugin(&self) -> bool { + self.get_run_plugin() + .map(|r| matches!(r.location, RunPluginLocation::Zellij(_))) + .unwrap_or(false) + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -681,6 +686,17 @@ pub struct Layout { pub swap_floating_layouts: Vec<SwapFloatingLayout>, } +/// Layout configuration for a single tab in multi-tab override +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct TabLayoutInfo { + pub tab_index: usize, + pub tab_name: Option<String>, + pub tiled_layout: TiledPaneLayout, + pub floating_layouts: Vec<FloatingPaneLayout>, + pub swap_tiled_layouts: Option<Vec<SwapTiledLayout>>, + pub swap_floating_layouts: Option<Vec<SwapFloatingLayout>>, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub enum PercentOrFixed { Percent(usize), // 1 to 100 @@ -1189,39 +1205,70 @@ impl Layout { pub fn list_available_layouts( layout_dir: Option<PathBuf>, default_layout_name: &Option<String>, - ) -> Vec<LayoutInfo> { - let mut available_layouts = layout_dir + ) -> (Vec<LayoutInfo>, Vec<LayoutWithError>) { + let (mut available_layouts, layouts_with_errors) = layout_dir .clone() .or_else(|| default_layout_dir()) - .and_then(|layout_dir| match std::fs::read_dir(layout_dir) { - Ok(layout_files) => Some(layout_files), + .and_then(|layout_dir| match std::fs::read_dir(&layout_dir) { + Ok(layout_files) => Some((layout_files, layout_dir)), Err(_) => None, }) - .map(|layout_files| { + .map(|(layout_files, layout_dir)| { let mut available_layouts = vec![]; + let mut layouts_with_errors = vec![]; for file in layout_files { if let Ok(file) = file { if file.path().extension().map(|e| e.to_ascii_lowercase()) == Some(std::ffi::OsString::from("kdl")) { - if Layout::from_path_or_default_without_config( + let layout_name = file + .path() + .file_stem() + .and_then(|f| f.to_str()) + .map(|f| f.to_string()) + .unwrap_or_default(); + + match Layout::from_path_or_default_without_config( Some(&file.path()), - layout_dir.clone(), - ) - .is_ok() - { - if let Some(file_name) = file.path().file_stem() { + Some(layout_dir.clone()), + ) { + Ok(_layout) => { + let file_path = layout_dir.join(file.path()); // TODO: do we + // need + // file_stem() + // here too? available_layouts.push(LayoutInfo::File( - file_name.to_string_lossy().to_string(), + layout_name, + LayoutMetadata::from(&file_path), )) - } + }, + Err(config_error) => { + let file_path = file.path(); + let file_name = + file_path.to_str().unwrap_or("unknown").to_string(); + let source_code = + std::fs::read_to_string(&file_path).unwrap_or_default(); + + let error = match config_error { + ConfigError::KdlError(kdl_err) => { + LayoutParsingError::KdlError { + kdl_error: kdl_err, + file_name, + source_code, + } + }, + _ => LayoutParsingError::SyntaxError, + }; + layouts_with_errors + .push(LayoutWithError { layout_name, error }); + }, } } } } - available_layouts + (available_layouts, layouts_with_errors) }) - .unwrap_or_else(Default::default); + .unwrap_or_else(|| (Default::default(), Default::default())); let default_layout_name = default_layout_name .as_ref() .map(|d| d.as_str()) @@ -1242,7 +1289,7 @@ impl Layout { a_name.cmp(&b_name) } }); - available_layouts + (available_layouts, layouts_with_errors) } pub fn from_layout_info( layout_dir: &Option<PathBuf>, @@ -1250,7 +1297,7 @@ impl Layout { ) -> Result<Layout, ConfigError> { let mut should_start_layout_commands_suspended = false; let (path_to_raw_layout, raw_layout, raw_swap_layouts) = match layout_info { - LayoutInfo::File(layout_name_without_extension) => { + LayoutInfo::File(layout_name_without_extension, _layout_metadata) => { let layout_dir = layout_dir.clone().or_else(|| default_layout_dir()); let (path_to_layout, stringified_layout, swap_layouts) = Self::stringified_from_dir( @@ -1293,7 +1340,7 @@ impl Layout { ) -> Result<(Layout, Config), ConfigError> { let mut should_start_layout_commands_suspended = false; let (path_to_raw_layout, raw_layout, raw_swap_layouts) = match layout_info { - LayoutInfo::File(layout_name_without_extension) => { + LayoutInfo::File(layout_name_without_extension, _layout_metadata) => { let layout_dir = layout_dir.clone().or_else(|| default_layout_dir()); let (path_to_layout, stringified_layout, swap_layouts) = Self::stringified_from_dir( @@ -1371,6 +1418,19 @@ impl Layout { let raw_layout = String::new(); Ok(raw_layout) } + pub fn from_path_without_config(layout_path: &PathBuf) -> Result<Layout, ConfigError> { + // (path_to_layout as String, stringified_layout, Option<path_to_swap_layout as String, stringified_swap_layout>) + let (path_to_layout, raw_layout, raw_swap_layouts) = + Layout::stringified_from_path(layout_path)?; + Layout::from_kdl( + &raw_layout, + Some(path_to_layout), + raw_swap_layouts + .as_ref() + .map(|(r, f)| (r.as_str(), f.as_str())), + None, + ) + } pub fn from_path_or_default( layout_path: Option<&PathBuf>, layout_dir: Option<PathBuf>, diff --git a/zellij-utils/src/input/plugins.rs b/zellij-utils/src/input/plugins.rs index 892c7a66..fb63a786 100644 --- a/zellij-utils/src/input/plugins.rs +++ b/zellij-utils/src/input/plugins.rs @@ -68,6 +68,7 @@ impl PluginConfig { || tag == "share" || tag == "multiple-select" || tag == "sequence" + || tag == "layout-manager" { Some(PluginConfig { path: PathBuf::from(&tag), diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs index e803f51e..4ade64b6 100644 --- a/zellij-utils/src/ipc/protobuf_conversion.rs +++ b/zellij-utils/src/ipc/protobuf_conversion.rs @@ -5,9 +5,10 @@ use crate::{ ClientToServerMsg as ProtoClientToServerMsg, ColorRegistersMsg, ConfigFileUpdatedMsg, ConnStatusMsg, ConnectedMsg, DetachSessionMsg, ExitMsg, ExitReason as ProtoExitReason, FailedToStartWebServerMsg, FirstClientConnectedMsg, ForegroundColorMsg, - InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, LogErrorMsg, LogMsg, - QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg, - ServerToClientMsg as ProtoServerToClientMsg, StartWebServerMsg, SwitchSessionMsg, + InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, LayoutMetadata as ProtoLayoutMetadata, + LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, QueryTerminalSizeMsg, + RenamedSessionMsg, RenderMsg, ServerToClientMsg as ProtoServerToClientMsg, + StartWebServerMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata, TerminalPixelDimensionsMsg, TerminalResizeMsg, UnblockCliPipeInputMsg, UnblockInputThreadMsg, WebServerStartedMsg, }, @@ -1098,25 +1099,15 @@ impl From<crate::input::actions::Action> ActionType::NextSwapLayout(NextSwapLayoutAction {}) }, crate::input::actions::Action::OverrideLayout { - tiled_layout, - floating_layouts, - swap_tiled_layouts, - swap_floating_layouts, - tab_name, + tabs, retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_active_tab, } => ActionType::OverrideLayout(OverrideLayoutAction { - tiled_layout: tiled_layout.map(|l| l.into()), - floating_layouts: floating_layouts.into_iter().map(|l| l.into()).collect(), - swap_tiled_layouts: swap_tiled_layouts - .map(|layouts| layouts.into_iter().map(|l| l.into()).collect()) - .unwrap_or_default(), - swap_floating_layouts: swap_floating_layouts - .map(|layouts| layouts.into_iter().map(|l| l.into()).collect()) - .unwrap_or_default(), - tab_name, + tabs: tabs.into_iter().map(|t| t.into()).collect(), retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_active_tab, }), crate::input::actions::Action::QueryTabNames => { ActionType::QueryTabNames(QueryTabNamesAction {}) @@ -1695,45 +1686,16 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> ActionType::NextSwapLayout(_) => Ok(crate::input::actions::Action::NextSwapLayout), ActionType::OverrideLayout(override_layout_action) => { Ok(crate::input::actions::Action::OverrideLayout { - tiled_layout: override_layout_action - .tiled_layout - .map(|l| l.try_into()) - .transpose()?, - floating_layouts: override_layout_action - .floating_layouts + tabs: override_layout_action + .tabs .into_iter() - .map(|l| l.try_into()) + .map(|t| t.try_into()) .collect::<Result<Vec<_>>>()?, - swap_tiled_layouts: if override_layout_action.swap_tiled_layouts.is_empty() { - None - } else { - Some( - override_layout_action - .swap_tiled_layouts - .into_iter() - .map(|l| l.try_into()) - .collect::<Result<Vec<_>>>()?, - ) - }, - swap_floating_layouts: if override_layout_action - .swap_floating_layouts - .is_empty() - { - None - } else { - Some( - override_layout_action - .swap_floating_layouts - .into_iter() - .map(|l| l.try_into()) - .collect::<Result<Vec<_>>>()?, - ) - }, - tab_name: override_layout_action.tab_name, retain_existing_terminal_panes: override_layout_action .retain_existing_terminal_panes, retain_existing_plugin_panes: override_layout_action .retain_existing_plugin_panes, + apply_only_to_active_tab: override_layout_action.apply_only_to_active_tab, }) }, ActionType::QueryTabNames(_) => Ok(crate::input::actions::Action::QueryTabNames), @@ -2031,14 +1993,19 @@ impl From<crate::data::LayoutInfo> { fn from(layout: crate::data::LayoutInfo) -> Self { use crate::client_server_contract::client_server_contract::layout_info::LayoutType; - let layout_type = match layout { - crate::data::LayoutInfo::BuiltIn(name) => LayoutType::BuiltinName(name), - crate::data::LayoutInfo::File(path) => LayoutType::FilePath(path), - crate::data::LayoutInfo::Url(url) => LayoutType::Url(url), - crate::data::LayoutInfo::Stringified(content) => LayoutType::Stringified(content), + let (layout_type, layout_metadata) = match layout { + crate::data::LayoutInfo::BuiltIn(name) => (LayoutType::BuiltinName(name), None), + crate::data::LayoutInfo::File(path, metadata) => { + (LayoutType::FilePath(path), Some(metadata.into())) + }, + crate::data::LayoutInfo::Url(url) => (LayoutType::Url(url), None), + crate::data::LayoutInfo::Stringified(content) => { + (LayoutType::Stringified(content), None) + }, }; Self { layout_type: Some(layout_type), + layout_metadata, } } } @@ -2053,7 +2020,14 @@ impl TryFrom<crate::client_server_contract::client_server_contract::LayoutInfo> use crate::client_server_contract::client_server_contract::layout_info::LayoutType; match layout.layout_type { Some(LayoutType::BuiltinName(name)) => Ok(crate::data::LayoutInfo::BuiltIn(name)), - Some(LayoutType::FilePath(path)) => Ok(crate::data::LayoutInfo::File(path)), + Some(LayoutType::FilePath(path)) => { + let layout_metadata = layout + .layout_metadata + .map(|m| m.try_into()) + .transpose()? + .unwrap_or_default(); + Ok(crate::data::LayoutInfo::File(path, layout_metadata)) + }, Some(LayoutType::Url(url)) => Ok(crate::data::LayoutInfo::Url(url)), Some(LayoutType::Stringified(content)) => { Ok(crate::data::LayoutInfo::Stringified(content)) @@ -2063,6 +2037,77 @@ impl TryFrom<crate::client_server_contract::client_server_contract::LayoutInfo> } } +impl From<crate::data::LayoutMetadata> for ProtoLayoutMetadata { + fn from(metadata: crate::data::LayoutMetadata) -> Self { + ProtoLayoutMetadata { + tabs: metadata.tabs.into_iter().map(|t| t.into()).collect(), + creation_time: metadata.creation_time, + update_time: metadata.update_time, + } + } +} + +impl TryFrom<ProtoLayoutMetadata> for crate::data::LayoutMetadata { + type Error = anyhow::Error; + fn try_from(proto_metadata: ProtoLayoutMetadata) -> Result<Self> { + let tabs = proto_metadata + .tabs + .into_iter() + .map(|t| t.try_into()) + .collect::<Result<Vec<_>>>()?; + Ok(crate::data::LayoutMetadata { + tabs, + creation_time: proto_metadata.creation_time, + update_time: proto_metadata.update_time, + }) + } +} + +impl From<crate::data::TabMetadata> for ProtoTabMetadata { + fn from(metadata: crate::data::TabMetadata) -> Self { + ProtoTabMetadata { + pane_metadata: metadata.panes.into_iter().map(|p| p.into()).collect(), + name: metadata.name, + } + } +} + +impl TryFrom<ProtoTabMetadata> for crate::data::TabMetadata { + type Error = anyhow::Error; + fn try_from(proto_metadata: ProtoTabMetadata) -> Result<Self> { + let panes = proto_metadata + .pane_metadata + .into_iter() + .map(|p| p.try_into()) + .collect::<Result<Vec<_>>>()?; + Ok(crate::data::TabMetadata { + panes, + name: proto_metadata.name, + }) + } +} + +impl From<crate::data::PaneMetadata> for ProtoPaneMetadata { + fn from(metadata: crate::data::PaneMetadata) -> Self { + ProtoPaneMetadata { + name: metadata.name, + is_plugin: metadata.is_plugin, + is_builtin_plugin: metadata.is_builtin_plugin, + } + } +} + +impl TryFrom<ProtoPaneMetadata> for crate::data::PaneMetadata { + type Error = anyhow::Error; + fn try_from(proto_metadata: ProtoPaneMetadata) -> Result<Self> { + Ok(crate::data::PaneMetadata { + name: proto_metadata.name, + is_plugin: proto_metadata.is_plugin, + is_builtin_plugin: proto_metadata.is_builtin_plugin, + }) + } +} + impl From<ExitReason> for ProtoExitReason { fn from(reason: ExitReason) -> Self { match reason { @@ -2686,6 +2731,82 @@ impl From<crate::input::layout::Run> } } +// TabLayoutInfo conversion +impl From<crate::input::layout::TabLayoutInfo> + for crate::client_server_contract::client_server_contract::TabLayoutInfo +{ + fn from(tab_info: crate::input::layout::TabLayoutInfo) -> Self { + Self { + tab_index: tab_info.tab_index as u32, + tab_name: tab_info.tab_name, + tiled_layout: Some(tab_info.tiled_layout.into()), + floating_layouts: tab_info + .floating_layouts + .into_iter() + .map(|l| l.into()) + .collect(), + swap_tiled_layouts: tab_info + .swap_tiled_layouts + .unwrap_or_default() + .into_iter() + .map(|l| l.into()) + .collect(), + swap_floating_layouts: tab_info + .swap_floating_layouts + .unwrap_or_default() + .into_iter() + .map(|l| l.into()) + .collect(), + } + } +} + +impl TryFrom<crate::client_server_contract::client_server_contract::TabLayoutInfo> + for crate::input::layout::TabLayoutInfo +{ + type Error = anyhow::Error; + + fn try_from( + protobuf_tab: crate::client_server_contract::client_server_contract::TabLayoutInfo, + ) -> Result<Self> { + Ok(crate::input::layout::TabLayoutInfo { + tab_index: protobuf_tab.tab_index as usize, + tab_name: protobuf_tab.tab_name.filter(|s| !s.is_empty()), + tiled_layout: protobuf_tab + .tiled_layout + .ok_or_else(|| anyhow!("missing tiled_layout"))? + .try_into()?, + floating_layouts: protobuf_tab + .floating_layouts + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>>>()?, + swap_tiled_layouts: if protobuf_tab.swap_tiled_layouts.is_empty() { + None + } else { + Some( + protobuf_tab + .swap_tiled_layouts + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>>>()?, + ) + }, + swap_floating_layouts: if protobuf_tab.swap_floating_layouts.is_empty() { + None + } else { + Some( + protobuf_tab + .swap_floating_layouts + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>>>()?, + ) + }, + }) + } +} + // TiledPaneLayout conversion impl From<crate::input::layout::TiledPaneLayout> for crate::client_server_contract::client_server_contract::TiledPaneLayout diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs index 216c6e14..01275198 100644 --- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs +++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs @@ -1,8 +1,8 @@ use super::test_framework::*; use crate::data::{ BareKey, CommandOrPlugin, ConnectToSession, Direction, FloatingPaneCoordinates, InputMode, - KeyModifier, KeyWithModifier, LayoutInfo, OriginatingPlugin, PaneId, PluginTag, Resize, - WebSharing, + KeyModifier, KeyWithModifier, LayoutInfo, LayoutMetadata, OriginatingPlugin, PaneId, PluginTag, + Resize, WebSharing, }; use crate::input::actions::{Action, SearchDirection, SearchOption}; use crate::input::cli_assets::CliAssets; @@ -2054,13 +2054,10 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::OverrideLayout { - tiled_layout: None, - floating_layouts: vec![], - swap_tiled_layouts: None, - swap_floating_layouts: None, - tab_name: None, + tabs: vec![], retain_existing_terminal_panes: false, retain_existing_plugin_panes: false, + apply_only_to_active_tab: false, }, terminal_id: Some(1), client_id: Some(100), @@ -3050,7 +3047,14 @@ fn test_server_messages() { name: Some("new_session_name".to_owned()), tab_position: Some(5), pane_id: Some((5, true)), - layout: Some(LayoutInfo::File("/path/to/my/file.kdl".to_owned())), + layout: Some(LayoutInfo::File( + "/path/to/my/file.kdl".to_owned(), + LayoutMetadata { + tabs: vec![], + creation_time: "0".to_owned(), + update_time: "0".to_owned() + } + )), cwd: Some(PathBuf::from("/path/to/cwd")), } }); diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs index 7603af3f..95d67ffd 100644 --- a/zellij-utils/src/kdl/mod.rs +++ b/zellij-utils/src/kdl/mod.rs @@ -1,15 +1,16 @@ mod kdl_layout_parser; use crate::data::{ BareKey, Direction, FloatingPaneCoordinates, InputMode, KeyWithModifier, LayoutInfo, - MultiplayerColors, Palette, PaletteColor, PaneId, PaneInfo, PaneManifest, PermissionType, - Resize, SessionInfo, StyleDeclaration, Styling, TabInfo, WebSharing, DEFAULT_STYLES, + LayoutMetadata, MultiplayerColors, Palette, PaletteColor, PaneId, PaneInfo, PaneManifest, + PermissionType, Resize, SessionInfo, StyleDeclaration, Styling, TabInfo, WebSharing, + DEFAULT_STYLES, }; use crate::envs::EnvironmentVariables; use crate::home::{find_default_config_dir, get_layout_dir}; use crate::input::config::{Config, ConfigError, KdlError}; use crate::input::keybinds::Keybinds; use crate::input::layout::{ - Layout, PluginUserConfiguration, RunPlugin, RunPluginOrAlias, SplitSize, + Layout, PluginUserConfiguration, RunPlugin, RunPluginOrAlias, SplitSize, TabLayoutInfo, }; use crate::input::options::{Clipboard, OnForceClose, Options}; use crate::input::permission::{GrantedPermission, PermissionCache}; @@ -1727,13 +1728,10 @@ impl TryFrom<(&KdlNode, &Options)> for Action { let command_metadata = action_children.iter().next(); if command_metadata.is_none() { return Ok(Action::OverrideLayout { - tiled_layout: None, - floating_layouts: vec![], - swap_tiled_layouts: None, - swap_floating_layouts: None, - tab_name: None, + tabs: vec![], retain_existing_terminal_panes: false, retain_existing_plugin_panes: false, + apply_only_to_active_tab: false, }); } @@ -1760,6 +1758,9 @@ impl TryFrom<(&KdlNode, &Options)> for Action { kdl_child_bool_value_for_entry(c_m, "retain_existing_plugin_panes") }) .unwrap_or(false); + let apply_only_to_active_tab = command_metadata + .and_then(|c_m| kdl_child_bool_value_for_entry(c_m, "apply_only_to_active_tab")) + .unwrap_or(false); let layout_dir = config_options .layout_dir @@ -1804,26 +1805,38 @@ impl TryFrom<(&KdlNode, &Options)> for Action { let (tab_name, layout, floating_panes_layout) = tabs.drain(..).next().unwrap(); let name = tab_name.or(name); - Ok(Action::OverrideLayout { - tiled_layout: Some(layout), + let tab_layout_info = TabLayoutInfo { + tab_index: 0, + tab_name: name, + tiled_layout: layout, floating_layouts: floating_panes_layout, swap_tiled_layouts, swap_floating_layouts, - tab_name: name, + }; + + Ok(Action::OverrideLayout { + tabs: vec![tab_layout_info], retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_active_tab, }) } else { let (layout, floating_panes_layout) = layout.new_tab(); - Ok(Action::OverrideLayout { - tiled_layout: Some(layout), + let tab_layout_info = TabLayoutInfo { + tab_index: 0, + tab_name: name, + tiled_layout: layout, floating_layouts: floating_panes_layout, swap_tiled_layouts, swap_floating_layouts, - tab_name: name, + }; + + Ok(Action::OverrideLayout { + tabs: vec![tab_layout_info], retain_existing_terminal_panes, retain_existing_plugin_panes, + apply_only_to_active_tab, }) } }, @@ -5154,7 +5167,9 @@ impl SessionInfo { match layout_source { Some(layout_source) => match layout_source { "built-in" => Some(LayoutInfo::BuiltIn(layout_name)), - "file" => Some(LayoutInfo::File(layout_name)), + "file" => { + Some(LayoutInfo::File(layout_name, LayoutMetadata::default())) + }, _ => None, }, None => None, @@ -5288,7 +5303,7 @@ impl SessionInfo { let mut available_layouts_children = KdlDocument::new(); for layout_info in &self.available_layouts { let (layout_name, layout_source) = match layout_info { - LayoutInfo::File(name) => (name.clone(), "file"), + LayoutInfo::File(name, _layout_metadata) => (name.clone(), "file"), LayoutInfo::BuiltIn(name) => (name.clone(), "built-in"), LayoutInfo::Url(url) => (url.clone(), "url"), LayoutInfo::Stringified(_stringified) => ("stringified-layout".to_owned(), "N/A"), @@ -5906,9 +5921,9 @@ fn serialize_and_deserialize_session_info_with_data() { connected_clients: 2, is_current_session: false, available_layouts: vec![ - LayoutInfo::File("layout1".to_owned()), + LayoutInfo::File("layout1".to_owned(), LayoutMetadata::default()), LayoutInfo::BuiltIn("layout2".to_owned()), - LayoutInfo::File("layout3".to_owned()), + LayoutInfo::File("layout3".to_owned(), LayoutMetadata::default()), ], plugins: Default::default(), web_client_count: 2, diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap index 6353ee0e..9fa94e0b 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap @@ -114,6 +114,13 @@ keybinds clear-defaults=true { } SwitchToMode "normal" } + bind "l" { + LaunchOrFocusPlugin "zellij:layout-manager" { + floating true + move_to_focused_tab true + } + SwitchToMode "normal" + } bind "Ctrl o" { SwitchToMode "normal"; } bind "p" { LaunchOrFocusPlugin "plugin-manager" { diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap index e562e030..c9d93654 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap @@ -114,6 +114,13 @@ keybinds clear-defaults=true { } SwitchToMode "normal" } + bind "l" { + LaunchOrFocusPlugin "zellij:layout-manager" { + floating true + move_to_focused_tab true + } + SwitchToMode "normal" + } bind "Ctrl o" { SwitchToMode "normal"; } bind "p" { LaunchOrFocusPlugin "plugin-manager" { diff --git a/zellij-utils/src/plugin_api/action.proto b/zellij-utils/src/plugin_api/action.proto index de8ef44f..6b899b48 100644 --- a/zellij-utils/src/plugin_api/action.proto +++ b/zellij-utils/src/plugin_api/action.proto @@ -162,14 +162,20 @@ message NewTabPayload { optional UnblockCondition first_pane_unblock_condition = 9; } +message TabLayoutInfo { + uint32 tab_index = 1; + optional string tab_name = 2; + TiledPaneLayout tiled_layout = 3; + repeated FloatingPaneLayout floating_layouts = 4; + repeated SwapTiledLayout swap_tiled_layouts = 5; + repeated SwapFloatingLayout swap_floating_layouts = 6; +} + message OverrideLayoutPayload { - optional TiledPaneLayout tiled_layout = 1; - repeated FloatingPaneLayout floating_layouts = 2; - repeated SwapTiledLayout swap_tiled_layouts = 3; - repeated SwapFloatingLayout swap_floating_layouts = 4; - optional string tab_name = 5; - bool retain_existing_terminal_panes = 10; - bool retain_existing_plugin_panes = 11; + repeated TabLayoutInfo tabs = 1; + bool retain_existing_terminal_panes = 2; + bool retain_existing_plugin_panes = 3; + bool apply_only_to_active_tab = 4; } message Action { diff --git a/zellij-utils/src/plugin_api/action.rs b/zellij-utils/src/plugin_api/action.rs index 860b090b..aa4c8b09 100644 --- a/zellij-utils/src/plugin_api/action.rs +++ b/zellij-utils/src/plugin_api/action.rs @@ -62,6 +62,7 @@ pub use super::generated_api::api::{ SwapFloatingLayout as ProtobufSwapFloatingLayout, SwapTiledLayout as ProtobufSwapTiledLayout, SwitchToModePayload, + TabLayoutInfo as ProtobufTabLayoutInfo, TiledPaneLayout as ProtobufTiledPaneLayout, TiledPlacement as ProtobufTiledPlacement, UnblockCondition as ProtobufUnblockCondition, @@ -83,7 +84,7 @@ use crate::input::layout::SplitSize; use crate::input::layout::{ FloatingPaneLayout, LayoutConstraint, PercentOrFixed, PluginAlias, PluginUserConfiguration, Run, RunPlugin, RunPluginLocation, RunPluginOrAlias, SplitDirection, SwapFloatingLayout, - SwapTiledLayout, TiledPaneLayout, + SwapTiledLayout, TabLayoutInfo, TiledPaneLayout, }; use crate::input::mouse::{MouseEvent, MouseEventType}; use crate::position::Position; @@ -723,48 +724,15 @@ impl TryFrom<ProtobufAction> for Action { }, Some(ProtobufActionName::OverrideLayout) => match protobuf_action.optional_payload { Some(OptionalPayload::OverrideLayoutPayload(payload)) => { - let tiled_layout = payload.tiled_layout.map(|l| l.try_into()).transpose()?; - - let floating_layouts = payload - .floating_layouts - .into_iter() - .map(|l| l.try_into()) - .collect::<Result<Vec<_>, _>>()?; - - let swap_tiled_layouts = if payload.swap_tiled_layouts.is_empty() { - None - } else { - Some( - payload - .swap_tiled_layouts - .into_iter() - .map(|l| l.try_into()) - .collect::<Result<Vec<_>, _>>()?, - ) - }; - - let swap_floating_layouts = if payload.swap_floating_layouts.is_empty() { - None - } else { - Some( - payload - .swap_floating_layouts - .into_iter() - .map(|l| l.try_into()) - .collect::<Result<Vec<_>, _>>()?, - ) - }; - - let tab_name = payload.tab_name.filter(|s| !s.is_empty()); - Ok(Action::OverrideLayout { - tiled_layout, - floating_layouts, - swap_tiled_layouts, - swap_floating_layouts, - tab_name, + tabs: payload + .tabs + .into_iter() + .map(|t| t.try_into()) + .collect::<Result<Vec<_>, _>>()?, retain_existing_terminal_panes: payload.retain_existing_terminal_panes, retain_existing_plugin_panes: payload.retain_existing_plugin_panes, + apply_only_to_active_tab: payload.apply_only_to_active_tab, }) }, Some(_) => Err("Mismatched payload for OverrideLayout"), @@ -1556,56 +1524,24 @@ impl TryFrom<Action> for ProtobufAction { optional_payload: None, }), Action::OverrideLayout { - tiled_layout, - floating_layouts, - swap_tiled_layouts, - swap_floating_layouts, - tab_name, + tabs, retain_existing_terminal_panes, retain_existing_plugin_panes, - } => { - let protobuf_tiled_layout = tiled_layout.map(|l| l.try_into()).transpose()?; - - let protobuf_floating_layouts = floating_layouts - .into_iter() - .map(|l| l.try_into()) - .collect::<Result<Vec<_>, _>>()?; - - let protobuf_swap_tiled_layouts = swap_tiled_layouts - .map(|layouts| { - layouts - .into_iter() - .map(|l| l.try_into()) - .collect::<Result<Vec<_>, _>>() - }) - .transpose()? - .unwrap_or_default(); - - let protobuf_swap_floating_layouts = swap_floating_layouts - .map(|layouts| { - layouts + apply_only_to_active_tab, + } => Ok(ProtobufAction { + name: ProtobufActionName::OverrideLayout as i32, + optional_payload: Some(OptionalPayload::OverrideLayoutPayload( + OverrideLayoutPayload { + tabs: tabs .into_iter() - .map(|l| l.try_into()) - .collect::<Result<Vec<_>, _>>() - }) - .transpose()? - .unwrap_or_default(); - - Ok(ProtobufAction { - name: ProtobufActionName::OverrideLayout as i32, - optional_payload: Some(OptionalPayload::OverrideLayoutPayload( - OverrideLayoutPayload { - tiled_layout: protobuf_tiled_layout, - floating_layouts: protobuf_floating_layouts, - swap_tiled_layouts: protobuf_swap_tiled_layouts, - swap_floating_layouts: protobuf_swap_floating_layouts, - tab_name: tab_name.clone(), - retain_existing_terminal_panes, - retain_existing_plugin_panes, - }, - )), - }) - }, + .map(|t| t.try_into()) + .collect::<Result<Vec<_>, _>>()?, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + apply_only_to_active_tab, + }, + )), + }), Action::QueryTabNames => Ok(ProtobufAction { name: ProtobufActionName::QueryTabNames as i32, optional_payload: None, @@ -2739,6 +2675,77 @@ impl TryFrom<CommandOrPlugin> for ProtobufCommandOrPlugin { } } +impl TryFrom<ProtobufTabLayoutInfo> for TabLayoutInfo { + type Error = &'static str; + + fn try_from(protobuf_tab: ProtobufTabLayoutInfo) -> Result<Self, Self::Error> { + Ok(TabLayoutInfo { + tab_index: protobuf_tab.tab_index as usize, + tab_name: protobuf_tab.tab_name.filter(|s| !s.is_empty()), + tiled_layout: protobuf_tab + .tiled_layout + .ok_or("missing tiled_layout")? + .try_into()?, + floating_layouts: protobuf_tab + .floating_layouts + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>, _>>()?, + swap_tiled_layouts: if protobuf_tab.swap_tiled_layouts.is_empty() { + None + } else { + Some( + protobuf_tab + .swap_tiled_layouts + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>, _>>()?, + ) + }, + swap_floating_layouts: if protobuf_tab.swap_floating_layouts.is_empty() { + None + } else { + Some( + protobuf_tab + .swap_floating_layouts + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>, _>>()?, + ) + }, + }) + } +} + +impl TryFrom<TabLayoutInfo> for ProtobufTabLayoutInfo { + type Error = &'static str; + + fn try_from(tab_info: TabLayoutInfo) -> Result<Self, Self::Error> { + Ok(ProtobufTabLayoutInfo { + tab_index: tab_info.tab_index as u32, + tab_name: tab_info.tab_name, + tiled_layout: Some(tab_info.tiled_layout.try_into()?), + floating_layouts: tab_info + .floating_layouts + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>, _>>()?, + swap_tiled_layouts: tab_info + .swap_tiled_layouts + .unwrap_or_default() + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>, _>>()?, + swap_floating_layouts: tab_info + .swap_floating_layouts + .unwrap_or_default() + .into_iter() + .map(|l| l.try_into()) + .collect::<Result<Vec<_>, _>>()?, + }) + } +} + impl TryFrom<ProtobufTiledPaneLayout> for TiledPaneLayout { type Error = &'static str; fn try_from(protobuf: ProtobufTiledPaneLayout) -> Result<Self, Self::Error> { diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto index 3bdeea19..10125387 100644 --- a/zellij-utils/src/plugin_api/event.proto +++ b/zellij-utils/src/plugin_api/event.proto @@ -62,6 +62,7 @@ enum EventType { UserAction = 37; ActionComplete = 38; CwdChanged = 39; + AvailableLayoutInfo = 40; } message EventNameList { @@ -103,6 +104,7 @@ message Event { UserActionPayload user_action_payload = 31; ActionCompletePayload action_complete_payload = 32; CwdChangedPayload cwd_changed_payload = 33; + AvailableLayoutInfoPayload available_layout_info_payload = 34; } } @@ -202,6 +204,11 @@ message SessionUpdatePayload { repeated ResurrectableSession resurrectable_sessions = 2; } +message AvailableLayoutInfoPayload { + repeated LayoutInfo available_layouts = 1; + repeated LayoutWithError layouts_with_errors = 2; +} + message RunCommandResultPayload { optional int32 exit_code = 1; bytes stdout = 2; @@ -318,6 +325,54 @@ message PluginInfo { message LayoutInfo { string name = 1; string source = 2; + optional LayoutMetadata layout_metadata = 5; +} + +message LayoutWithError { + string layout_name = 1; + LayoutParsingError error = 2; +} + +message LayoutParsingError { + oneof error_type { + KdlErrorVariant kdl_error = 1; + SyntaxError syntax_error = 2; + } +} + +message KdlErrorVariant { + KdlError kdl_error = 1; + string file_name = 2; + string source_code = 3; +} + +message SyntaxError { + // Empty message, just a marker +} + +message KdlError { + string error_message = 1; + optional uint64 offset = 2; + optional uint64 len = 3; + optional string help_message = 4; +} + +message LayoutMetadata { + repeated TabMetadata tabs = 1; + string creation_time = 2; + string update_time = 3; + +} + +message TabMetadata { + repeated PaneMetadata pane_metadata = 1; + optional string name = 2; +} + +message PaneMetadata { + optional string name = 1; + bool is_plugin = 2; + bool is_builtin_plugin = 3; } message ResurrectableSession { diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 4e430b0b..80d77e56 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -1,23 +1,29 @@ pub use super::generated_api::api::{ action::{Action as ProtobufAction, Position as ProtobufPosition}, event::{ - event::Payload as ProtobufEventPayload, pane_scrollback_response, - ActionCompletePayload as ProtobufActionCompletePayload, ClientInfo as ProtobufClientInfo, - ClientPaneHistory as ProtobufClientPaneHistory, + event::Payload as ProtobufEventPayload, + layout_parsing_error::ErrorType as ProtobufLayoutParsingErrorType, + pane_scrollback_response, ActionCompletePayload as ProtobufActionCompletePayload, + AvailableLayoutInfoPayload as ProtobufAvailableLayoutInfoPayload, + ClientInfo as ProtobufClientInfo, ClientPaneHistory as ProtobufClientPaneHistory, ClientTabHistory as ProtobufClientTabHistory, ContextItem as ProtobufContextItem, CopyDestination as ProtobufCopyDestination, CwdChangedPayload as ProtobufCwdChangedPayload, Event as ProtobufEvent, EventNameList as ProtobufEventNameList, EventType as ProtobufEventType, FileMetadata as ProtobufFileMetadata, - InputModeKeybinds as ProtobufInputModeKeybinds, KeyBind as ProtobufKeyBind, - LayoutInfo as ProtobufLayoutInfo, ModeUpdatePayload as ProtobufModeUpdatePayload, + InputModeKeybinds as ProtobufInputModeKeybinds, KdlError as ProtobufKdlError, + KdlErrorVariant as ProtobufKdlErrorVariant, KeyBind as ProtobufKeyBind, + LayoutInfo as ProtobufLayoutInfo, LayoutMetadata as ProtobufLayoutMetadata, + LayoutParsingError as ProtobufLayoutParsingError, + LayoutWithError as ProtobufLayoutWithError, ModeUpdatePayload as ProtobufModeUpdatePayload, PaneContents as ProtobufPaneContents, PaneContentsEntry as ProtobufPaneContentsEntry, PaneId as ProtobufPaneId, PaneInfo as ProtobufPaneInfo, - PaneManifest as ProtobufPaneManifest, + PaneManifest as ProtobufPaneManifest, PaneMetadata as ProtobufPaneMetadata, PaneRenderReportPayload as ProtobufPaneRenderReportPayload, PaneScrollbackResponse as ProtobufPaneScrollbackResponse, PaneType as ProtobufPaneType, PluginInfo as ProtobufPluginInfo, ResurrectableSession as ProtobufResurrectableSession, SelectedText as ProtobufSelectedText, SessionManifest as ProtobufSessionManifest, - TabInfo as ProtobufTabInfo, UserActionPayload as ProtobufUserActionPayload, + SyntaxError as ProtobufSyntaxError, TabInfo as ProtobufTabInfo, + TabMetadata as ProtobufTabMetadata, UserActionPayload as ProtobufUserActionPayload, WebServerStatusPayload as ProtobufWebServerStatusPayload, WebSharing as ProtobufWebSharing, *, }, @@ -28,9 +34,10 @@ pub use super::generated_api::api::{ #[allow(hidden_glob_reexports)] use crate::data::{ ClientId, ClientInfo, CopyDestination, Event, EventType, FileMetadata, InputMode, - KeyWithModifier, LayoutInfo, ModeInfo, Mouse, PaneContents, PaneId, PaneInfo, PaneManifest, - PaneScrollbackResponse, PermissionStatus, PluginCapabilities, PluginInfo, SelectedText, - SessionInfo, Style, TabInfo, WebServerStatus, WebSharing, + KeyWithModifier, LayoutInfo, LayoutMetadata, ModeInfo, Mouse, PaneContents, PaneId, PaneInfo, + PaneManifest, PaneMetadata, PaneScrollbackResponse, PermissionStatus, PluginCapabilities, + PluginInfo, SelectedText, SessionInfo, Style, TabInfo, TabMetadata, WebServerStatus, + WebSharing, }; use crate::errors::prelude::*; @@ -458,6 +465,29 @@ impl TryFrom<ProtobufEvent> for Event { }, _ => Err("Malformed payload for the CwdChanged Event"), }, + Some(ProtobufEventType::AvailableLayoutInfo) => match protobuf_event.payload { + Some(ProtobufEventPayload::AvailableLayoutInfoPayload( + available_layout_info_payload, + )) => { + let mut available_layouts: Vec<LayoutInfo> = vec![]; + let mut layouts_with_errors: Vec<crate::data::LayoutWithError> = vec![]; + + for protobuf_layout_info in available_layout_info_payload.available_layouts { + available_layouts.push(LayoutInfo::try_from(protobuf_layout_info)?); + } + + for protobuf_error in available_layout_info_payload.layouts_with_errors { + layouts_with_errors + .push(crate::data::LayoutWithError::try_from(protobuf_error)?); + } + + Ok(Event::AvailableLayoutInfo( + available_layouts, + layouts_with_errors, + )) + }, + _ => Err("Malformed payload for the AvailableLayoutInfo Event"), + }, None => Err("Unknown Protobuf Event"), } } @@ -910,6 +940,30 @@ impl TryFrom<Event> for ProtobufEvent { payload: Some(event::Payload::CwdChangedPayload(cwd_changed_payload)), }) }, + Event::AvailableLayoutInfo(available_layouts, layouts_with_errors) => { + let mut protobuf_available_layouts = vec![]; + let mut protobuf_layouts_with_errors = vec![]; + + for layout_info in available_layouts { + protobuf_available_layouts.push(layout_info.try_into()?); + } + + for layout_error in layouts_with_errors { + protobuf_layouts_with_errors.push(layout_error.try_into()?); + } + + let available_layout_info_payload = ProtobufAvailableLayoutInfoPayload { + available_layouts: protobuf_available_layouts, + layouts_with_errors: protobuf_layouts_with_errors, + }; + + Ok(ProtobufEvent { + name: ProtobufEventType::AvailableLayoutInfo as i32, + payload: Some(event::Payload::AvailableLayoutInfoPayload( + available_layout_info_payload, + )), + }) + }, } } } @@ -1078,21 +1132,25 @@ impl TryFrom<LayoutInfo> for ProtobufLayoutInfo { type Error = &'static str; fn try_from(layout_info: LayoutInfo) -> Result<Self, &'static str> { match layout_info { - LayoutInfo::File(name) => Ok(ProtobufLayoutInfo { + LayoutInfo::File(name, layout_metadata) => Ok(ProtobufLayoutInfo { source: "file".to_owned(), name, + layout_metadata: Some(layout_metadata.try_into()?), }), LayoutInfo::BuiltIn(name) => Ok(ProtobufLayoutInfo { source: "built-in".to_owned(), name, + layout_metadata: None, }), LayoutInfo::Url(name) => Ok(ProtobufLayoutInfo { source: "url".to_owned(), name, + layout_metadata: None, }), LayoutInfo::Stringified(stringified_layout) => Ok(ProtobufLayoutInfo { source: "stringified".to_owned(), name: stringified_layout.clone(), + layout_metadata: None, }), } } @@ -1102,7 +1160,14 @@ impl TryFrom<ProtobufLayoutInfo> for LayoutInfo { type Error = &'static str; fn try_from(protobuf_layout_info: ProtobufLayoutInfo) -> Result<Self, &'static str> { match protobuf_layout_info.source.as_str() { - "file" => Ok(LayoutInfo::File(protobuf_layout_info.name)), + "file" => { + let layout_metadata = protobuf_layout_info + .layout_metadata + .map(|m| m.try_into()) + .transpose()? + .unwrap_or_default(); + Ok(LayoutInfo::File(protobuf_layout_info.name, layout_metadata)) + }, "built-in" => Ok(LayoutInfo::BuiltIn(protobuf_layout_info.name)), "url" => Ok(LayoutInfo::Url(protobuf_layout_info.name)), "stringified" => Ok(LayoutInfo::Stringified(protobuf_layout_info.name)), @@ -1111,6 +1176,183 @@ impl TryFrom<ProtobufLayoutInfo> for LayoutInfo { } } +impl TryFrom<ProtobufLayoutMetadata> for LayoutMetadata { + type Error = &'static str; + fn try_from(protobuf_metadata: ProtobufLayoutMetadata) -> Result<Self, &'static str> { + let tabs = protobuf_metadata + .tabs + .into_iter() + .map(|t| t.try_into()) + .collect::<Result<Vec<_>, _>>()?; + Ok(LayoutMetadata { + tabs, + creation_time: protobuf_metadata.creation_time, + update_time: protobuf_metadata.update_time, + }) + } +} + +impl TryFrom<LayoutMetadata> for ProtobufLayoutMetadata { + type Error = &'static str; + fn try_from(metadata: LayoutMetadata) -> Result<Self, &'static str> { + let tabs = metadata + .tabs + .into_iter() + .map(|t| t.try_into()) + .collect::<Result<Vec<_>, _>>()?; + Ok(ProtobufLayoutMetadata { + tabs, + creation_time: metadata.creation_time, + update_time: metadata.update_time, + }) + } +} + +impl TryFrom<ProtobufTabMetadata> for TabMetadata { + type Error = &'static str; + fn try_from(protobuf_metadata: ProtobufTabMetadata) -> Result<Self, &'static str> { + let panes = protobuf_metadata + .pane_metadata + .into_iter() + .map(|p| p.try_into()) + .collect::<Result<Vec<_>, _>>()?; + Ok(TabMetadata { + panes, + name: protobuf_metadata.name, + }) + } +} + +impl TryFrom<TabMetadata> for ProtobufTabMetadata { + type Error = &'static str; + fn try_from(metadata: TabMetadata) -> Result<Self, &'static str> { + let pane_metadata = metadata + .panes + .into_iter() + .map(|p| p.try_into()) + .collect::<Result<Vec<_>, _>>()?; + Ok(ProtobufTabMetadata { + pane_metadata, + name: metadata.name, + }) + } +} + +impl TryFrom<ProtobufPaneMetadata> for PaneMetadata { + type Error = &'static str; + fn try_from(protobuf_metadata: ProtobufPaneMetadata) -> Result<Self, &'static str> { + Ok(PaneMetadata { + name: protobuf_metadata.name, + is_plugin: protobuf_metadata.is_plugin, + is_builtin_plugin: protobuf_metadata.is_builtin_plugin, + }) + } +} + +impl TryFrom<PaneMetadata> for ProtobufPaneMetadata { + type Error = &'static str; + fn try_from(metadata: PaneMetadata) -> Result<Self, &'static str> { + Ok(ProtobufPaneMetadata { + name: metadata.name, + is_plugin: metadata.is_plugin, + is_builtin_plugin: metadata.is_builtin_plugin, + }) + } +} + +// LayoutWithError conversions +impl TryFrom<ProtobufLayoutWithError> for crate::data::LayoutWithError { + type Error = &'static str; + fn try_from(protobuf: ProtobufLayoutWithError) -> Result<Self, Self::Error> { + Ok(crate::data::LayoutWithError { + layout_name: protobuf.layout_name, + error: protobuf.error.ok_or("Missing error field")?.try_into()?, + }) + } +} + +impl TryFrom<crate::data::LayoutWithError> for ProtobufLayoutWithError { + type Error = &'static str; + fn try_from(layout_error: crate::data::LayoutWithError) -> Result<Self, Self::Error> { + Ok(ProtobufLayoutWithError { + layout_name: layout_error.layout_name, + error: Some(layout_error.error.try_into()?), + }) + } +} + +// LayoutParsingError conversions +impl TryFrom<ProtobufLayoutParsingError> for crate::data::LayoutParsingError { + type Error = &'static str; + fn try_from(protobuf: ProtobufLayoutParsingError) -> Result<Self, Self::Error> { + match protobuf.error_type.ok_or("Missing error_type")? { + ProtobufLayoutParsingErrorType::KdlError(kdl_variant) => { + Ok(crate::data::LayoutParsingError::KdlError { + kdl_error: kdl_variant + .kdl_error + .ok_or("Missing kdl_error")? + .try_into()?, + file_name: kdl_variant.file_name, + source_code: kdl_variant.source_code, + }) + }, + ProtobufLayoutParsingErrorType::SyntaxError(_) => { + Ok(crate::data::LayoutParsingError::SyntaxError) + }, + } + } +} + +impl TryFrom<crate::data::LayoutParsingError> for ProtobufLayoutParsingError { + type Error = &'static str; + fn try_from(error: crate::data::LayoutParsingError) -> Result<Self, Self::Error> { + let error_type = match error { + crate::data::LayoutParsingError::KdlError { + kdl_error, + file_name, + source_code, + } => ProtobufLayoutParsingErrorType::KdlError(ProtobufKdlErrorVariant { + kdl_error: Some(kdl_error.try_into()?), + file_name, + source_code, + }), + crate::data::LayoutParsingError::SyntaxError => { + ProtobufLayoutParsingErrorType::SyntaxError(ProtobufSyntaxError {}) + }, + }; + Ok(ProtobufLayoutParsingError { + error_type: Some(error_type), + }) + } +} + +// KdlError conversions +impl TryFrom<ProtobufKdlError> for crate::input::config::KdlError { + type Error = &'static str; + fn try_from(protobuf: ProtobufKdlError) -> Result<Self, Self::Error> { + Ok(crate::input::config::KdlError { + error_message: protobuf.error_message, + src: None, // We don't serialize NamedSource + offset: protobuf.offset.map(|o| o as usize), + len: protobuf.len.map(|l| l as usize), + help_message: protobuf.help_message, + }) + } +} + +impl TryFrom<crate::input::config::KdlError> for ProtobufKdlError { + type Error = &'static str; + fn try_from(kdl: crate::input::config::KdlError) -> Result<Self, Self::Error> { + Ok(ProtobufKdlError { + error_message: kdl.error_message, + // src is not serialized + offset: kdl.offset.map(|o| o as u64), + len: kdl.len.map(|l| l as u64), + help_message: kdl.help_message, + }) + } +} + impl TryFrom<CopyDestination> for ProtobufCopyDestination { type Error = &'static str; fn try_from(copy_destination: CopyDestination) -> Result<Self, &'static str> { @@ -1620,6 +1862,7 @@ impl TryFrom<ProtobufEventType> for EventType { ProtobufEventType::UserAction => EventType::UserAction, ProtobufEventType::ActionComplete => EventType::ActionComplete, ProtobufEventType::CwdChanged => EventType::CwdChanged, + ProtobufEventType::AvailableLayoutInfo => EventType::AvailableLayoutInfo, }) } } @@ -1667,6 +1910,7 @@ impl TryFrom<EventType> for ProtobufEventType { EventType::UserAction => ProtobufEventType::UserAction, EventType::ActionComplete => ProtobufEventType::ActionComplete, EventType::CwdChanged => ProtobufEventType::CwdChanged, + EventType::AvailableLayoutInfo => ProtobufEventType::AvailableLayoutInfo, }) } } @@ -2268,9 +2512,23 @@ fn serialize_session_update_event_with_non_default_values() { connected_clients: 2, is_current_session: true, available_layouts: vec![ - LayoutInfo::File("layout 1".to_owned()), + LayoutInfo::File( + "layout 1".to_owned(), + LayoutMetadata { + tabs: vec![], + creation_time: "0".to_owned(), + update_time: "0".to_owned(), + }, + ), LayoutInfo::BuiltIn("layout2".to_owned()), - LayoutInfo::File("layout3".to_owned()), + LayoutInfo::File( + "layout3".to_owned(), + LayoutMetadata { + tabs: vec![], + creation_time: "0".to_owned(), + update_time: "0".to_owned(), + }, + ), ], plugins, web_clients_allowed: false, @@ -2287,9 +2545,23 @@ fn serialize_session_update_event_with_non_default_values() { connected_clients: 0, is_current_session: false, available_layouts: vec![ - LayoutInfo::File("layout 1".to_owned()), + LayoutInfo::File( + "layout 1".to_owned(), + LayoutMetadata { + tabs: vec![], + creation_time: "0".to_owned(), + update_time: "0".to_owned(), + }, + ), LayoutInfo::BuiltIn("layout2".to_owned()), - LayoutInfo::File("layout3".to_owned()), + LayoutInfo::File( + "layout3".to_owned(), + LayoutMetadata { + tabs: vec![], + creation_time: "0".to_owned(), + update_time: "0".to_owned(), + }, + ), ], plugins: Default::default(), web_clients_allowed: false, diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index fb456ccd..0a0ad2b0 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -166,6 +166,16 @@ enum CommandName { SendSigintToPaneId = 171; SendSigkillToPaneId = 172; GetPanePid = 173; + OverrideLayout = 174; + SaveLayout = 175; + DeleteLayout = 176; + EditLayout = 177; + GenerateRandomName = 178; + DumpLayout = 179; + ParseLayout = 180; + RenameLayout = 181; + GetLayoutDir = 182; + GetFocusedPaneInfo = 183; } message PluginCommand { @@ -280,6 +290,17 @@ message PluginCommand { PaneId send_sigint_to_pane_id_payload = 121; PaneId send_sigkill_to_pane_id_payload = 122; GetPanePidPayload get_pane_pid_payload = 123; + OverrideLayoutPayload override_layout_payload = 124; + SaveLayoutPayload save_layout_payload = 125; + DeleteLayoutPayload delete_layout_payload = 126; + EditLayoutPayload edit_layout_payload = 127; + GenerateRandomNamePayload generate_random_name_payload = 128; + DumpLayoutPayload dump_layout_payload = 129; + ParseLayoutPayload parse_layout_payload = 130; + RenameLayoutPayload rename_layout_payload = 131; + GetLayoutDirPayload get_layout_dir_payload = 132; + DumpSessionLayoutPayload dump_session_layout_payload = 133; + GetFocusedPaneInfoPayload get_focused_pane_info_payload = 134; } } @@ -288,6 +309,10 @@ message NewTabPayload { optional string cwd = 2; } +message DumpSessionLayoutPayload { + optional uint32 tab_index = 1; +} + message RunActionPayload { action.Action action = 1; repeated ContextItem context = 2; @@ -755,3 +780,115 @@ message GetPanePidResponse { string error = 2; } } + +message SaveLayoutResponse { + oneof result { + bool success = 1; + string error = 2; + } +} + +message DeleteLayoutResponse { + oneof result { + bool success = 1; + string error = 2; + } +} + +message EditLayoutResponse { + oneof result { + bool success = 1; + string error = 2; + } +} + +message GenerateRandomNameResponse { + string name = 1; +} + +message OverrideLayoutPayload { + event.LayoutInfo layout_info = 1; + repeated ContextItem context = 2; + bool retain_existing_terminal_panes = 3; + bool retain_existing_plugin_panes = 4; + bool apply_only_to_active_tab = 5; +} + +message SaveLayoutPayload { + string layout_name = 1; + string layout_kdl = 2; + bool overwrite = 3; +} + +message DeleteLayoutPayload { + string layout_name = 1; +} + +message EditLayoutPayload { + string layout_name = 1; + repeated ContextItem context = 2; +} + +message GenerateRandomNamePayload {} + +message DumpLayoutPayload { + string layout_name = 1; +} + +message DumpLayoutResponse { + oneof result { + string layout_content = 1; + string error = 2; + } +} + +message DumpSessionLayoutResponse { + oneof result { + string layout_content = 1; + string error = 2; + } + optional api.event.LayoutMetadata metadata = 3; +} + +message ParseLayoutPayload { + string layout_string = 1; +} + +message ParseLayoutResponse { + oneof result { + api.event.LayoutMetadata metadata = 1; + api.event.LayoutParsingError error = 2; + } +} + +message RenameLayoutPayload { + string old_layout_name = 1; + string new_layout_name = 2; +} + +message RenameLayoutResponse { + oneof result { + bool success = 1; + string error = 2; + } +} + +message GetLayoutDirPayload {} + +message GetLayoutDirResponse { + string layout_dir = 1; +} + +message GetFocusedPaneInfoPayload {} + +message GetFocusedPaneInfoResponse { + oneof result { + FocusedPaneInfo focused_pane_info = 1; + string error = 2; + } +} + +message FocusedPaneInfo { + uint32 focused_tab_index = 1; + PaneId focused_pane_id = 2; +} diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index 542e1ea2..8c4bf451 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -3,35 +3,48 @@ pub use super::generated_api::api::{ event::{EventNameList as ProtobufEventNameList, Header}, input_mode::InputMode as ProtobufInputMode, plugin_command::{ - get_pane_pid_response, plugin_command::Payload, BreakPanesToNewTabPayload, - BreakPanesToTabWithIndexPayload, ChangeFloatingPanesCoordinatesPayload, - ChangeHostFolderPayload, ClearScreenForPaneIdPayload, CliPipeOutputPayload, - CloseMultiplePanesPayload, CloseTabWithIndexPayload, CommandName, ContextItem, - CopyToClipboardPayload, CreateTokenResponse as ProtobufCreateTokenResponse, - CreateTokenResponse, CursorPosition, EditScrollbackForPaneWithIdPayload, - EmbedMultiplePanesPayload, EnvVariable, ExecCmdPayload, + delete_layout_response, dump_layout_response, dump_session_layout_response, + edit_layout_response, get_focused_pane_info_response, get_pane_pid_response, + parse_layout_response, plugin_command::Payload, rename_layout_response, + save_layout_response, BreakPanesToNewTabPayload, BreakPanesToTabWithIndexPayload, + ChangeFloatingPanesCoordinatesPayload, ChangeHostFolderPayload, + ClearScreenForPaneIdPayload, CliPipeOutputPayload, CloseMultiplePanesPayload, + CloseTabWithIndexPayload, CommandName, ContextItem, CopyToClipboardPayload, + CreateTokenResponse as ProtobufCreateTokenResponse, CreateTokenResponse, CursorPosition, + DeleteLayoutPayload, DeleteLayoutResponse as ProtobufDeleteLayoutResponse, + DumpLayoutPayload, DumpLayoutResponse as ProtobufDumpLayoutResponse, + DumpSessionLayoutPayload, DumpSessionLayoutResponse as ProtobufDumpSessionLayoutResponse, + EditLayoutPayload, EditLayoutResponse as ProtobufEditLayoutResponse, + EditScrollbackForPaneWithIdPayload, EmbedMultiplePanesPayload, EnvVariable, ExecCmdPayload, FixedOrPercent as ProtobufFixedOrPercent, FixedOrPercentValue as ProtobufFixedOrPercentValue, FloatMultiplePanesPayload, - FloatingPaneCoordinates as ProtobufFloatingPaneCoordinates, GenerateWebLoginTokenPayload, - GetPanePidPayload, GetPanePidResponse as ProtobufGetPanePidResponse, - GetPaneScrollbackPayload, GroupAndUngroupPanesPayload, HidePaneWithIdPayload, - HighlightAndUnhighlightPanesPayload, HttpVerb as ProtobufHttpVerb, IdAndNewName, - KeyToRebind, KeyToUnbind, KillSessionsPayload, ListTokensResponse, LoadNewPluginPayload, - MessageToPluginPayload, MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, - MovePayload, NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload, - NewTabsWithLayoutInfoPayload, OpenCommandPaneFloatingNearPluginPayload, - OpenCommandPaneInPlaceOfPluginPayload, OpenCommandPaneNearPluginPayload, - OpenCommandPanePayload, OpenFileFloatingNearPluginPayload, OpenFileInPlaceOfPluginPayload, + FloatingPaneCoordinates as ProtobufFloatingPaneCoordinates, FocusedPaneInfo, + GenerateRandomNamePayload, + GenerateRandomNameResponse as ProtobufGenerateRandomNameResponse, + GenerateWebLoginTokenPayload, GetFocusedPaneInfoPayload, + GetFocusedPaneInfoResponse as ProtobufGetFocusedPaneInfoResponse, GetLayoutDirPayload, + GetLayoutDirResponse as ProtobufGetLayoutDirResponse, GetPanePidPayload, + GetPanePidResponse as ProtobufGetPanePidResponse, GetPaneScrollbackPayload, + GroupAndUngroupPanesPayload, HidePaneWithIdPayload, HighlightAndUnhighlightPanesPayload, + HttpVerb as ProtobufHttpVerb, IdAndNewName, KeyToRebind, KeyToUnbind, KillSessionsPayload, + ListTokensResponse, LoadNewPluginPayload, MessageToPluginPayload, + MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, MovePayload, + NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload, NewTabsWithLayoutInfoPayload, + OpenCommandPaneFloatingNearPluginPayload, OpenCommandPaneInPlaceOfPluginPayload, + OpenCommandPaneNearPluginPayload, OpenCommandPanePayload, + OpenFileFloatingNearPluginPayload, OpenFileInPlaceOfPluginPayload, OpenFileNearPluginPayload, OpenFilePayload, OpenTerminalFloatingNearPluginPayload, - OpenTerminalInPlaceOfPluginPayload, OpenTerminalNearPluginPayload, + OpenTerminalInPlaceOfPluginPayload, OpenTerminalNearPluginPayload, OverrideLayoutPayload, PageScrollDownInPaneIdPayload, PageScrollUpInPaneIdPayload, PaneId as ProtobufPaneId, - PaneIdAndFloatingPaneCoordinates, PaneType as ProtobufPaneType, - PluginCommand as ProtobufPluginCommand, PluginMessagePayload, RebindKeysPayload, - ReconfigurePayload, ReloadPluginPayload, RenameWebLoginTokenPayload, - RenameWebTokenResponse, ReplacePaneWithExistingPanePayload, RequestPluginPermissionPayload, - RerunCommandPanePayload, ResizePaneIdWithDirectionPayload, ResizePayload, - RevokeAllWebTokensResponse, RevokeTokenResponse, RevokeWebLoginTokenPayload, - RunActionPayload, RunCommandPayload, ScrollDownInPaneIdPayload, + PaneIdAndFloatingPaneCoordinates, PaneType as ProtobufPaneType, ParseLayoutPayload, + ParseLayoutResponse as ProtobufParseLayoutResponse, PluginCommand as ProtobufPluginCommand, + PluginMessagePayload, RebindKeysPayload, ReconfigurePayload, ReloadPluginPayload, + RenameLayoutPayload, RenameLayoutResponse as ProtobufRenameLayoutResponse, + RenameWebLoginTokenPayload, RenameWebTokenResponse, ReplacePaneWithExistingPanePayload, + RequestPluginPermissionPayload, RerunCommandPanePayload, ResizePaneIdWithDirectionPayload, + ResizePayload, RevokeAllWebTokensResponse, RevokeTokenResponse, RevokeWebLoginTokenPayload, + RunActionPayload, RunCommandPayload, SaveLayoutPayload, + SaveLayoutResponse as ProtobufSaveLayoutResponse, ScrollDownInPaneIdPayload, ScrollToBottomInPaneIdPayload, ScrollToTopInPaneIdPayload, ScrollUpInPaneIdPayload, SetFloatingPanePinnedPayload, SetSelfMouseSelectionSupportPayload, SetTimeoutPayload, ShowCursorPayload, ShowPaneWithIdPayload, StackPanesPayload, SubscribePayload, @@ -44,8 +57,10 @@ pub use super::generated_api::api::{ }; use crate::data::{ - ConnectToSession, FloatingPaneCoordinates, GetPanePidResponse, HttpVerb, InputMode, - KeyWithModifier, MessageToPlugin, NewPluginArgs, PaneId, PermissionType, PluginCommand, + ConnectToSession, DeleteLayoutResponse, EditLayoutResponse, FloatingPaneCoordinates, + GetFocusedPaneInfoResponse, GetPanePidResponse, HttpVerb, InputMode, KeyWithModifier, + MessageToPlugin, NewPluginArgs, PaneId, PermissionType, PluginCommand, RenameLayoutResponse, + SaveLayoutResponse, }; use crate::input::actions::Action; use crate::input::layout::SplitSize; @@ -227,6 +242,135 @@ impl From<GetPanePidResponse> for ProtobufGetPanePidResponse { } } +impl From<GetFocusedPaneInfoResponse> for ProtobufGetFocusedPaneInfoResponse { + fn from(response: GetFocusedPaneInfoResponse) -> Self { + match response { + GetFocusedPaneInfoResponse::Ok { tab_index, pane_id } => { + let protobuf_pane_id = ProtobufPaneId { + pane_type: match pane_id { + PaneId::Terminal(_) => ProtobufPaneType::Terminal as i32, + PaneId::Plugin(_) => ProtobufPaneType::Plugin as i32, + }, + id: match pane_id { + PaneId::Terminal(id) | PaneId::Plugin(id) => id, + }, + }; + ProtobufGetFocusedPaneInfoResponse { + result: Some(get_focused_pane_info_response::Result::FocusedPaneInfo( + FocusedPaneInfo { + focused_tab_index: tab_index as u32, + focused_pane_id: Some(protobuf_pane_id), + }, + )), + } + }, + GetFocusedPaneInfoResponse::Err(err) => ProtobufGetFocusedPaneInfoResponse { + result: Some(get_focused_pane_info_response::Result::Error(err)), + }, + } + } +} + +impl TryFrom<ProtobufSaveLayoutResponse> for SaveLayoutResponse { + type Error = &'static str; + fn try_from(protobuf_response: ProtobufSaveLayoutResponse) -> Result<Self, &'static str> { + match protobuf_response.result { + Some(save_layout_response::Result::Success(_)) => Ok(SaveLayoutResponse::Ok(())), + Some(save_layout_response::Result::Error(error)) => Ok(SaveLayoutResponse::Err(error)), + None => Err("Empty SaveLayoutResponse"), + } + } +} + +impl From<SaveLayoutResponse> for ProtobufSaveLayoutResponse { + fn from(response: SaveLayoutResponse) -> Self { + match response { + SaveLayoutResponse::Ok(_) => ProtobufSaveLayoutResponse { + result: Some(save_layout_response::Result::Success(true)), + }, + SaveLayoutResponse::Err(error) => ProtobufSaveLayoutResponse { + result: Some(save_layout_response::Result::Error(error)), + }, + } + } +} + +impl TryFrom<ProtobufDeleteLayoutResponse> for DeleteLayoutResponse { + type Error = &'static str; + fn try_from(protobuf_response: ProtobufDeleteLayoutResponse) -> Result<Self, &'static str> { + match protobuf_response.result { + Some(delete_layout_response::Result::Success(_)) => Ok(DeleteLayoutResponse::Ok(())), + Some(delete_layout_response::Result::Error(error)) => { + Ok(DeleteLayoutResponse::Err(error)) + }, + None => Err("Empty DeleteLayoutResponse"), + } + } +} + +impl From<DeleteLayoutResponse> for ProtobufDeleteLayoutResponse { + fn from(response: DeleteLayoutResponse) -> Self { + match response { + DeleteLayoutResponse::Ok(_) => ProtobufDeleteLayoutResponse { + result: Some(delete_layout_response::Result::Success(true)), + }, + DeleteLayoutResponse::Err(error) => ProtobufDeleteLayoutResponse { + result: Some(delete_layout_response::Result::Error(error)), + }, + } + } +} + +impl TryFrom<ProtobufRenameLayoutResponse> for RenameLayoutResponse { + type Error = &'static str; + fn try_from(protobuf_response: ProtobufRenameLayoutResponse) -> Result<Self, &'static str> { + match protobuf_response.result { + Some(rename_layout_response::Result::Success(_)) => Ok(RenameLayoutResponse::Ok(())), + Some(rename_layout_response::Result::Error(error)) => { + Ok(RenameLayoutResponse::Err(error)) + }, + None => Err("Empty RenameLayoutResponse"), + } + } +} + +impl From<RenameLayoutResponse> for ProtobufRenameLayoutResponse { + fn from(response: RenameLayoutResponse) -> Self { + match response { + RenameLayoutResponse::Ok(_) => ProtobufRenameLayoutResponse { + result: Some(rename_layout_response::Result::Success(true)), + }, + RenameLayoutResponse::Err(error) => ProtobufRenameLayoutResponse { + result: Some(rename_layout_response::Result::Error(error)), + }, + } + } +} + +impl TryFrom<ProtobufEditLayoutResponse> for EditLayoutResponse { + type Error = &'static str; + fn try_from(protobuf_response: ProtobufEditLayoutResponse) -> Result<Self, &'static str> { + match protobuf_response.result { + Some(edit_layout_response::Result::Success(_)) => Ok(EditLayoutResponse::Ok(())), + Some(edit_layout_response::Result::Error(error)) => Ok(EditLayoutResponse::Err(error)), + None => Err("Empty EditLayoutResponse"), + } + } +} + +impl From<EditLayoutResponse> for ProtobufEditLayoutResponse { + fn from(response: EditLayoutResponse) -> Self { + match response { + EditLayoutResponse::Ok(_) => ProtobufEditLayoutResponse { + result: Some(edit_layout_response::Result::Success(true)), + }, + EditLayoutResponse::Err(error) => ProtobufEditLayoutResponse { + result: Some(edit_layout_response::Result::Error(error)), + }, + } + } +} + impl TryFrom<(InputMode, KeyWithModifier, Vec<Action>)> for KeyToRebind { type Error = &'static str; fn try_from( @@ -1047,8 +1191,13 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand { None => Ok(PluginCommand::WatchFilesystem), }, Some(CommandName::DumpSessionLayout) => match protobuf_plugin_command.payload { - Some(_) => Err("DumpSessionLayout should have no payload, found a payload"), - None => Ok(PluginCommand::DumpSessionLayout), + Some(Payload::DumpSessionLayoutPayload(payload)) => { + Ok(PluginCommand::DumpSessionLayout { + tab_index: payload.tab_index.map(|i| i as usize), + }) + }, + None => Ok(PluginCommand::DumpSessionLayout { tab_index: None }), + _ => Err("Mismatched payload for DumpSessionLayout"), }, Some(CommandName::CloseSelf) => match protobuf_plugin_command.payload { Some(_) => Err("CloseSelf should have no payload, found a payload"), @@ -1215,6 +1364,70 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand { }, _ => Err("Mismatched payload for GetPanePid"), }, + Some(CommandName::OverrideLayout) => match protobuf_plugin_command.payload { + Some(Payload::OverrideLayoutPayload(override_layout_payload)) => { + let layout_info = override_layout_payload + .layout_info + .ok_or("OverrideLayout missing layout_info")? + .try_into() + .map_err(|_| "Failed to parse LayoutInfo")?; + let context = override_layout_payload + .context + .into_iter() + .map(|c| (c.name, c.value)) + .collect(); + Ok(PluginCommand::OverrideLayout( + layout_info, + override_layout_payload.retain_existing_terminal_panes, + override_layout_payload.retain_existing_plugin_panes, + override_layout_payload.apply_only_to_active_tab, + context, + )) + }, + _ => Err("Mismatched payload for OverrideLayout"), + }, + Some(CommandName::SaveLayout) => match protobuf_plugin_command.payload { + Some(Payload::SaveLayoutPayload(save_layout_payload)) => { + Ok(PluginCommand::SaveLayout { + layout_name: save_layout_payload.layout_name, + layout_kdl: save_layout_payload.layout_kdl, + overwrite: save_layout_payload.overwrite, + }) + }, + _ => Err("Mismatched payload for SaveLayout"), + }, + Some(CommandName::DeleteLayout) => match protobuf_plugin_command.payload { + Some(Payload::DeleteLayoutPayload(delete_layout_payload)) => { + Ok(PluginCommand::DeleteLayout { + layout_name: delete_layout_payload.layout_name, + }) + }, + _ => Err("Mismatched payload for DeleteLayout"), + }, + Some(CommandName::RenameLayout) => match protobuf_plugin_command.payload { + Some(Payload::RenameLayoutPayload(rename_layout_payload)) => { + Ok(PluginCommand::RenameLayout { + old_layout_name: rename_layout_payload.old_layout_name, + new_layout_name: rename_layout_payload.new_layout_name, + }) + }, + _ => Err("Mismatched payload for RenameLayout"), + }, + Some(CommandName::EditLayout) => match protobuf_plugin_command.payload { + Some(Payload::EditLayoutPayload(edit_layout_payload)) => { + let layout_name = edit_layout_payload.layout_name; + let context: BTreeMap<String, String> = edit_layout_payload + .context + .into_iter() + .map(|e| (e.name, e.value)) + .collect(); + Ok(PluginCommand::EditLayout { + layout_name, + context, + }) + }, + _ => Err("Mismatched payload for EditLayout"), + }, Some(CommandName::MovePaneWithPaneId) => match protobuf_plugin_command.payload { Some(Payload::MovePaneWithPaneIdPayload(move_pane_with_pane_id_payload)) => { match move_pane_with_pane_id_payload.pane_id { @@ -1845,6 +2058,21 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand { }, _ => Err("Mismatched payload for CopyToClipboard"), }, + Some(CommandName::GenerateRandomName) => Ok(PluginCommand::GenerateRandomName), + Some(CommandName::DumpLayout) => match protobuf_plugin_command.payload { + Some(Payload::DumpLayoutPayload(payload)) => { + Ok(PluginCommand::DumpLayout(payload.layout_name)) + }, + _ => Err("Mismatched payload for DumpLayout"), + }, + Some(CommandName::ParseLayout) => match protobuf_plugin_command.payload { + Some(Payload::ParseLayoutPayload(payload)) => { + Ok(PluginCommand::ParseLayout(payload.layout_string)) + }, + _ => Err("Mismatched payload for ParseLayout"), + }, + Some(CommandName::GetLayoutDir) => Ok(PluginCommand::GetLayoutDir), + Some(CommandName::GetFocusedPaneInfo) => Ok(PluginCommand::GetFocusedPaneInfo), None => Err("Unrecognized plugin command"), } } @@ -2417,9 +2645,13 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand { name: CommandName::WatchFilesystem as i32, payload: None, }), - PluginCommand::DumpSessionLayout => Ok(ProtobufPluginCommand { + PluginCommand::DumpSessionLayout { tab_index } => Ok(ProtobufPluginCommand { name: CommandName::DumpSessionLayout as i32, - payload: None, + payload: tab_index.map(|idx| { + Payload::DumpSessionLayoutPayload(DumpSessionLayoutPayload { + tab_index: Some(idx as u32), + }) + }), }), PluginCommand::CloseSelf => Ok(ProtobufPluginCommand { name: CommandName::CloseSelf as i32, @@ -2545,6 +2777,69 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand { pane_id: Some(pane_id.try_into()?), })), }), + PluginCommand::OverrideLayout( + layout_info, + retain_existing_terminal_panes, + retain_existing_plugin_panes, + apply_only_to_active_tab, + context, + ) => Ok(ProtobufPluginCommand { + name: CommandName::OverrideLayout as i32, + payload: Some(Payload::OverrideLayoutPayload(OverrideLayoutPayload { + layout_info: layout_info.try_into().ok(), + context: context + .into_iter() + .map(|(name, value)| ContextItem { name, value }) + .collect(), + retain_existing_terminal_panes, + retain_existing_plugin_panes, + apply_only_to_active_tab, + })), + }), + PluginCommand::SaveLayout { + layout_name, + layout_kdl, + overwrite, + } => Ok(ProtobufPluginCommand { + name: CommandName::SaveLayout as i32, + payload: Some(Payload::SaveLayoutPayload(SaveLayoutPayload { + layout_name, + layout_kdl, + overwrite, + })), + }), + PluginCommand::DeleteLayout { layout_name } => Ok(ProtobufPluginCommand { + name: CommandName::DeleteLayout as i32, + payload: Some(Payload::DeleteLayoutPayload(DeleteLayoutPayload { + layout_name, + })), + }), + PluginCommand::RenameLayout { + old_layout_name, + new_layout_name, + } => Ok(ProtobufPluginCommand { + name: CommandName::RenameLayout as i32, + payload: Some(Payload::RenameLayoutPayload(RenameLayoutPayload { + old_layout_name, + new_layout_name, + })), + }), + PluginCommand::EditLayout { + layout_name, + context, + } => { + let context: Vec<_> = context + .into_iter() + .map(|(name, value)| ContextItem { name, value }) + .collect(); + Ok(ProtobufPluginCommand { + name: CommandName::EditLayout as i32, + payload: Some(Payload::EditLayoutPayload(EditLayoutPayload { + layout_name, + context, + })), + }) + }, PluginCommand::MovePaneWithPaneId(pane_id) => Ok(ProtobufPluginCommand { name: CommandName::MovePaneWithPaneId as i32, payload: Some(Payload::MovePaneWithPaneIdPayload( @@ -3073,6 +3368,34 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand { text, })), }), + PluginCommand::GenerateRandomName => Ok(ProtobufPluginCommand { + name: CommandName::GenerateRandomName as i32, + payload: Some(Payload::GenerateRandomNamePayload( + GenerateRandomNamePayload {}, + )), + }), + PluginCommand::DumpLayout(layout_name) => Ok(ProtobufPluginCommand { + name: CommandName::DumpLayout as i32, + payload: Some(Payload::DumpLayoutPayload(DumpLayoutPayload { + layout_name, + })), + }), + PluginCommand::ParseLayout(layout_string) => Ok(ProtobufPluginCommand { + name: CommandName::ParseLayout as i32, + payload: Some(Payload::ParseLayoutPayload(ParseLayoutPayload { + layout_string, + })), + }), + PluginCommand::GetLayoutDir => Ok(ProtobufPluginCommand { + name: CommandName::GetLayoutDir as i32, + payload: Some(Payload::GetLayoutDirPayload(GetLayoutDirPayload {})), + }), + PluginCommand::GetFocusedPaneInfo => Ok(ProtobufPluginCommand { + name: CommandName::GetFocusedPaneInfo as i32, + payload: Some(Payload::GetFocusedPaneInfoPayload( + GetFocusedPaneInfoPayload {}, + )), + }), } } } diff --git a/zellij-utils/src/sessions.rs b/zellij-utils/src/sessions.rs index b72be064..57d79779 100644 --- a/zellij-utils/src/sessions.rs +++ b/zellij-utils/src/sessions.rs @@ -493,6 +493,12 @@ pub fn get_name_generator() -> impl Iterator<Item = String> { names::Generator::new(&ADJECTIVES, &NOUNS, names::Name::Plain) } +/// Generates a random human-readable name using curated adjectives and nouns. +/// Returns a single name in the format: AdjectiveNoun (e.g., "BraveRustacean") +pub fn generate_random_name() -> String { + get_name_generator().next().unwrap() +} + const ADJECTIVES: &[&'static str] = &[ "adamant", "adept", diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap index 3e60c317..9635303b 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap @@ -4326,6 +4326,36 @@ Config { bare_key: Char( 'l', ), + key_modifiers: {}, + }: [ + LaunchOrFocusPlugin { + plugin: RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "layout-manager", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + should_float: true, + move_to_focused_tab: true, + should_open_in_place: false, + skip_cache: false, + }, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + 'l', + ), key_modifiers: { Alt, }, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap index e0fa22f9..879be0ef 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap @@ -4326,6 +4326,36 @@ Config { bare_key: Char( 'l', ), + key_modifiers: {}, + }: [ + LaunchOrFocusPlugin { + plugin: RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "layout-manager", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + should_float: true, + move_to_focused_tab: true, + should_open_in_place: false, + skip_cache: false, + }, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + 'l', + ), key_modifiers: { Alt, }, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap index e9935ef3..bc5119f7 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap @@ -4326,6 +4326,36 @@ Config { bare_key: Char( 'l', ), + key_modifiers: {}, + }: [ + LaunchOrFocusPlugin { + plugin: RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "layout-manager", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + should_float: true, + move_to_focused_tab: true, + should_open_in_place: false, + skip_cache: false, + }, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + 'l', + ), key_modifiers: { Alt, }, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap index 368b5d88..477e152b 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap @@ -4326,6 +4326,36 @@ Config { bare_key: Char( 'l', ), + key_modifiers: {}, + }: [ + LaunchOrFocusPlugin { + plugin: RunPlugin( + RunPlugin { + _allow_exec_host_cmd: false, + location: Zellij( + PluginTag( + "layout-manager", + ), + ), + configuration: PluginUserConfiguration( + {}, + ), + initial_cwd: None, + }, + ), + should_float: true, + move_to_focused_tab: true, + should_open_in_place: false, + skip_cache: false, + }, + SwitchToMode { + input_mode: Normal, + }, + ], + KeyWithModifier { + bare_key: Char( + 'l', + ), key_modifiers: { Alt, }, |
