Repository: qutebrowser/qutebrowser

Issue:
Make `tab-focus last` work in different way with counts.
This is feature from VimFx' command `tab_select_most_recent`:
- w/o counts it focuses last focused tab - this is implemented in qute;
- **with counts it focuses to [count]th last focused tab** - requires "tab focus stack".

Rationale:
- count for `tab-focus last` doesn't work in any special way than for `tab-focus` so there is room for that change;
- I have lots of tabs opened and when I do long jumps sometimes I don't remember title text in tab to fast-switch to it by filtering title but I remember that it was 4th last focused.


Discussion:

WloHu writes:

> This is feature from VimFx' command `tab_select_most_recent`:
> - w/o counts it focuses last focused tab - this is implemented in qute;
> - with counts it focuses to [count]th last focused tab - requires "tab focus stack".

Some people will want to keep the current behavior, although I personally
prefer tab stacking too. I would personally love to have <Ctrl-o> and <Ctrl-i>
for tabs (although others seem to want them for the page: #2642).

The best solution I can come up with now is new special arguments `last_stack`
and `next_stack` (or maybe, we should make a new command for this entirely).

For implementation, we can't store indexes (as an index can change), so we can
probably store references to tabs. We can limit the size of the stack to
prevent leaking too much, and we can drop entries 'above us' in the stack if
focusing a new tab.

Should be relatively easy, but this will be touching a lot more code. I'll see
if I can look a this once the PR queue is trimmed down.

Thanks for issue link. I didn't know about `:buffer`. This is great! I used to do the same in Firefox with `urlbar.restrict` settings.

`<Ctrl-o>` seems like extension to what I want. It requires support for both tab-stack and scroll-stack so it seems like ultimate solution. If you're planning to do it `<Ctrl-o>` IMO it can be a composition of 2 commands used to handle mentioned stacks.  This would solve:

> although others seem to want them for the page

because it will allow to add bindings for navigating previously visited tabs, scroll jumps and `<Ctrl-o>` (both).

> so we can probably store references to tabs.

I wonder what `:buffer` uses for managing them.
> I wonder what :buffer uses for managing them.

It stores indexes but not for long. It only builds the completion model when the relevant completion shows and re-builds while open in same cases (like a tab closing).

Resolving patch:
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -22,6 +22,7 @@
 import enum
 import itertools
 import typing
+import functools
 
 import attr
 from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QUrl, QObject, QSizeF, Qt,
@@ -888,6 +889,10 @@ def __init__(self, *, win_id: int, private: bool,
         self._tab_event_filter = eventfilter.TabEventFilter(
             self, parent=self)
         self.backend = None
+        # If true, this tab has been requested to be removed (or is removed).
+        self.pending_removal = False
+        self.shutting_down.connect(functools.partial(
+            setattr, self, 'pending_removal', True))
 
         # FIXME:qtwebengine  Should this be public api via self.hints?
         #                    Also, should we get it out of objreg?
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -157,18 +157,28 @@ def _cntwidget(self, count=None):
         else:
             return None
 
-    def _tab_focus_last(self, *, show_error=True):
+    def _tab_focus_stack(self, mode: str, *, show_error=True):
         """Select the tab which was last focused."""
         try:
-            tab = objreg.get('last-focused-tab', scope='window',
-                             window=self._win_id)
-        except KeyError:
+            tab_deque = self._tabbed_browser.tab_deque
+            cur_tab = self._cntwidget()
+            if mode == "last":
+                tab = tab_deque.last(cur_tab)
+            elif mode == "stack-prev":
+                tab = tab_deque.prev(cur_tab)
+            elif mode == "stack-next":
+                tab = tab_deque.next(cur_tab)
+            else:
+                raise NotImplementedError(
+                    "Missing implementation for stack mode!")
+
+        except IndexError:
             if not show_error:
                 return
-            raise cmdutils.CommandError("No last focused tab!")
+            raise cmdutils.CommandError("Could not find requested tab!")
         idx = self._tabbed_browser.widget.indexOf(tab)
         if idx == -1:
-            raise cmdutils.CommandError("Last focused tab vanished!")
+            raise cmdutils.CommandError("Requested tab vanished!")
         self._set_current_index(idx)
 
     def _get_selection_override(self, prev, next_, opposite):
@@ -890,7 +900,7 @@ def buffer(self, index=None, count=None):
         tabbed_browser.widget.setCurrentWidget(tab)
 
     @cmdutils.register(instance='command-dispatcher', scope='window')
-    @cmdutils.argument('index', choices=['last'])
+    @cmdutils.argument('index', choices=['last', 'stack-next', 'stack-prev'])
     @cmdutils.argument('count', value=cmdutils.Value.count)
     def tab_focus(self, index: typing.Union[str, int] = None,
                   count: int = None, no_last: bool = False) -> None:
@@ -901,16 +911,18 @@ def tab_focus(self, index: typing.Union[str, int] = None,
 
         Args:
             index: The tab index to focus, starting with 1. The special value
-                   `last` focuses the last focused tab (regardless of count).
-                   Negative indices count from the end, such that -1 is the
-                   last tab.
+                   `last` focuses the last focused tab (regardless of count),
+                   and `stack-prev`/`stack-next` traverse a stack of visited
+                   tabs. Negative indices count from the end, such that -1 is
+                   the last tab.
             count: The tab index to focus, starting with 1.
             no_last: Whether to avoid focusing last tab if already focused.
         """
         index = count if count is not None else index
 
-        if index == 'last':
-            self._tab_focus_last()
+        if index in {
+                'last', 'stack-next', 'stack-prev'} and isinstance(index, str):
+            self._tab_focus_stack(index)
             return
         elif index is None:
             self.tab_next()
@@ -922,7 +934,7 @@ def tab_focus(self, index: typing.Union[str, int] = None,
             index = self._count() + index + 1
 
         if not no_last and index == self._current_index() + 1:
-            self._tab_focus_last(show_error=False)
+            self._tab_focus_stack('last', show_error=False)
             return
 
         if 1 <= index <= self._count():
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -21,6 +21,9 @@
 
 import collections
 import functools
+import collections
+import weakref
+import typing
 
 import attr
 from PyQt5.QtWidgets import QSizePolicy, QWidget, QApplication
@@ -46,6 +49,91 @@ class UndoEntry:
     pinned = attr.ib()
 
 
+class TabDeque:
+    """Class which manages the 'last visited' tab stack.
+
+    Instead of handling deletions by clearing old entries, they are handled by
+    checking if they exist on access. This allows us to save an iteration on
+    every tab delete.
+
+    Currently, we assume we will switch to the tab returned by any of the
+    getter functions. This is done because the on_switch functions will be
+    called upon switch, and we don't want to duplicate entries in the stack
+    for a single switch.
+
+    """
+
+    def __init__(self) -> None:
+        self._stack = collections.deque(
+            maxlen=config.val.tabs.focus_stack_size
+        )  # type:typing.Deque[weakref.ReferenceType[QWidget]]
+        # Items that have been removed from the primary stack.
+        self._stack_deleted = [
+        ]  # type: typing.List[weakref.ReferenceType[QWidget]]
+        self._ignore_next = False
+        self._keep_deleted_next = False
+
+    def on_switch(self, old_tab: QWidget) -> None:
+        """Record tab switch events."""
+        if self._ignore_next:
+            self._ignore_next = False
+            self._keep_deleted_next = False
+            return
+        tab = weakref.ref(old_tab)
+        if self._stack_deleted and not self._keep_deleted_next:
+            self._stack_deleted = []
+        self._keep_deleted_next = False
+        self._stack.append(tab)
+
+    def prev(self, cur_tab: QWidget) -> QWidget:
+        """Get the 'previous' tab in the stack.
+
+        Throws IndexError on failure.
+        """
+        tab = None
+        while tab is None or tab.pending_removal or tab is cur_tab:
+            tab = self._stack.pop()()
+        self._stack_deleted.append(weakref.ref(cur_tab))
+        self._ignore_next = True
+        return tab
+
+    def next(self, cur_tab: QWidget, *, keep_overflow=True) -> QWidget:
+        """Get the 'next' tab in the stack.
+
+        Throws IndexError on failure.
+        """
+        tab = None
+        while tab is None or tab.pending_removal or tab is cur_tab:
+            tab = self._stack_deleted.pop()()
+        # On next tab-switch, current tab will be added to stack as normal.
+        # However, we shouldn't wipe the overflow stack as normal.
+        if keep_overflow:
+            self._keep_deleted_next = True
+        return tab
+
+    def last(self, cur_tab: QWidget) -> QWidget:
+        """Get the last tab.
+
+        Throws IndexError on failure.
+        """
+        try:
+            return self.next(cur_tab, keep_overflow=False)
+        except IndexError:
+            return self.prev(cur_tab)
+
+    def update_size(self) -> None:
+        """Update the maxsize of this TabDeque."""
+        newsize = config.val.tabs.focus_stack_size
+        if newsize < 0:
+            newsize = None
+        if newsize == 0:
+            # We can't seem to blacklist 0 but allow -1, so treat 0 as size 1
+            # for basic functionality.
+            newsize = 1
+        # We can't resize a collections.deque so just recreate it >:(
+        self._stack = collections.deque(self._stack, maxlen=newsize)
+
+
 class TabDeletedError(Exception):
 
     """Exception raised when _tab_index is called for a deleted tab."""
@@ -144,6 +232,7 @@ def __init__(self, *, win_id, private, parent=None):
         self._global_marks = {}
         self.default_window_icon = self.widget.window().windowIcon()
         self.is_private = private
+        self.tab_deque = TabDeque()
         config.instance.changed.connect(self._on_config_changed)
 
     def _update_stack_size(self):
@@ -166,6 +255,8 @@ def _on_config_changed(self, option):
             self._update_stack_size()
         elif option in ['tabs.title.format', 'tabs.title.format_pinned']:
             self.widget.update_tab_titles()
+        elif option == "tabs.focus_stack_size":
+            self.tab_deque.update_size()
 
     def _tab_index(self, tab):
         """Get the index of a given tab.
@@ -351,10 +442,8 @@ def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False):
                                   "TabbedWidget!".format(tab))
         if tab is self._now_focused:
             self._now_focused = None
-        if tab is objreg.get('last-focused-tab', None, scope='window',
-                             window=self._win_id):
-            objreg.delete('last-focused-tab', scope='window',
-                          window=self._win_id)
+
+        tab.pending_removal = True
 
         if tab.url().isEmpty():
             # There are some good reasons why a URL could be empty
@@ -718,7 +807,7 @@ def on_mode_left(self, mode):
 
     @pyqtSlot(int)
     def _on_current_changed(self, idx):
-        """Set last-focused-tab and leave hinting mode when focus changed."""
+        """Add prev tab to stack and leave hinting mode when focus changed."""
         mode_on_change = config.val.tabs.mode_on_change
         if idx == -1 or self.shutting_down:
             # closing the last tab (before quitting) or shutting down
@@ -746,8 +835,7 @@ def _on_current_changed(self, idx):
                 current_mode not in modeman.PROMPT_MODES):
             modeman.enter(self._win_id, tab.data.input_mode, 'restore')
         if self._now_focused is not None:
