Add session support.

Closes #12.
See #499.
See #11.

This adds PyYAML as a new dependency.

It adds the following new commands:

    :session-delete <name>
    Delete a session.

    :session-load <name>
    Load a session.

    :session-save [<name>]
    Save a session.

    :wq [<name>]
    Save open pages and quit.

And the following new settings:

    general -> save-session:
    Whether to always save the open pages.
This commit is contained in:
Florian Bruhin 2015-02-16 20:26:09 +01:00
parent 53b024f246
commit 8f1d81a644
22 changed files with 846 additions and 31 deletions

View File

@ -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

View File

@ -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.

View File

@ -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*]+

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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')

View File

@ -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.

View File

@ -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)

View File

@ -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(

View File

@ -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."""

View File

@ -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:

View File

@ -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))

View File

@ -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',

View File

@ -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([])

View File

@ -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()

View File

@ -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

View File

@ -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):

View File

@ -144,6 +144,7 @@ def _module_versions():
('pypeg2', ['__version__']),
('jinja2', ['__version__']),
('pygments', ['__version__']),
('yaml', ['__version__']),
])
for name, attributes in modules.items():
try:

View File

@ -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'),

View File

@ -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: