Merge branch 'editor-watch'

This commit is contained in:
Florian Bruhin 2018-02-07 22:31:49 +01:00
commit 3306247ae5
9 changed files with 127 additions and 41 deletions

View File

@ -45,6 +45,8 @@ Changed
- The `url.incdec_segments` option now also can take `port` as possible segment.
- QtWebEngine: `:view-source` now uses Chromium's `view-source:` scheme.
- Tabs now show their full title as tooltip.
- When an editor is spawned with `:open-editor` and `:config-edit`, the changes
are now applied as soon as the file is saved in the editor.
Fixed
~~~~~

View File

@ -1616,9 +1616,9 @@ class CommandDispatcher:
caret_position = elem.caret_position()
ed = editor.ExternalEditor(self._tabbed_browser)
ed.editing_finished.connect(functools.partial(
self.on_editing_finished, elem))
ed = editor.ExternalEditor(watch=True, parent=self._tabbed_browser)
ed.file_updated.connect(functools.partial(
self.on_file_updated, elem))
ed.edit(text, caret_position)
@cmdutils.register(instance='command-dispatcher', scope='window')
@ -1631,10 +1631,10 @@ class CommandDispatcher:
tab = self._current_widget()
tab.elements.find_focused(self._open_editor_cb)
def on_editing_finished(self, elem, text):
def on_file_updated(self, elem, text):
"""Write the editor text into the form field and clean up tempfile.
Callback for GUIProcess when the editor was closed.
Callback for GUIProcess when the edited text was updated.
Args:
elem: The WebElementWrapper which was modified.
@ -2141,7 +2141,7 @@ class CommandDispatcher:
ed = editor.ExternalEditor(self._tabbed_browser)
# Passthrough for openurl args (e.g. -t, -b, -w)
ed.editing_finished.connect(functools.partial(
ed.file_updated.connect(functools.partial(
self._open_if_changed, old_url=old_url, bg=bg, tab=tab,
window=window, private=private, related=related))

View File

@ -253,7 +253,7 @@ class ConfigCommands:
Args:
no_source: Don't re-source the config file after editing.
"""
def on_editing_finished():
def on_file_updated():
"""Source the new config when editing finished.
This can't use cmdexc.CommandError as it's run async.
@ -263,9 +263,9 @@ class ConfigCommands:
except configexc.ConfigFileErrors as e:
message.error(str(e))
ed = editor.ExternalEditor(self._config)
ed = editor.ExternalEditor(watch=True, parent=self._config)
if not no_source:
ed.editing_finished.connect(on_editing_finished)
ed.file_updated.connect(on_file_updated)
filename = os.path.join(standarddir.config(), 'config.py')
ed.edit_file(filename)

View File

@ -193,7 +193,7 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit):
if run:
self.command_accept()
ed.editing_finished.connect(callback)
ed.file_updated.connect(callback)
ed.edit(self.text())
@pyqtSlot(usertypes.KeyMode)

View File

@ -22,7 +22,8 @@
import os
import tempfile
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QProcess
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QProcess,
QFileSystemWatcher)
from qutebrowser.config import config
from qutebrowser.utils import message, log
@ -39,19 +40,25 @@ class ExternalEditor(QObject):
_remove_file: Whether the file should be removed when the editor is
closed.
_proc: The GUIProcess of the editor.
_watcher: A QFileSystemWatcher to watch the edited file for changes.
Only set if watch=True.
"""
editing_finished = pyqtSignal(str)
file_updated = pyqtSignal(str)
def __init__(self, parent=None):
def __init__(self, parent=None, watch=False):
super().__init__(parent)
self._filename = None
self._proc = None
self._remove_file = None
self._watcher = QFileSystemWatcher(parent=self) if watch else None
self._content = None
def _cleanup(self):
"""Clean up temporary files after the editor closed."""
assert self._remove_file is not None
if self._watcher:
self._watcher.removePaths(self._watcher.files())
if self._filename is None or not self._remove_file:
# Could not create initial file.
return
@ -65,7 +72,7 @@ class ExternalEditor(QObject):
message.error("Failed to delete tempfile... ({})".format(e))
@pyqtSlot(int, QProcess.ExitStatus)
def on_proc_closed(self, exitcode, exitstatus):
def on_proc_closed(self, _exitcode, exitstatus):
"""Write the editor text into the form field and clean up tempfile.
Callback for QProcess when the editor was closed.
@ -75,22 +82,9 @@ class ExternalEditor(QObject):
# No error/cleanup here, since we already handle this in
# on_proc_error.
return
try:
if exitcode != 0:
return
encoding = config.val.editor.encoding
try:
with open(self._filename, 'r', encoding=encoding) as f:
text = f.read()
except OSError as e:
# NOTE: Do not replace this with "raise CommandError" as it's
# executed async.
message.error("Failed to read back edited file: {}".format(e))
return
log.procs.debug("Read back: {}".format(text))
self.editing_finished.emit(text)
finally:
self._cleanup()
# do a final read to make sure we don't miss the last signal
self._on_file_changed(self._filename)
self._cleanup()
@pyqtSlot(QProcess.ProcessError)
def on_proc_error(self, _err):
@ -128,6 +122,21 @@ class ExternalEditor(QObject):
line, column = self._calc_line_and_column(text, caret_position)
self._start_editor(line=line, column=column)
@pyqtSlot(str)
def _on_file_changed(self, path):
try:
with open(path, 'r', encoding=config.val.editor.encoding) as f:
text = f.read()
except OSError as e:
# NOTE: Do not replace this with "raise CommandError" as it's
# executed async.
message.error("Failed to read back edited file: {}".format(e))
return
log.procs.debug("Read back: {}".format(text))
if self._content != text:
self._content = text
self.file_updated.emit(text)
def edit_file(self, filename):
"""Edit the file with the given filename."""
self._filename = filename
@ -147,6 +156,10 @@ class ExternalEditor(QObject):
editor = config.val.editor.command
executable = editor[0]
if self._watcher:
self._watcher.addPath(self._filename)
self._watcher.fileChanged.connect(self._on_file_changed)
args = [self._sub_placeholder(arg, line, column) for arg in editor[1:]]
log.procs.debug("Calling \"{}\" with args {}".format(executable, args))
self._proc.start(executable, args)

View File

@ -119,7 +119,7 @@ Feature: Opening external editors
# There's no guarantee that the tab gets deleted...
@posix @flaky
Scenario: Spawning an editor and closing the tab
When I set up a fake editor that waits
When I set up a fake editor that writes "foobar" on save
And I open data/editor.html
And I run :click-element id qute-textarea
And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
@ -129,6 +129,19 @@ Feature: Opening external editors
And I kill the waiting editor
Then the error "Edited element vanished" should be shown
# Could not get signals working on Windows
@posix
Scenario: Spawning an editor and saving
When I set up a fake editor that writes "foobar" on save
And I open data/editor.html
And I run :click-element id qute-textarea
And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
And I run :open-editor
And I save without exiting the editor
And I wait for "Read back: foobar" in the log
And I run :click-element id qute-button
Then the javascript message "text: foobar" should be logged
Scenario: Spawning an editor in caret mode
When I set up a fake editor returning "foobar"
And I open data/editor.html

View File

@ -22,6 +22,7 @@ import json
import textwrap
import os
import signal
import time
import pytest_bdd as bdd
bdd.scenarios('editor.feature')
@ -70,8 +71,9 @@ def set_up_editor_empty(quteproc, tmpdir):
set_up_editor(quteproc, tmpdir, "")
@bdd.when(bdd.parsers.parse('I set up a fake editor that waits'))
def set_up_editor_wait(quteproc, tmpdir):
@bdd.when(bdd.parsers.parse('I set up a fake editor that writes "{text}" on '
'save'))
def set_up_editor_wait(quteproc, tmpdir, text):
"""Set up editor.command to a small python script inserting a text."""
assert not utils.is_windows
pidfile = tmpdir / 'editor_pid'
@ -82,12 +84,20 @@ def set_up_editor_wait(quteproc, tmpdir):
import time
import signal
def handle(sig, _frame):
with open(sys.argv[1], 'w', encoding='utf-8') as f:
f.write({text!r})
if sig == signal.SIGUSR1:
sys.exit(0)
signal.signal(signal.SIGUSR1, handle)
signal.signal(signal.SIGUSR2, handle)
with open(r'{pidfile}', 'w') as f:
f.write(str(os.getpid()))
signal.signal(signal.SIGUSR1, lambda s, f: sys.exit(0))
time.sleep(100)
""".format(pidfile=pidfile)))
""".format(pidfile=pidfile, text=text)))
editor = json.dumps([sys.executable, str(script), '{}'])
quteproc.set_setting('editor.command', editor)
@ -101,3 +111,19 @@ def kill_editor_wait(tmpdir):
# for posix, there IS a member so we need to ignore useless-suppression
# pylint: disable=no-member,useless-suppression
os.kill(pid, signal.SIGUSR1)
@bdd.when(bdd.parsers.parse('I save without exiting the editor'))
def save_editor_wait(tmpdir):
"""Trigger the waiting editor to write without exiting."""
pidfile = tmpdir / 'editor_pid'
# give the "editor" process time to write its pid
for _ in range(10):
if pidfile.check():
break
time.sleep(1)
pid = int(pidfile.read())
# windows has no SIGUSR2, but we don't run this on windows anyways
# for posix, there IS a member so we need to ignore useless-suppression
# pylint: disable=no-member,useless-suppression
os.kill(pid, signal.SIGUSR2)

View File

@ -22,7 +22,7 @@ import logging
import unittest.mock
import pytest
from PyQt5.QtCore import QUrl, QProcess
from PyQt5.QtCore import QUrl
from qutebrowser.config import configcommands
from qutebrowser.commands import cmdexc
@ -330,7 +330,7 @@ class TestEdit:
def _write_file(editor_self):
with open(editor_self._filename, 'w', encoding='utf-8') as f:
f.write(text)
editor_self.on_proc_closed(0, QProcess.NormalExit)
editor_self.file_updated.emit(text)
return mocker.patch('qutebrowser.config.configcommands.editor.'
'ExternalEditor._start_editor', autospec=True,

View File

@ -19,6 +19,7 @@
"""Tests for qutebrowser.misc.editor."""
import time
import os
import os.path
import logging
@ -37,7 +38,7 @@ def patch_things(config_stub, monkeypatch, stubs):
@pytest.fixture
def editor(caplog):
def editor(caplog, qtbot):
ed = editormod.ExternalEditor()
yield ed
with caplog.at_level(logging.ERROR):
@ -118,12 +119,12 @@ class TestFileHandling:
os.remove(filename)
def test_unreadable(self, message_mock, editor, caplog):
def test_unreadable(self, message_mock, editor, caplog, qtbot):
"""Test file handling when closing with an unreadable file."""
editor.edit("")
filename = editor._filename
assert os.path.exists(filename)
os.chmod(filename, 0o077)
os.chmod(filename, 0o277)
if os.access(filename, os.R_OK):
# Docker container or similar
pytest.skip("File was still readable")
@ -173,12 +174,43 @@ def test_modify(qtbot, editor, initial_text, edited_text):
with open(editor._filename, 'w', encoding='utf-8') as f:
f.write(edited_text)
with qtbot.wait_signal(editor.editing_finished) as blocker:
with qtbot.wait_signal(editor.file_updated) as blocker:
editor._proc.finished.emit(0, QProcess.NormalExit)
assert blocker.args == [edited_text]
def _update_file(filename, contents):
"""Update the given file and make sure its mtime changed.
This might write the file multiple times, but different systems have
different mtime's, so we can't be sure how long to wait otherwise.
"""
old_mtime = new_mtime = os.stat(filename).st_mtime
while old_mtime == new_mtime:
time.sleep(0.1)
with open(filename, 'w', encoding='utf-8') as f:
f.write(contents)
new_mtime = os.stat(filename).st_mtime
def test_modify_watch(qtbot):
"""Test that saving triggers file_updated when watch=True."""
editor = editormod.ExternalEditor(watch=True)
editor.edit('foo')
with qtbot.wait_signal(editor.file_updated, timeout=3000) as blocker:
_update_file(editor._filename, 'bar')
assert blocker.args == ['bar']
with qtbot.wait_signal(editor.file_updated) as blocker:
_update_file(editor._filename, 'baz')
assert blocker.args == ['baz']
with qtbot.assert_not_emitted(editor.file_updated):
editor._proc.finished.emit(0, QProcess.NormalExit)
@pytest.mark.parametrize('text, caret_position, result', [
('', 0, (1, 1)),
('a', 0, (1, 1)),