Repository: qutebrowser/qutebrowser

Issue:
tab-detach steals focus from primary window.
Using `:tab-detach` to separate out a webpage into its own unique window results in any newly opened url via qutebrowser to fixate on the detached window instead of the primary instance which the tab was detached from.

This makes tab detaching essentially useless as a feature. 
Even if one has the foresight to split off the "new session" window via `:tab-detach` and leaving the remaining tab as the tab one actually intended to "detach".

This proceedure must be repeated, usually by closing all tabs of the second window, leaving the tab they wish detached, and detaching into a "new session", ad infinitum in a recursive pattern.

It's an extremely awkward process to say the least.



Discussion:
As mentioned in IRC, currently it's always the [last opened window](https://github.com/The-Compiler/qutebrowser/blob/master/qutebrowser/mainwindow/mainwindow.py#L70-L78) which gets used for IPC currently - it should be easy to add some flag to windows opened via `:tab-detach` so they get ignored here.

I'm aware of how `:tab-detach` works, as you explained on IRC, but I'm pointing out that its current behaviour renders its purpose pretty close to useless without a great deal of effort.

I know you are - I'm repeating things here for possible contributors who want to pick this up :smile:

@Earnestly In master, the functionality of opening new tabs was extended via an option `general -> new-instance-open-target.window` which apart from the old default of `last-opened` now has two more options:
- `last-focused` to spawn new tabs in the last window you had focused (mimics e.g. firefox behavior)
- `last-visible` to spawn new tabs in the last window you had visible (where focusing also counts as updating the visibility), which is useful in a one-window-per-workspace model

Do either of these two options solve this issue for you? If not, what kind of functionality _would_ you like to see? FWIW somebody already requested something like #881, maybe you could voice your opinion there to revive that feature request.

It helps certainly but still requires unnecessary effort.  Counting `last-focused` and `last-visible` as essentially the same, the user now as to remember which window they focused last which is tedious when opening URLs from IRC and then having to either hunt down which window they opened in or actively switching to the window you want to become the 'master' session.

Ideally the first session opened would simply remain the master until a command is used to start a new one or the master session is closed causing the next in order to adopt it.  I don't know how feasible this would be to implement though since it involves some kind of state tracking, or you can enumerate sockets and use a numerical first-match approach.

I'm not really sure if this suggestion is wise either, I haven't used this feature since reporting this issue as I've become accustomed to it not existing.

> Counting last-focused and last-visible as essentially the same

I don't think these are the same at all. `last-visible` will always open it on whatever window is on the same workspace, so you immediately know where it goes and never have to “hunt down” anything, and also doesn't require you to remember anything to reason about it (as long as you don't have multiple visible windows at the same time). Also, qutebrowser will set an urgency hint when you open a new tab (unless it's market as “silent”) so it's not like hunting down the right window is hard. :p

> Ideally the first session opened would simply remain the master until a command is used to start a new one or the master session is closed causing the next in order to adopt it. I don't know how feasible this would be to implement though since it involves some kind of state tracking, or you can enumerate sockets and use a numerical first-match approach.

It would be trivial to add a `first-opened` analog to `last-opened` which would do exactly that: open tabs in the first window you opened, by ID. (So your initial master window would remain the tab target until you completely close it, at which point the second window you opened would be, and so on)

Maybe in a perfect world the window would be presented as some form of enumeration like “last-visible, last-focused, last-opened” (the equivalent to the current default) which would start with the first choice and only move on if that option is _ambiguous_.

So for example, if I set it to “last-visible, first-opened” then whenever I have two windows visible at the same time, it would pick the one that was opened before the other. But adding support for that would be hard for another reason: It would require figuring out whether there are multiple visible windows, rather than just using the current approach of having a single global variable that gets overwritten whenever a new window is made visible.

