zellij icon indicating copy to clipboard operation
zellij copied to clipboard

[Feature request] Restore Zellij environment after system restart

Open VasanthakumarV opened this issue 4 years ago • 36 comments

Currently, all the Zellij sessions are lost after the System restarts, it will be nice to have them restored for a seamless experience.

Reference

  • tmux-resurrect is a plugin for tmux used for this exact purpose.
    • https://github.com/tmux-plugins/tmux-resurrect#about - lists the things that tmux-resurrect can restore
    • https://github.com/tmux-plugins/tmux-resurrect/blob/master/docs/restoring_programs.md - has information on the programs that can be restored
  • From discord, rough pointers on implementation
1. Persist the buffers of each pane to the hard-drive
2. Keep track of the running process in each pane and be able to restart it on startup
3. Somehow keep track of the actual application state of each pane 
(eg. if you're in vim and you are scrolled down to a specific place, you need to start there)

VasanthakumarV avatar Jun 11 '21 09:06 VasanthakumarV

This is an awesome feature!

From the faq: The vim and nvim sessions seem to be handled by tpope's obsession.

a-kenji avatar Jun 11 '21 12:06 a-kenji

We might want to use XDG_STATE_HOME to store relevant data for this feature.

a-kenji avatar Jun 16 '21 06:06 a-kenji

Should add that screen has this built in and might be something to look into.

piperun avatar Oct 07 '21 12:10 piperun

One thing I'd like to add, which would be more or less the same feature, but more manual control:

Allow storing / loading sessions from/to file. That way, you could even start a session, store it somewhere in the cloud, then resume it (within reason) on a different machine, three days later.

I feel like, once session restoration after restart is working, this would probably be a no-brainer: Just expose the save/load functionality to the user, e.g. ctrl + o -> (s)ave session to file / (l)oad session from file

TheSHEEEP avatar Apr 12 '22 08:04 TheSHEEEP

I second @TheSHEEEP's proposal. Having an ability to dump an existing session to a file would be great ! Also I think this feature would be worth being exposed from the command line too, e.g.

zellij --dump-session > dump.yml

ngirard avatar May 12 '22 06:05 ngirard

Hi there! I am wondering if this feature was implemented already or is it still under development?

bhfmts avatar Oct 04 '22 19:10 bhfmts

I think an MVP would be being able to restore all tabs, layouts, and each pane's current working directory. Restoring active processes like nvim sessions could be a follow on feature. Is this something that would be better implemented as a plugin?

mike-lloyd03 avatar Dec 13 '22 08:12 mike-lloyd03

I personally can not imagine myself using tmux without the session save/autoload. I love what you've achieved with Zellij and am considering to switching to it, but until there will be session saving/restoring - I can not switch for work, there's too many folders and panes and tabs to open each morning. I'll probably use Zellij for personal projects/off work coding. Session saving/restoring would be a killer feature if it's included out of the box, I'm sure Plugin would also work, as soon as there's session saving - I'm switching ;)

RomkaHorokhov avatar Jan 27 '23 09:01 RomkaHorokhov

Just to chime in:

  1. I'd be happy to have this feature implemented as a built-in offering. I agree with @mike-lloyd03 regarding the phases of implementation.
  2. The hardest part here, I believe, would be to serialize the current session geometry to a file. Zellij intentionally does not implement its pane map state as a nested logical tree, in order to allow the greater flexibility in non-directional resizes we all know and love. The layouts however are a nested logical tree.

The implementer should figure out a way to turn this: [ {x: 0, y: 0, width: 100, height: 50}, {x: 0, y: 50, height: 50, width: 50}, {x: 50, y: 50, height: 50, width: 50} ]

Into this:

layout {
    pane
    pane split_direction="vertical" {
        pane
        pane
    }
}

The code base right now only does this the other way around. Similar implementations from other multiplexers - for the reason stated above - likely won't work. If someone wishes to take this up - awesome.

imsnif avatar Jan 27 '23 11:01 imsnif

@imsnif let me try it!

sundy-li avatar Feb 02 '23 05:02 sundy-li

@imsnif Do we need to save the session state to disk in layout format though? What's stopping us from writing to disk exactly what you have ([ {x: 0, y: 0, width: 100, height: 50}, ...]) and configuring session restoration to parse that?

Or are we hoping to use the layout argument in start_client() to achieve session restoration? If so, we'd have to parse the session state to the layout format and write it to disk every time the layout changes (new split pane, new tab, rename something, etc...).

Is that the best way forward?

@sundy-li have you done any work on this?

mike-lloyd03 avatar Feb 11 '23 20:02 mike-lloyd03

@mike-lloyd03 - layout files is how we serialize our session state. While we could theoretically massage the entire code-base to add an additional serialization layer as you propose, it'll both be an incredible amount of work and an incredible lot to maintain.

It's logically possible (mostly) to perform this sort of serialization that I spoke about, and once we have that it'll be much cleaner to maintain and edit (you'd also be able to edit your saved layout/session files to fit other cases, for example).

So I essentially think this is the best way forward, yeah. :)

(There will probably be additional places where we need to update the session state to disk, eg. when opening a new app - which is something we cannot detect, so we'll probably end up doing this on every keystroke... but really, this is the easier part - once we have this serialization layer, we're home :) )

imsnif avatar Feb 12 '23 09:02 imsnif

@imsnif Thank you. I'd like to at least take a whack at the algorithm for serializing structs into layouts. Can you provide me with a few more example cases (preferably edge cases).

And also is the example you gave above a vector of PaneGeoms?

mike-lloyd03 avatar Feb 21 '23 03:02 mike-lloyd03

@mike-lloyd03 - sorry, I don't have a lot of time for doing back-and-forth on this. There are a lot of examples of layouts in the documentation, eg. here: https://zellij.dev/documentation/creating-a-layout.html

And yeah, my example assumed PaneGeom. We of course don't keep things exactly like this, but as mentioned: once you get this basic unit to work we can start working on integrating it with everything else.

imsnif avatar Feb 21 '23 08:02 imsnif

I think this is not started yet because it seems big. I'm going to start this with saving at least tab layout to a file and see how it goes.

olekspickle avatar Mar 08 '23 14:03 olekspickle

@alekspickle Go for it. I started working on it a bit. It seems pretty straightforward since it's just an algorithm problem. I just haven't had time.

Layouts now support floating panes though so that might complicate it somewhat.

mike-lloyd03 avatar Mar 08 '23 15:03 mike-lloyd03

how can one save a setup that they want as a default? i do not want to save my setup but basically i want to have a default setup when i make a new session with zellij

eleijonmarck avatar Mar 10 '23 08:03 eleijonmarck

i guess, i could create a layout that is loaded by default on a new session? 🤔

I am also curious if i can specify the default session by default in the configuration, so far i have not found that option

eleijonmarck avatar Mar 10 '23 09:03 eleijonmarck

@eleijonmarck hijacking a thread is not nice. It is generally better to ask all help questions in discord or create new issue that can be closed with an answer right away.

If you dump the config you can just specify the name of a default layout with default_layout name If you want help with configuration to avoid reading corresponding docs part or the config itself, just ask in discord, people usually active there.

olekspickle avatar Mar 10 '23 11:03 olekspickle

@alekspickle the question is related as I was asking for a current workaround. @alekspickle it's also generally not nice to tell someone they have hijacked a thread in open source software. we are all here to freely ask question. you are rather shying away potential users from using the software by that behavior.

eleijonmarck avatar Mar 15 '23 20:03 eleijonmarck

@eleijonmarck , @alekspickle - I'm sure neither of you were meaning to be unkind or impolite to the other person. I ask you - let's please cool things down and enjoy our terminal emulators.

imsnif avatar Mar 15 '23 21:03 imsnif

Any updates?

bazuka5801 avatar Mar 28 '23 00:03 bazuka5801

Hi, as I could not find any mention of progress on this issue, I decided to give it a try as practice. I am not new to programming but I am new to Rust and it was challenging! However I have managed to solve the "hard part" of the problem according to @imsnif, ie serialize the session geometry (Vec<PaneGeom>) to a string; actually I did Vec<PaneGeom> -> Vec<TiledPaneLayout> -> String.

Here is an example of geometry session to serialize: [{"x": 0, "y": 0, "cols": 40, "rows": 30}, {"x": 0, "y": 30, "cols": 40, "rows": 30}, {"x": 40, "y": 0, "cols": 40, "rows":20}, {"x": 40, "y": 20, "cols": 20, "rows": 20}, {"x": 60, "y": 20, "cols": 20, "rows": 20}, {"x": 40, "y": 40, "cols": 40, "rows": 20}]

A representation of the layout so you don't have to scramble your brain trying to decipher:

+-----------------------+
|     1     |     3     |
|           |-----------|
|-----------|  4  |  5  |
|     2     |-----------|
|           |     6     |
+-----------------------+

The kdl string:

tab {
    pane split_direction="vertical" {
        pane size=40 {
            pane size=30
            pane size=30
        }
        pane size=40 {
            pane size=20
            pane size=20 split_direction="vertical" {
                pane size=20
                pane size=20
            }
            pane size=20
        }
    }
}

I did a few more examples in order to test the edge cases and I think I cover them all. As I said I am new to Rust but I think I managed to do clean code and it is suitable to integrate to the code base.

Could some contributors help me and/or guide me on what should be done next?

AlixBernard avatar May 16 '23 10:05 AlixBernard

@AlixBernard Great work! I think some comments discouraged me to finish it.

You can start by opening a PR so the contributors can at least evaluate. As a bonus points, do you think it covers new-ish swap_layouts?

olekspickle avatar May 16 '23 11:05 olekspickle

Hey @AlixBernard - this looks great! I'm looking forward to reading the algorithm.

