From dd828173252011a7f713036ceee33f4dee144494 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Sun, 14 Jun 2026 15:16:45 +0200 Subject: [PATCH 1/5] Rewrite tab selection tracking for sheets Sheet tracking is required for several reasons: 1. There are non-text sheets (HTML and images) that do not have views and thus do not show up in various view-specific APIs. 2. You can select multiple sheets, which the default behavior supports, and we do not want to regress here, so we have to track all selected sheets at a time. This requires much more complex tracking code and persistence because we cannot simply attach a timestamp to a view's settings object anymore. As such, we now store and synchronize our recorded selection history inside the window's settings object, keyed by the group id (which is stable across restarts) and by computing a stable sheet id, and need to implement various cleanup logic, including code that de-duplicates sheets from a previous multi-selection when it has later been selected alone, and much more. Another annoying part is that EventListener hooks do not fire for sheets, which is why we implement a polling mechanism to synchronize selections also when those sheets are selected. --- AGENTS.md | 34 +++--- plugin/__init__.py | 13 ++- plugin/captions.py | 19 ++- plugin/commands.py | 219 +++-------------------------------- plugin/history.py | 281 +++++++++++++++++++++++++++++++++++++++++++++ plugin/listener.py | 59 +++++----- plugin/mru.py | 70 +++++++++++ plugin/session.py | 85 ++++++++++++++ plugin/sheets.py | 94 +++++++++++++++ plugin/state.py | 61 +++++----- 10 files changed, 644 insertions(+), 291 deletions(-) create mode 100644 plugin/history.py create mode 100644 plugin/mru.py create mode 100644 plugin/session.py create mode 100644 plugin/sheets.py diff --git a/AGENTS.md b/AGENTS.md index 76c988e..fb210f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,28 +2,20 @@ - This is a Sublime Text package, not a standalone app. - Runtime target is Sublime Text's Python 3.14 plugin host. -- Root `main.py` is the only plugin entry file. -- `main.py` clears cached `plugin.*` modules before star-importing `plugin`, so keep exposed plugin symbols in `plugin/__init__.py`. -- Real implementation lives under `plugin/`. -- Modifier-release detection lives in `plugin/ctrl_release/`. +- `main.py` is the only entry file. It clears cached `plugin.*` modules before star-importing `plugin`. +- Keep exported plugin symbols in `plugin/__init__.py`; real implementation lives under `plugin/`. - Per-window state is in-memory only, keyed by `window.id()` in `plugin/state.py`. -- `TabStackListener.on_activated` updates MRU order, but ignores activation during an active quick-panel session. -- `TabStackListener.on_close` removes closed views from all window stacks. -- Key bindings are shared among all platforms and live in `Default.sublime-keymap`. -- The quick-panel session context key is `setting.tab_stack_quick_panel`. -- When that setting is `false`, `ctrl+tab`, `ctrl+shift+tab`, `ctrl+up`, and `ctrl+down` start `show_tab_stack`. -- When that setting is `true`, those keys call `tab_stack_move`, and `escape` or `ctrl+escape` call `tab_stack_cancel`. -- `plugin/ctrl_release/linux.py` uses X11/XWayland polling via `ctypes`; native Wayland without XWayland is unsupported. -- `plugin/ctrl_release/windows.py` uses `GetAsyncKeyState` polling via `ctypes`. -- `plugin/ctrl_release/macos.py` uses CoreGraphics polling via `ctypes`. -- `show_tab_stack` starts a Ctrl-release poller and commits when Ctrl is released. -- Tab captions come from `plugin/captions.py`; views that are widgets, panels, or non-tab elements are excluded from the MRU list. -- Keep the MRU list per window and preserve the current preview behavior: preview must not reorder the stack. +- MRU order is per window. `TabStackListener.on_activated` updates history, except during an active quick-panel session. +- `TabStackListener.on_close` prunes closed sheets from history and removes empty window state. +- Tab captions come from `plugin/captions.py`. Widgets, panels, and transient/non-tab views are excluded from the MRU list. +- Ctrl-release detection lives in `plugin/ctrl_release/`. Linux needs X11 or XWayland; native Wayland is unsupported. +- `show_tab_stack` opens the quick panel, starts a Ctrl-release poller, and commits when Ctrl is released. +- The quick-panel context key is `tab_stack.quick_panel`. +- When `tab_stack.quick_panel` is `false`, `ctrl+tab`, `ctrl+shift+tab`, `ctrl+up`, and `ctrl+down` start `show_tab_stack`. +- When `tab_stack.quick_panel` is `true`, those keys call `move`, and `ctrl+escape` calls `tab_stack_cancel`. ## Verification -- Run dev tools with `uv run`, for example `uv run ruff check .` and `uv run ruff format .`. -- `pyproject.toml` only defines `ruff` settings. -- Use `ruff check .` for linting via `uv run`. -- Use `ruff format .` for formatting via `uv run`. -- No repo-local test runner or task file is currently present. +- Use `uv run ruff check .` for linting. +- Use `uv run ruff format .` for formatting. +- `pyproject.toml` only defines Ruff settings; there is no repo-local test runner. diff --git a/plugin/__init__.py b/plugin/__init__.py index 2f7cbd3..f4adc99 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -1,13 +1,16 @@ from .commands import * # noqa: F401,F403 from .ctrl_release import plugin_unloaded as _ctrl_release_plugin_unloaded from .listener import * # noqa: F401,F403 -from .state import iter_states +from .state import iter_states as _iter_states def plugin_unloaded() -> None: - for state in iter_states(): - if state.poller is not None: - state.poller.stop() - state.poller = None + for state in _iter_states(): + if state.selection_poller is not None: + state.selection_poller.stop() + state.selection_poller = None + if state.ctrl_release_poller is not None: + state.ctrl_release_poller.stop() + state.ctrl_release_poller = None _ctrl_release_plugin_unloaded() diff --git a/plugin/captions.py b/plugin/captions.py index f34f669..2370d90 100644 --- a/plugin/captions.py +++ b/plugin/captions.py @@ -3,13 +3,20 @@ from pathlib import Path -def caption_for_view(view, window) -> list[str]: - title = view.name() or (Path(view.file_name()).name if view.file_name() else "Untitled") - path = "" - file_name = view.file_name() +def caption_for_sheet_identities(identities, window) -> list[str]: + titles = [identity["name"] for identity in identities] + paths = [ + _relative_path(identity["path"], window) for identity in identities if identity["path"] + ] + + return [" | ".join(titles), " | ".join(paths)] + + +def _title_from_sheet(sheet) -> str: + file_name = sheet.file_name() if file_name: - path = _relative_path(file_name, window) - return [title, path] + return Path(file_name).name + return "Untitled" def _relative_path(file_name: str, window) -> str: diff --git a/plugin/commands.py b/plugin/commands.py index 7ff7b6a..6bc4e9c 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -1,50 +1,31 @@ from __future__ import annotations -from dataclasses import dataclass - -from ._compat import sublime, sublime_plugin -from .captions import caption_for_view -from .ctrl_release import CtrlReleasePoller, is_available +from ._compat import sublime_plugin +from .ctrl_release import is_available +from .history import current_group_selection_state, sync_selection_history +from .mru import collect_entries +from .session import cancel_session, show_panel from .state import get_state -@dataclass(slots=True) -class _Entry: - view_id: int - caption: list[str] - - class ShowTabStackCommand(sublime_plugin.WindowCommand): def run(self) -> None: window = self.window - if window is None: - return - - if not is_available(): + if window is None or not is_available(): return state = get_state(window) - entries = _collect_entries(window, state) + active_group = window.active_group() + state.session_origin_selection = current_group_selection_state(window, active_group) + history = sync_selection_history(window, prune_removed_sheets=True) + entries = collect_entries(window, history) if not entries: return state.session_active = True - state.session_origin_view_id = _active_view_id(window) - state.session_selected_index = _initial_index(state, entries) - _show_panel(window, state, entries) - - state.poller = CtrlReleasePoller(lambda: _commit_on_release(window, state), 25) - state.poller.start() - - -class TabStackCommitCommand(sublime_plugin.WindowCommand): - def run(self) -> None: - window = self.window - if window is None: - return - - state = get_state(window) - _commit_session(window, state) + state.session_group = active_group + state.session_entries = entries + show_panel(window, state) class TabStackCancelCommand(sublime_plugin.WindowCommand): @@ -54,176 +35,4 @@ def run(self) -> None: return state = get_state(window) - _cancel_session(window, state) - - -def _collect_entries(window, state) -> list[_Entry]: - active_group_ids = {view.id() for view in window.views_in_group(window.active_group())} - entries: list[_Entry] = [] - pruned_mru_ids: list[int] = [] - - hydrate_mru_state(state, window) - - for view_id in state.mru_view_ids: - view = _find_view_by_id(window, view_id) - if view is None: - continue - pruned_mru_ids.append(view_id) - if view_id not in active_group_ids: - continue - entries.append(_Entry(view_id=view_id, caption=caption_for_view(view, window))) - - state.mru_view_ids = pruned_mru_ids - return entries - - -def hydrate_mru_state(state, window) -> None: - if state.mru_initialized: - return - - state.mru_view_ids = _initial_view_ids(window.views()) - state.mru_initialized = True - - -def _initial_view_ids(views) -> list[int]: - tab_views = list(enumerate(views)) - tab_views.sort(key=lambda item: _activation_sort_key(item[1], item[0])) - return [view.id() for _, view in tab_views] - - -def _activation_sort_key(view, fallback_index: int) -> tuple[bool, float, int]: - value = view.settings().get("tab_stack.last_activated") - if value is None: - return True, 0.0, fallback_index - return False, -float(value), fallback_index - - -def _active_view_id(window) -> int | None: - view = window.active_view() - if view is None: - return None - return view.id() - - -def _initial_index(state, entries: list[_Entry]) -> int: - origin_id = state.session_origin_view_id - if origin_id is None: - return 0 - for entry in entries: - if entry.view_id == origin_id: - return 1 if len(entries) > 1 else 0 - return 0 - - -def _show_panel(window, state, entries: list[_Entry]) -> None: - items = [entry.caption for entry in entries] - - def on_select(index: int) -> None: - if index < 0: - _handle_panel_closed(window, state) - return - state.session_selected_index = index - _commit_session(window, state) - - def on_highlight(index: int) -> None: - if index < 0 or index >= len(entries): - return - state.session_selected_index = index - _preview_entry(window, state, entries[index]) - - window.show_quick_panel( - items, - on_select, - selected_index=state.session_selected_index, - on_highlight=on_highlight, - placeholder="Release ctrl to close; Hit ctrl+escape to abort", - ) - if entries: - _preview_entry(window, state, entries[state.session_selected_index]) - - -def _preview_entry(window, state, entry: _Entry) -> None: - if state.session_preview_view_id == entry.view_id: - return - - view = _find_view_by_id(window, entry.view_id) - if view is None: - return - - state.session_preview_view_id = entry.view_id - window.focus_view(view) - - -def _commit_on_release(window, state) -> None: - if not state.session_active: - return - _commit_session(window, state) - - -def _commit_session(window, state) -> None: - if not state.session_active: - return - - entries = _collect_entries(window, state) - if not entries: - _cancel_session(window, state) - return - - index = _clamp_index(state.session_selected_index, len(entries)) - view = _find_view_by_id(window, entries[index].view_id) - if view is None: - _cancel_session(window, state) - return - - _promote_view_id(state, view.id()) - _finish_session(window, state) - window.run_command("hide_overlay", {"cancel": True}) - window.focus_view(view) - - -def _cancel_session(window, state) -> None: - if not state.session_active: - return - - origin_id = state.session_origin_view_id - origin_view = _find_view_by_id(window, origin_id) if origin_id is not None else None - _finish_session(window, state) - window.run_command("hide_overlay", {"cancel": True}) - if origin_view is not None: - window.focus_view(origin_view) - - -def _finish_session(window, state) -> None: - state.session_tagged_view_ids.clear() - state.clear_session() - - -def _promote_view_id(state, view_id: int) -> None: - state.mru_view_ids = [item for item in state.mru_view_ids if item != view_id] - state.mru_view_ids.insert(0, view_id) - - -def _clamp_index(index: int, size: int) -> int: - if size <= 0: - return 0 - if index < 0: - return 0 - if index >= size: - return size - 1 - return index - - -def _find_view_by_id(window, view_id: int | None): - if view_id is None: - return None - view = sublime.View(view_id) - view_window = view.window() - if view_window is None: - return None - if view_window.id() != window.id(): - return None - return view - - -def _handle_panel_closed(window, state) -> None: - _cancel_session(window, state) + cancel_session(window, state) diff --git a/plugin/history.py b/plugin/history.py new file mode 100644 index 0000000..eee38d5 --- /dev/null +++ b/plugin/history.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from typing import Any, cast + +from ._compat import sublime +from .sheets import SheetIdentity, find_sheet_by_identity_and_group, sheet_identity +from .state import ( + GroupSelectionState, + SheetSelectionHistory, + TabStackWindowState, + get_state, +) + +_HISTORY_KEY = "tab_stack.sheet_selection_history" + + +class SelectionHistoryPoller: + def __init__(self, state: TabStackWindowState, window, interval_ms: int = 500) -> None: + self._state = state + self._window = window + self._interval_ms = interval_ms + self._active = False + + def start(self) -> None: + if self._active: + return + self._active = True + self._schedule() + + def stop(self) -> None: + self._active = False + + def _schedule(self) -> None: + if not self._active: + return + sublime.set_timeout_async(self._poll, self._interval_ms) + + def _poll(self) -> None: + if not self._active: + return + if not self._state.session_active: + sync_selection_history(self._window) + self._schedule() + + +def current_group_selection_state(window, group: int) -> GroupSelectionState | None: + selected_sheets = list(window.selected_sheets_in_group(group)) + selected_identities = [ + sheet_identity(sheet, window) for sheet in selected_sheets if sheet.group() is not None + ] + if not selected_identities: + return None + + active_sheet_index = None + active_sheet = window.active_sheet_in_group(group) + if active_sheet is not None and active_sheet.group() is not None: + active_identity = sheet_identity(active_sheet, window) + try: + active_sheet_index = selected_identities.index(active_identity) + except ValueError: + active_sheet_index = None + + return { + "active_sheet_index": active_sheet_index, + "selected_sheets": selected_identities, + } + + +def history_for_window(window) -> SheetSelectionHistory: + history = window.settings().get(_HISTORY_KEY) + if isinstance(history, dict) and is_current_history(history): + return cast(SheetSelectionHistory, history) + + history = default_history() + window.settings().set(_HISTORY_KEY, history) + return history + + +def store_history(window, history: SheetSelectionHistory) -> None: + window.settings().set(_HISTORY_KEY, history) + + +def default_history() -> SheetSelectionHistory: + return {"groups": {}} + + +def is_current_history(history: dict[str, Any]) -> bool: + groups = history.get("groups") + if not isinstance(groups, dict): + return False + + for group_state_stack in groups.values(): + if not isinstance(group_state_stack, list): + return False + for group_state in group_state_stack: + if not isinstance(group_state, dict): + return False + if set(group_state) != {"active_sheet_index", "selected_sheets"}: + return False + if group_state["active_sheet_index"] is not None and not isinstance( + group_state["active_sheet_index"], int + ): + return False + if not isinstance(group_state["selected_sheets"], list): + return False + + return True + + +def sync_selection_history( + window, + prune_removed_sheets: bool = False, +) -> SheetSelectionHistory: + history = history_for_window(window) + groups = history["groups"] + changed = False + + if prune_removed_sheets: + for group_key, group_state_stack in list(groups.items()): + updated_stack = prune_history_stack_for_live_sheets( + window, int(group_key), group_state_stack + ) + if updated_stack != group_state_stack: + changed = True + if updated_stack: + groups[group_key] = updated_stack + else: + del groups[group_key] + + active_group = window.active_group() + current = current_group_selection_state(window, active_group) + if current is not None: + group_key = str(active_group) + existing_stack = groups.get(group_key, []) + updated_stack = prepend_group_state(existing_stack, current) + if updated_stack != existing_stack: + groups[group_key] = updated_stack + changed = True + + if changed: + store_history(window, history) + return history + + +def prepend_group_state( + existing_stack: list[GroupSelectionState], + current: GroupSelectionState, +) -> list[GroupSelectionState]: + return prune_history_stack([current, *existing_stack]) + + +def prune_history_stack_for_live_sheets( + window, + group: int, + group_state_stack: list[GroupSelectionState], +) -> list[GroupSelectionState]: + updated_stack: list[GroupSelectionState] = [] + + for group_state in group_state_stack: + pruned_group_state = prune_group_state_for_live_sheets(window, group, group_state) + if pruned_group_state is not None: + updated_stack.append(pruned_group_state) + + return updated_stack + + +def prune_group_state_for_live_sheets( + window, + group: int, + group_state: GroupSelectionState, +) -> GroupSelectionState | None: + selected_sheets = [ + identity + for identity in group_state["selected_sheets"] + if find_sheet_by_identity_and_group(window, identity, group) is not None + ] + if not selected_sheets: + return None + + return { + "active_sheet_index": prune_active_sheet_index( + group_state["active_sheet_index"], + group_state["selected_sheets"], + selected_sheets, + ), + "selected_sheets": selected_sheets, + } + + +def prune_active_sheet_index( + active_sheet_index: int | None, + original_selected_sheets: list[SheetIdentity], + pruned_selected_sheets: list[SheetIdentity], +) -> int | None: + if active_sheet_index is None: + return None + if active_sheet_index < 0 or active_sheet_index >= len(original_selected_sheets): + return None + + active_identity = original_selected_sheets[active_sheet_index] + try: + return pruned_selected_sheets.index(active_identity) + except ValueError: + return 0 if pruned_selected_sheets else None + + +def prune_group_state( + group_state: GroupSelectionState, + seen_identities: list[SheetIdentity], +) -> GroupSelectionState | None: + selected_sheets = [ + identity for identity in group_state["selected_sheets"] if identity not in seen_identities + ] + if not selected_sheets: + return None + + seen_identities.extend(selected_sheets) + return { + "active_sheet_index": prune_active_sheet_index( + group_state["active_sheet_index"], + group_state["selected_sheets"], + selected_sheets, + ), + "selected_sheets": selected_sheets, + } + + +def prune_history_stack( + group_state_stack: list[GroupSelectionState], + removed_identities: list[SheetIdentity] = [], +) -> list[GroupSelectionState]: + updated_stack: list[GroupSelectionState] = [] + seen_identities: list[SheetIdentity] = [*removed_identities] + + for group_state in group_state_stack: + pruned_group_state = prune_group_state( + group_state, + seen_identities, + ) + if pruned_group_state is not None: + updated_stack.append(pruned_group_state) + return updated_stack + + +def prune_sheet_from_history(window, sheet) -> None: + state = get_state(window) + sheet_id = sheet_identity(sheet, window) + if state.session_origin_selection is not None: + pruned_selected_sheets = [ + identity + for identity in state.session_origin_selection["selected_sheets"] + if identity != sheet_id + ] + if pruned_selected_sheets: + state.session_origin_selection = { + "active_sheet_index": prune_active_sheet_index( + state.session_origin_selection["active_sheet_index"], + state.session_origin_selection["selected_sheets"], + pruned_selected_sheets, + ), + "selected_sheets": pruned_selected_sheets, + } + else: + state.session_origin_selection = None + + history = history_for_window(window) + groups = history["groups"] + changed = False + + for group, group_state_stack in list(groups.items()): + updated_stack = prune_history_stack(group_state_stack, [sheet_id]) + + if updated_stack != group_state_stack: + if updated_stack: + groups[group] = updated_stack + else: + del groups[group] + changed = True + + if changed: + store_history(window, history) diff --git a/plugin/listener.py b/plugin/listener.py index b021bbb..3ff2225 100644 --- a/plugin/listener.py +++ b/plugin/listener.py @@ -1,11 +1,21 @@ from __future__ import annotations -from time import time - from ._compat import sublime, sublime_plugin -from .commands import hydrate_mru_state from .ctrl_release import is_available -from .state import get_state, remove_view_from_all, remove_window_state +from .history import ( + SelectionHistoryPoller, + history_for_window, + prune_sheet_from_history, + sync_selection_history, +) +from .sheets import apply_group_selection +from .state import get_state, remove_window_state + + +def _ensure_selection_poller(window, state) -> None: + if state.selection_poller is None: + state.selection_poller = SelectionHistoryPoller(state, window) + state.selection_poller.start() class TabStackListener(sublime_plugin.EventListener): @@ -15,18 +25,11 @@ def on_activated(self, view) -> None: return state = get_state(window) - if not state.mru_initialized: - hydrate_mru_state(state, window) - return - - hydrate_mru_state(state, window) if state.session_active: return - view_id = view.id() - view.settings().set("tab_stack.last_activated", time()) - state.mru_view_ids = [item for item in state.mru_view_ids if item != view_id] - state.mru_view_ids.insert(0, view_id) + _ensure_selection_poller(window, state) + sync_selection_history(window) def on_query_context(self, view, key, operator, operand, match_all): if key == "tab_stack.ctrl_release_available": @@ -51,13 +54,17 @@ def on_query_context(self, view, key, operator, operand, match_all): return None def on_close(self, view) -> None: - remove_view_from_all(view.id()) window = view.window() + if window is not None: + sheet = view.sheet() + if sheet is not None: + prune_sheet_from_history(window, sheet) if window is not None and not window.views(): remove_window_state(window.id()) def on_pre_close(self, view) -> None: - if view.is_transient(): + sheet = view.sheet() + if sheet is None or sheet.is_transient() or sheet.is_semi_transient(): return window = view.window() @@ -65,15 +72,13 @@ def on_pre_close(self, view) -> None: return state = get_state(window) - if not state.mru_initialized: - hydrate_mru_state(state, window) - - closing_view_id = view.id() - open_views = window.views() - for view_id in state.mru_view_ids: - if view_id == closing_view_id: - continue - for next_view in open_views: - if next_view.id() == view_id: - window.focus_view(next_view) - return + if state.session_active: + return + + history = history_for_window(window) + group_state_stack = history["groups"].get(str(sheet.group())) + if group_state_stack is None or len(group_state_stack) < 2: + return + + previous_selection = group_state_stack[1] + apply_group_selection(window, previous_selection, sheet.group()) diff --git a/plugin/mru.py b/plugin/mru.py new file mode 100644 index 0000000..394565a --- /dev/null +++ b/plugin/mru.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .captions import caption_for_sheet_identities +from .sheets import SheetIdentity, sheet_identity +from .state import GroupSelectionState, SheetSelectionHistory + + +@dataclass(slots=True) +class Entry: + selection: GroupSelectionState + caption: list[str] + + +def collect_entries(window, history: SheetSelectionHistory) -> list[Entry]: + entries: list[Entry] = [] + active_group = window.active_group() + seen_identities: set[tuple[str, int, str | None, str | None]] = set() + + group_state_stack = history["groups"].get(str(active_group)) + if group_state_stack: + for group_state in group_state_stack: + entries.append( + Entry( + selection=group_state, + caption=caption_for_sheet_identities(group_state["selected_sheets"], window), + ) + ) + seen_identities.update(_selection_identity_keys(group_state)) + + for sheet in _non_transient_sheets_in_group(window, active_group): + identity = _identity_key(sheet_identity(sheet, window)) + if identity in seen_identities: + continue + + entries.append(_entry_for_sheet(sheet, window)) + seen_identities.add(identity) + + return entries + + +def _entry_for_sheet(sheet, window) -> Entry: + identity = sheet_identity(sheet, window) + return Entry( + selection={ + "active_sheet_index": 0, + "selected_sheets": [identity], + }, + caption=caption_for_sheet_identities([identity], window), + ) + + +def _non_transient_sheets_in_group(window, group: int): + return [sheet for sheet in window.sheets_in_group(group) if not sheet.is_transient()] + + +def _selection_identity_keys( + selection: GroupSelectionState, +) -> set[tuple[str, int, str | None, str | None]]: + return {_identity_key(identity) for identity in selection["selected_sheets"]} + + +def _identity_key(identity: SheetIdentity) -> tuple[str, int, str | None, str | None]: + return ( + identity["name"], + identity["occurrence"], + identity["kind"], + identity["path"], + ) diff --git a/plugin/session.py b/plugin/session.py new file mode 100644 index 0000000..4e24e54 --- /dev/null +++ b/plugin/session.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from .ctrl_release import CtrlReleasePoller +from .sheets import apply_group_selection +from .state import TabStackWindowState + + +def show_panel(window, state) -> None: + entries = state.session_entries + items = [entry.caption for entry in entries] + state.session_selected_index = min(len(items), 1) + + def on_select(index: int) -> None: + if index < 0: + handle_panel_closed(window, state) + return + state.session_selected_index = index + _commit_session(window, state) + + def on_highlight(index: int) -> None: + if index < 0 or index >= len(entries): + return + state.session_selected_index = index + preview_entry(window, state) + + window.show_quick_panel( + items, + on_select, + selected_index=state.session_selected_index, + on_highlight=on_highlight, + placeholder="Release ctrl to close; Hit ctrl+escape to abort", + ) + + if state.ctrl_release_poller: + state.ctrl_release_poller.stop() + state.ctrl_release_poller = CtrlReleasePoller( + lambda: _commit_session(window, state), + 25, + ) + state.ctrl_release_poller.start() + + +def preview_entry(window, state: TabStackWindowState) -> None: + if state.session_entries is None or len(state.session_entries) < state.session_selected_index: + return + entry = state.session_entries[state.session_selected_index] + apply_group_selection(window, entry.selection, state.session_group) + + +def _commit_session(window, state) -> None: + if not state.session_active: + return + + entries = state.session_entries + if not entries: + cancel_session(window, state) + return + + index = clamp_index(state.session_selected_index, len(entries)) + selection = entries[index].selection + if not apply_group_selection(window, selection, state.session_group): + cancel_session(window, state) + return + + window.run_command("hide_overlay", {"cancel": True}) + state.clear_session() + + +def cancel_session(window, state) -> None: + if not state.session_active: + return + + origin = state.session_origin_selection + window.run_command("hide_overlay", {"cancel": True}) + if origin is not None: + apply_group_selection(window, origin, state.session_group) + state.clear_session() + + +def clamp_index(index: int, size: int) -> int: + return max(0, min(index, size - 1)) + + +def handle_panel_closed(window, state) -> None: + cancel_session(window, state) diff --git a/plugin/sheets.py b/plugin/sheets.py new file mode 100644 index 0000000..3f45752 --- /dev/null +++ b/plugin/sheets.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from .state import GroupSelectionState + + +class SheetIdentity(TypedDict): + name: str + occurrence: int + kind: str | None + path: str | None + + +def active_sheet_identity(window) -> SheetIdentity | None: + sheet = window.active_sheet() + if sheet is None: + return None + return sheet_identity(sheet, window) + + +def find_sheet_by_identity_and_group(window, identity: SheetIdentity, group: int): + matching = [ + sheet + for sheet in window.sheets_in_group(group) + if (sheet.name() or sheet_title(sheet)) == identity["name"] + and type(sheet).__name__ == identity["kind"] + and sheet.file_name() == identity["path"] + ] + for sheet in matching: + if sheet_occurrence(sheet, window) == identity["occurrence"]: + return sheet + return None + + +def sheet_identity(sheet, window) -> SheetIdentity: + return { + "name": sheet.name() or sheet_title(sheet), + "occurrence": sheet_occurrence(sheet, window), + "kind": type(sheet).__name__, + "path": sheet.file_name(), + } + + +def sheet_occurrence(sheet, window) -> int: + group = sheet.group() + if group is None: + return 0 + name = sheet.name() or sheet_title(sheet) + kind = type(sheet).__name__ + path = sheet.file_name() + + matching_sheet_ids = [ + candidate.id() + for candidate in window.sheets_in_group(group) + if (candidate.name() or sheet_title(candidate)) == name + and type(candidate).__name__ == kind + and candidate.file_name() == path + ] + try: + return matching_sheet_ids.index(sheet.id()) + except ValueError: + return 0 + + +def sheet_title(sheet) -> str: + file_name = sheet.file_name() + if file_name: + return Path(file_name).name + return "Untitled" + + +def find_live_sheets(window, identities: list[SheetIdentity], group: int) -> list[object]: + return [ + live_sheet + for identity in identities + if (live_sheet := find_sheet_by_identity_and_group(window, identity, group)) is not None + ] + + +def apply_group_selection(window, selection: GroupSelectionState, group: int) -> bool: + sheets_to_select = find_live_sheets(window, selection["selected_sheets"], group) + if not sheets_to_select: + return False + + active_sheet_index = selection["active_sheet_index"] + if active_sheet_index is None or not 0 <= active_sheet_index < len(sheets_to_select): + active_sheet_index = 0 + + window.select_sheets(sheets_to_select) + window.focus_sheet(sheets_to_select[active_sheet_index]) + return True diff --git a/plugin/state.py b/plugin/state.py index 0285c92..f8d3ddf 100644 --- a/plugin/state.py +++ b/plugin/state.py @@ -1,29 +1,43 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypedDict + +from .sheets import SheetIdentity + +if TYPE_CHECKING: + from .mru import Entry + + +class GroupSelectionState(TypedDict): + active_sheet_index: int | None + selected_sheets: list[SheetIdentity] + + +class SheetSelectionHistory(TypedDict): + # Only strings may be used as keys in ST settings. + groups: dict[str, list[GroupSelectionState]] @dataclass(slots=True) class TabStackWindowState: - window_id: int - mru_view_ids: list[int] = field(default_factory=list) - mru_initialized: bool = False session_active: bool = False - session_origin_view_id: int | None = None + session_origin_selection: GroupSelectionState | None = None session_selected_index: int = 0 - session_preview_view_id: int | None = None - session_tagged_view_ids: set[int] = field(default_factory=set) - poller: Any = None + session_group: int = 0 + session_entries: list[Entry] | None = None + selection_poller: Any = None + ctrl_release_poller: Any = None def clear_session(self) -> None: self.session_active = False - self.session_origin_view_id = None + self.session_origin_selection = None self.session_selected_index = 0 - self.session_preview_view_id = None - if self.poller is not None: - self.poller.stop() - self.poller = None + self.session_group = 0 + self.session_entries = None + if self.ctrl_release_poller is not None: + self.ctrl_release_poller.stop() + self.ctrl_release_poller = None _WINDOW_STATE: dict[int, TabStackWindowState] = {} @@ -33,25 +47,18 @@ def get_state(window) -> TabStackWindowState: window_id = window.id() state = _WINDOW_STATE.get(window_id) if state is None: - state = TabStackWindowState(window_id=window_id) + state = TabStackWindowState() _WINDOW_STATE[window_id] = state return state def remove_window_state(window_id: int) -> None: state = _WINDOW_STATE.pop(window_id, None) - if state is not None and state.poller is not None: - state.poller.stop() - - -def remove_view_from_all(view_id: int) -> None: - for state in _WINDOW_STATE.values(): - state.mru_view_ids = [item for item in state.mru_view_ids if item != view_id] - state.session_tagged_view_ids.discard(view_id) - if state.session_preview_view_id == view_id: - state.session_preview_view_id = None - if state.session_origin_view_id == view_id: - state.session_origin_view_id = None + if state is not None: + if state.selection_poller is not None: + state.selection_poller.stop() + if state.ctrl_release_poller is not None: + state.ctrl_release_poller.stop() def iter_states() -> list[TabStackWindowState]: From 2f010eed5a2f2e8640ef6f9c0ed8b89da6906c70 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Sun, 14 Jun 2026 23:39:34 +0200 Subject: [PATCH 2/5] Update README for sheet tracking changes --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b9b698f..701dbd6 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ It keeps tab order per window and previews tabs in the quick panel before committing the switch. It also preserves tab ordering across sessions -by storing each view's last activation time. +and supports multiple selections. When closing a tab, -TabStack focuses the most recently used remaining tab. +TabStack restores the previous selection. [Video Showcase][] @@ -27,6 +27,7 @@ git clone https://github.com/FichteFoll/TabStack To update later, run `git pull` inside the `TabStack` directory and restart Sublime Text. + ## Usage The key bindings are: @@ -44,6 +45,14 @@ Use the same keys to move through the list while it is open. Release `ctrl` to commit the selected tab. Press `ctrl+escape` to cancel. +### Technical Limitations + +- Due to gaps in the Sublime Text API, + it is currently not possible to trigger this package + via a key bindings + when a non-text view (image or HTML) is currently active. + + ## Platform Support TabStack supports **Linux, Windows, and macOS**. From eea3693d47ca3dbe7c6cbbce71326c3c4f40f136 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Mon, 15 Jun 2026 00:00:25 +0200 Subject: [PATCH 3/5] Remove context for key bindings for non-view sheets Unfortunately, contexts just do not work for non-view sheets, so I have to resort to disabling the safeguard context that checks if the necessary system libraries can be opened. Instead, we defer to the default commands dynamically during command execution so that tab switching still works in some capacity. --- Default.sublime-keymap | 12 ++++++++---- README.md | 7 ------- plugin/commands.py | 10 ++++++++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Default.sublime-keymap b/Default.sublime-keymap index ead68a5..34129e4 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -2,17 +2,21 @@ { "keys": ["ctrl+tab"], "command": "show_tab_stack", + "args": {"forward": true}, "context": [ - {"key": "tab_stack.quick_panel", "operator": "equal", "operand": false}, - {"key": "tab_stack.ctrl_release_available", "operator": "equal", "operand": true} + // This context cannot be evaluated on non-view sheets. + // Window settings cannot be queried via `settings.*` either. + // https://github.com/sublimehq/sublime_text/issues/5377 + // {"key": "tab_stack.ctrl_release_available", "operator": "equal", "operand": true} ] }, + // We just override this binding to prevent accidental presses of it. { "keys": ["ctrl+shift+tab"], "command": "show_tab_stack", + "args": {"forward": false}, "context": [ - {"key": "tab_stack.quick_panel", "operator": "equal", "operand": false}, - {"key": "tab_stack.ctrl_release_available", "operator": "equal", "operand": true} + // {"key": "tab_stack.ctrl_release_available", "operator": "equal", "operand": true} ] }, { diff --git a/README.md b/README.md index 701dbd6..d78b073 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,6 @@ Use the same keys to move through the list while it is open. Release `ctrl` to commit the selected tab. Press `ctrl+escape` to cancel. -### Technical Limitations - -- Due to gaps in the Sublime Text API, - it is currently not possible to trigger this package - via a key bindings - when a non-text view (image or HTML) is currently active. - ## Platform Support diff --git a/plugin/commands.py b/plugin/commands.py index 6bc4e9c..1184962 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -9,9 +9,15 @@ class ShowTabStackCommand(sublime_plugin.WindowCommand): - def run(self) -> None: + def run(self, *, forward=True) -> None: window = self.window - if window is None or not is_available(): + if window is None: + return + elif not is_available(): + window.run_command("next_view_in_stack" if forward else "prev_view_in_stack") + return + elif not forward: + # This is a fake binding. return state = get_state(window) From 1bfe07699b01af25ecec90ceb871c045a858285a Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Mon, 15 Jun 2026 02:51:34 +0200 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugin/history.py | 17 +++++++++++------ plugin/session.py | 11 ++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/plugin/history.py b/plugin/history.py index eee38d5..8288c00 100644 --- a/plugin/history.py +++ b/plugin/history.py @@ -117,9 +117,15 @@ def sync_selection_history( if prune_removed_sheets: for group_key, group_state_stack in list(groups.items()): - updated_stack = prune_history_stack_for_live_sheets( - window, int(group_key), group_state_stack - ) + try: + group = int(group_key) + except (TypeError, ValueError): + # Unexpected/corrupt key; drop it to avoid crashing. + del groups[group_key] + changed = True + continue + + updated_stack = prune_history_stack_for_live_sheets(window, group, group_state_stack) if updated_stack != group_state_stack: changed = True if updated_stack: @@ -227,11 +233,10 @@ def prune_group_state( def prune_history_stack( group_state_stack: list[GroupSelectionState], - removed_identities: list[SheetIdentity] = [], + removed_identities: list[SheetIdentity] | None = None, ) -> list[GroupSelectionState]: updated_stack: list[GroupSelectionState] = [] - seen_identities: list[SheetIdentity] = [*removed_identities] - + seen_identities: list[SheetIdentity] = list(removed_identities or []) for group_state in group_state_stack: pruned_group_state = prune_group_state( group_state, diff --git a/plugin/session.py b/plugin/session.py index 4e24e54..a66bc4f 100644 --- a/plugin/session.py +++ b/plugin/session.py @@ -6,9 +6,8 @@ def show_panel(window, state) -> None: - entries = state.session_entries - items = [entry.caption for entry in entries] - state.session_selected_index = min(len(items), 1) + items = [entry.caption for entry in state.session_entries] + state.session_selected_index = 1 if len(items) > 1 else 0 def on_select(index: int) -> None: if index < 0: @@ -18,7 +17,7 @@ def on_select(index: int) -> None: _commit_session(window, state) def on_highlight(index: int) -> None: - if index < 0 or index >= len(entries): + if index < 0 or index >= len(state.session_entries): return state.session_selected_index = index preview_entry(window, state) @@ -41,7 +40,9 @@ def on_highlight(index: int) -> None: def preview_entry(window, state: TabStackWindowState) -> None: - if state.session_entries is None or len(state.session_entries) < state.session_selected_index: + if state.session_entries is None: + return + if not 0 <= state.session_selected_index < len(state.session_entries): return entry = state.session_entries[state.session_selected_index] apply_group_selection(window, entry.selection, state.session_group) From 16d1d83f08831d39a0e2a4504da6e0f0318701e2 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Mon, 15 Jun 2026 02:51:59 +0200 Subject: [PATCH 5/5] Update AGENTS.md --- AGENTS.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb210f3..b58a2ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,26 @@ # TabStack Agent Notes +## Entry Points + - This is a Sublime Text package, not a standalone app. - Runtime target is Sublime Text's Python 3.14 plugin host. -- `main.py` is the only entry file. It clears cached `plugin.*` modules before star-importing `plugin`. -- Keep exported plugin symbols in `plugin/__init__.py`; real implementation lives under `plugin/`. -- Per-window state is in-memory only, keyed by `window.id()` in `plugin/state.py`. -- MRU order is per window. `TabStackListener.on_activated` updates history, except during an active quick-panel session. -- `TabStackListener.on_close` prunes closed sheets from history and removes empty window state. -- Tab captions come from `plugin/captions.py`. Widgets, panels, and transient/non-tab views are excluded from the MRU list. -- Ctrl-release detection lives in `plugin/ctrl_release/`. Linux needs X11 or XWayland; native Wayland is unsupported. +- `main.py` is the only entry file; it clears cached `plugin.*` modules before star-importing `plugin`. +- Keep exported plugin symbols in `plugin/__init__.py`; implementation lives under `plugin/`. + +## Behavior + +- State is in-memory per window, keyed by `window.id()` in `plugin/state.py`. +- MRU order is per window. +- `TabStackListener.on_activated` updates history unless a quick-panel session is active. +- `TabStackListener.on_close` prunes closed sheets and removes empty window state. +- Tab captions come from `plugin/captions.py`; widgets, panels, and transient/non-tab views are excluded from MRU. +- Ctrl-release detection lives in `plugin/ctrl_release/`. +- Linux needs X11 or XWayland for Ctrl-release polling; native Wayland is not supported. - `show_tab_stack` opens the quick panel, starts a Ctrl-release poller, and commits when Ctrl is released. - The quick-panel context key is `tab_stack.quick_panel`. -- When `tab_stack.quick_panel` is `false`, `ctrl+tab`, `ctrl+shift+tab`, `ctrl+up`, and `ctrl+down` start `show_tab_stack`. -- When `tab_stack.quick_panel` is `true`, those keys call `move`, and `ctrl+escape` calls `tab_stack_cancel`. +- When `tab_stack.quick_panel` is `false`, `ctrl+tab` starts `show_tab_stack`. `ctrl+shift+tab` is bound as a no-op to prevent accidental presses. + start `show_tab_stack`. +- When `tab_stack.quick_panel` is `true`, `ctrl+tab`, `ctrl+shift+tab`, `ctrl+up`, and `ctrl+down` call `move`, and `ctrl+escape` calls `tab_stack_cancel`. ## Verification