> I don't think these are the same at all. `last-visible` will always open it on whatever window is on the same workspace, so you immediately know where it goes and never have to “hunt down” anything, and also doesn't require you to remember anything to reason about it (as long as you don't have multiple visible windows at the same time). 

I understand the difference but an example problem is that my browser workspace is not shared with my irc workspace.

> Also, qutebrowser will set an urgency hint

This is a good point, urgency hints aren't typically very common for me personally so perhaps it would not be too much effort to hunt it down.  This creates an additional issue where I accidently open a new tab on the wrong detached browser window.  It's just awkward.

> It would be trivial to add a `first-opened` analog to `last-opened` which would do exactly that: open tabs in the first window you opened, by ID. (So your initial master window would remain the tab target until you completely close it, at which point the second window you opened would be, and so on)

This would probably be the best compromise.

> So for example, if I set it to “last-visible, first-opened” then whenever I have two windows visible at the same time, it would pick the one that was opened before the other. But adding support for that would be hard for another reason: It would require figuring out whether there are multiple visible windows, rather than just using the current approach of having a single global variable that gets overwritten whenever a new window is made visible.

I think this is getting too complex personally.

I agree a `first-opened` value should be trivial and probably useful for some people. @haasn do you want to work on this?


Resolving patch:
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -230,8 +230,10 @@ def data(readonly=False):
             ('new-instance-open-target.window',
              SettingValue(typ.String(
                  valid_values=typ.ValidValues(
-                     ('last-opened', "Open new tabs in the last opened "
-                                     "window."),
+                     ('first-opened', "Open new tabs in the first (oldest) "
+                                      "opened window."),
+                     ('last-opened', "Open new tabs in the last (newest) "
+                                     "opened window."),
                      ('last-focused', "Open new tabs in the most recently "
                                       "focused window."),
                      ('last-visible', "Open new tabs in the most recently "
diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -54,44 +54,61 @@ def get_window(via_ipc, force_window=False, force_tab=False,
     """
     if force_window and force_tab:
         raise ValueError("force_window and force_tab are mutually exclusive!")
+
     if not via_ipc:
         # Initial main window
         return 0
-    window_to_raise = None
+
+    open_target = config.get('general', 'new-instance-open-target')
+
+    # Apply any target overrides, ordered by precedence
     if force_target is not None:
         open_target = force_target
-    else:
-        open_target = config.get('general', 'new-instance-open-target')
-    if (open_target == 'window' or force_window) and not force_tab:
+    if force_window:
+        open_target = 'window'
+    if force_tab and open_target == 'window':
+        open_target = 'tab'  # for lack of a better default
+
+    window = None
+    raise_window = False
+
+    # Try to find the existing tab target if opening in a tab
+    if open_target != 'window':
+        window = get_target_window()
+        raise_window = open_target not in ['tab-silent', 'tab-bg-silent']
+
+    # Otherwise, or if no window was found, create a new one
+    if window is None:
         window = MainWindow()
         window.show()
-        win_id = window.win_id
-        window_to_raise = window
-    else:
-        try:
-            win_mode = config.get('general', 'new-instance-open-target.window')
-            if win_mode == 'last-focused':
-                window = objreg.last_focused_window()
-            elif win_mode == 'last-opened':
-                window = objreg.last_window()
-            elif win_mode == 'last-visible':
-                window = objreg.last_visible_window()
-        except objreg.NoWindow:
-            # There is no window left, so we open a new one
-            window = MainWindow()
-            window.show()
-            win_id = window.win_id
-            window_to_raise = window
-        win_id = window.win_id
-        if open_target not in ['tab-silent', 'tab-bg-silent']:
-            window_to_raise = window
-    if window_to_raise is not None:
-        window_to_raise.setWindowState(
-            window.windowState() & ~Qt.WindowMinimized | Qt.WindowActive)
-        window_to_raise.raise_()
-        window_to_raise.activateWindow()
-        QApplication.instance().alert(window_to_raise)
-    return win_id
+        raise_window = True
+
+    if raise_window:
+        window.setWindowState(window.windowState() & ~Qt.WindowMinimized)
+        window.setWindowState(window.windowState() | Qt.WindowActive)
+        window.raise_()
+        window.activateWindow()
+        QApplication.instance().alert(window)
+
+    return window.win_id
+
+
+def get_target_window():
+    """Get the target window for new tabs, or None if none exist."""
+    try:
+        win_mode = config.get('general', 'new-instance-open-target.window')
+        if win_mode == 'last-focused':
+            return objreg.last_focused_window()
+        elif win_mode == 'first-opened':
+            return objreg.window_by_index(0)
+        elif win_mode == 'last-opened':
+            return objreg.window_by_index(-1)
+        elif win_mode == 'last-visible':
+            return objreg.last_visible_window()
+        else:
+            raise ValueError("Invalid win_mode {}".format(win_mode))
+    except objreg.NoWindow:
+        return None
 
 
 class MainWindow(QWidget):
diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py
--- a/qutebrowser/utils/objreg.py
+++ b/qutebrowser/utils/objreg.py
@@ -298,13 +298,13 @@ def last_focused_window():
     try:
         return get('last-focused-main-window')
     except KeyError:
-        return last_window()
+        return window_by_index(-1)
 
 
-def last_window():
-    """Get the last opened window object."""
+def window_by_index(idx):
+    """Get the Nth opened window object."""
     if not window_registry:
         raise NoWindow()
     else:
-        key = sorted(window_registry)[-1]
+        key = sorted(window_registry)[idx]
         return window_registry[key]


Test patch:
diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py
--- a/tests/end2end/features/conftest.py
+++ b/tests/end2end/features/conftest.py
@@ -156,15 +156,18 @@ def open_path(quteproc, path):
     """Open a URL.
 
     If used like "When I open ... in a new tab", the URL is opened in a new
-    tab. With "... in a new window", it's opened in a new window.
+    tab. With "... in a new window", it's opened in a new window. With
+    "... as a URL", it's opened according to new-instance-open-target.
     """
     new_tab = False
     new_window = False
+    as_url = False
     wait = True
 
     new_tab_suffix = ' in a new tab'
     new_window_suffix = ' in a new window'
     do_not_wait_suffix = ' without waiting'
+    as_url_suffix = ' as a URL'
 
     if path.endswith(new_tab_suffix):
         path = path[:-len(new_tab_suffix)]
@@ -172,12 +175,16 @@ def open_path(quteproc, path):
     elif path.endswith(new_window_suffix):
         path = path[:-len(new_window_suffix)]
         new_window = True
+    elif path.endswith(as_url_suffix):
+        path = path[:-len(as_url_suffix)]
+        as_url = True
 
     if path.endswith(do_not_wait_suffix):
         path = path[:-len(do_not_wait_suffix)]
         wait = False
 
-    quteproc.open_path(path, new_tab=new_tab, new_window=new_window, wait=wait)
+    quteproc.open_path(path, new_tab=new_tab, new_window=new_window,
+                       as_url=as_url, wait=wait)
 
 
 @bdd.when(bdd.parsers.parse("I set {sect} -> {opt} to {value}"))
diff --git a/tests/end2end/features/invoke.feature b/tests/end2end/features/invoke.feature
new file mode 100644
--- /dev/null
+++ b/tests/end2end/features/invoke.feature
@@ -0,0 +1,108 @@
+Feature: Invoking a new process
+    Simulate what happens when running qutebrowser with an existing instance
+
+    Background:
+        Given I clean up open tabs
+
+    Scenario: Using new-instance-open-target = tab
+        When I set general -> new-instance-open-target to tab
+        And I open data/title.html
+        And I open data/search.html as a URL
+        Then the following tabs should be open:
+            - data/title.html
+            - data/search.html (active)
+
+    Scenario: Using new-instance-open-target = tab-bg
+        When I set general -> new-instance-open-target to tab-bg
+        And I open data/title.html
+        And I open data/search.html as a URL
+        Then the following tabs should be open:
+            - data/title.html (active)
+            - data/search.html
+
+    Scenario: Using new-instance-open-target = window
+        When I set general -> new-instance-open-target to window
+        And I open data/title.html
+        And I open data/search.html as a URL
+        Then the session should look like:
+            windows:
+            - tabs:
+              - history:
+                - url: about:blank
+                - url: http://localhost:*/data/title.html
+            - tabs:
+              - history:
+                - url: http://localhost:*/data/search.html
+
+    Scenario: Using new-instance-open-target.window = last-opened
+        When I set general -> new-instance-open-target to tab
+        And I set general -> new-instance-open-target.window to last-opened
+        And I open data/title.html
+        And I open data/search.html in a new window
+        And I open data/hello.txt as a URL
+        Then the session should look like:
+            windows:
+            - tabs:
+              - history:
+                - url: about:blank
+                - url: http://localhost:*/data/title.html
+            - tabs:
+              - history:
+                - url: http://localhost:*/data/search.html
+              - history:
+                - url: http://localhost:*/data/hello.txt
+
+    Scenario: Using new-instance-open-target.window = first-opened
+        When I set general -> new-instance-open-target to tab
+        And I set general -> new-instance-open-target.window to first-opened
+        And I open data/title.html
+        And I open data/search.html in a new window
+        And I open data/hello.txt as a URL
+        Then the session should look like:
+            windows:
+            - tabs:
+              - history:
+                - url: about:blank
+                - url: http://localhost:*/data/title.html
+              - history:
+                - url: http://localhost:*/data/hello.txt
+            - tabs:
+              - history:
+                - url: http://localhost:*/data/search.html
+
+    # issue #1060
+
+    Scenario: Using target.window = first-opened after tab-detach
+        When I set general -> new-instance-open-target to tab
+        And I set general -> new-instance-open-target.window to first-opened
+        And I open data/title.html
+        And I open data/search.html in a new tab
+        And I run :tab-detach
+        And I open data/hello.txt as a URL
+        Then the session should look like:
+            windows:
+            - tabs:
+              - history:
+                - url: about:blank
+                - url: http://localhost:*/data/title.html
+              - history:
+                - url: http://localhost:*/data/hello.txt
+            - tabs:
+              - history:
+                - url: http://localhost:*/data/search.html
+
+    Scenario: Opening a new qutebrowser instance with no parameters
+        When I set general -> new-instance-open-target to tab
+        And I set general -> startpage to about:blank
+        And I open data/title.html
+        And I spawn a new window
+        And I wait until about:blank is loaded
+        Then the session should look like:
+            windows:
+            - tabs:
+              - history:
+                - url: about:blank
+                - url: http://localhost:*/data/title.html
+            - tabs:
+              - history:
+                - url: about:blank
diff --git a/tests/end2end/features/test_invoke_bdd.py b/tests/end2end/features/test_invoke_bdd.py
new file mode 100644
--- /dev/null
+++ b/tests/end2end/features/test_invoke_bdd.py
@@ -0,0 +1,30 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.
+
+import pytest_bdd as bdd
+bdd.scenarios('invoke.feature')
+
+
+@bdd.when(bdd.parsers.parse("I spawn a new window"))
+def invoke_with(quteproc):
+    """Spawn a new window via IPC call."""
+    quteproc.log_summary("Create a new window")
+    quteproc.send_ipc([], target_arg='window')
+    quteproc.wait_for(category='init', module='app',
+                      function='_open_startpage', message='Opening startpage')
diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py
--- a/tests/end2end/fixtures/quteprocess.py
+++ b/tests/end2end/fixtures/quteprocess.py
@@ -365,6 +365,15 @@ def after_test(self, did_fail):
         finally:
             super().after_test()
 
+    def send_ipc(self, commands, target_arg=''):
+        """Send a raw command to the running IPC socket."""
+        time.sleep(self._delay / 1000)
+
+        assert self._ipc_socket is not None
+        ipc.send_to_running_instance(self._ipc_socket, commands, target_arg)
+        self.wait_for(category='ipc', module='ipc', function='on_ready_read',
+                      message='Read from socket *')
+
     def send_cmd(self, command, count=None, invalid=False, *, escape=True):
         """Send a command to the running qutebrowser instance.
 
@@ -379,18 +388,13 @@ def send_cmd(self, command, count=None, invalid=False, *, escape=True):
             summary += ' (count {})'.format(count)
         self.log_summary(summary)
 
-        assert self._ipc_socket is not None
-
-        time.sleep(self._delay / 1000)
-
         if escape:
             command = command.replace('\\', r'\\')
 
         if count is not None:
             command = ':{}:{}'.format(count, command.lstrip(':'))
 
-        ipc.send_to_running_instance(self._ipc_socket, [command],
-                                     target_arg='')
+        self.send_ipc([command])
         if not invalid:
             self.wait_for(category='commands', module='command',
                           function='run', message='command called: *')
@@ -418,18 +422,22 @@ def temp_setting(self, sect, opt, value):
         yield
         self.set_setting(sect, opt, old_value)
 
-    def open_path(self, path, *, new_tab=False, new_window=False, port=None,
-                  https=False, wait=True):
+    def open_path(self, path, *, new_tab=False, new_window=False, as_url=False,
+                  port=None, https=False, wait=True):
         """Open the given path on the local webserver in qutebrowser."""
         url = self.path_to_url(path, port=port, https=https)
-        self.open_url(url, new_tab=new_tab, new_window=new_window, wait=wait)
+        self.open_url(url, new_tab=new_tab, new_window=new_window,
+                      as_url=as_url, wait=wait)
 
-    def open_url(self, url, *, new_tab=False, new_window=False, wait=True):
+    def open_url(self, url, *, new_tab=False, new_window=False, as_url=False,
+                 wait=True):
         """Open the given url in qutebrowser."""
         if new_tab and new_window:
             raise ValueError("new_tab and new_window given!")
 
-        if new_tab:
+        if as_url:
+            self.send_cmd(url, invalid=True)
+        elif new_tab:
             self.send_cmd(':open -t ' + url)
         elif new_window:
             self.send_cmd(':open -w ' + url)