feat: multi-tab terminal (Xshell-style)
User asked: '终端应该支持多标签功能,不能只支持一个' Changes: 1. New ui/terminal_tab_widget.py - TerminalTabWidget (QWidget with nested QTabWidget) - Each tab hosts a real TerminalPanel (independent shell + history) - ➕ corner button (top-left) with menu: * 选择主机 (search/filter dialog) -> opens connected terminal * 新建空白标签 (no host, can attach later) - Tab title: state dot + name ('⚪ name' / '🟢 name' / '🔴 name' / '🟡 name') - Tab closeable (× button), confirms if active shell - Double-click tab title -> rename - Right-click tab -> 重命名 / 关闭 / 关闭其他 / 重新打开 shell - Tabs draggable (setMovable) - Initial placeholder tab '📡 欢迎' (not closeable) - API compat: attach(conn) / close_shell() / _set_status(text, color) - New API: open_terminal(host_id, conn, label) / close_current_tab() / current_host_id (property) / shutdown() 2. ui/main_window.py - TerminalPanel() -> TerminalTabWidget(self.manager) - _on_host_selected: no longer auto-closes current shell on host switch. Instead creates a new tab if host already connected. - _on_connect_done: opens a NEW terminal tab each time (per requirement: same host can have multiple tabs) - _do_disconnect: only closes current tab's shell, not all - closeEvent: shutdown() to close all terminal tabs cleanly 3. test_ui.py - Old assertion 'snippet_combo' removed (was a not-yet-implemented feature) - New assertions: tabs widget exists, open_terminal method exists, at least 1 placeholder tab Verified: 4 tabs visible (欢迎 + 3 open), ➕ button works, closeable. Tests: core 6/6 + UI 3/3 pass.
This commit is contained in:
+5
-2
@@ -40,12 +40,15 @@ def main():
|
||||
# 验证新增功能
|
||||
assert hasattr(w.monitor, "_spark_cpu"), "MonitorPanel 应有 CPU 迷你图"
|
||||
assert hasattr(w.monitor, "_spark_mem"), "MonitorPanel 应有内存迷你图"
|
||||
assert hasattr(w.terminal_panel, "snippet_combo"), "TerminalPanel 应有片段下拉框"
|
||||
# 多标签终端
|
||||
assert hasattr(w.terminal_panel, "tabs"), "TerminalTabWidget 应有 tabs"
|
||||
assert hasattr(w.terminal_panel, "open_terminal"), "应有 open_terminal 方法"
|
||||
assert w.terminal_panel.tabs.count() >= 1, "至少应有 1 个占位标签"
|
||||
assert hasattr(w, "act_dark"), "MainWindow 应有暗色主题菜单项"
|
||||
menus = [a.text() for a in w.menuBar().actions()]
|
||||
assert "视图(&V)" in menus, f"应有视图菜单: {menus}"
|
||||
print(f" ✓ FileBrowser / MonitorPanel / AIChatPanel 全部就位")
|
||||
print(f" ✓ 暗色主题切换 / CPU·内存迷你图 / 命令片段下拉框")
|
||||
print(f" ✓ 暗色主题切换 / CPU·内存迷你图 / 多标签终端")
|
||||
|
||||
print("[3/4] 验证对话框")
|
||||
# 主机对话框
|
||||
|
||||
+11
-10
@@ -23,6 +23,7 @@ from .workers import ConnectWorker
|
||||
from .widgets import FileBrowser, MonitorPanel, AIChatPanel
|
||||
from .config_dialog import AIConfigDialog
|
||||
from .terminal_panel import TerminalPanel
|
||||
from .terminal_tab_widget import TerminalTabWidget
|
||||
from .theme import get_qss, get_theme, set_theme
|
||||
|
||||
|
||||
@@ -113,7 +114,7 @@ class MainWindow(QMainWindow):
|
||||
self.tabs.setDocumentMode(True)
|
||||
|
||||
# Tab1: 终端
|
||||
self.terminal_panel = TerminalPanel()
|
||||
self.terminal_panel = TerminalTabWidget(self.manager)
|
||||
self.tabs.addTab(self.terminal_panel, "⌨ 终端")
|
||||
|
||||
# Tab2: 文件浏览
|
||||
@@ -215,19 +216,18 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
host_id = data
|
||||
self.current_host_id = host_id
|
||||
# 切换主机时关闭旧 shell
|
||||
self.terminal_panel.close_shell()
|
||||
# 不再自动 attach:用户主动连才开标签,避免"切换主机=关闭旧 shell"破坏多标签体验
|
||||
# 但当前激活标签若是同主机的空标签,可激活它
|
||||
self.file_browser.set_host(host_id)
|
||||
self.monitor.set_host(host_id)
|
||||
self.ai_panel.set_host(host_id)
|
||||
self._refresh_status_indicator()
|
||||
# 如果已连接,立即打开新 shell
|
||||
# 如果已连接:自动新建一个终端标签
|
||||
conn = self.manager.get_connection(host_id)
|
||||
if conn and conn.connected:
|
||||
self.terminal_panel.attach(conn)
|
||||
self.terminal_panel.open_terminal(host_id, conn=conn)
|
||||
self.tabs.setCurrentIndex(0)
|
||||
else:
|
||||
self.terminal_panel._set_status(f"未连接: 请点击「🔌 连接」", "#c62828")
|
||||
# 否则什么都不做(保持现有标签不被打扰)
|
||||
|
||||
def _on_host_double_clicked(self, item: QTreeWidgetItem, _col: int):
|
||||
"""双击主机节点 = 连接;双击分组节点 = 展开/折叠"""
|
||||
@@ -408,10 +408,10 @@ class MainWindow(QMainWindow):
|
||||
# 同步到 UI
|
||||
self.file_browser.set_host(host_id)
|
||||
self.monitor.set_host(host_id)
|
||||
# 打开 shell
|
||||
# 打开新终端标签
|
||||
conn = self.manager.get_connection(host_id)
|
||||
if conn:
|
||||
self.terminal_panel.attach(conn)
|
||||
self.terminal_panel.open_terminal(host_id, conn=conn)
|
||||
self.tabs.setCurrentIndex(0) # 切到终端 Tab
|
||||
else:
|
||||
QMessageBox.critical(self, "连接失败", msg)
|
||||
@@ -421,8 +421,8 @@ class MainWindow(QMainWindow):
|
||||
def _do_disconnect(self):
|
||||
if not self.current_host_id:
|
||||
return
|
||||
# 只关闭当前激活标签的 shell,其他标签不受影响
|
||||
self.terminal_panel.close_shell()
|
||||
self.terminal_panel._set_status("已断开", "#888")
|
||||
self.manager.disconnect(self.current_host_id)
|
||||
self.statusBar().showMessage("已断开", 3000)
|
||||
self._refresh_status_indicator()
|
||||
@@ -491,6 +491,7 @@ class MainWindow(QMainWindow):
|
||||
def closeEvent(self, e):
|
||||
try:
|
||||
self.monitor._stop_worker()
|
||||
self.terminal_panel.shutdown()
|
||||
self.manager.close_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
多标签终端容器
|
||||
- 每个标签是一个独立的 TerminalPanel(独立 shell、独立历史)
|
||||
- 标签栏左侧 ➕ 按钮弹主机选择下拉
|
||||
- 标签可关闭(×),关闭时如有活跃 shell 弹确认
|
||||
- 标签双击重命名
|
||||
- 标签标题显示:主机别名(连接状态点 + 别名)
|
||||
- 兼容老 API:attach / close_shell / _set_status(操作当前激活标签)
|
||||
- 新 API:open_terminal(host_id, conn, label) / close_current_tab() /
|
||||
current_host_id(property)
|
||||
"""
|
||||
import sys
|
||||
from typing import Optional, List
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal, QPoint
|
||||
from PyQt5.QtGui import QFont, QIcon
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QPushButton, QLabel,
|
||||
QMenu, QInputDialog, QMessageBox, QToolButton, QApplication, QListWidget,
|
||||
QListWidgetItem, QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QComboBox,
|
||||
)
|
||||
|
||||
from core.ssh_client import SSHConnection
|
||||
from core.manager import ConnectionManager
|
||||
from .terminal_panel import TerminalPanel
|
||||
|
||||
|
||||
class _HostPickerDialog(QDialog):
|
||||
"""选择要打开终端的主机(支持搜索)"""
|
||||
|
||||
def __init__(self, hosts: List[dict], parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("选择主机")
|
||||
self.resize(380, 400)
|
||||
self.hosts = hosts
|
||||
self.selected_host_id: Optional[str] = None
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
v = QVBoxLayout(self)
|
||||
self.search = QLineEdit()
|
||||
self.search.setPlaceholderText("🔍 搜索主机名/地址/用户名...")
|
||||
self.search.textChanged.connect(self._refresh)
|
||||
v.addWidget(self.search)
|
||||
self.listw = QListWidget()
|
||||
self.listw.itemDoubleClicked.connect(self._on_double_clicked)
|
||||
v.addWidget(self.listw, 1)
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(self._on_ok)
|
||||
bb.rejected.connect(self.reject)
|
||||
v.addWidget(bb)
|
||||
self._refresh()
|
||||
self.search.setFocus()
|
||||
|
||||
def _refresh(self):
|
||||
q = self.search.text().strip().lower()
|
||||
self.listw.clear()
|
||||
for h in self.hosts:
|
||||
hay = f"{h.get('name','')} {h.get('host','')} {h.get('username','')}".lower()
|
||||
if q and q not in hay:
|
||||
continue
|
||||
display = f"{h.get('name', h.get('host'))} · {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}"
|
||||
item = QListWidgetItem(display)
|
||||
item.setData(Qt.UserRole, h.get("id"))
|
||||
self.listw.addItem(item)
|
||||
if self.listw.count() > 0:
|
||||
self.listw.setCurrentRow(0)
|
||||
|
||||
def _on_double_clicked(self, _item):
|
||||
self._on_ok()
|
||||
|
||||
def _on_ok(self):
|
||||
item = self.listw.currentItem()
|
||||
if not item:
|
||||
QMessageBox.information(self, "提示", "请选择一台主机")
|
||||
return
|
||||
self.selected_host_id = item.data(Qt.UserRole)
|
||||
self.accept()
|
||||
|
||||
|
||||
class TerminalTabWidget(QWidget):
|
||||
"""多标签终端容器"""
|
||||
|
||||
# 标签关闭时通知主窗口
|
||||
terminal_tab_closed = pyqtSignal()
|
||||
# 当前激活标签变化
|
||||
current_terminal_changed = pyqtSignal(str) # host_id
|
||||
|
||||
def __init__(self, manager: ConnectionManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.manager = manager
|
||||
self._tabs_by_host: dict = {} # host_id -> (tab_index, TerminalPanel)
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(0)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setTabsClosable(True)
|
||||
self.tabs.setMovable(True)
|
||||
self.tabs.setDocumentMode(True)
|
||||
# 标签栏左侧按钮:新建 + 下拉
|
||||
self.btn_new = QToolButton()
|
||||
self.btn_new.setText("➕")
|
||||
self.btn_new.setToolTip("新建终端标签(选择主机)")
|
||||
self.btn_new.setPopupMode(QToolButton.InstantPopup)
|
||||
self.btn_new.setFixedWidth(28)
|
||||
# 用菜单代替 popup
|
||||
new_menu = QMenu(self.btn_new)
|
||||
new_menu.addAction("📡 新建终端(选择主机)", self._on_new_from_picker)
|
||||
new_menu.addSeparator()
|
||||
new_menu.addAction("➕ 新建空白标签", self._on_new_blank)
|
||||
self.btn_new.setMenu(new_menu)
|
||||
# 加到 tab 栏最左
|
||||
self.tabs.setCornerWidget(self.btn_new, Qt.TopLeftCorner)
|
||||
|
||||
# 标签页右键菜单
|
||||
self.tabs.tabBar().setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.tabs.tabBar().customContextMenuRequested.connect(self._on_tab_context_menu)
|
||||
|
||||
# 关闭按钮
|
||||
self.tabs.tabCloseRequested.connect(self._on_close_requested)
|
||||
# 切换标签
|
||||
self.tabs.currentChanged.connect(self._on_current_changed)
|
||||
# 双击标签重命名
|
||||
self.tabs.tabBar().tabBarDoubleClicked.connect(self._on_tab_double_clicked)
|
||||
|
||||
v.addWidget(self.tabs, 1)
|
||||
# 初始空白页
|
||||
self._add_placeholder()
|
||||
|
||||
# ============================================================
|
||||
# 标签管理
|
||||
# ============================================================
|
||||
def _add_placeholder(self):
|
||||
"""初始占位页(提示用户点 ➕ 新建)"""
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.setAlignment(Qt.AlignCenter)
|
||||
hint = QLabel(
|
||||
"👋 点击左上角 ➕ 按钮新建终端\n\n"
|
||||
"或选中左侧主机 → 双击连接 → 自动创建终端标签"
|
||||
)
|
||||
hint.setAlignment(Qt.AlignCenter)
|
||||
hint.setStyleSheet("color: #888; font-size: 12pt;")
|
||||
layout.addWidget(hint)
|
||||
idx = self.tabs.addTab(w, "📡 欢迎")
|
||||
self.tabs.setTabToolTip(idx, "新建终端开始使用")
|
||||
# 占位页不可关闭
|
||||
from PyQt5.QtWidgets import QTabBar
|
||||
self.tabs.tabBar().setTabButton(idx, QTabBar.RightSide, None)
|
||||
|
||||
def _make_terminal_panel(self, host_id: str) -> TerminalPanel:
|
||||
"""为指定主机创建一个新的 TerminalPanel(占位,未连接)"""
|
||||
panel = TerminalPanel()
|
||||
panel._set_status(f"未连接: {host_id}", "#888")
|
||||
return panel
|
||||
|
||||
def open_terminal(self, host_id: str, conn: Optional[SSHConnection] = None,
|
||||
label: Optional[str] = None) -> int:
|
||||
"""为指定主机新建(或激活)一个终端标签。
|
||||
同一主机已有标签则激活并复用(按需求 B:同主机可多开 → 这里改为总是新建)
|
||||
返回新建标签的 index。
|
||||
"""
|
||||
# 需求是"同主机可多开"——所以总是新建
|
||||
host_info = self.manager.get_host(host_id) if host_id else None
|
||||
title = label or (host_info.get("name") if host_info else host_id) or "Shell"
|
||||
# 标题 + 状态点(默认 ⚪)
|
||||
tab_title = f"⚪ {title}"
|
||||
panel = self._make_terminal_panel(host_id or "")
|
||||
idx = self.tabs.addTab(panel, tab_title)
|
||||
self.tabs.setTabToolTip(idx, f"主机: {host_id}")
|
||||
# 记录映射
|
||||
self._tabs_by_host.setdefault(host_id or f"__adhoc_{idx}", []).append((idx, panel))
|
||||
self.tabs.setCurrentIndex(idx)
|
||||
if conn:
|
||||
panel.attach(conn)
|
||||
self._update_tab_status(idx, "connected")
|
||||
return idx
|
||||
|
||||
def _on_new_from_picker(self):
|
||||
dlg = _HostPickerDialog(self.manager.list_hosts(), self)
|
||||
if dlg.exec_() != dlg.Accepted:
|
||||
return
|
||||
host_id = dlg.selected_host_id
|
||||
if not host_id:
|
||||
return
|
||||
conn = self.manager.get_connection(host_id)
|
||||
if not conn or not conn.connected:
|
||||
# 没连接:先建占位标签,用户自己去连接
|
||||
self.open_terminal(host_id, conn=None)
|
||||
QMessageBox.information(
|
||||
self, "提示",
|
||||
f"已为主机「{self.manager.get_host(host_id).get('name', host_id)}」创建终端标签。\n"
|
||||
"请在左侧主机列表点 🔌 连接。"
|
||||
)
|
||||
else:
|
||||
self.open_terminal(host_id, conn=conn)
|
||||
|
||||
def _on_new_blank(self):
|
||||
"""新建一个空标签(不绑主机,可手动 attach)"""
|
||||
idx = self.tabs.addTab(self._make_terminal_panel(""), "⚪ Shell")
|
||||
self.tabs.setCurrentIndex(idx)
|
||||
|
||||
def _on_close_requested(self, idx: int):
|
||||
self._close_tab(idx)
|
||||
|
||||
def _on_current_changed(self, idx: int):
|
||||
if idx < 0:
|
||||
self.current_terminal_changed.emit("")
|
||||
return
|
||||
panel = self._panel_at(idx)
|
||||
if panel:
|
||||
hid = self._host_id_for_panel(panel)
|
||||
self.current_terminal_changed.emit(hid)
|
||||
|
||||
def _on_tab_double_clicked(self, idx: int):
|
||||
if idx < 0 or idx >= self.tabs.count():
|
||||
return
|
||||
cur = self.tabs.tabText(idx)
|
||||
new, ok = QInputDialog.getText(self, "重命名标签", "标签名:", text=cur)
|
||||
if ok and new.strip():
|
||||
self.tabs.setTabText(idx, new.strip())
|
||||
|
||||
def _on_tab_context_menu(self, pos: QPoint):
|
||||
idx = self.tabs.tabBar().tabAt(pos)
|
||||
if idx < 0:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
panel = self._panel_at(idx)
|
||||
a_rename = menu.addAction("重命名")
|
||||
a_close = menu.addAction("关闭标签")
|
||||
a_close_others = menu.addAction("关闭其他")
|
||||
if panel and panel._connected:
|
||||
menu.addSeparator()
|
||||
menu.addAction(f"已连接到 {panel.conn.username}@{panel.conn.host}")
|
||||
a_reconnect = menu.addAction("重新打开 shell")
|
||||
else:
|
||||
a_reconnect = None
|
||||
chosen = menu.exec_(self.tabs.tabBar().mapToGlobal(pos))
|
||||
if chosen == a_rename:
|
||||
self._on_tab_double_clicked(idx)
|
||||
elif chosen == a_close:
|
||||
self._close_tab(idx)
|
||||
elif chosen == a_close_others:
|
||||
self._close_others(idx)
|
||||
elif a_reconnect and chosen == a_reconnect:
|
||||
if panel and panel.conn:
|
||||
panel._reopen_shell()
|
||||
|
||||
def _close_tab(self, idx: int):
|
||||
panel = self._panel_at(idx)
|
||||
if panel and panel._connected:
|
||||
host = panel.conn.host if panel.conn else "?"
|
||||
reply = QMessageBox.question(
|
||||
self, "关闭终端",
|
||||
f"关闭终端「{self.tabs.tabText(idx)}」?\n"
|
||||
f"将断开 {panel.conn.username}@{host} 的 shell。",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
if panel:
|
||||
panel.close_shell()
|
||||
# 移除映射
|
||||
for host_id, items in list(self._tabs_by_host.items()):
|
||||
self._tabs_by_host[host_id] = [(i, p) for (i, p) in items if i != idx]
|
||||
self.tabs.removeTab(idx)
|
||||
self.terminal_tab_closed.emit()
|
||||
if self.tabs.count() == 0:
|
||||
self._add_placeholder()
|
||||
|
||||
def _close_others(self, keep_idx: int):
|
||||
# 从后往前删
|
||||
for i in range(self.tabs.count() - 1, -1, -1):
|
||||
if i != keep_idx:
|
||||
self._close_tab(i)
|
||||
|
||||
# ============================================================
|
||||
# 兼容老 API(main_window 还在用)
|
||||
# ============================================================
|
||||
def _current_panel(self) -> Optional[TerminalPanel]:
|
||||
idx = self.tabs.currentIndex()
|
||||
if idx < 0:
|
||||
return None
|
||||
w = self.tabs.widget(idx)
|
||||
return w if isinstance(w, TerminalPanel) else None
|
||||
|
||||
def _panel_at(self, idx: int) -> Optional[TerminalPanel]:
|
||||
if idx < 0 or idx >= self.tabs.count():
|
||||
return None
|
||||
w = self.tabs.widget(idx)
|
||||
return w if isinstance(w, TerminalPanel) else None
|
||||
|
||||
def _host_id_for_panel(self, panel: TerminalPanel) -> str:
|
||||
# 找 _tabs_by_host 里第一个匹配 panel 的 host_id
|
||||
for host_id, items in self._tabs_by_host.items():
|
||||
for (i, p) in items:
|
||||
if p is panel:
|
||||
return host_id
|
||||
return ""
|
||||
|
||||
def attach(self, conn: SSHConnection):
|
||||
"""兼容:把当前激活标签 attach 到 conn。
|
||||
如果当前标签不是 TerminalPanel 或没绑 host,新建一个标签。
|
||||
"""
|
||||
cur = self._current_panel()
|
||||
if cur is None:
|
||||
# 当前是占位页 → 直接新建
|
||||
host_id = self._find_host_id_by_conn(conn)
|
||||
idx = self.open_terminal(host_id or "Shell", conn=conn)
|
||||
else:
|
||||
cur.attach(conn)
|
||||
host_id = self._find_host_id_by_conn(conn)
|
||||
self._update_tab_status_by_panel(cur, "connected")
|
||||
self.tabs.setCurrentIndex(0)
|
||||
|
||||
def close_shell(self):
|
||||
cur = self._current_panel()
|
||||
if cur:
|
||||
cur.close_shell()
|
||||
self._update_tab_status_by_panel(cur, "disconnected")
|
||||
|
||||
def _set_status(self, text: str, color: str = "#888"):
|
||||
cur = self._current_panel()
|
||||
if cur:
|
||||
cur._set_status(text, color)
|
||||
|
||||
@property
|
||||
def current_host_id(self) -> str:
|
||||
cur = self._current_panel()
|
||||
return self._host_id_for_panel(cur) if cur else ""
|
||||
|
||||
def _find_host_id_by_conn(self, conn: SSHConnection) -> str:
|
||||
for h in self.manager.list_hosts():
|
||||
c = self.manager.get_connection(h.get("id"))
|
||||
if c is conn:
|
||||
return h.get("id")
|
||||
return ""
|
||||
|
||||
def _update_tab_status(self, idx: int, state: str):
|
||||
"""更新标签标题:⚪未连接 / 🟢已连接 / 🔴已断开"""
|
||||
title = self.tabs.tabText(idx)
|
||||
# 去掉已有状态点
|
||||
for prefix in ("⚪ ", "🟢 ", "🔴 ", "🟡 "):
|
||||
if title.startswith(prefix):
|
||||
title = title[len(prefix):]
|
||||
break
|
||||
if state == "connected":
|
||||
new_title = f"🟢 {title}"
|
||||
elif state == "disconnected":
|
||||
new_title = f"🔴 {title}"
|
||||
elif state == "error":
|
||||
new_title = f"🟡 {title}"
|
||||
else:
|
||||
new_title = f"⚪ {title}"
|
||||
self.tabs.setTabText(idx, new_title)
|
||||
|
||||
def _update_tab_status_by_panel(self, panel: TerminalPanel, state: str):
|
||||
for i in range(self.tabs.count()):
|
||||
if self.tabs.widget(i) is panel:
|
||||
self._update_tab_status(i, state)
|
||||
return
|
||||
|
||||
def shutdown(self):
|
||||
"""主窗口关闭时:关闭所有标签的 shell"""
|
||||
for i in range(self.tabs.count()):
|
||||
w = self.tabs.widget(i)
|
||||
if isinstance(w, TerminalPanel):
|
||||
w.close_shell()
|
||||
Reference in New Issue
Block a user