-            objreg.register('last-focused-tab', self._now_focused, update=True,
-                            scope='window', window=self._win_id)
+            self.tab_deque.on_switch(self._now_focused)
         log.modes.debug("Mode after tab change: {} (mode_on_change = {})"
                         .format(current_mode.name, mode_on_change))
         self._now_focused = tab


Test patch:
diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature
--- a/tests/end2end/features/tabs.feature
+++ b/tests/end2end/features/tabs.feature
@@ -290,7 +290,112 @@ Feature: Tab management
 
     Scenario: :tab-focus last with no last focused tab
         When I run :tab-focus last
-        Then the error "No last focused tab!" should be shown
+        Then the error "Could not find requested tab!" should be shown
+
+    Scenario: :tab-focus prev stacking
+        When I open data/numbers/1.txt
+        And I open data/numbers/2.txt in a new tab
+        And I open data/numbers/3.txt in a new tab
+        And I open data/numbers/4.txt in a new tab
+        And I open data/numbers/5.txt in a new tab
+        And I run :tab-focus 1
+        And I run :tab-focus 5
+        And I run :tab-focus 2
+        And I run :tab-focus 4
+        And I run :tab-focus 3
+        And I run :repeat 2 tab-focus stack-prev
+        Then the following tabs should be open:
+            - data/numbers/1.txt
+            - data/numbers/2.txt (active)
+            - data/numbers/3.txt
+            - data/numbers/4.txt
+            - data/numbers/5.txt
+
+    Scenario: :tab-focus next stacking
+        When I open data/numbers/1.txt
+        And I open data/numbers/2.txt in a new tab
+        And I open data/numbers/3.txt in a new tab
+        And I open data/numbers/4.txt in a new tab
+        And I open data/numbers/5.txt in a new tab
+        And I run :tab-focus 1
+        And I run :tab-focus 5
+        And I run :tab-focus 2
+        And I run :tab-focus 4
+        And I run :tab-focus 3
+        And I run :repeat 3 tab-focus stack-prev
+        And I run :repeat 2 tab-focus stack-next
+        Then the following tabs should be open:
+            - data/numbers/1.txt
+            - data/numbers/2.txt
+            - data/numbers/3.txt
+            - data/numbers/4.txt (active)
+            - data/numbers/5.txt
+
+    Scenario: :tab-focus stacking limit
+        When I set tabs.focus_stack_size to 1
+        And I open data/numbers/1.txt
+        And I open data/numbers/2.txt in a new tab
+        And I open data/numbers/3.txt in a new tab
+        And I open data/numbers/4.txt in a new tab
+        And I open data/numbers/5.txt in a new tab
+        And I run :repeat 2 tab-focus stack-prev
+        And I run :tab-focus stack-next
+        And I set tabs.focus_stack_size to 10
+        And I run :tab-focus 1
+        And I run :tab-focus 5
+        And I run :tab-focus 2
+        And I run :tab-focus 4
+        And I run :tab-focus 3
+        And I run :repeat 4 tab-focus stack-prev
+        Then the error "Could not find requested tab!" should be shown
+        And the following tabs should be open:
+            - data/numbers/1.txt (active)
+            - data/numbers/2.txt
+            - data/numbers/3.txt
+            - data/numbers/4.txt
+            - data/numbers/5.txt
+
+    Scenario: :tab-focus stacking and last
+        When I open data/numbers/1.txt
+        And I open data/numbers/2.txt in a new tab
+        And I open data/numbers/3.txt in a new tab
+        And I open data/numbers/4.txt in a new tab
+        And I open data/numbers/5.txt in a new tab
+        And I run :tab-focus 1
+        And I run :tab-focus 5
+        And I run :tab-focus 2
+        And I run :tab-focus 4
+        And I run :tab-focus 3
+        And I run :repeat 2 tab-focus stack-prev
+        And I run :repeat 3 tab-focus last
+        Then the following tabs should be open:
+            - data/numbers/1.txt
+            - data/numbers/2.txt
+            - data/numbers/3.txt
+            - data/numbers/4.txt (active)
+            - data/numbers/5.txt
+
+
+    Scenario: :tab-focus last after moving current tab
+        When I open data/numbers/1.txt
+        And I open data/numbers/2.txt in a new tab
+        And I open data/numbers/3.txt in a new tab
+        And I run :tab-move 2
+        And I run :tab-focus last
+        Then the following tabs should be open:
+            - data/numbers/1.txt
+            - data/numbers/3.txt
+            - data/numbers/2.txt (active)
+
+    Scenario: :tab-focus last after closing a lower number tab
+        When I open data/numbers/1.txt
+        And I open data/numbers/2.txt in a new tab
+        And I open data/numbers/3.txt in a new tab
+        And I run :tab-close with count 1
+        And I run :tab-focus last
+        Then the following tabs should be open:
+            - data/numbers/2.txt (active)
+            - data/numbers/3.txt
 
     # tab-prev/tab-next
 
@@ -759,7 +864,6 @@ Feature: Tab management
         And I open data/numbers/2.txt in a new tab
         And I open data/numbers/3.txt in a new tab
         And I run :tab-close with count 1
-        And I run :tab-focus 2
         And I run :tab-move with count 1
         And I run :undo
         Then the following tabs should be open: