summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.flake83
-rw-r--r--README.asciidoc1
-rw-r--r--doc/help/commands.asciidoc40
-rw-r--r--doc/help/settings.asciidoc12
-rw-r--r--doc/qutebrowser.1.asciidoc3
-rw-r--r--qutebrowser/app.py35
-rw-r--r--qutebrowser/browser/tabhistory.py179
-rw-r--r--qutebrowser/browser/webpage.py57
-rw-r--r--qutebrowser/completion/completer.py11
-rw-r--r--qutebrowser/completion/models/completion.py13
-rw-r--r--qutebrowser/config/configdata.py4
-rw-r--r--qutebrowser/mainwindow/mainwindow.py48
-rw-r--r--qutebrowser/misc/earlyinit.py8
-rw-r--r--qutebrowser/misc/sessions.py298
-rw-r--r--qutebrowser/qutebrowser.py2
-rw-r--r--qutebrowser/test/__init__.py6
-rw-r--r--qutebrowser/test/browser/test_tabhistory.py130
-rw-r--r--qutebrowser/utils/qtutils.py20
-rw-r--r--qutebrowser/utils/usertypes.py2
-rw-r--r--qutebrowser/utils/version.py1
-rw-r--r--scripts/setupcommon.py2
-rwxr-xr-xsetup.py2
22 files changed, 846 insertions, 31 deletions
diff --git a/.flake8 b/.flake8
index df41bdd4e..aa38c6f62 100644
--- a/.flake8
+++ b/.flake8
@@ -6,5 +6,6 @@
# F841: unused variable
# F401: Unused import
# E402: module level import not at top of file
-ignore=E265,E501,F841,F401,E402
+# E266: too many leading '#' for block comment
+ignore=E265,E501,F841,F401,E402,E266
max_complexity = 12
diff --git a/README.asciidoc b/README.asciidoc
index d66c060fa..f3c6caedf 100644
--- a/README.asciidoc
+++ b/README.asciidoc
@@ -93,6 +93,7 @@ The following software and libraries are required to run qutebrowser:
* http://fdik.org/pyPEG/[pyPEG2]
* http://jinja.pocoo.org/[jinja2]
* http://pygments.org/[pygments]
+* http://pyyaml.org/wiki/PyYAML[PyYAML]
To generate the documentation for the `:help` command, when using the git
repository (rather than a release), http://asciidoc.org/[asciidoc] is needed.
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index 354ef90e4..dd6ede107 100644
--- a/doc/help/commands.asciidoc
+++ b/doc/help/commands.asciidoc
@@ -36,6 +36,9 @@
|<<restart,restart>>|Restart qutebrowser while keeping existing tabs open.
|<<save,save>>|Save configs and state.
|<<search,search>>|Search for a text on the current page.
+|<<session-delete,session-delete>>|Delete a session.
+|<<session-load,session-load>>|Load a session.
+|<<session-save,session-save>>|Save a session.
|<<set,set>>|Set an option.
|<<set-cmd-text,set-cmd-text>>|Preset the statusbar to some text.
|<<spawn,spawn>>|Spawn a command in a shell.
@@ -50,6 +53,7 @@
|<<unbind,unbind>>|Unbind a keychain.
|<<undo,undo>>|Re-open a closed tab (optionally skipping [count] closed tabs).
|<<view-source,view-source>>|Show the source of the current page.
+|<<wq,wq>>|Save open pages and quit.
|<<yank,yank>>|Yank the current URL/title to the clipboard or primary selection.
|<<zoom,zoom>>|Set the zoom level for the current tab.
|<<zoom-in,zoom-in>>|Increase the zoom level for the current tab.
@@ -396,6 +400,33 @@ Search for a text on the current page.
==== optional arguments
* +*-r*+, +*--reverse*+: Reverse search direction.
+[[session-delete]]
+=== session-delete
+Syntax: +:session-delete 'name'+
+
+Delete a session.
+
+==== positional arguments
+* +'name'+: The name of the session.
+
+[[session-load]]
+=== session-load
+Syntax: +:session-load 'name'+
+
+Load a session.
+
+==== positional arguments
+* +'name'+: The name of the session.
+
+[[session-save]]
+=== session-save
+Syntax: +:session-save ['name']+
+
+Save a session.
+
+==== positional arguments
+* +'name'+: The name of the session.
+
[[set]]
=== set
Syntax: +:set [*--temp*] [*--print*] ['section'] ['option'] ['value']+
@@ -538,6 +569,15 @@ Re-open a closed tab (optionally skipping [count] closed tabs).
=== view-source
Show the source of the current page.
+[[wq]]
+=== wq
+Syntax: +:wq ['name']+
+
+Save open pages and quit.
+
+==== positional arguments
+* +'name'+: The name of the session.
+
[[yank]]
=== yank
Syntax: +:yank [*--title*] [*--sel*]+
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index 6da68cf54..803418403 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -20,6 +20,7 @@
|<<general-default-encoding,default-encoding>>|Default encoding to use for websites.
|<<general-new-instance-open-target,new-instance-open-target>>|How to open links in an existing instance if a new one is launched.
|<<general-log-javascript-console,log-javascript-console>>|Whether to log javascript console messages.
+|<<general-save-session,save-session>>|Whether to always save the open pages.
|==============
.Quick reference for section ``ui''
@@ -386,6 +387,17 @@ Valid values:
Default: +pass:[false]+
+[[general-save-session]]
+=== save-session
+Whether to always save the open pages.
+
+Valid values:
+
+ * +true+
+ * +false+
+
+Default: +pass:[false]+
+
== ui
General options related to the user interface.
diff --git a/doc/qutebrowser.1.asciidoc b/doc/qutebrowser.1.asciidoc
index a3e49fc6c..3ea324ff5 100644
--- a/doc/qutebrowser.1.asciidoc
+++ b/doc/qutebrowser.1.asciidoc
@@ -43,6 +43,9 @@ It was inspired by other browsers/addons like dwb and Vimperator/Pentadactyl.
*-s* 'SECTION' 'OPTION' 'VALUE', *--set* 'SECTION' 'OPTION' 'VALUE'::
Set a temporary setting for this session.
+*-r* 'SESSION', *--restore* 'SESSION'::
+ Restore a named session.
+
=== debug arguments
*-l* 'LOGLEVEL', *--loglevel* 'LOGLEVEL'::
Set loglevel
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
index ea2bdd3de..8d0f54ab2 100644
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -43,7 +43,8 @@ from qutebrowser.config import style, config, websettings, configexc
from qutebrowser.browser import quickmarks, cookies, cache, adblock, history
from qutebrowser.browser.network import qutescheme, proxy
from qutebrowser.mainwindow import mainwindow
-from qutebrowser.misc import crashdialog, readline, ipc, earlyinit, savemanager
+from qutebrowser.misc import (crashdialog, readline, ipc, earlyinit,
+ savemanager, sessions)
from qutebrowser.misc import utilcmds # pylint: disable=unused-import
from qutebrowser.keyinput import modeman
from qutebrowser.utils import (log, version, message, utils, qtutils, urlutils,
@@ -179,6 +180,9 @@ class Application(QApplication):
history.init()
log.init.debug("Initializing crashlog...")
self._handle_segfault()
+ log.init.debug("Initializing sessions...")
+ session_manager = sessions.SessionManager(self)
+ objreg.register('session-manager', session_manager)
log.init.debug("Initializing js-bridge...")
js_bridge = qutescheme.JSBridge(self)
objreg.register('js-bridge', js_bridge)
@@ -199,11 +203,13 @@ class Application(QApplication):
log.init.debug("Initializing cache...")
diskcache = cache.DiskCache(self)
objreg.register('cache', diskcache)
- log.init.debug("Initializing main window...")
- win_id = mainwindow.MainWindow.spawn(
- False if self._args.nowindow else True)
- main_window = objreg.get('main-window', scope='window', window=win_id)
- self.setActiveWindow(main_window)
+ if not session_manager.exists(self._args.session):
+ log.init.debug("Initializing main window...")
+ win_id = mainwindow.MainWindow.spawn(
+ False if self._args.nowindow else True)
+ main_window = objreg.get('main-window', scope='window',
+ window=win_id)
+ self.setActiveWindow(main_window)
def _init_icon(self):
"""Initialize the icon of qutebrowser."""
@@ -261,10 +267,27 @@ class Application(QApplication):
except (configexc.Error, configparser.Error) as e:
message.error('current', "set: {} - {}".format(
e.__class__.__name__, e))
+ self._load_session(self._args.session)
self.process_pos_args(self._args.command)
self._open_startpage()
self._open_quickstart()
+ def _load_session(self, name):
+ """Load the default session.
+
+ Args:
+ name: The name of the session to load.
+ """
+ session_manager = objreg.get('session-manager')
+ try:
+ session_manager.load(name)
+ except sessions.SessionNotFoundError:
+ pass
+ except sessions.SessionError:
+ log.init.exception("Failed to load default session")
+ else:
+ session_manager.delete('default')
+
def _get_window(self, via_ipc, force_window=False, force_tab=False):
"""Helper function for process_pos_args to get a window id.
diff --git a/qutebrowser/browser/tabhistory.py b/qutebrowser/browser/tabhistory.py
new file mode 100644
index 000000000..dacce636f
--- /dev/null
+++ b/qutebrowser/browser/tabhistory.py
@@ -0,0 +1,179 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015 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/>.
+
+"""Utilities related to QWebHistory."""
+
+
+from PyQt5.QtCore import QByteArray, QDataStream, QIODevice, QUrl
+
+from qutebrowser.utils import utils, qtutils
+
+
+HISTORY_STREAM_VERSION = 2
+BACK_FORWARD_TREE_VERSION = 2
+
+
+class TabHistoryItem:
+
+ """A single item in the tab history.
+
+ Attributes:
+ url: The QUrl of this item.
+ title: The title as string of this item.
+ active: Whether this item is the item currently navigated to.
+ user_data: The user data for this item.
+ """
+
+ def __init__(self, url, original_url, title, active=False, user_data=None):
+ self.url = url
+ self.original_url = original_url
+ self.title = title
+ self.active = active
+ self.user_data = user_data
+
+ def __repr__(self):
+ return utils.get_repr(self, constructor=True, url=self.url,
+ original_url=self.original_url, title=self.title,
+ active=self.active, user_data=self.user_data)
+
+
+def _encode_url(url):
+ """Encode an QUrl suitable to pass to QWebHistory."""
+ data = bytes(QUrl.toPercentEncoding(url.toString(), b':/#?&+=@%*'))
+ return data.decode('ascii')
+
+
+def _serialize_item(i, item, stream):
+ """Serialize a single WebHistoryItem into a QDataStream.
+
+ Args:
+ i: The index of the current item.
+ item: The WebHistoryItem to write.
+ stream: The QDataStream to write to.
+ """
+ ### Source/WebCore/history/qt/HistoryItemQt.cpp restoreState
+ ## urlString
+ stream.writeQString(_encode_url(item.url))
+ ## title
+ stream.writeQString(item.title)
+ ## originalURLString
+ stream.writeQString(_encode_url(item.original_url))
+
+ ### Source/WebCore/history/HistoryItem.cpp decodeBackForwardTree
+ ## backForwardTreeEncodingVersion
+ stream.writeUInt32(BACK_FORWARD_TREE_VERSION)
+ ## size (recursion stack)
+ stream.writeUInt64(0)
+ ## node->m_documentSequenceNumber
+ # If two HistoryItems have the same document sequence number, then they
+ # refer to the same instance of a document. Traversing history from one
+ # such HistoryItem to another preserves the document.
+ stream.writeInt64(i + 1)
+ ## size (node->m_documentState)
+ stream.writeUInt64(0)
+ ## node->m_formContentType
+ # info used to repost form data
+ stream.writeQString(None)
+ ## hasFormData
+ stream.writeBool(False)
+ ## node->m_itemSequenceNumber
+ # If two HistoryItems have the same item sequence number, then they are
+ # clones of one another. Traversing history from one such HistoryItem to
+ # another is a no-op. HistoryItem clones are created for parent and
+ # sibling frames when only a subframe navigates.
+ stream.writeInt64(i + 1)
+ ## node->m_referrer
+ stream.writeQString(None)
+ ## node->m_scrollPoint (x)
+ try:
+ stream.writeInt32(item.user_data['scroll-pos'].x())
+ except (KeyError, TypeError):
+ stream.writeInt32(0)
+ ## node->m_scrollPoint (y)
+ try:
+ stream.writeInt32(item.user_data['scroll-pos'].y())
+ except (KeyError, TypeError):
+ stream.writeInt32(0)
+ ## node->m_pageScaleFactor
+ try:
+ stream.writeFloat(item.user_data['zoom'])
+ except (KeyError, TypeError):
+ stream.writeFloat(1)
+ ## hasStateObject
+ # Support for HTML5 History
+ stream.writeBool(False)
+ ## node->m_target
+ stream.writeQString(None)
+
+ ### Source/WebCore/history/qt/HistoryItemQt.cpp restoreState
+ ## validUserData
+ # We could restore the user data here, but we prefer to use the
+ # QWebHistoryItem API for that.
+ stream.writeBool(False)
+
+
+def serialize(items):
+ """Serialize a list of QWebHistoryItems to a data stream.
+
+ Args:
+ items: An iterable of WebHistoryItems.
+
+ Return:
+ A (stream, data, user_data) tuple.
+ stream: The resetted QDataStream.
+ data: The QByteArray with the raw data.
+ user_data: A list with each item's user data.
+
+ Warning:
+ If 'data' goes out of scope, reading from 'stream' will result in a
+ segfault!
+ """
+
+ data = QByteArray()
+ stream = QDataStream(data, QIODevice.ReadWrite)
+ user_data = []
+
+ current_idx = None
+
+ for i, item in enumerate(items):
+ if item.active:
+ if current_idx is not None:
+ raise ValueError("Multiple active items ({} and {}) "
+ "found!".format(current_idx, i))
+ else:
+ current_idx = i
+
+ if items:
+ if current_idx is None:
+ raise ValueError("No active item found!")
+ else:
+ current_idx = 0
+
+ ### Source/WebKit/qt/Api/qwebhistory.cpp operator<<
+ stream.writeInt(HISTORY_STREAM_VERSION)
+ stream.writeInt(len(items))
+ stream.writeInt(current_idx)
+
+ for i, item in enumerate(items):
+ _serialize_item(i, item, stream)
+ user_data.append(item.user_data)
+
+ stream.device().reset()
+ qtutils.check_qdatastream(stream)
+ return stream, data, user_data
diff --git a/qutebrowser/browser/webpage.py b/qutebrowser/browser/webpage.py
index 53ffe040f..46f0dfd5f 100644
--- a/qutebrowser/browser/webpage.py
+++ b/qutebrowser/browser/webpage.py
@@ -21,7 +21,8 @@
import functools
-from PyQt5.QtCore import pyqtSlot, pyqtSignal, PYQT_VERSION, Qt, QUrl
+from PyQt5.QtCore import (pyqtSlot, pyqtSignal, PYQT_VERSION, Qt, QUrl, QPoint,
+ QTimer)
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
from PyQt5.QtWidgets import QFileDialog
@@ -29,7 +30,7 @@ from PyQt5.QtPrintSupport import QPrintDialog
from PyQt5.QtWebKitWidgets import QWebPage
from qutebrowser.config import config
-from qutebrowser.browser import http
+from qutebrowser.browser import http, tabhistory
from qutebrowser.browser.network import networkmanager
from qutebrowser.utils import (message, usertypes, log, jinja, qtutils, utils,
objreg)
@@ -73,6 +74,10 @@ class BrowserPage(QWebPage):
self.loadStarted.connect(self.on_load_started)
self.featurePermissionRequested.connect(
self.on_feature_permission_requested)
+ self.saveFrameStateRequested.connect(
+ self.on_save_frame_state_requested)
+ self.restoreFrameStateRequested.connect(
+ self.on_restore_frame_state_requested)
if PYQT_VERSION > 0x050300:
# WORKAROUND (remove this when we bump the requirements to 5.3.1)
@@ -213,6 +218,23 @@ class BrowserPage(QWebPage):
else:
nam.shutdown()
+ def load_history(self, entries):
+ """Load the history from a list of TabHistoryItem objects."""
+ stream, _data, user_data = tabhistory.serialize(entries)
+ history = self.history()
+ qtutils.deserialize_stream(stream, history)
+ for i, data in enumerate(user_data):
+ history.itemAt(i).setUserData(data)
+ cur_data = history.currentItem().userData()
+ if cur_data is not None:
+ frame = self.mainFrame()
+ if 'zoom' in cur_data:
+ frame.setZoomFactor(cur_data['zoom'])
+ if ('scroll-pos' in cur_data and
+ frame.scrollPosition() == QPoint(0, 0)):
+ QTimer.singleShot(0, functools.partial(
+ frame.setScrollPosition, cur_data['scroll-pos']))
+
def display_content(self, reply, mimetype):
"""Display a QNetworkReply with an explicitely set mimetype."""
self.mainFrame().setContent(reply.readAll(), mimetype, reply.url())
@@ -338,6 +360,37 @@ class BrowserPage(QWebPage):
if frame is cancelled_frame and feature == cancelled_feature:
question.abort()
+ def on_save_frame_state_requested(self, frame, item):
+ """Save scroll position and zoom in history.
+
+ Args:
+ frame: The QWebFrame which gets saved.
+ item: The QWebHistoryItem to be saved.
+ """
+ if frame != self.mainFrame():
+ return
+ data = {
+ 'zoom': frame.zoomFactor(),
+ 'scroll-pos': frame.scrollPosition(),
+ }
+ item.setUserData(data)
+
+ def on_restore_frame_state_requested(self, frame):
+ """Restore scroll position and zoom from history.
+
+ Args:
+ frame: The QWebFrame which gets restored.
+ """
+ if frame != self.mainFrame():
+ return
+ data = self.history().currentItem().userData()
+ if data is None:
+ return
+ if 'zoom' in data:
+ frame.setZoomFactor(data['zoom'])
+ if 'scroll-pos' in data and frame.scrollPosition() == QPoint(0, 0):
+ frame.setScrollPosition(data['scroll-pos'])
+
def userAgentForUrl(self, url):
"""Override QWebPage::userAgentForUrl to customize the user agent."""
ua = config.get('network', 'user-agent')
diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py
index fb9078510..52f396c1b 100644
--- a/qutebrowser/completion/completer.py
+++ b/qutebrowser/completion/completer.py
@@ -61,6 +61,7 @@ class Completer(QObject):
self._init_static_completions()
self._init_setting_completions()
self.init_quickmark_completions()
+ self.init_session_completion()
self._timer = QTimer()
self._timer.setSingleShot(True)
self._timer.setInterval(0)
@@ -114,6 +115,16 @@ class Completer(QObject):
self._models[usertypes.Completion.quickmark_by_name] = CFM(
models.QuickmarkCompletionModel('name', self), self)
+ @pyqtSlot()
+ def init_session_completion(self):
+ """Initialize session completion model."""
+ try:
+ self._models[usertypes.Completion.sessions].deleteLater()
+ except KeyError:
+ pass
+ self._models[usertypes.Completion.sessions] = CFM(
+ models.SessionCompletionModel(self), self)
+
def _get_completion_model(self, completion, parts, cursor_part):
"""Get a completion model based on an enum member.
diff --git a/qutebrowser/completion/models/completion.py b/qutebrowser/completion/models/completion.py
index 09263970a..b15019140 100644
--- a/qutebrowser/completion/models/completion.py
+++ b/qutebrowser/completion/models/completion.py
@@ -236,3 +236,16 @@ class QuickmarkCompletionModel(base.BaseCompletionModel):
else:
raise ValueError("Invalid value '{}' for match_field!".format(
match_field))
+
+
+class SessionCompletionModel(base.BaseCompletionModel):
+
+ """A CompletionModel filled with session names."""
+
+ # pylint: disable=abstract-method
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ cat = self.new_category("Sessions")
+ for name in objreg.get('session-manager').list_sessions():
+ self.new_item(cat, name)
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
index 4befbc03c..546cb0db9 100644
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -193,6 +193,10 @@ DATA = collections.OrderedDict([
('log-javascript-console',
SettingValue(typ.Bool(), 'false'),
"Whether to log javascript console messages."),
+
+ ('save-session',
+ SettingValue(typ.Bool(), 'false'),
+ "Whether to always save the open pages."),
)),
('ui', sect.KeyValue(
diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py
index 0de93aabc..16e405014 100644
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -54,7 +54,14 @@ class MainWindow(QWidget):
_commandrunner: The main CommandRunner instance.
"""
- def __init__(self, win_id, parent=None):
+ def __init__(self, win_id, geometry=None, parent=None):
+ """Create a new main window.
+
+ Args:
+ win_id: The ID the new window whouls get.
+ geometry: The geometry to load, as a bytes-object (or None).
+ parent: The parent the window should get.
+ """
super().__init__(parent)
self.setAttribute(Qt.WA_DeleteOnClose)
self._commandrunner = None
@@ -71,8 +78,10 @@ class MainWindow(QWidget):
window=win_id)
self.setWindowTitle('qutebrowser')
- if win_id == 0:
- self._load_geometry()
+ if geometry is not None:
+ self._load_geometry(geometry)
+ elif win_id == 0:
+ self._load_state_geometry()
else:
self._set_default_geometry()
log.init.debug("Initial mainwindow geometry: {}".format(
@@ -134,27 +143,27 @@ class MainWindow(QWidget):
self.resize_completion()
@classmethod
- def spawn(cls, show=True):
+ def spawn(cls, show=True, geometry=None):
"""Create a new main window.
Args:
show: Show the window after creating.
+ geometry: The geometry to load, as a bytes-object.
Return:
The new window id.
"""
win_id = next(win_id_gen)
- win = MainWindow(win_id)
+ win = MainWindow(win_id, geometry=geometry)
if show:
win.show()
return win_id
- def _load_geometry(self):
+ def _load_state_geometry(self):
"""Load the geometry from the state file."""
state_config = objreg.get('state-config')
try:
data = state_config['geometry']['mainwindow']
- log.init.debug("Restoring mainwindow from {}".format(data))
geom = base64.b64decode(data, validate=True)
except KeyError:
# First start
@@ -163,14 +172,18 @@ class MainWindow(QWidget):
log.init.exception("Error while reading geometry")
self._set_default_geometry()
else:
- try:
- ok = self.restoreGeometry(geom)
- except KeyError:
- log.init.exception("Error while restoring geometry.")
- self._set_default_geometry()
- if not ok:
- log.init.warning("Error while restoring geometry.")
- self._set_default_geometry()
+ self._load_geometry(geom)
+
+ def _load_geometry(self, geom):
+ """Load geometry from a bytes object.
+
+ If loading fails, loads default geometry.
+ """
+ log.init.debug("Loading mainwindow from {}".format(geom))
+ ok = self.restoreGeometry(geom)
+ if not ok:
+ log.init.warning("Error while loading geometry.")
+ self._set_default_geometry()
def _connect_resize_completion(self):
"""Connect the resize_completion signal and resize it once."""
@@ -266,6 +279,11 @@ class MainWindow(QWidget):
quickmark_manager = objreg.get('quickmark-manager')
quickmark_manager.changed.connect(completer.init_quickmark_completions)
+ # sessions completion
+ session_manager = objreg.get('session-manager')
+ session_manager.update_completion.connect(
+ completer.init_session_completion)
+
@pyqtSlot()
def resize_completion(self):
"""Adjust completion according to config."""
diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py
index eda1e3fee..6591ba4f4 100644
--- a/qutebrowser/misc/earlyinit.py
+++ b/qutebrowser/misc/earlyinit.py
@@ -245,6 +245,14 @@ def check_libraries():
windows="Install from http://www.lfd.uci.edu/"
"~gohlke/pythonlibs/#pygments or via pip.",
pip="pygments"),
+ 'yaml':
+ _missing_str("PyYAML",
+ debian="apt-get install python3-yaml",
+ arch="pacman -S python-yaml",
+ windows="Use the installers at "
+ "http://pyyaml.org/download/pyyaml/ (py3.4) "
+ "or Install via pip.",
+ pip="PyYAML"),
}
for name, text in modules.items():
try:
diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py
new file mode 100644
index 000000000..b6ed46d39
--- /dev/null
+++ b/qutebrowser/misc/sessions.py
@@ -0,0 +1,298 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015 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/>.
+
+"""Management of sessions - saved tabs/windows."""
+
+import os
+import os.path
+import functools
+
+from PyQt5.QtCore import (pyqtSignal, QStandardPaths, QUrl, QObject, QPoint,
+ QTimer)
+from PyQt5.QtWidgets import QApplication
+import yaml
+try:
+ from yaml import CSafeLoader as YamlLoader, CSafeDumper as YamlDumper
+except ImportError:
+ from yaml import SafeLoader as YamlLoader, SafeDumper as YamlDumper
+
+from qutebrowser.browser import tabhistory
+from qutebrowser.utils import standarddir, objreg, qtutils, log, usertypes
+from qutebrowser.commands import cmdexc, cmdutils
+from qutebrowser.mainwindow import mainwindow
+
+
+class SessionError(Exception):
+
+ """Exception raised when a session failed to load/save."""
+
+
+class SessionNotFoundError(SessionError):
+
+ """Exception raised when a session to be loaded was not found."""
+
+
+class SessionManager(QObject):
+
+ """Manager for sessions.
+
+ Attributes:
+ _base_path: The path to store sessions under.
+
+ Signals:
+ update_completion: Emitted when the session completion should get
+ updated.
+ """
+
+ update_completion = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ save_manager = objreg.get('save-manager')
+ save_manager.add_saveable(
+ 'default-session', functools.partial(self.save, 'default'),
+ config_opt=('general', 'save-session'))
+ self._base_path = os.path.join(
+ standarddir.get(QStandardPaths.DataLocation), 'sessions')
+ if not os.path.exists(self._base_path):
+ os.mkdir(self._base_path)
+
+ def _get_session_path(self, name, check_exists=False):
+ """Get the session path based on a session name or absolute path.
+
+ Args:
+ name: The name of the session.
+ check_exists: Whether it should also be checked if the session
+ exists.
+ """
+ path = os.path.expanduser(name)
+ if os.path.isabs(path) and ((not check_exists) or
+ os.path.exists(path)):
+ return path
+ else:
+ path = os.path.join(self._base_path, name + '.yml')
+ if check_exists and not os.path.exists(path):
+ raise SessionNotFoundError(path)
+ else:
+ return path
+
+ def exists(self, name):
+ """Check if a named session exists."""
+ try:
+ self._get_session_path(name, check_exists=True)
+ except SessionNotFoundError:
+ return False
+ else:
+ return True
+
+ def _save_tab(self, tab, active):
+ """Get a dict with data for a single tab.
+
+ Args:
+ tab: The WebView to save.
+ active: Whether the tab is currently active.
+ """
+ data = {'history': []}
+ if active:
+ data['active'] = True
+ history = tab.page().history()
+ for idx, item in enumerate(history.items()):
+ qtutils.ensure_valid(item)
+ item_data = {
+ 'url': bytes(item.url().toEncoded()).decode('ascii'),
+ 'title': item.title(),
+ }
+ if item.originalUrl() != item.url():
+ encoded = item.originalUrl().toEncoded()
+ item_data['original-url'] = bytes(encoded).decode('ascii')
+ user_data = item.userData()
+ if history.currentItemIndex() == idx:
+ item_data['active'] = True
+ if user_data is None:
+ pos = tab.page().mainFrame().scrollPosition()
+ data['zoom'] = tab.zoomFactor()
+ data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()}
+ data['history'].append(item_data)
+
+ if user_data is not None:
+ if 'zoom' in user_data:
+ data['zoom'] = user_data['zoom']
+ if 'scroll-pos' in user_data:
+ pos = user_data['scroll-pos']
+ data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()}
+ return data
+
+ def _save_all(self):
+ """Get a dict with data for all windows/tabs."""
+ data = {'windows': []}
+ for win_id in objreg.window_registry:
+ tabbed_browser = objreg.get('tabbed-browser', scope='window',
+ window=win_id)
+ main_window = objreg.get('main-window', scope='window',
+ window=win_id)
+ win_data = {}
+ active_window = QApplication.instance().activeWindow()
+ if getattr(active_window, 'win_id', None) == win_id:
+ win_data['active'] = True
+ win_data['geometry'] = bytes(main_window.saveGeometry())
+ win_data['tabs'] = []
+ for i, tab in enumerate(tabbed_browser.widgets()):
+ active = i == tabbed_browser.currentIndex()
+ win_data['tabs'].append(self._save_tab(tab, active))
+ data['windows'].append(win_data)
+ return data
+
+ def save(self, name):
+ """Save a named session."""
+ path = self._get_session_path(name)
+
+ log.misc.debug("Saving session {} to {}...".format(name, path))
+ data = self._save_all()
+ log.misc.vdebug("Saving data: {}".format(data))
+ try:
+ with qtutils.savefile_open(path) as f:
+ yaml.dump(data, f, Dumper=YamlDumper, default_flow_style=False,
+ encoding='utf-8', allow_unicode=True)
+ except (OSError, UnicodeEncodeError, yaml.YAMLError) as e:
+ raise SessionError(e)
+ else:
+ self.update_completion.emit()
+
+ def _load_tab(self, new_tab, data):
+ """Load yaml data into a newly opened tab."""
+ entries = []
+ for histentry in data['history']:
+ user_data = {}
+ if 'zoom' in data:
+ user_data['zoom'] = data['zoom']
+ if 'scroll-pos' in data:
+ pos = data['scroll-pos']
+ user_data['scroll-pos'] = QPoint(pos['x'], pos['y'])
+ active = histentry.get('active', False)
+ url = QUrl.fromEncoded(histentry['url'].encode('ascii'))
+ if 'original-url' in histentry:
+ orig_url = QUrl.fromEncoded(
+ histentry['original-url'].encode('ascii'))
+ else:
+ orig_url = url
+ entry = tabhistory.TabHistoryItem(
+ url=url, original_url=orig_url, title=histentry['title'],
+ active=active, user_data=user_data)
+ entries.append(entry)
+ if active:
+ new_tab.titleChanged.emit(histentry['title'])
+ try:
+ new_tab.page().load_history(entries)
+ except ValueError as e:
+ raise SessionError(e)
+
+ def load(self, name):
+ """Load a named session."""
+ path = self._get_session_path(name, check_exists=True)
+ try:
+ with open(path, encoding='utf-8') as f:
+ data = yaml.load(f, Loader=YamlLoader)
+ except (OSError, UnicodeDecodeError, yaml.YAMLError) as e:
+ raise SessionError(e)
+ log.misc.debug("Loading session {} from {}...".format(name, path))
+ for win in data['windows']:
+ win_id = mainwindow.MainWindow.spawn(geometry=win['geometry'])
+ tabbed_browser = objreg.get('tabbed-browser', scope='window',
+ window=win_id)
+ tab_to_focus = None
+ for i, tab in enumerate(win['tabs']):
+ new_tab = tabbed_browser.tabopen()
+ self._load_tab(new_tab, tab)
+ if tab.get('active', False):
+ tab_to_focus = i
+ if tab_to_focus is not None:
+ tabbed_browser.setCurrentIndex(tab_to_focus)
+ if win.get('active', False):
+ QTimer.singleShot(0, tabbed_browser.activateWindow)
+
+ def delete(self, name):
+ """Delete a session."""
+ path = self._get_session_path(name, check_exists=True)
+ os.remove(path)
+ self.update_completion.emit()
+
+ def list_sessions(self):
+ """Get a list of all session names."""
+ sessions = []
+ for filename in os.listdir(self._base_path):
+ base, ext = os.path.splitext(filename)
+ if ext == '.yml':
+ sessions.append(base)
+ return sessions
+
+ @cmdutils.register(completion=[usertypes.Completion.sessions],
+ instance='session-manager')
+ def session_load(self, name):
+ """Load a session.
+
+ Args:
+ name: The name of the session.
+ """
+ try:
+ self.load(name)
+ except SessionNotFoundError:
+ raise cmdexc.CommandError("Session {} not found!".format(name))
+ except SessionError as e:
+ raise cmdexc.CommandError("Error while loading session: {}"
+ .format(e))
+
+ @cmdutils.register(name=['session-save', 'w'],
+ completion=[usertypes.Completion.sessions],
+ instance='session-manager')
+ def session_save(self, name='default'):
+ """Save a session.
+
+ Args:
+ name: The name of the session.
+ """
+ try:
+ self.save(name)
+ except SessionError as e:
+ raise cmdexc.CommandError("Error while saving session: {}"
+ .format(e))
+
+ @cmdutils.register(name='wq', completion=[usertypes.Completion.sessions],
+ instance='session-manager')
+ def save_and_quit(self, name='default'):
+ """Save open pages and quit.
+
+ Args:
+ name: The name of the session.
+ """
+ self.session_save(name)
+ QApplication.closeAllWindows()
+
+ @cmdutils.register(completion=[usertypes.Completion.sessions],
+ instance='session-manager')
+ def session_delete(self, name):
+ """Delete a session.
+
+ Args:
+ name: The name of the session.
+ """
+ try:
+ self.delete(name)
+ except OSError as e:
+ raise cmdexc.CommandError("Error while deleting session: {}"
+ .format(e))
diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py
index 7cc5042c4..00b41c39b 100644
--- a/qutebrowser/qutebrowser.py
+++ b/qutebrowser/qutebrowser.py
@@ -53,6 +53,8 @@ def get_argparser():
"this session.", nargs=3, action='append',
dest='temp_settings', default=[],
metavar=('SECTION', 'OPTION', 'VALUE'))
+ parser.add_argument('-r', '--restore', help="Restore a named session.",
+ dest='session', default='default')
debug = parser.add_argument_group('debug arguments')
debug.add_argument('-l', '--loglevel', dest='loglevel',
diff --git a/qutebrowser/test/__init__.py b/qutebrowser/test/__init__.py
index cdb45eecc..8c1a54b99 100644
--- a/qutebrowser/test/__init__.py
+++ b/qutebrowser/test/__init__.py
@@ -18,3 +18,9 @@
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""The qutebrowser test suite."""
+
+from PyQt5.QtWidgets import QApplication
+
+# We create a singleton QApplication here.
+
+qApp = QApplication([])
diff --git a/qutebrowser/test/browser/test_tabhistory.py b/qutebrowser/test/browser/test_tabhistory.py
new file mode 100644
index 000000000..6752ce7eb
--- /dev/null
+++ b/qutebrowser/test/browser/test_tabhistory.py
@@ -0,0 +1,130 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2015 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/>.
+
+"""Tests for webelement.tabhistory."""
+
+import unittest
+
+from PyQt5.QtCore import QUrl
+from PyQt5.QtWebKitWidgets import QWebPage
+
+from qutebrowser.browser import tabhistory
+from qutebrowser.browser.tabhistory import TabHistoryItem as Item
+from qutebrowser.utils import qtutils
+
+
+class SerializeHistoryTests(unittest.TestCase):
+
+ """Tests for serialize()."""
+
+ def setUp(self):
+ self.page = QWebPage()
+ self.history = self.page.history()
+ self.assertEqual(self.history.count(), 0)
+
+ self.items = [Item(QUrl('https://www.heise.de/'),
+ QUrl('http://www.heise.de/'),
+ 'heise'),
+ Item(QUrl('http://example.com/%E2%80%A6'),
+ QUrl('http://example.com/%E2%80%A6'),
+ 'percent', active=True),
+ Item(QUrl('http://example.com/?foo=bar'),
+ QUrl('http://original.url.example.com/'),
+ 'arg', user_data={'foo': 23, 'bar': 42})]
+ stream, _data, self.user_data = tabhistory.serialize(self.items)
+ qtutils.deserialize_stream(stream, self.history)
+
+ def test_count(self):
+ """Check if the history's count was loaded correctly."""
+ self.assertEqual(self.history.count(), len(self.items))
+
+ def test_valid(self):
+ """Check if all items are valid."""
+ for i, _item in enumerate(self.items):
+ self.assertTrue(self.history.itemAt(i).isValid())
+
+ def test_no_userdata(self):
+ """Check if all items have no user data."""
+ for i, _item in enumerate(self.items):
+ self.assertIsNone(self.history.itemAt(i).userData())
+
+ def test_userdata(self):
+ """Check if all user data has been restored to self.user_data."""
+ for item, user_data in zip(self.items, self.user_data):
+ self.assertEqual(user_data, item.user_data)
+
+ def test_currentitem(self):
+ """Check if the current item index was loaded correctly."""
+ self.assertEqual(self.history.currentItemIndex(), 1)
+
+ def test_urls(self):
+ """Check if the URLs were loaded correctly."""
+ for i, item in enumerate(self.items):
+ with self.subTest(i=i, item=item):
+ self.assertEqual(self.history.itemAt(i).url(), item.url)
+
+ def test_original_urls(self):
+ """Check if the original URLs were loaded correctly."""
+ for i, item in enumerate(self.items):
+ with self.subTest(i=i, item=item):
+ self.assertEqual(self.history.itemAt(i).originalUrl(),
+ item.original_url)
+
+ def test_titles(self):
+ """Check if the titles were loaded correctly."""
+ for i, item in enumerate(self.items):
+ with self.subTest(i=i, item=item):
+ self.assertEqual(self.history.itemAt(i).title(), item.title)
+
+
+class SerializeHistorySpecialTests(unittest.TestCase):
+
+ """Tests for serialize() without items set up in setUp."""
+
+ def setUp(self):
+ self.page = QWebPage()
+ self.history = self.page.history()
+ self.assertEqual(self.history.count(), 0)
+
+ def test_no_active_item(self):
+ """Check tabhistory.serialize with no active item."""
+ items = [Item(QUrl(), QUrl(), '')]
+ with self.assertRaises(ValueError):
+ tabhistory.serialize(items)
+
+ def test_two_active_items(self):
+ """Check tabhistory.serialize with two active items."""
+ items = [Item(QUrl(), QUrl(), '', active=True),
+ Item(QUrl(), QUrl(), ''),
+ Item(QUrl(), QUrl(), '', active=True)]
+ with self.assertRaises(ValueError):
+ tabhistory.serialize(items)
+
+ def test_empty(self):
+ """Check tabhistory.serialize with no items."""
+ items = []
+ stream, _data, user_data = tabhistory.serialize(items)
+ qtutils.deserialize_stream(stream, self.history)
+ self.assertEqual(self.history.count(), 0)
+ self.assertEqual(self.history.currentItemIndex(), 0)
+ self.assertFalse(user_data)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py
index 7379b1a35..17d97cecd 100644
--- a/qutebrowser/utils/qtutils.py
+++ b/qutebrowser/utils/qtutils.py
@@ -133,7 +133,7 @@ def ensure_not_null(obj):
raise QtValueError(obj)
-def _check_qdatastream(stream):
+def check_qdatastream(stream):
"""Check the status of a QDataStream and raise OSError if it's not ok."""
status_to_str = {
QDataStream.Ok: "The data stream is operating normally.",
@@ -151,16 +151,28 @@ def serialize(obj):
"""Serialize an object into a QByteArray."""
data = QByteArray()
stream = QDataStream(data, QIODevice.WriteOnly)
- stream << obj # pylint: disable=pointless-statement
- _check_qdatastream(stream)
+ serialize_stream(stream, obj)
return data
def deserialize(data, obj):
"""Deserialize an object from a QByteArray."""
stream = QDataStream(data, QIODevice.ReadOnly)
+ deserialize_stream(stream, obj)
+
+
+def serialize_stream(stream, obj):
+ """Serialize an object into a QDataStream."""
+ check_qdatastream(stream)
+ stream << obj # pylint: disable=pointless-statement
+ check_qdatastream(stream)
+
+
+def deserialize_stream(stream, obj):
+ """Deserialize a QDataStream into an object."""
+ check_qdatastream(stream)
stream >> obj # pylint: disable=pointless-statement
- _check_qdatastream(stream)
+ check_qdatastream(stream)
@contextlib.contextmanager
diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py
index 9759a3189..05db40c4a 100644
--- a/qutebrowser/utils/usertypes.py
+++ b/qutebrowser/utils/usertypes.py
@@ -237,7 +237,7 @@ KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt',
# Available command completions
Completion = enum('Completion', ['command', 'section', 'option', 'value',
'helptopic', 'quickmark_by_url',
- 'quickmark_by_name'])
+ 'quickmark_by_name', 'sessions'])
class Question(QObject):
diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py
index e41631ca7..43320e939 100644
--- a/qutebrowser/utils/version.py
+++ b/qutebrowser/utils/version.py
@@ -144,6 +144,7 @@ def _module_versions():
('pypeg2', ['__version__']),
('jinja2', ['__version__']),
('pygments', ['__version__']),
+ ('yaml', ['__version__']),
])
for name, attributes in modules.items():
try:
diff --git a/scripts/setupcommon.py b/scripts/setupcommon.py
index db0d4da79..0be88d695 100644
--- a/scripts/setupcommon.py
+++ b/scripts/setupcommon.py
@@ -98,7 +98,7 @@ setupdata = {
'description': _get_constant('description'),
'long_description': read_file('README.asciidoc'),
'url': 'http://www.qutebrowser.org/',
- 'requires': ['pypeg2', 'jinja2', 'pygments'],
+ 'requires': ['pypeg2', 'jinja2', 'pygments', 'PyYAML'],
'author': _get_constant('author'),
'author_email': _get_constant('email'),
'license': _get_constant('license'),
diff --git a/setup.py b/setup.py
index 85ad052bb..b62a75ba2 100755
--- a/setup.py
+++ b/setup.py
@@ -44,7 +44,7 @@ try:
['qutebrowser = qutebrowser.qutebrowser:main']},
test_suite='qutebrowser.test',
zip_safe=True,
- install_requires=['pypeg2', 'jinja2', 'pygments'],
+ install_requires=['pypeg2', 'jinja2', 'pygments', 'PyYAML'],
**common.setupdata
)
finally: