half-working auth prompts
This commit is contained in:
parent
903e31efa4
commit
33088588d9
@ -645,9 +645,9 @@ class Quitter:
|
|||||||
|
|
||||||
deferrer = False
|
deferrer = False
|
||||||
for win_id in objreg.window_registry:
|
for win_id in objreg.window_registry:
|
||||||
prompter = objreg.get('prompter', None, scope='window',
|
prompt_container = objreg.get('prompt-container', None,
|
||||||
window=win_id)
|
scope='window', window=win_id)
|
||||||
if prompter is not None and prompter.shutdown():
|
if prompt_container is not None and prompt_container.shutdown():
|
||||||
deferrer = True
|
deferrer = True
|
||||||
if deferrer:
|
if deferrer:
|
||||||
# If shutdown was called while we were asking a question, we're in
|
# If shutdown was called while we were asking a question, we're in
|
||||||
|
@ -182,11 +182,14 @@ class MainWindow(QWidget):
|
|||||||
self._add_overlay(self._keyhint, self._keyhint.update_geometry)
|
self._add_overlay(self._keyhint, self._keyhint.update_geometry)
|
||||||
self._messageview = messageview.MessageView(parent=self)
|
self._messageview = messageview.MessageView(parent=self)
|
||||||
self._add_overlay(self._messageview, self._messageview.update_geometry)
|
self._add_overlay(self._messageview, self._messageview.update_geometry)
|
||||||
self._promptcontainer = prompt.PromptContainer(self)
|
|
||||||
self._add_overlay(self._promptcontainer,
|
self._prompt_container = prompt.PromptContainer(self.win_id, self)
|
||||||
self._promptcontainer.update_geometry,
|
self._add_overlay(self._prompt_container,
|
||||||
|
self._prompt_container.update_geometry,
|
||||||
centered=True)
|
centered=True)
|
||||||
self._promptcontainer.hide()
|
objreg.register('prompt-container', self._prompt_container,
|
||||||
|
scope='window', window=self.win_id)
|
||||||
|
self._prompt_container.hide()
|
||||||
|
|
||||||
log.init.debug("Initializing modes...")
|
log.init.debug("Initializing modes...")
|
||||||
modeman.init(self.win_id, self)
|
modeman.init(self.win_id, self)
|
||||||
@ -385,7 +388,6 @@ class MainWindow(QWidget):
|
|||||||
cmd = self._get_object('status-command')
|
cmd = self._get_object('status-command')
|
||||||
message_bridge = self._get_object('message-bridge')
|
message_bridge = self._get_object('message-bridge')
|
||||||
mode_manager = self._get_object('mode-manager')
|
mode_manager = self._get_object('mode-manager')
|
||||||
#prompter = self._get_object('prompter')
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
self.tabbed_browser.close_window.connect(self.close)
|
self.tabbed_browser.close_window.connect(self.close)
|
||||||
@ -395,7 +397,7 @@ class MainWindow(QWidget):
|
|||||||
mode_manager.entered.connect(status.on_mode_entered)
|
mode_manager.entered.connect(status.on_mode_entered)
|
||||||
mode_manager.left.connect(status.on_mode_left)
|
mode_manager.left.connect(status.on_mode_left)
|
||||||
mode_manager.left.connect(cmd.on_mode_left)
|
mode_manager.left.connect(cmd.on_mode_left)
|
||||||
#mode_manager.left.connect(prompter.on_mode_left)
|
mode_manager.left.connect(self._prompt_container.on_mode_left)
|
||||||
|
|
||||||
# commands
|
# commands
|
||||||
keyparsers[usertypes.KeyMode.normal].keystring_updated.connect(
|
keyparsers[usertypes.KeyMode.normal].keystring_updated.connect(
|
||||||
@ -419,8 +421,8 @@ class MainWindow(QWidget):
|
|||||||
message_bridge.s_set_text.connect(status.set_text)
|
message_bridge.s_set_text.connect(status.set_text)
|
||||||
message_bridge.s_maybe_reset_text.connect(status.txt.maybe_reset_text)
|
message_bridge.s_maybe_reset_text.connect(status.txt.maybe_reset_text)
|
||||||
message_bridge.s_set_cmd_text.connect(cmd.set_cmd_text)
|
message_bridge.s_set_cmd_text.connect(cmd.set_cmd_text)
|
||||||
#message_bridge.s_question.connect(prompter.ask_question,
|
message_bridge.s_question.connect(self._prompt_container.ask_question,
|
||||||
# Qt.DirectConnection)
|
Qt.DirectConnection)
|
||||||
|
|
||||||
# statusbar
|
# statusbar
|
||||||
tabs.current_tab_changed.connect(status.prog.on_tab_changed)
|
tabs.current_tab_changed.connect(status.prog.on_tab_changed)
|
||||||
|
@ -19,13 +19,17 @@
|
|||||||
|
|
||||||
"""Showing prompts above the statusbar."""
|
"""Showing prompts above the statusbar."""
|
||||||
|
|
||||||
|
import sip
|
||||||
import collections
|
import collections
|
||||||
|
|
||||||
from PyQt5.QtCore import pyqtSignal, Qt
|
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QTimer
|
||||||
from PyQt5.QtWidgets import QWidget, QGridLayout, QVBoxLayout, QLineEdit, QLabel, QSpacerItem
|
from PyQt5.QtWidgets import (QWidget, QGridLayout, QVBoxLayout, QLineEdit,
|
||||||
|
QLabel, QSpacerItem, QWidgetItem)
|
||||||
|
|
||||||
from qutebrowser.config import style, config
|
from qutebrowser.config import style, config
|
||||||
from qutebrowser.utils import usertypes
|
from qutebrowser.utils import usertypes, log, utils, qtutils
|
||||||
|
from qutebrowser.keyinput import modeman
|
||||||
|
from qutebrowser.commands import cmdutils
|
||||||
|
|
||||||
|
|
||||||
AuthTuple = collections.namedtuple('AuthTuple', ['user', 'password'])
|
AuthTuple = collections.namedtuple('AuthTuple', ['user', 'password'])
|
||||||
@ -36,19 +40,65 @@ class Error(Exception):
|
|||||||
"""Base class for errors in this module."""
|
"""Base class for errors in this module."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedOperationError(Exception):
|
||||||
|
|
||||||
|
"""Raised when the prompt class doesn't support the requested operation."""
|
||||||
|
|
||||||
|
|
||||||
class PromptContainer(QWidget):
|
class PromptContainer(QWidget):
|
||||||
|
|
||||||
|
"""Container for prompts to be shown above the statusbar.
|
||||||
|
|
||||||
|
The way in which multiple questions are handled deserves some explanation.
|
||||||
|
|
||||||
|
If a question is blocking, we *need* to ask it immediately, and can't wait
|
||||||
|
for previous questions to finish. We could theoretically ask a blocking
|
||||||
|
question inside of another blocking one, so in ask_question we simply save
|
||||||
|
the current prompt state on the stack, let the user answer the *most
|
||||||
|
recent* question, and then restore the previous state.
|
||||||
|
|
||||||
|
With a non-blocking question, things are a bit easier. We simply add it to
|
||||||
|
self._queue if we're still busy handling another question, since it can be
|
||||||
|
answered at any time.
|
||||||
|
|
||||||
|
In either case, as soon as we finished handling a question, we call
|
||||||
|
_pop_later() which schedules a _pop to ask the next question in _queue. We
|
||||||
|
schedule it rather than doing it immediately because then the order of how
|
||||||
|
things happen is clear, e.g. on_mode_left can't happen after we already set
|
||||||
|
up the *new* question.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_shutting_down: Whether we're currently shutting down the prompter and
|
||||||
|
should ignore future questions to avoid segfaults.
|
||||||
|
_loops: A list of local EventLoops to spin in when blocking.
|
||||||
|
_queue: A deque of waiting questions.
|
||||||
|
_prompt: The current prompt object if we're handling a question.
|
||||||
|
_layout: The layout used to show prompts in.
|
||||||
|
_win_id: The window ID this object is associated with.
|
||||||
|
"""
|
||||||
|
|
||||||
update_geometry = pyqtSignal()
|
update_geometry = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, win_id, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName('Prompt')
|
self.setObjectName('Prompt')
|
||||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||||
self._layout = QVBoxLayout(self)
|
self._layout = QVBoxLayout(self)
|
||||||
self._layout.setContentsMargins(10, 10, 10, 10)
|
self._layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
self._prompt = None
|
||||||
style.set_register_stylesheet(self,
|
style.set_register_stylesheet(self,
|
||||||
generator=self._generate_stylesheet)
|
generator=self._generate_stylesheet)
|
||||||
|
|
||||||
|
# FIXME review this
|
||||||
|
self._shutting_down = False
|
||||||
|
self._loops = []
|
||||||
|
self._queue = collections.deque()
|
||||||
|
self._win_id = win_id
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return utils.get_repr(self, loops=len(self._loops),
|
||||||
|
queue=len(self._queue), prompt=self._prompt)
|
||||||
|
|
||||||
def _generate_stylesheet(self):
|
def _generate_stylesheet(self):
|
||||||
"""Generate a stylesheet with the right edge rounded."""
|
"""Generate a stylesheet with the right edge rounded."""
|
||||||
stylesheet = """
|
stylesheet = """
|
||||||
@ -78,28 +128,228 @@ class PromptContainer(QWidget):
|
|||||||
else:
|
else:
|
||||||
raise ValueError("Invalid position {}!".format(position))
|
raise ValueError("Invalid position {}!".format(position))
|
||||||
|
|
||||||
def _show_prompt(self, prompt):
|
def _pop_later(self):
|
||||||
while True:
|
"""Helper to call self._pop as soon as everything else is done."""
|
||||||
# FIXME do we really want to delete children?
|
QTimer.singleShot(0, self._pop)
|
||||||
child = self._layout.takeAt(0)
|
|
||||||
if child is None:
|
|
||||||
break
|
|
||||||
child.deleteLater()
|
|
||||||
|
|
||||||
self._layout.addWidget(prompt)
|
def _pop(self):
|
||||||
|
"""Pop a question from the queue and ask it, if there are any."""
|
||||||
|
log.prompt.debug("Popping from queue {}".format(self._queue))
|
||||||
|
if self._queue:
|
||||||
|
question = self._queue.popleft()
|
||||||
|
if not sip.isdeleted(question):
|
||||||
|
# the question could already be deleted, e.g. by a cancelled
|
||||||
|
# download. See
|
||||||
|
# https://github.com/The-Compiler/qutebrowser/issues/415
|
||||||
|
self.ask_question(question, blocking=False)
|
||||||
|
|
||||||
|
def _show_prompt(self, prompt):
|
||||||
|
"""SHow the given prompt object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: A Prompt object or None.
|
||||||
|
|
||||||
|
Return: True if a prompt was shown, False otherwise.
|
||||||
|
"""
|
||||||
|
# Before we set a new prompt, make sure the old one is what we expect
|
||||||
|
# This will also work if self._prompt is None and verify nothing is
|
||||||
|
# displayed.
|
||||||
|
#
|
||||||
|
# Note that we don't delete the old prompt here, as we might be in the
|
||||||
|
# middle of saving/restoring an old prompt object.
|
||||||
|
assert self._layout.count() in [0, 1], self._layout.count()
|
||||||
|
item = self._layout.takeAt(0)
|
||||||
|
if item is None:
|
||||||
|
assert self._prompt is None, self._prompt
|
||||||
|
else:
|
||||||
|
if (not isinstance(item, QWidgetItem) or
|
||||||
|
item.widget() is not self._prompt):
|
||||||
|
raise AssertionError("Expected {} to be in layout but got "
|
||||||
|
"{}!".format(self._prompt, item))
|
||||||
|
item.widget().hide()
|
||||||
|
|
||||||
|
log.prompt.debug("Displaying prompt {}".format(prompt))
|
||||||
|
self._prompt = prompt
|
||||||
|
if prompt is None:
|
||||||
|
self.hide()
|
||||||
|
return False
|
||||||
|
|
||||||
|
prompt.question.aborted.connect(
|
||||||
|
lambda: modeman.maybe_leave(self._win_id, prompt.KEY_MODE,
|
||||||
|
'aborted'))
|
||||||
|
modeman.enter(self._win_id, prompt.KEY_MODE, 'question asked')
|
||||||
|
self._prompt = prompt
|
||||||
|
self._layout.addWidget(self._prompt)
|
||||||
|
self._prompt.show()
|
||||||
|
self.show()
|
||||||
|
self._prompt.setFocus()
|
||||||
|
self.setFocus()
|
||||||
self.update_geometry.emit()
|
self.update_geometry.emit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Cancel all blocking questions.
|
||||||
|
|
||||||
|
Quits and removes all running event loops.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
True if loops needed to be aborted,
|
||||||
|
False otherwise.
|
||||||
|
"""
|
||||||
|
self._shutting_down = True
|
||||||
|
if self._loops:
|
||||||
|
for loop in self._loops:
|
||||||
|
loop.quit()
|
||||||
|
loop.deleteLater()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@cmdutils.register(instance='prompt-container', hide=True, scope='window',
|
||||||
|
modes=[usertypes.KeyMode.prompt,
|
||||||
|
usertypes.KeyMode.yesno])
|
||||||
|
def prompt_accept(self, value=None):
|
||||||
|
"""Accept the current prompt.
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
This executes the next action depending on the question mode, e.g. asks
|
||||||
|
for the password or leaves the mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: If given, uses this value instead of the entered one.
|
||||||
|
For boolean prompts, "yes"/"no" are accepted as value.
|
||||||
|
"""
|
||||||
|
done = self._prompt.accept(value)
|
||||||
|
if done:
|
||||||
|
self._prompt.question.done()
|
||||||
|
modeman.maybe_leave(self._win_id, self._prompt.KEY_MODE,
|
||||||
|
':prompt-accept')
|
||||||
|
|
||||||
|
@cmdutils.register(instance='prompt-container', hide=True, scope='window',
|
||||||
|
modes=[usertypes.KeyMode.yesno],
|
||||||
|
deprecated='Use :prompt-accept yes instead!')
|
||||||
|
def prompt_yes(self):
|
||||||
|
"""Answer yes to a yes/no prompt."""
|
||||||
|
self.prompt_accept('yes')
|
||||||
|
|
||||||
|
@cmdutils.register(instance='prompt-container', hide=True, scope='window',
|
||||||
|
modes=[usertypes.KeyMode.yesno],
|
||||||
|
deprecated='Use :prompt-accept no instead!')
|
||||||
|
def prompt_no(self):
|
||||||
|
"""Answer no to a yes/no prompt."""
|
||||||
|
self.prompt_accept('no')
|
||||||
|
|
||||||
|
@pyqtSlot(usertypes.KeyMode)
|
||||||
|
def on_mode_left(self, mode):
|
||||||
|
"""Clear and reset input when the mode was left."""
|
||||||
|
# FIXME when is this not the case?
|
||||||
|
if (self._prompt is not None and
|
||||||
|
mode == self._prompt.KEY_MODE):
|
||||||
|
question = self._prompt.question
|
||||||
|
self._show_prompt(None)
|
||||||
|
# FIXME move this somewhere else?
|
||||||
|
if question.answer is None and not question.is_aborted:
|
||||||
|
question.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@cmdutils.register(instance='prompter', hide=True, scope='window',
|
||||||
|
modes=[usertypes.KeyMode.prompt], maxsplit=0)
|
||||||
|
def prompt_open_download(self, cmdline: str=None):
|
||||||
|
"""Immediately open a download.
|
||||||
|
|
||||||
|
If no specific command is given, this will use the system's default
|
||||||
|
application to open the file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmdline: The command which should be used to open the file. A `{}`
|
||||||
|
is expanded to the temporary file name. If no `{}` is
|
||||||
|
present, the filename is automatically appended to the
|
||||||
|
cmdline.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._prompt.download_open(cmdline)
|
||||||
|
except UnsupportedOperationError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pyqtSlot(usertypes.Question, bool)
|
||||||
|
def ask_question(self, question, blocking):
|
||||||
|
"""Display a prompt for a given question.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: The Question object to ask.
|
||||||
|
blocking: If True, this function blocks and returns the result.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
The answer of the user when blocking=True.
|
||||||
|
None if blocking=False.
|
||||||
|
"""
|
||||||
|
log.prompt.debug("Asking question {}, blocking {}, loops {}, queue "
|
||||||
|
"{}".format(question, blocking, self._loops,
|
||||||
|
self._queue))
|
||||||
|
|
||||||
|
if self._shutting_down:
|
||||||
|
# If we're currently shutting down we have to ignore this question
|
||||||
|
# to avoid segfaults - see
|
||||||
|
# https://github.com/The-Compiler/qutebrowser/issues/95
|
||||||
|
log.prompt.debug("Ignoring question because we're shutting down.")
|
||||||
|
question.abort()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._prompt is not None and not blocking:
|
||||||
|
# We got an async question, but we're already busy with one, so we
|
||||||
|
# just queue it up for later.
|
||||||
|
log.prompt.debug("Adding {} to queue.".format(question))
|
||||||
|
self._queue.append(question)
|
||||||
|
return
|
||||||
|
|
||||||
|
if blocking:
|
||||||
|
# If we're blocking we save the old state on the stack, so we can
|
||||||
|
# restore it after exec, if exec gets called multiple times.
|
||||||
|
old_prompt = self._prompt
|
||||||
|
|
||||||
|
classes = {
|
||||||
|
usertypes.PromptMode.yesno: YesNoPrompt,
|
||||||
|
usertypes.PromptMode.text: LineEditPrompt,
|
||||||
|
usertypes.PromptMode.user_pwd: AuthenticationPrompt,
|
||||||
|
usertypes.PromptMode.download: DownloadFilenamePrompt,
|
||||||
|
usertypes.PromptMode.alert: AlertPrompt,
|
||||||
|
}
|
||||||
|
klass = classes[question.mode]
|
||||||
|
self._show_prompt(klass(question))
|
||||||
|
if blocking:
|
||||||
|
loop = qtutils.EventLoop()
|
||||||
|
self._loops.append(loop)
|
||||||
|
loop.destroyed.connect(lambda: self._loops.remove(loop))
|
||||||
|
question.completed.connect(loop.quit)
|
||||||
|
question.completed.connect(loop.deleteLater)
|
||||||
|
loop.exec_()
|
||||||
|
# FIXME don't we end up connecting modeman signals twice here now?
|
||||||
|
if not self._show_prompt(old_prompt):
|
||||||
|
# Nothing left to restore, so we can go back to popping async
|
||||||
|
# questions.
|
||||||
|
if self._queue:
|
||||||
|
self._pop_later()
|
||||||
|
return question.answer
|
||||||
|
else:
|
||||||
|
question.completed.connect(self._pop_later)
|
||||||
|
|
||||||
|
|
||||||
class _BasePrompt(QWidget):
|
class _BasePrompt(QWidget):
|
||||||
|
|
||||||
"""Base class for all prompts."""
|
"""Base class for all prompts."""
|
||||||
|
|
||||||
|
KEY_MODE = usertypes.KeyMode.prompt
|
||||||
|
|
||||||
def __init__(self, question, parent=None):
|
def __init__(self, question, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._question = question
|
self.question = question
|
||||||
self._layout = QGridLayout(self)
|
self._layout = QGridLayout(self)
|
||||||
self._layout.setVerticalSpacing(15)
|
self._layout.setVerticalSpacing(15)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return utils.get_repr(self, question=self.question, constructor=True)
|
||||||
|
|
||||||
def _init_title(self, title, *, span=1):
|
def _init_title(self, title, *, span=1):
|
||||||
label = QLabel('<b>{}</b>'.format(title), self)
|
label = QLabel('<b>{}</b>'.format(title), self)
|
||||||
self._layout.addWidget(label, 0, 0, 1, span)
|
self._layout.addWidget(label, 0, 0, 1, span)
|
||||||
@ -107,6 +357,9 @@ class _BasePrompt(QWidget):
|
|||||||
def accept(self, value=None):
|
def accept(self, value=None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def open_download(self, _cmdline):
|
||||||
|
raise UnsupportedOperationError
|
||||||
|
|
||||||
|
|
||||||
class LineEditPrompt(_BasePrompt):
|
class LineEditPrompt(_BasePrompt):
|
||||||
|
|
||||||
@ -120,7 +373,8 @@ class LineEditPrompt(_BasePrompt):
|
|||||||
|
|
||||||
def accept(self, value=None):
|
def accept(self, value=None):
|
||||||
text = value if value is not None else self._lineedit.text()
|
text = value if value is not None else self._lineedit.text()
|
||||||
self._question.answer = text
|
self.question.answer = text
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class DownloadFilenamePrompt(LineEditPrompt):
|
class DownloadFilenamePrompt(LineEditPrompt):
|
||||||
@ -130,7 +384,7 @@ class DownloadFilenamePrompt(LineEditPrompt):
|
|||||||
def __init__(self, question, parent=None):
|
def __init__(self, question, parent=None):
|
||||||
super().__init__(question, parent)
|
super().__init__(question, parent)
|
||||||
# FIXME show :prompt-open-download keybinding
|
# FIXME show :prompt-open-download keybinding
|
||||||
# key_mode = self.KEY_MODES[self._question.mode]
|
# key_mode = self.KEY_MODES[self.question.mode]
|
||||||
# key_config = objreg.get('key-config')
|
# key_config = objreg.get('key-config')
|
||||||
# all_bindings = key_config.get_reverse_bindings_for(key_mode.name)
|
# all_bindings = key_config.get_reverse_bindings_for(key_mode.name)
|
||||||
# bindings = all_bindings.get('prompt-open-download', [])
|
# bindings = all_bindings.get('prompt-open-download', [])
|
||||||
@ -140,7 +394,14 @@ class DownloadFilenamePrompt(LineEditPrompt):
|
|||||||
|
|
||||||
def accept(self, value=None):
|
def accept(self, value=None):
|
||||||
text = value if value is not None else self._lineedit.text()
|
text = value if value is not None else self._lineedit.text()
|
||||||
self._question.answer = usertypes.FileDownloadTarget(text)
|
self.question.answer = usertypes.FileDownloadTarget(text)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def download_open(self, cmdline):
|
||||||
|
self.question.answer = usertypes.OpenFileDownloadTarget(cmdline)
|
||||||
|
modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt,
|
||||||
|
'download open')
|
||||||
|
self.question.done()
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationPrompt(_BasePrompt):
|
class AuthenticationPrompt(_BasePrompt):
|
||||||
@ -167,6 +428,9 @@ class AuthenticationPrompt(_BasePrompt):
|
|||||||
self._layout.addWidget(help_1, 4, 0)
|
self._layout.addWidget(help_1, 4, 0)
|
||||||
self._layout.addWidget(help_2, 5, 0)
|
self._layout.addWidget(help_2, 5, 0)
|
||||||
|
|
||||||
|
# FIXME needed?
|
||||||
|
self._user_lineedit.setFocus()
|
||||||
|
|
||||||
def accept(self, value=None):
|
def accept(self, value=None):
|
||||||
if value is not None:
|
if value is not None:
|
||||||
if ':' not in value:
|
if ':' not in value:
|
||||||
@ -174,14 +438,23 @@ class AuthenticationPrompt(_BasePrompt):
|
|||||||
"username:password, but {} was given".format(
|
"username:password, but {} was given".format(
|
||||||
value))
|
value))
|
||||||
username, password = value.split(':', maxsplit=1)
|
username, password = value.split(':', maxsplit=1)
|
||||||
self._question.answer = AuthTuple(username, password)
|
self.question.answer = AuthTuple(username, password)
|
||||||
|
return True
|
||||||
|
elif self._user_lineedit.hasFocus():
|
||||||
|
# Earlier, tab was bound to :prompt-accept, so to still support that
|
||||||
|
# we simply switch the focus when tab was pressed.
|
||||||
|
self._password_lineedit.setFocus()
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
self._question.answer = AuthTuple(self._user_lineedit.text(),
|
self.question.answer = AuthTuple(self._user_lineedit.text(),
|
||||||
self._password_lineedit.text())
|
self._password_lineedit.text())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class YesNoPrompt(_BasePrompt):
|
class YesNoPrompt(_BasePrompt):
|
||||||
|
|
||||||
|
KEY_MODE = usertypes.KeyMode.yesno
|
||||||
|
|
||||||
def __init__(self, question, parent=None):
|
def __init__(self, question, parent=None):
|
||||||
super().__init__(question, parent)
|
super().__init__(question, parent)
|
||||||
self._init_title(question.text)
|
self._init_title(question.text)
|
||||||
@ -192,13 +465,14 @@ class YesNoPrompt(_BasePrompt):
|
|||||||
|
|
||||||
def accept(self, value=None):
|
def accept(self, value=None):
|
||||||
if value is None:
|
if value is None:
|
||||||
self._question.answer = self._question.default
|
self.question.answer = self.question.default
|
||||||
elif value == 'yes':
|
elif value == 'yes':
|
||||||
self._question.answer = True
|
self.question.answer = True
|
||||||
elif value == 'no':
|
elif value == 'no':
|
||||||
self._question.answer = False
|
self.question.answer = False
|
||||||
else:
|
else:
|
||||||
raise Error("Invalid value {} - expected yes/no!".format(value))
|
raise Error("Invalid value {} - expected yes/no!".format(value))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class AlertPrompt(_BasePrompt):
|
class AlertPrompt(_BasePrompt):
|
||||||
@ -213,3 +487,4 @@ class AlertPrompt(_BasePrompt):
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
raise Error("No value is permitted with alert prompts!")
|
raise Error("No value is permitted with alert prompts!")
|
||||||
# Doing nothing otherwise
|
# Doing nothing otherwise
|
||||||
|
return True
|
||||||
|
@ -25,8 +25,7 @@ from PyQt5.QtWidgets import QWidget, QHBoxLayout, QStackedLayout, QSizePolicy
|
|||||||
from qutebrowser.config import config, style
|
from qutebrowser.config import config, style
|
||||||
from qutebrowser.utils import usertypes, log, objreg, utils
|
from qutebrowser.utils import usertypes, log, objreg, utils
|
||||||
from qutebrowser.mainwindow.statusbar import (command, progress, keystring,
|
from qutebrowser.mainwindow.statusbar import (command, progress, keystring,
|
||||||
percentage, url, prompt,
|
percentage, url, tabindex)
|
||||||
tabindex)
|
|
||||||
from qutebrowser.mainwindow.statusbar import text as textwidget
|
from qutebrowser.mainwindow.statusbar import text as textwidget
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,84 +0,0 @@
|
|||||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
|
||||||
|
|
||||||
# Copyright 2014-2016 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
|
|
||||||
#
|
|
||||||
# This file is part of qutebrowser.
|
|
||||||
#
|
|
||||||
# qutebrowser is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# qutebrowser is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
"""Prompt shown in the statusbar."""
|
|
||||||
|
|
||||||
import functools
|
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize
|
|
||||||
from PyQt5.QtWidgets import QHBoxLayout, QWidget, QLineEdit, QSizePolicy
|
|
||||||
|
|
||||||
from qutebrowser.mainwindow.statusbar import textbase, prompter
|
|
||||||
from qutebrowser.utils import objreg, utils
|
|
||||||
from qutebrowser.misc import miscwidgets as misc
|
|
||||||
|
|
||||||
|
|
||||||
class PromptLineEdit(misc.MinimalLineEditMixin, QLineEdit):
|
|
||||||
|
|
||||||
"""QLineEdit with a minimal stylesheet."""
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
QLineEdit.__init__(self, parent)
|
|
||||||
misc.MinimalLineEditMixin.__init__(self)
|
|
||||||
self.textChanged.connect(self.updateGeometry)
|
|
||||||
|
|
||||||
def sizeHint(self):
|
|
||||||
"""Dynamically calculate the needed size."""
|
|
||||||
height = super().sizeHint().height()
|
|
||||||
text = self.text()
|
|
||||||
if not text:
|
|
||||||
text = 'x'
|
|
||||||
width = self.fontMetrics().width(text)
|
|
||||||
return QSize(width, height)
|
|
||||||
|
|
||||||
|
|
||||||
class Prompt(QWidget):
|
|
||||||
|
|
||||||
"""The prompt widget shown in the statusbar.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
txt: The TextBase instance (QLabel) used to display the prompt text.
|
|
||||||
lineedit: The MinimalLineEdit instance (QLineEdit) used for the input.
|
|
||||||
_hbox: The QHBoxLayout used to display the text and prompt.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, win_id, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
objreg.register('prompt', self, scope='window', window=win_id)
|
|
||||||
self._hbox = QHBoxLayout(self)
|
|
||||||
self._hbox.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self._hbox.setSpacing(5)
|
|
||||||
|
|
||||||
self.txt = textbase.TextBase()
|
|
||||||
self._hbox.addWidget(self.txt)
|
|
||||||
|
|
||||||
self.lineedit = PromptLineEdit()
|
|
||||||
self.lineedit.setSizePolicy(QSizePolicy.MinimumExpanding,
|
|
||||||
QSizePolicy.Fixed)
|
|
||||||
self._hbox.addWidget(self.lineedit)
|
|
||||||
|
|
||||||
prompter_obj = prompter.Prompter(win_id)
|
|
||||||
objreg.register('prompter', prompter_obj, scope='window',
|
|
||||||
window=win_id)
|
|
||||||
self.destroyed.connect(
|
|
||||||
functools.partial(objreg.delete, 'prompter', scope='window',
|
|
||||||
window=win_id))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return utils.get_repr(self)
|
|
@ -1,293 +0,0 @@
|
|||||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
|
||||||
|
|
||||||
# Copyright 2014-2016 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
|
|
||||||
#
|
|
||||||
# This file is part of qutebrowser.
|
|
||||||
#
|
|
||||||
# qutebrowser is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# qutebrowser is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
"""Manager for questions to be shown in the statusbar."""
|
|
||||||
|
|
||||||
import sip
|
|
||||||
import collections
|
|
||||||
|
|
||||||
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, QObject
|
|
||||||
from PyQt5.QtWidgets import QLineEdit
|
|
||||||
|
|
||||||
from qutebrowser.keyinput import modeman
|
|
||||||
from qutebrowser.commands import cmdutils, cmdexc
|
|
||||||
from qutebrowser.utils import usertypes, log, qtutils, objreg, utils
|
|
||||||
|
|
||||||
|
|
||||||
class Prompter(QObject):
|
|
||||||
|
|
||||||
"""Manager for questions to be shown in the statusbar.
|
|
||||||
|
|
||||||
The way in which multiple questions are handled deserves some explanation.
|
|
||||||
|
|
||||||
If a question is blocking, we *need* to ask it immediately, and can't wait
|
|
||||||
for previous questions to finish. We could theoretically ask a blocking
|
|
||||||
question inside of another blocking one, so in ask_question we simply save
|
|
||||||
the current prompt state on the stack, let the user answer the *most
|
|
||||||
recent* question, and then restore the previous state.
|
|
||||||
|
|
||||||
With a non-blocking question, things are a bit easier. We simply add it to
|
|
||||||
self._queue if we're still busy handling another question, since it can be
|
|
||||||
answered at any time.
|
|
||||||
|
|
||||||
In either case, as soon as we finished handling a question, we call
|
|
||||||
_pop_later() which schedules a _pop to ask the next question in _queue. We
|
|
||||||
schedule it rather than doing it immediately because then the order of how
|
|
||||||
things happen is clear, e.g. on_mode_left can't happen after we already set
|
|
||||||
up the *new* question.
|
|
||||||
|
|
||||||
Class Attributes:
|
|
||||||
KEY_MODES: A mapping of PromptModes to KeyModes.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
_shutting_down: Whether we're currently shutting down the prompter and
|
|
||||||
should ignore future questions to avoid segfaults.
|
|
||||||
_question: A Question object with the question to be asked to the user.
|
|
||||||
_loops: A list of local EventLoops to spin in when blocking.
|
|
||||||
_queue: A deque of waiting questions.
|
|
||||||
_prompt: The current prompt object if we're handling a question.
|
|
||||||
_win_id: The window ID this object is associated with.
|
|
||||||
"""
|
|
||||||
|
|
||||||
KEY_MODES = {
|
|
||||||
usertypes.PromptMode.yesno: usertypes.KeyMode.yesno,
|
|
||||||
usertypes.PromptMode.text: usertypes.KeyMode.prompt,
|
|
||||||
usertypes.PromptMode.user_pwd: usertypes.KeyMode.prompt,
|
|
||||||
usertypes.PromptMode.alert: usertypes.KeyMode.prompt,
|
|
||||||
usertypes.PromptMode.download: usertypes.KeyMode.prompt,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, win_id, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self._shutting_down = False
|
|
||||||
self._question = None
|
|
||||||
self._loops = []
|
|
||||||
self._queue = collections.deque()
|
|
||||||
self._prompt = None
|
|
||||||
self._win_id = win_id
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return utils.get_repr(self, loops=len(self._loops),
|
|
||||||
question=self._question, queue=len(self._queue),
|
|
||||||
prompt=self._prompt)
|
|
||||||
|
|
||||||
def _pop_later(self):
|
|
||||||
"""Helper to call self._pop as soon as everything else is done."""
|
|
||||||
QTimer.singleShot(0, self._pop)
|
|
||||||
|
|
||||||
def _pop(self):
|
|
||||||
"""Pop a question from the queue and ask it, if there are any."""
|
|
||||||
log.statusbar.debug("Popping from queue {}".format(self._queue))
|
|
||||||
if self._queue:
|
|
||||||
question = self._queue.popleft()
|
|
||||||
if not sip.isdeleted(question):
|
|
||||||
# the question could already be deleted, e.g. by a cancelled
|
|
||||||
# download. See
|
|
||||||
# https://github.com/The-Compiler/qutebrowser/issues/415
|
|
||||||
self.ask_question(question, blocking=False)
|
|
||||||
|
|
||||||
def _restore_prompt(self, prompt):
|
|
||||||
"""Restore an old prompt which was interrupted.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt: A Prompt object or None.
|
|
||||||
|
|
||||||
Return: True if a prompt was restored, False otherwise.
|
|
||||||
"""
|
|
||||||
log.statusbar.debug("Restoring prompt {}".format(prompt))
|
|
||||||
if prompt is None:
|
|
||||||
self._prompt.hide() # FIXME
|
|
||||||
self._prompt = None
|
|
||||||
return False
|
|
||||||
self._question = ctx.question
|
|
||||||
self._prompt = prompt
|
|
||||||
# FIXME do promptcintainer stuff here??
|
|
||||||
prompt.show()
|
|
||||||
mode = self.KEY_MODES[ctx.question.mode]
|
|
||||||
ctx.question.aborted.connect(
|
|
||||||
lambda: modeman.maybe_leave(self._win_id, mode, 'aborted'))
|
|
||||||
modeman.enter(self._win_id, mode, 'question asked')
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _display_question(self):
|
|
||||||
"""Display the question saved in self._question."""
|
|
||||||
handlers = {
|
|
||||||
usertypes.PromptMode.yesno: self._display_question_yesno,
|
|
||||||
usertypes.PromptMode.text: self._display_question_input,
|
|
||||||
usertypes.PromptMode.user_pwd: self._display_question_input,
|
|
||||||
usertypes.PromptMode.download: self._display_question_input,
|
|
||||||
usertypes.PromptMode.alert: self._display_question_alert,
|
|
||||||
}
|
|
||||||
handler = handlers[self._question.mode]
|
|
||||||
handler(prompt)
|
|
||||||
log.modes.debug("Question asked, focusing {!r}".format(
|
|
||||||
prompt.lineedit))
|
|
||||||
prompt.lineedit.setFocus()
|
|
||||||
prompt.show()
|
|
||||||
# FIXME
|
|
||||||
self._prompt = prompt
|
|
||||||
|
|
||||||
def shutdown(self):
|
|
||||||
"""Cancel all blocking questions.
|
|
||||||
|
|
||||||
Quits and removes all running event loops.
|
|
||||||
|
|
||||||
Return:
|
|
||||||
True if loops needed to be aborted,
|
|
||||||
False otherwise.
|
|
||||||
"""
|
|
||||||
self._shutting_down = True
|
|
||||||
if self._loops:
|
|
||||||
for loop in self._loops:
|
|
||||||
loop.quit()
|
|
||||||
loop.deleteLater()
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@pyqtSlot(usertypes.KeyMode)
|
|
||||||
def on_mode_left(self, mode):
|
|
||||||
"""Clear and reset input when the mode was left."""
|
|
||||||
prompt = objreg.get('prompt', scope='window', window=self._win_id)
|
|
||||||
if mode in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]:
|
|
||||||
prompt.txt.setText('')
|
|
||||||
prompt.lineedit.clear()
|
|
||||||
prompt.lineedit.setEchoMode(QLineEdit.Normal)
|
|
||||||
self._prompt.hide() # FIXME
|
|
||||||
self._prompt = None
|
|
||||||
if self._question.answer is None and not self._question.is_aborted:
|
|
||||||
self._question.cancel()
|
|
||||||
|
|
||||||
@cmdutils.register(instance='prompter', hide=True, scope='window',
|
|
||||||
modes=[usertypes.KeyMode.prompt,
|
|
||||||
usertypes.KeyMode.yesno])
|
|
||||||
def prompt_accept(self, value=None):
|
|
||||||
"""Accept the current prompt.
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
This executes the next action depending on the question mode, e.g. asks
|
|
||||||
for the password or leaves the mode.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: If given, uses this value instead of the entered one.
|
|
||||||
For boolean prompts, "yes"/"no" are accepted as value.
|
|
||||||
"""
|
|
||||||
prompt = objreg.get('prompt', scope='window', window=self._win_id)
|
|
||||||
text = value if value is not None else prompt.lineedit.text()
|
|
||||||
|
|
||||||
self._prompt.accept(text)
|
|
||||||
modeman.maybe_leave(self._win_id, self._question.mode,
|
|
||||||
':prompt-accept')
|
|
||||||
self._question.done()
|
|
||||||
|
|
||||||
@cmdutils.register(instance='prompter', hide=True, scope='window',
|
|
||||||
modes=[usertypes.KeyMode.yesno],
|
|
||||||
deprecated='Use :prompt-accept yes instead!')
|
|
||||||
def prompt_yes(self):
|
|
||||||
"""Answer yes to a yes/no prompt."""
|
|
||||||
self.prompt_accept('yes')
|
|
||||||
|
|
||||||
@cmdutils.register(instance='prompter', hide=True, scope='window',
|
|
||||||
modes=[usertypes.KeyMode.yesno],
|
|
||||||
deprecated='Use :prompt-accept no instead!')
|
|
||||||
def prompt_no(self):
|
|
||||||
"""Answer no to a yes/no prompt."""
|
|
||||||
self.prompt_accept('no')
|
|
||||||
|
|
||||||
@cmdutils.register(instance='prompter', hide=True, scope='window',
|
|
||||||
modes=[usertypes.KeyMode.prompt], maxsplit=0)
|
|
||||||
def prompt_open_download(self, cmdline: str=None):
|
|
||||||
"""Immediately open a download.
|
|
||||||
|
|
||||||
If no specific command is given, this will use the system's default
|
|
||||||
application to open the file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cmdline: The command which should be used to open the file. A `{}`
|
|
||||||
is expanded to the temporary file name. If no `{}` is
|
|
||||||
present, the filename is automatically appended to the
|
|
||||||
cmdline.
|
|
||||||
"""
|
|
||||||
if self._question.mode != usertypes.PromptMode.download:
|
|
||||||
# We just ignore this if we don't have a download question.
|
|
||||||
return
|
|
||||||
self._question.answer = usertypes.OpenFileDownloadTarget(cmdline)
|
|
||||||
modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt,
|
|
||||||
'download open')
|
|
||||||
self._question.done()
|
|
||||||
|
|
||||||
@pyqtSlot(usertypes.Question, bool)
|
|
||||||
def ask_question(self, question, blocking):
|
|
||||||
"""Display a question in the statusbar.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
question: The Question object to ask.
|
|
||||||
blocking: If True, this function blocks and returns the result.
|
|
||||||
|
|
||||||
Return:
|
|
||||||
The answer of the user when blocking=True.
|
|
||||||
None if blocking=False.
|
|
||||||
"""
|
|
||||||
log.statusbar.debug("Asking question {}, blocking {}, loops {}, queue "
|
|
||||||
"{}".format(question, blocking, self._loops,
|
|
||||||
self._queue))
|
|
||||||
|
|
||||||
if self._shutting_down:
|
|
||||||
# If we're currently shutting down we have to ignore this question
|
|
||||||
# to avoid segfaults - see
|
|
||||||
# https://github.com/The-Compiler/qutebrowser/issues/95
|
|
||||||
log.statusbar.debug("Ignoring question because we're shutting "
|
|
||||||
"down.")
|
|
||||||
question.abort()
|
|
||||||
return None
|
|
||||||
|
|
||||||
if self._prompt is not None and not blocking:
|
|
||||||
# We got an async question, but we're already busy with one, so we
|
|
||||||
# just queue it up for later.
|
|
||||||
log.statusbar.debug("Adding {} to queue.".format(question))
|
|
||||||
self._queue.append(question)
|
|
||||||
return
|
|
||||||
|
|
||||||
if blocking:
|
|
||||||
# If we're blocking we save the old state on the stack, so we can
|
|
||||||
# restore it after exec, if exec gets called multiple times.
|
|
||||||
old_prompt = self._prompt
|
|
||||||
|
|
||||||
self._question = question
|
|
||||||
self._display_question()
|
|
||||||
mode = self.KEY_MODES[self._question.mode]
|
|
||||||
question.aborted.connect(
|
|
||||||
lambda: modeman.maybe_leave(self._win_id, mode, 'aborted'))
|
|
||||||
modeman.enter(self._win_id, mode, 'question asked')
|
|
||||||
if blocking:
|
|
||||||
loop = qtutils.EventLoop()
|
|
||||||
self._loops.append(loop)
|
|
||||||
loop.destroyed.connect(lambda: self._loops.remove(loop))
|
|
||||||
question.completed.connect(loop.quit)
|
|
||||||
question.completed.connect(loop.deleteLater)
|
|
||||||
loop.exec_()
|
|
||||||
if not self._restore_prompt(old_prompt):
|
|
||||||
# Nothing left to restore, so we can go back to popping async
|
|
||||||
# questions.
|
|
||||||
if self._queue:
|
|
||||||
self._pop_later()
|
|
||||||
return self._question.answer
|
|
||||||
else:
|
|
||||||
question.completed.connect(self._pop_later)
|
|
@ -94,7 +94,7 @@ LOGGER_NAMES = [
|
|||||||
'commands', 'signals', 'downloads',
|
'commands', 'signals', 'downloads',
|
||||||
'js', 'qt', 'rfc6266', 'ipc', 'shlexer',
|
'js', 'qt', 'rfc6266', 'ipc', 'shlexer',
|
||||||
'save', 'message', 'config', 'sessions',
|
'save', 'message', 'config', 'sessions',
|
||||||
'webelem'
|
'webelem', 'prompt'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -139,6 +139,7 @@ message = logging.getLogger('message')
|
|||||||
config = logging.getLogger('config')
|
config = logging.getLogger('config')
|
||||||
sessions = logging.getLogger('sessions')
|
sessions = logging.getLogger('sessions')
|
||||||
webelem = logging.getLogger('webelem')
|
webelem = logging.getLogger('webelem')
|
||||||
|
prompt = logging.getLogger('prompt')
|
||||||
|
|
||||||
|
|
||||||
ram_handler = None
|
ram_handler = None
|
||||||
|
Loading…
Reference in New Issue
Block a user