From 8f1d81a6441ef9df88799ca9969122fb7fcf4f3b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Mon, 16 Feb 2015 20:26:09 +0100 Subject: [PATCH] Add session support. Closes #12. See #499. See #11. This adds PyYAML as a new dependency. It adds the following new commands: :session-delete Delete a session. :session-load Load a session. :session-save [] Save a session. :wq [] Save open pages and quit. And the following new settings: general -> save-session: Whether to always save the open pages. --- .flake8 | 3 +- README.asciidoc | 1 + doc/help/commands.asciidoc | 40 +++ doc/help/settings.asciidoc | 12 + doc/qutebrowser.1.asciidoc | 3 + qutebrowser/app.py | 35 ++- qutebrowser/browser/tabhistory.py | 179 ++++++++++++ qutebrowser/browser/webpage.py | 57 +++- qutebrowser/completion/completer.py | 11 + qutebrowser/completion/models/completion.py | 13 + qutebrowser/config/configdata.py | 4 + qutebrowser/mainwindow/mainwindow.py | 48 +++- qutebrowser/misc/earlyinit.py | 8 + qutebrowser/misc/sessions.py | 298 ++++++++++++++++++++ qutebrowser/qutebrowser.py | 2 + qutebrowser/test/__init__.py | 6 + qutebrowser/test/browser/test_tabhistory.py | 130 +++++++++ qutebrowser/utils/qtutils.py | 20 +- qutebrowser/utils/usertypes.py | 2 +- qutebrowser/utils/version.py | 1 + scripts/setupcommon.py | 2 +- setup.py | 2 +- 22 files changed, 846 insertions(+), 31 deletions(-) create mode 100644 qutebrowser/browser/tabhistory.py create mode 100644 qutebrowser/misc/sessions.py create mode 100644 qutebrowser/test/browser/test_tabhistory.py 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 qutebrowser while keeping existing tabs open. |<>|Save configs and state. |<>|Search for a text on the current page. +|<>|Delete a session. +|<>|Load a session. +|<>|Save a session. |<>|Set an option. |<>|Preset the statusbar to some text. |<>|Spawn a command in a shell. @@ -50,6 +53,7 @@ |<>|Unbind a keychain. |<>|Re-open a closed tab (optionally skipping [count] closed tabs). |<>|Show the source of the current page. +|<>|Save open pages and quit. |<>|Yank the current URL/title to the clipboard or primary selection. |<>|Set the zoom level for the current tab. |<>|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 @@ |<>|Default encoding to use for websites. |<>|How to open links in an existing instance if a new one is launched. |<>|Whether to log javascript console messages. +|<>|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) +# +# 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 . + +"""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) +# +# 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 . + +"""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 . """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) +# +# 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 . + +"""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: