Save the layout state of every layout a tab has used to sessions

save_as_session recorded only the state of the tab's active layout, so the state
of any layout the tab had been toggled away from was lost. Toggling a splits tab
to the stack layout and saving discarded the splits tree entirely, because Stack
has no layout_state() of its own, and switching back to splits after restoring
rebuilt the tree from scratch along the default axis.

The session file also carried no last_used_layout, so _last_used_layout was None
after a restore and toggle_layout <current layout> silently did nothing, since it
delegates to last_used_layout() which returns early when unset. The key that
produced the stack layout no longer undid it.

Tab now keeps the layouts it has used, by name, and writes their state plus
last_used_layout into the existing set_layout_state JSON rather than adding new
session commands. Older versions of kitty ignore unknown keys in that dict but
abort on unknown commands, so nesting keeps session files readable by them.

Restoring the state of a layout that is not being made current must not reorder
the window list, since only the current layout determines window order, hence
apply_to_window_list. Splits.set_layout_state also has to accept state for a
layout whose tree is still empty, which is the case for a layout that has not
laid out any windows yet.

Fixes #10320
This commit is contained in:
Chance Zibolski 2026-08-04 21:16:54 -07:00
parent 51b0993b5c
commit 1b81045008
5 changed files with 79 additions and 6 deletions

View file

@ -575,11 +575,17 @@ class Layout:
s: dict[str, Any],
all_windows: WindowList,
window_id_mapper: Callable[[WindowList], dict[int, int]] = create_window_id_map_for_unserialize,
apply_to_window_list: bool = True,
) -> bool:
"""
apply_to_window_list=False computes the group id mapping without changing the
window list. Needed when restoring the state of more than one layout, since
only the layout being made current should order the windows.
"""
if s.get('class') != self.__class__.__name__:
return False
window_id_map = create_window_id_map_for_unserialize(all_windows)
m = all_windows.unserialize_layout_state(s['all_windows'], window_id_map)
m = all_windows.unserialize_layout_state(s['all_windows'], window_id_map, apply=apply_to_window_list)
if m is None:
return False
return self.set_layout_state(s, m.get)

View file

@ -964,7 +964,10 @@ class Splits(Layout):
new_root = Pair()
new_root.unserialize(layout_state['pairs'], map_group_id)
before = frozenset(self.pairs_root.all_window_ids())
if before == frozenset(new_root.all_window_ids()):
# An empty tree means this layout has not laid out any windows yet, which is
# the case when restoring the state of a layout that was not the active one.
# do_layout() places any groups the restored tree is missing.
if not before or before == frozenset(new_root.all_window_ids()):
self.pairs_root = new_root
self.layout_opts = SplitsLayoutOpts(layout_state['opts'])
if 'maximized' in layout_state:

View file

