diff --git a/docs/sessions.rst b/docs/sessions.rst index 29a363d45..1c32b45d4 100644 --- a/docs/sessions.rst +++ b/docs/sessions.rst @@ -61,6 +61,9 @@ easily swap between them, kitty has you covered. You can use the In this manner you can define as many projects/sessions as you like and easily switch between them with a keypress. +You can also close sessions using the :ac:`close_session` action, which closes +all windows in the session with a single keypress. + Displaying the currently active session name ---------------------------------------------- diff --git a/kitty/actions.py b/kitty/actions.py index 2fa59ef82..7fed0fe63 100644 --- a/kitty/actions.py +++ b/kitty/actions.py @@ -27,6 +27,7 @@ groups: dict[ActionGroup, str] = { 'lay': 'Layouts', 'misc': 'Miscellaneous', 'debug': 'Debugging', + 'session': 'Sessions', } group_title = groups.__getitem__ diff --git a/kitty/boss.py b/kitty/boss.py index 66de9e298..ff4b9eb99 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -122,7 +122,15 @@ from .notifications import NotificationManager from .options.types import Options, nullable_colors from .options.utils import MINIMUM_FONT_SIZE, KeyboardMode, KeyDefinition from .os_window_size import initial_window_size_func -from .session import Session, create_sessions, default_save_as_session_opts, get_os_window_sizing_data, goto_session, save_as_session +from .session import ( + Session, + close_session_with_confirm, + create_sessions, + default_save_as_session_opts, + get_os_window_sizing_data, + goto_session, + save_as_session, +) from .shaders import load_shader_programs from .simple_cli_definitions import grab_keyboard_docs from .tabs import SpecialWindow, SpecialWindowInstance, Tab, TabDict, TabManager @@ -1025,7 +1033,7 @@ class Boss: def close_window(self) -> None: self.mark_window_for_close(self.window_for_dispatch) - def close_windows_with_confirmation_msg(self, windows: Iterable[Window], active_window: Window | None) -> tuple[str, int]: + def close_windows_with_confirmation_msg(self, windows: Iterable[Window], active_window: Window | None = None) -> tuple[str, int]: num_running_programs = 0 num_background_programs = 0 count_background = get_options().confirm_os_window_close[1] @@ -1268,6 +1276,12 @@ class Boss: for window in tab: self.mark_window_for_close(window) + def close_windows_no_confirm(self, windows: Sequence[Window]) -> None: + if self.current_visual_select is not None: + self.cancel_current_visual_select() + for window in windows: + self.mark_window_for_close(window) + @ac('win', 'Toggle the fullscreen status of the active OS Window') def toggle_fullscreen(self, os_window_id: int = 0) -> None: if os_window_id == 0: @@ -1538,6 +1552,14 @@ class Boss: return t.created_in_session_name return '' + @property + def all_loaded_session_names(self) -> Iterator[str]: + seen = set() + for w in self.all_windows: + if w.created_in_session_name and w.created_in_session_name not in seen: + seen.add(w.created_in_session_name) + yield w.created_in_session_name + def refresh_active_tab_bar(self) -> bool: tm = self.active_tab_manager if tm: @@ -3057,14 +3079,29 @@ class Boss: ) return q if isinstance(q, Window) else None - @ac('misc', 'Switch to the specified session, creating it if not already present. See :ref:`goto_session`.') + @ac('session', 'Switch to the specified session, creating it if not already present. See :ref:`goto_session`.') def goto_session(self, *cmdline: str) -> None: goto_session(self, cmdline) - @ac('misc', 'Save the current kitty state as a session file. See :ref:`save_as_session`.') + @ac('session', 'Save the current kitty state as a session file. See :ref:`save_as_session`.') def save_as_session(self, *cmdline: str) -> None: save_as_session(self, cmdline) + @ac('session', ''' + Close a session, that is, close all windows that belong to the session. + Examples:: + # Ask for the session to close + map f1 close_session + # Close the currently active session + map f1 close_session . + # Close session by name + map f1 close_session "my session" + # Close session by path to session file + map f1 close_session "/path/to/session/file.kitty-session" + ''') + def close_session(self, *cmdline: str) -> None: + close_session_with_confirm(self, cmdline) + @ac('tab', 'Interactively select a tab to switch to') def select_tab(self) -> None: diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 2197f01e8..d02365a1f 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -78,6 +78,7 @@ class InvalidMods(ValueError): 'pass_selection_to_program', 'new_window', 'new_tab', 'new_os_window', 'new_window_with_cwd', 'new_tab_with_cwd', 'new_os_window_with_cwd', 'launch', 'mouse_handle_click', 'show_error', 'goto_session', 'save_as_session', + 'close_session', ) def shlex_parse(func: str, rest: str) -> FuncArgsType: return func, to_cmdline(rest) diff --git a/kitty/session.py b/kitty/session.py index e859877bf..d296245de 100644 --- a/kitty/session.py +++ b/kitty/session.py @@ -199,7 +199,6 @@ def session_arg_to_name(session_arg: str) -> str: return session_name - def parse_session( raw: str, opts: Options, environ: Mapping[str, str] | None = None, session_arg: str = '', session_path: str = '' ) -> Generator[Session, None, None]: @@ -434,6 +433,58 @@ def get_all_known_sessions() -> dict[str, str]: return all_known_sessions +def close_session_with_confirm(boss: BossType, cmdline: Sequence[str]) -> None: + if not cmdline: + names = sorted(boss.all_loaded_session_names, key=lambda x: x.lower()) + if not names: + boss.ring_bell_if_allowed() + return + if len(names) == 1: + return close_session_with_confirm(boss, names) + def chosen(name: str | None) -> None: + if name: + close_session_with_confirm(boss, (name,)) + boss.choose_entry( + _('Select a session to close'), ((name, name) for name in names), chosen) + return + if len(cmdline) != 1: + boss.show_error(_('Invalid close_session specification'), _('{} is not a valid argument to close_session').format(shlex.join(cmdline))) + return + path_or_name = cmdline[0] + if path_or_name == '.': + if name := boss.active_session: + close_session_with_confirm(boss, (name,)) + else: + boss.ring_bell_if_allowed() + return + if '/' in path_or_name: + path_to_name = {v: k for k, v in get_all_known_sessions().items()} + name = path_to_name.get(path_or_name, '') + if not name: + boss.ring_bell_if_allowed() + return + else: + name = path_or_name + windows = tuple(w for w in boss.all_windows if w.created_in_session_name == name) + if not windows: + return + msg, num_active_windows = boss.close_windows_with_confirmation_msg(windows, boss.active_window) + x = get_options().confirm_os_window_close[0] + num = num_active_windows if x < 0 else len(windows) + needs_confirmation = x != 0 and num >= abs(x) + + def do_close(confirmed: bool) -> None: + if confirmed: + boss.close_windows_no_confirm(windows) + + if needs_confirmation: + msg = msg or _('It has {} windows?').format(num) + msg = _('Are you sure you want to close this session?') + ' ' + msg + boss.confirm(msg, do_close, window=boss.active_window, title=_('Close session?')) + else: + do_close(True) + + def choose_session(boss: BossType) -> None: all_known_sessions = get_all_known_sessions() hmap = {n: len(goto_session_history)-i for i, n in enumerate(goto_session_history)} diff --git a/kitty/types.py b/kitty/types.py index fe45fe0a6..a63db5e9a 100644 --- a/kitty/types.py +++ b/kitty/types.py @@ -215,7 +215,7 @@ def modmap() -> dict[str, int]: if TYPE_CHECKING: from typing import Literal - ActionGroup = Literal['cp', 'sc', 'win', 'tab', 'mouse', 'mk', 'lay', 'misc', 'debug'] + ActionGroup = Literal['cp', 'sc', 'win', 'tab', 'mouse', 'mk', 'lay', 'misc', 'debug', 'session'] else: ActionGroup = str