So, the high level things we need to do to make this feature happen are:

  1. Implement this algorithm as a key binding and/or CLI command for Zellij to dump the current layout in a tab and the screen (all the tabs in the screen). (Bonus: also dump the x/y/width/height of the floating panes)
  2. Make this algorithm also be aware of fixed vs. flexible panes (right now the algorithm does fixed, as I understand it? But most of the time we'd want it to do flexible (percentages), and also a combination of the two - we have some ways of handling this, I can happily provide more info)
  3. Give the algorithm access to information about the running command/plugin inside each pane (we'll have to implement some stuff here, but a lot of it is already there - also happy to provide more drilled down guidance)
  4. Write some mechanism that dumps the current layout (possibly behind a config flag) on every change in 3.
  5. Done - we've got persistent sessions

I don't know how much of this you'd like to sign up for, but we can totally do it in chunks so that if you find you don't have the time or it's not fun for you, you won't lose the work you did. I find this feature to be high priority and so will try to make it a priority regarding guidance and such.

imsnif avatar May 16 '23 13:05 imsnif

(also, forgot to mention: I'm happy to drill down into each one of these points with more specific pointers to places in the code base if you'd like)

imsnif avatar May 16 '23 14:05 imsnif

As for infrastucture code needed for actions to work you can take a look at this draft

olekspickle avatar May 16 '23 14:05 olekspickle

Thanks! I think I should have specified that it is still really basic though, so far I only have a few functions that do the jobs (the main one being kdl_string_from_geoms). It needs to be adapted to fit into the code base and to take into account everything else, I don't think it is advanced enough for a PR. I will add it below so you can judge by yourselves. I haven't investigated what are swap layouts so I assume they are not supported.

Here is some code with examples that can be put in zellij/zellij-utils/src/main.rs and run with cd zellij/zellij-utils && cargo run

Code
use std::collections::HashMap;
use zellij_utils::input::layout::{SplitDirection, SplitSize, TiledPaneLayout};
use zellij_utils::pane_size::{Dimension, PaneGeom};

fn main() {
    // Set test data
    let vec_string_geoms = vec![
        r#"[ {"x": 0, "y": 0, "cols": 100, "rows": 50}, {"x": 0, "y": 50, "rows": 50, "cols": 50}, {"x": 50, "y": 50, "rows": 50, "cols": 50} ]"#,
        r#"[{"x": 0, "y": 0, "cols": 80, "rows": 30}, {"x": 0, "y": 30, "rows": 30, "cols": 30}, {"x": 30, "y": 30, "rows": 30, "cols": 50}]"#,
        r#"[{"x": 0, "y": 0, "cols": 60, "rows": 40}, {"x": 60, "y": 0, "rows": 40, "cols": 20}, {"x": 0, "y": 40, "rows": 20, "cols": 60}, {"x": 60, "y": 40, "rows": 20, "cols": 20}]"#,
        r#"[{"x": 0, "y": 0, "cols": 40, "rows": 20}, {"x": 40, "y": 0, "rows": 20, "cols": 40}, {"x": 0, "y": 20, "rows": 20, "cols": 25}, {"x": 25, "y": 20, "rows": 20, "cols": 30}, {"x": 55, "y": 20, "rows": 20, "cols": 25}, {"x": 0, "y": 40, "rows": 20, "cols": 40}, {"x": 40, "y": 40, "rows": 20, "cols": 40}]"#,
        r#"[{"x": 0, "y": 0, "cols": 40, "rows": 30}, {"x": 0, "y": 30, "cols": 40, "rows": 30}, {"x": 40, "y": 0, "cols": 40, "rows":20}, {"x": 40, "y": 20, "cols": 20, "rows": 20}, {"x": 60, "y": 20, "cols": 20, "rows": 20}, {"x": 40, "y": 40, "cols": 40, "rows": 20}]"#,
        r#"[{"x": 0, "y": 0, "cols": 30, "rows": 20}, {"x": 0, "y": 20, "cols": 30, "rows": 20}, {"x": 0, "y": 40, "cols": 30, "rows": 10}, {"x": 30, "y": 0, "cols": 30, "rows": 50}, {"x": 0, "y": 50, "cols": 60, "rows": 10}, {"x": 60, "y": 0, "cols": 20, "rows": 60}]"#,
    ];
    let vec_hashmap_geoms: Vec<Vec<HashMap<String, usize>>> = vec_string_geoms
        .iter()
        .map(|s| serde_json::from_str(s).unwrap())
        .collect();
    let vec_geoms: Vec<Vec<PaneGeom>> = vec_hashmap_geoms
        .iter()
        .map(|hms| {
            hms.iter()
                .map(|hm| get_panegeom_from_hashmap(&hm))
                .collect()
        })
        .collect();

    for (i, geoms) in vec_geoms.iter().enumerate() {
        let kdl_string = kdl_string_from_geoms(&geoms);
        println!("========== {i} ==========");
        println!("{kdl_string}\n");
    }
}

pub fn get_panegeom_from_hashmap(hm: &HashMap<String, usize>) -> PaneGeom {
    PaneGeom {
        x: hm["x"] as usize,
        y: hm["y"] as usize,
        rows: Dimension::fixed(hm["rows"] as usize),
        cols: Dimension::fixed(hm["cols"] as usize),
        is_stacked: false,
    }
}

pub fn kdl_string_from_geoms(geoms: &Vec<PaneGeom>) -> String {
    let layout = get_layout_from_geoms(&geoms, None);
    let tab = if &layout.children_split_direction != &SplitDirection::default() {
        vec![layout]
    } else {
        layout.children
    };
    kdl_string_from_tab(&tab)
}

fn kdl_string_from_tab(tab: &Vec<TiledPaneLayout>) -> String {
    let mut kdl_string = String::from("tab {\n");
    let indent = "    ";
    let indent_level = 1;
    for layout in tab {
        kdl_string.push_str(&kdl_string_from_layout(&layout, indent, indent_level));
    }
    kdl_string.push_str("}");
    kdl_string
}

fn kdl_string_from_layout(layout: &TiledPaneLayout, indent: &str, indent_level: usize) -> String {
    let mut kdl_string = String::from(&indent.repeat(indent_level));
    kdl_string.push_str("pane ");
    match layout.split_size {
        Some(SplitSize::Fixed(size)) => kdl_string.push_str(&format!("size={size} ")),
        Some(SplitSize::Percent(size)) => kdl_string.push_str(&format!("size={size}% ")),
        None => (),
    };
    if layout.children_split_direction != SplitDirection::default() {
        let direction = match layout.children_split_direction {
            SplitDirection::Horizontal => "horizontal",
            SplitDirection::Vertical => "vertical",
        };
        kdl_string.push_str(&format!("split_direction=\"{direction}\" "));
    }
    if layout.children.is_empty() {
        kdl_string.push_str("\n");
    } else {
        kdl_string.push_str("{\n");
        for pane in &layout.children {
            kdl_string.push_str(&kdl_string_from_layout(&pane, indent, indent_level + 1));
        }
        kdl_string.push_str(&indent.repeat(indent_level));
        kdl_string.push_str("}\n");
    }
    kdl_string
}

fn get_layout_from_geoms(geoms: &Vec<PaneGeom>, split_size: Option<SplitSize>) -> TiledPaneLayout {
    let (children_split_direction, splits) = match get_splits(&geoms) {
        Some(x) => x,
        None => {
            return TiledPaneLayout {
                split_size,
                ..Default::default()
            }
        },
    };
    let mut children = Vec::new();
    let mut remaining_geoms = geoms.clone();
    for i in 1..splits.len() {
        let (v_min, v_max) = (splits[i - 1], splits[i]);
        let subgeoms: Vec<PaneGeom>;
        (subgeoms, remaining_geoms) = match children_split_direction {
            SplitDirection::Horizontal => remaining_geoms
                .clone()
                .into_iter()
                .partition(|g| g.y + g.rows.as_usize() <= v_max),
            SplitDirection::Vertical => remaining_geoms
                .clone()
                .into_iter()
                .partition(|g| g.x + g.cols.as_usize() <= v_max),
        };
        let subsplit_size = SplitSize::Fixed(v_max - v_min);
        children.push(get_layout_from_geoms(&subgeoms, Some(subsplit_size)));
    }
    TiledPaneLayout {
        children_split_direction,
        split_size,
        children,
        ..Default::default()
    }
}

fn get_x_lims(geoms: &Vec<PaneGeom>) -> Option<(usize, usize)> {
    match (
        geoms.iter().map(|g| g.x).min(),
        geoms.iter().map(|g| g.x + g.cols.as_usize()).max(),
    ) {
        (Some(x_min), Some(x_max)) => Some((x_min, x_max)),
        _ => None,
    }
}

fn get_y_lims(geoms: &Vec<PaneGeom>) -> Option<(usize, usize)> {
    match (
        geoms.iter().map(|g| g.y).min(),
        geoms.iter().map(|g| g.y + g.rows.as_usize()).max(),
    ) {
        (Some(y_min), Some(y_max)) => Some((y_min, y_max)),
        _ => None,
    }
}

fn get_splits(geoms: &Vec<PaneGeom>) -> Option<(SplitDirection, Vec<usize>)> {
    if geoms.len() == 1 {
        return None;
    }
    let (x_lims, y_lims) = match (get_x_lims(&geoms), get_y_lims(&geoms)) {
        (Some(x_lims), Some(y_lims)) => (x_lims, y_lims),
        _ => return None,
    };
    let mut direction = SplitDirection::default();
    let mut splits = match direction {
        SplitDirection::Vertical => get_vertical_splits(&geoms, x_lims, y_lims),
        SplitDirection::Horizontal => get_horizontal_splits(&geoms, x_lims, y_lims),
    };
    if splits.len() <= 2 {
        direction = !direction;
        splits = match direction {
            SplitDirection::Vertical => get_vertical_splits(&geoms, x_lims, y_lims),
            SplitDirection::Horizontal => get_horizontal_splits(&geoms, x_lims, y_lims),
        };
    }
    if splits.len() <= 2 {
        None
    } else {
        Some((direction, splits))
    }
}

fn get_vertical_splits(
    geoms: &Vec<PaneGeom>,
    x_lims: (usize, usize),
    y_lims: (usize, usize),
) -> Vec<usize> {
    let ((_, x_max), (y_min, y_max)) = (x_lims, y_lims);
    let height = y_max - y_min;
    let mut splits = Vec::new();
    for x in geoms.iter().map(|g| g.x) {
        if splits.contains(&x) {
            continue;
        }
        if geoms
            .iter()
            .filter(|g| g.x == x)
            .map(|g| g.rows.as_usize())
            .sum::<usize>()
            == height
        {
            splits.push(x);
        };
    }
    splits.push(x_max);
    splits
}

fn get_horizontal_splits(
    geoms: &Vec<PaneGeom>,
    x_lims: (usize, usize),
    y_lims: (usize, usize),
) -> Vec<usize> {
    let ((x_min, x_max), (_, y_max)) = (x_lims, y_lims);
    let width = x_max - x_min;
    let mut splits = Vec::new();
    for y in geoms.iter().map(|g| g.y) {
        if splits.contains(&y) {
            continue;
        }
        if geoms
            .iter()
            .filter(|g| g.y == y)
            .map(|g| g.cols.as_usize())
            .sum::<usize>()
            == width
        {
            splits.push(y);
        };
    }
    splits.push(y_max);
    splits
}

I use recursion in the code, as my first attempt without it was to messy and hard to read. The code could be optimized in some places by making x-sorted and y-sorted copies of Vec<PaneGeom> however as the number of PaneGeoms in a tab shouldn't be too high in practice so it may not be worth it, at least for now.

Here are some details on the algorithm implemented

Algorithm

Assumptions:

  • The input is a Vec<PaneGeom> where the PaneGeoms form a partition of the domain
  • The rectangles defined by PaneGeoms are can be formed by recursive horizontal and/or vertical splits

Limits:

  • Based only on PaneGeoms it cannot be inferred which split comes first (horizontal or vetical) when both are possible (imagine a rectangle with a cross splitting it for instance), in this case the default value of SplitDirection is prioritized

Algorithm:

  • Reconstruct TiledPaneLayout
    1. Consider all the PaneGeoms
    2. For each value of PaneGeom.x (that is not equal to the minimum of x, as it would be the left edge of the domain) group all the PaneGeom that have this value of x and:
    • if the sum of their rows is equal to the height of the domain, then add it to a list keeping track of the values of x splitting the domain
    1. If no value of x are found to split the domain then try again for the values of y with the sum of cols
    2. At his point if there is more than 1 PaneGeom then you should have the values of x or y splitting the domain
    3. Separate the PaneGeoms depending on where they are regarding the splitting values and repeat for each subdomain considering only their associated PaneGeoms
  • Serialize TiledPaneLayout to kdl format This one isn't complicated with recursion and can be seen in the code

I hope it is clear enough to understand, if not let me know and I'll try to explain it better when I have more time.

I feel like I don't know enough the whole repo to know how to integrate it to a structure or make it as its own serialization module, if you could suggest an idea that will be helpful.

As you pointed out, there is still a lot to do and I don't have the time to do it all so anyone can feel free to use the algorithm or the code to do it. However I'm willing to try to improve this piece of code to allow flexible panes (I will need more info about this), work with swap layouts if possible, and adapt it so it can be properly integrated to the code base (please point to me anything that should be modified).

Anyway I'm happy to hear that this feature is considered a priority and I hope that my contribution will help its implementation. I'll try to help as I can)

AlixBernard avatar May 16 '23 18:05 AlixBernard

I'll try to make a usable action out of this. There is a lot stuff to add, since layouts advanced quite a bit, but this is already good base. Huge effort, man!

olekspickle avatar May 17 '23 08:05 olekspickle

Thanks to the two of you! I'm very happy to see this collab and things moving forward on this.

Just a note about swap layouts: I think it's safe to ignore them. They can't be changed at runtime and are handled as default in various cases, so we can probably deal with them on the "session restoration" side rather than the session serialization side we're discussing now.

I haven't delved deeply into the algorithm, but I think even this implementation brings us 80% of the way there. Please do reach out here or on Discord if you have any questions (eg. things that need to be supported or not, to save you time!)

Looking forward to this!

imsnif avatar May 17 '23 08:05 imsnif