@ -54,7 +54,7 @@ from .fast_data_types import (
sync_os_window_title,
)
from .layout.base import DragOverlayMode, Layout
from .layout.interface import create_layout_object_for, evict_cached_layouts
from .layout.interface import all_layouts, create_layout_object_for, evict_cached_layouts
from .progress import ProgressState
from .tab_bar import TabBar, TabBarData, apply_title_template
from .types import ac
@ -216,6 +216,9 @@ class Tab: # {{{
self.windows: WindowList = WindowList(self)
self._last_used_layout: str | None = None
self._current_layout_name: str | None = None
# Layouts this tab has used, by name. Their state is preserved when
# switching away, so it can be saved to a session as well.
self._used_layouts: dict[str, Layout] = {}
self.cwd = self.args.directory
if no_initial_window:
self._set_current_layout(self.enabled_layouts[0])
@ -302,6 +305,7 @@ class Tab: # {{{
self._last_used_layout = self._current_layout_name
self.current_layout = self.create_layout_object(layout_name)
self._current_layout_name = layout_name
self._used_layouts[layout_name] = self.current_layout
self.mark_tab_bar_dirty()
def startup(self, session_tab: SessionTab) -> None:
@ -370,7 +374,22 @@ class Tab: # {{{
if active_window_id and not did_focus_matching_spec:
self.windows.set_active_window_group_for(active_window_id)
if session_tab.layout_state:
self.current_layout.unserialize(session_tab.layout_state, self.windows)
self.unserialize_layout_state_from_session(session_tab.layout_state)
def unserialize_layout_state_from_session(self, layout_state: dict[str, Any]) -> None:
self.current_layout.unserialize(layout_state, self.windows)
# Restore the state of layouts that were not active when the session was
# saved, so that switching to them gives back their saved arrangement
# rather than a freshly built one.
for name, state in (layout_state.get('other_layouts') or {}).items():
if name.partition(':')[0] not in all_layouts:
continue
layout = self.create_layout_object(name)
if layout.unserialize(state, self.windows, apply_to_window_list=False):
self._used_layouts[name] = layout
last_used = layout_state.get('last_used_layout')
if last_used and last_used != self._current_layout_name:
self._last_used_layout = last_used
def serialize_state(self) -> dict[str, Any]:
return {
@ -426,12 +445,27 @@ class Tab: # {{{
f'new_tab {self.name}'.rstrip(),
f'layout {layout}',
f'enabled_layouts {",".join(enabled_layouts)}',
f'set_layout_state {json.dumps(self.current_layout.serialize(self.windows))}',
f'set_layout_state {json.dumps(self.serialize_layout_state_for_session())}',
f'cd {most_common_cwd}',
'',
] + launch_cmds
return []
def serialize_layout_state_for_session(self) -> dict[str, Any]:
"""
The state of the current layout, with the state of any other layouts this tab
has used nested under other_layouts. Nesting rather than adding new session
commands keeps the file readable by older versions of kitty, which ignore
unknown keys in this dict but abort on unknown commands.
"""
ans = self.current_layout.serialize(self.windows)
others = {name: layout.serialize(self.windows) for name, layout in self._used_layouts.items() if name != self._current_layout_name}
if others:
ans['other_layouts'] = others
if self._last_used_layout:
ans['last_used_layout'] = self._last_used_layout
return ans
def data_for_tab_bar(self, is_active: bool) -> TabBarData:
t = self
title = t.name or t.title or appname

View file

@ -203,7 +203,12 @@ class WindowList:
'window_groups': [g.serialize_layout_state() for g in self.groups],
}
def unserialize_layout_state(self, state: dict[str, Any], window_id_map: dict[int, int]) -> dict[int, int] | None:
def unserialize_layout_state(self, state: dict[str, Any], window_id_map: dict[int, int], apply: bool = True) -> dict[int, int] | None:
"""
Returns a map of serialized group id to current group id. With apply=False the
map is computed but the window list is left unchanged, so that the state of
several layouts can be restored while only one of them orders the windows.
"""
if set(window_id_map.values()) != set(self.id_map):
# some window in this collection does not correspond to a
# serialized window
@ -233,6 +238,8 @@ class WindowList:
# we ignore them.
if len(ans) != len(self.groups):
return None
if not apply:
return ans
gmap = {g.id: g for g in self.groups}
groups = []
for wg in state['window_groups']:

View file

@ -16,6 +16,7 @@ from .base import BaseTest
class Window:
def __init__(self, win_id, overlay_for=None, overlay_window_id=None):
self.id = win_id
self.serialized_id = 0
self.overlay_for = overlay_for
self.overlay_window_id = overlay_window_id
self.is_visible_in_layout = True
@ -273,6 +274,28 @@ class TestLayout(BaseTest):
self.ae(q.neighbors_for_window(windows[2], all_windows), {'left': [1], 'right': [4], 'top': [2]})
self.ae(q.neighbors_for_window(windows[3], all_windows), {'left': [3], 'top': [2]})
def test_splits_unserialize_into_unused_layout(self):
# Restoring the state of a layout that was not the active one when the session
# was saved: its pairs tree is empty, and the window list must not be
# reordered, since only the layout being made current does that.
q = create_layout(Splits)
all_windows = create_windows(q, num=0)
for wid, loc in ((1, None), (2, 'hsplit'), (3, 'vsplit')):
win = Window(wid)
win.serialized_id = wid
q.add_window(all_windows, win, location=loc)
q(all_windows)
expected = {'horizontal': False, 'one': 1, 'two': {'one': 2, 'two': 3}}
self.ae(q.pairs_root.serialize(), expected)
state = q.serialize(all_windows)
groups_before = [g.id for g in all_windows.groups]
fresh = create_layout(Splits)
self.ae(fresh.pairs_root.serialize(), {})
self.assertTrue(fresh.unserialize(state, all_windows, apply_to_window_list=False))
self.ae(fresh.pairs_root.serialize(), expected)
self.ae([g.id for g in all_windows.groups], groups_before)
def test_splits_maximize(self):
q = create_layout(Splits)
all_windows = create_windows(q, num=0)