diff --git a/.gitignore b/.gitignore index 462918f..b502eee 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ output.md # Virtual environments .venv .sdk +.env* diff --git a/pyproject.toml b/pyproject.toml index 5ac25c0..b4c3dad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,23 +3,23 @@ build-backend = "hatchling.build" requires = ["hatchling"] [project] -name = "virtualbox-client" -version = "0.1.0" +dependencies = [ + "openai>=2.35.1", + "paramiko>=4.0.0", + "python-dotenv>=1.2.2", + "pywin32>=311", + "PySide6>=6.8.0", +] description = "Add your description here" +name = "virtualbox-client" readme = "README.md" requires-python = ">=3.14" -dependencies = [ - "openai>=2.35.1", - "paramiko>=4.0.0", - "python-dotenv>=1.2.2", - "pywin32>=311", - "PySide6>=6.8.0", -] +version = "0.1.0" [dependency-groups] dev = [ - "pyinstaller>=6.20.0", - "setuptools>=82.0.1", + "pyinstaller>=6.20.0", + "setuptools>=82.0.1", ] [tool.hatch.build.targets.wheel] diff --git a/src/virtualbox_client/app/__init__.py b/src/virtualbox_client/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/virtualbox_client/app/agent_worker.py b/src/virtualbox_client/app/agent_worker.py new file mode 100644 index 0000000..72d8ce4 --- /dev/null +++ b/src/virtualbox_client/app/agent_worker.py @@ -0,0 +1,136 @@ +from PySide6.QtCore import QObject, Signal +import threading + + +class AgentWorker(QObject): + """ + 后台线程执行 Agent 任务,通过 Qt Signal 发送日志到主线程。 + 使用 Signal 机制避免了定时器轮询队列带来的延迟和性能开销。 + 支持 VirtualBox 模式和纯 SSH 模式两种运行方式。 + """ + + # 定义信号:日志消息、任务完成(携带 vm_started_by_us)、任务错误、任务取消 + log_signal = Signal(str, str) # (message, level) + finished_signal = Signal(bool) # vm_started_by_us:是否由本任务启动的虚拟机 + error_signal = Signal(str) + cancelled_signal = Signal() + + def __init__(self, config: dict, task: str): + super().__init__() + self.config = config + self.task = task + # 使用共享的 threading.Event,同时控制 AgentWorker 和底层管理器的取消 + self.cancel_event = threading.Event() + # 保存 vm 引用以便在主线程也能取消底层操作 + self._vm = None + + def cancel(self): + """取消任务——设置取消事件,通知所有层级的操作立即停止""" + self.cancel_event.set() + if self._vm is not None: + try: + self._vm.cancel_operations() + except Exception: + pass + + def get_vm(self): + """获取 VM 管理器引用(供主线程关闭 VM 使用)""" + return self._vm + + def is_cancelled(self): + return self.cancel_event.is_set() + + def run(self): + # Windows COM 初始化(仅 VirtualBox 模式需要) + _com_initialized = False + ssh_only = self.config.get("ssh_only", False) + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + from virtualbox_client.utils.vm_agent import VMAgent + + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + self.log_signal.emit("🔌 使用纯 SSH 模式(不依赖 VirtualBox)", "info") + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + + vm.set_cancel_event(self.cancel_event) + self._vm = vm + + if self.is_cancelled(): + self.cancelled_signal.emit() + return + + agent = VMAgent( + vm, + api_key=self.config["api_key"], + base_url=self.config.get("base_url", ""), + model=self.config.get("model", "gpt-4o"), + sudo_password=self.config.get("ssh_pass", ""), + ) + + original_print = print + + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + + import builtins + + builtins.print = custom_print + + try: + if self.is_cancelled(): + self.cancelled_signal.emit() + return + + self.log_signal.emit(f"📋 {self.task}", "task") + _, vm_started_by_us = agent.run(self.task) + + if self.is_cancelled(): + self.cancelled_signal.emit() + return + + self.log_signal.emit("✅ 任务完成", "success") + self.finished_signal.emit(vm_started_by_us) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 错误: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass diff --git a/src/virtualbox_client/app/config_panel.py b/src/virtualbox_client/app/config_panel.py new file mode 100644 index 0000000..a4be9df --- /dev/null +++ b/src/virtualbox_client/app/config_panel.py @@ -0,0 +1,158 @@ +import os +import json +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QGroupBox, + QCheckBox, + QFormLayout, + QLineEdit, + QSpinBox, + QHBoxLayout, + QPushButton, + QMessageBox, +) + + +class ConfigPanel(QWidget): + """配置面板""" + + def __init__(self): + super().__init__() + self.setup_ui() + self.load_from_env() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + mode_group = QGroupBox("运行模式") + mode_layout = QVBoxLayout(mode_group) + self.ssh_only_check = QCheckBox("🔌 纯 SSH 模式(不依赖 VirtualBox)") + self.ssh_only_check.setToolTip( + "勾选后只需填写 SSH 连接信息,不需要 VirtualBox 环境和虚拟机名称" + ) + self.ssh_only_check.toggled.connect(self._on_mode_changed) + mode_layout.addWidget(self.ssh_only_check) + layout.addWidget(mode_group) + + vm_group = QGroupBox("虚拟机配置") + vm_form = QFormLayout(vm_group) + self.vm_name_edit = QLineEdit(placeholderText="例如: ubuntu20.04") + vm_form.addRow("虚拟机名称:", self.vm_name_edit) + self.ssh_host_edit = QLineEdit(placeholderText="例如: 127.0.0.1") + vm_form.addRow("SSH 主机:", self.ssh_host_edit) + self.ssh_port_spin = QSpinBox() + self.ssh_port_spin.setRange(1, 65535) + self.ssh_port_spin.setValue(2222) + vm_form.addRow("SSH 端口:", self.ssh_port_spin) + self.ssh_user_edit = QLineEdit(placeholderText="SSH 用户名") + vm_form.addRow("SSH 用户:", self.ssh_user_edit) + self.ssh_pass_edit = QLineEdit( + echoMode=QLineEdit.EchoMode.Password, placeholderText="SSH 密码" + ) + vm_form.addRow("SSH 密码:", self.ssh_pass_edit) + layout.addWidget(vm_group) + + llm_group = QGroupBox("大模型配置") + llm_form = QFormLayout(llm_group) + self.api_key_edit = QLineEdit( + echoMode=QLineEdit.EchoMode.Password, placeholderText="API Key" + ) + llm_form.addRow("API Key:", self.api_key_edit) + self.base_url_edit = QLineEdit(placeholderText="例如: https://api.deepseek.com") + llm_form.addRow("Base URL:", self.base_url_edit) + self.model_edit = QLineEdit(placeholderText="例如: deepseek-v4-flash") + llm_form.addRow("模型:", self.model_edit) + layout.addWidget(llm_group) + + btn_layout = QHBoxLayout() + self.load_env_btn = QPushButton("📂 从 .env 加载") + self.load_env_btn.clicked.connect(self.load_from_env) + btn_layout.addWidget(self.load_env_btn) + self.save_config_btn = QPushButton("💾 保存配置") + self.save_config_btn.clicked.connect(self.save_config) + btn_layout.addWidget(self.save_config_btn) + layout.addLayout(btn_layout) + layout.addStretch() + + def load_from_env(self): + self.vm_name_edit.setText(os.getenv("VM_NAME", "")) + self.ssh_host_edit.setText(os.getenv("VM_SSH_HOST", "127.0.0.1")) + try: + self.ssh_port_spin.setValue(int(os.getenv("VM_SSH_PORT", "2222"))) + except ValueError: + self.ssh_port_spin.setValue(2222) + self.ssh_user_edit.setText(os.getenv("VM_SSH_USER", "")) + self.ssh_pass_edit.setText(os.getenv("VM_SSH_PASS", "")) + self.api_key_edit.setText(os.getenv("LLM_API_KEY", "")) + self.base_url_edit.setText(os.getenv("LLM_BASE_URL", "")) + self.model_edit.setText(os.getenv("LLM_MODEL", "")) + + def save_config(self): + config = self.get_config() + try: + config_path = os.path.join( + os.path.dirname(__file__), "..", "..", "gui_config.json" + ) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + QMessageBox.information(self, "保存成功", f"配置已保存到 {config_path}") + except Exception as e: + QMessageBox.warning(self, "保存失败", f"保存配置失败: {e}") + + def _on_mode_changed(self): + ssh_only = self.ssh_only_check.isChecked() + self.vm_name_edit.setEnabled(not ssh_only) + if ssh_only: + self.vm_name_edit.setPlaceholderText("纯 SSH 模式无需虚拟机名称") + else: + self.vm_name_edit.setPlaceholderText("例如: ubuntu20.04") + + def get_config(self) -> dict: + return { + "vm_name": self.vm_name_edit.text().strip(), + "ssh_host": self.ssh_host_edit.text().strip(), + "ssh_port": self.ssh_port_spin.value(), + "ssh_user": self.ssh_user_edit.text().strip(), + "ssh_pass": self.ssh_pass_edit.text().strip(), + "api_key": self.api_key_edit.text().strip(), + "base_url": self.base_url_edit.text().strip(), + "model": self.model_edit.text().strip(), + "ssh_only": self.ssh_only_check.isChecked(), + } + + def validate(self) -> bool: + cfg = self.get_config() + missing = [] + if not cfg["ssh_only"] and not cfg["vm_name"]: + missing.append("虚拟机名称") + if not cfg["ssh_host"]: + missing.append("SSH 主机") + if not cfg["ssh_user"]: + missing.append("SSH 用户") + if not cfg["ssh_pass"]: + missing.append("SSH 密码") + if not cfg["api_key"]: + missing.append("API Key") + if missing: + QMessageBox.warning( + self, + "配置不完整", + "请填写以下字段:\n" + "\n".join(f" - {m}" for m in missing), + ) + return False + return True + + def set_enabled(self, enabled: bool): + self.ssh_only_check.setEnabled(enabled) + self.vm_name_edit.setEnabled(enabled and not self.ssh_only_check.isChecked()) + self.ssh_host_edit.setEnabled(enabled) + self.ssh_port_spin.setEnabled(enabled) + self.ssh_user_edit.setEnabled(enabled) + self.ssh_pass_edit.setEnabled(enabled) + self.api_key_edit.setEnabled(enabled) + self.base_url_edit.setEnabled(enabled) + self.model_edit.setEnabled(enabled) + self.load_env_btn.setEnabled(enabled) + self.save_config_btn.setEnabled(enabled) diff --git a/src/virtualbox_client/app/download_worker.py b/src/virtualbox_client/app/download_worker.py new file mode 100644 index 0000000..83661db --- /dev/null +++ b/src/virtualbox_client/app/download_worker.py @@ -0,0 +1,96 @@ +from PySide6.QtCore import QObject, Signal +import threading + + +class DownloadWorker(QObject): + """后台线程执行文件下载(支持 VirtualBox 和纯 SSH 模式)""" + + log_signal = Signal(str, str) + finished_signal = Signal(bool) + error_signal = Signal(str) + + def __init__(self, config: dict, remote_path: str, local_path: str): + super().__init__() + self.config = config + self.remote_path = remote_path + self.local_path = local_path + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + self.log_signal.emit( + f"📥 正在下载: {self.remote_path} -> {self.local_path}", "info" + ) + original_print = print + import builtins + + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + + builtins.print = custom_print + + try: + success = vm.download_file(self.remote_path, self.local_path) + if success: + self.log_signal.emit("✅ 文件下载成功", "success") + self.finished_signal.emit(True) + else: + self.log_signal.emit("❌ 文件下载失败", "error") + self.finished_signal.emit(False) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 下载错误: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass diff --git a/src/virtualbox_client/app/file_size_formatter.py b/src/virtualbox_client/app/file_size_formatter.py new file mode 100644 index 0000000..50b17d3 --- /dev/null +++ b/src/virtualbox_client/app/file_size_formatter.py @@ -0,0 +1,13 @@ +class FileSizeFormatter: + """文件大小格式化工具""" + + @staticmethod + def format(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + else: + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" diff --git a/src/virtualbox_client/app/log_panel.py b/src/virtualbox_client/app/log_panel.py new file mode 100644 index 0000000..6467a3a --- /dev/null +++ b/src/virtualbox_client/app/log_panel.py @@ -0,0 +1,89 @@ +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QCheckBox, + QPlainTextEdit, +) +from PySide6.QtGui import QFont, QTextCursor +from datetime import datetime + + +class LogPanel(QWidget): + """高性能日志面板(使用 QPlainTextEdit 替代 QTextEdit)""" + + def __init__(self): + super().__init__() + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + tool_layout = QHBoxLayout() + self.clear_btn = QPushButton("🗑️ 清空") + self.clear_btn.clicked.connect(self.clear_log) + tool_layout.addWidget(self.clear_btn) + tool_layout.addStretch() + self.auto_scroll_check = QCheckBox("📌 自动滚动") + self.auto_scroll_check.setChecked(True) + tool_layout.addWidget(self.auto_scroll_check) + layout.addLayout(tool_layout) + + self.log_output = QPlainTextEdit() + self.log_output.setReadOnly(True) + self.log_output.setMaximumBlockCount(10000) + self.log_output.setFont(QFont("Consolas", 10)) + self.log_output.setStyleSheet(""" + QPlainTextEdit { + background-color: #1e1e1e; + color: #d4d4d4; + border: 1px solid #333; + border-radius: 4px; + padding: 8px; + } + """) + layout.addWidget(self.log_output) + + self._prefix_map = { + "error": "❌ ", + "warning": "⚠️ ", + "success": "✅ ", + "task": "📋 ", + "info": "", + } + + def append_log(self, message: str, level: str = "info"): + timestamp = datetime.now().strftime("%H:%M:%S") + level = self._detect_level(message, level) + prefix = self._prefix_map.get(level, "") + line = f"[{timestamp}] {prefix}{message}" + self.log_output.appendPlainText(line) + if self.auto_scroll_check.isChecked(): + self.log_output.moveCursor(QTextCursor.MoveOperation.End) + + @staticmethod + def _detect_level(message: str, given_level: str) -> str: + if given_level != "info": + return given_level + msg_upper = message.upper() + if any( + x in msg_upper + for x in ["❌", "ERROR", "EXCEPTION", "FAILED", "失败", "错误"] + ): + return "error" + if any(x in msg_upper for x in ["⚠️", "WARN", "跳过", "重试"]): + return "warning" + if any(x in msg_upper for x in ["✅", "SUCCESS", "完成", "成功"]): + return "success" + return "info" + + def clear_log(self): + self.log_output.clear() + + def clear_and_set_header(self, task: str): + self.clear_log() + self.append_log("=" * 60, "info") + self.append_log(f"📋 开始执行任务: {task}", "info") + self.append_log("=" * 60, "info") diff --git a/src/virtualbox_client/app/script_worker.py b/src/virtualbox_client/app/script_worker.py new file mode 100644 index 0000000..45d05c6 --- /dev/null +++ b/src/virtualbox_client/app/script_worker.py @@ -0,0 +1,156 @@ +from PySide6.QtCore import QObject, Signal +import threading + + +class ScriptWorker(QObject): + log_signal = Signal(str, str) + finished_signal = Signal(bool) + error_signal = Signal(str) + + def __init__(self, config: dict, local_path: str): + super().__init__() + self.config = config + self.local_path = local_path + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + # 非纯 SSH 模式下,先确保虚拟机已启动 + if not ssh_only: + self.log_signal.emit("🚀 检查虚拟机状态...", "info") + vm_started = vm.start_vm(wait_for_ssh=True) + if not vm_started: + self.log_signal.emit("❌ 虚拟机启动失败", "error") + self.error_signal.emit("虚拟机启动失败") + return + self.log_signal.emit("✅ 虚拟机已就绪", "success") + if self.cancel_event.is_set(): + return + + import builtins + + original_print = print + + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + + builtins.print = custom_print + + try: + import os as _os + + script_name = _os.path.basename(self.local_path) + remote_path = "/tmp/" + script_name + log_path = "/tmp/" + script_name + ".log" + local_log_path = script_name + ".log" + + self.log_signal.emit( + "[1/3] Upload: " + self.local_path + " -> " + remote_path, "info" + ) + if not vm.upload_file(self.local_path, remote_path): + self.log_signal.emit("Upload failed", "error") + return + + self.log_signal.emit("[2/3] Execute: " + remote_path, "info") + # deploy_* 脚本需要 sudo 权限(系统级安装),其余脚本以普通用户执行 + if script_name.startswith("deploy_"): + cmd = ( + "chmod +x " + remote_path + " && " + "echo '" + + self.config["ssh_pass"] + + "' | sudo -S " + + remote_path + + " 2>&1 | " + "tee " + log_path + ) + self.log_signal.emit( + " 🔑 检测到部署脚本,以 sudo 权限执行", "info" + ) + else: + cmd = ( + "chmod +x " + remote_path + " && " + remote_path + " 2>&1 | " + "tee " + log_path + ) + self.log_signal.emit( + " 👤 检测到开发脚本,以普通用户权限执行", "info" + ) + out, err, code = vm.exec_command(cmd, get_pty=False) + self.log_signal.emit("Exit code: " + str(code), "info") + + self.log_signal.emit( + "[3/3] Download log: " + log_path + " -> " + local_log_path, "info" + ) + if vm.download_file(log_path, local_log_path): + self.log_signal.emit("Log saved: " + local_log_path, "success") + else: + self.log_signal.emit( + "Tee log download failed, saving terminal output", "warning" + ) + if out: + with open(local_log_path, "w") as f: + f.write(out) + self.log_signal.emit( + "Output saved: " + local_log_path, "success" + ) + + vm.exec_command('rm -f "' + remote_path + '" "' + log_path + '"') + self.log_signal.emit("Remote temp files cleaned", "info") + self.log_signal.emit("Script execution complete!", "success") + self.finished_signal.emit(True) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit("Missing dependency: " + str(e), "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit("Script error: " + str(e), "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass diff --git a/src/virtualbox_client/app/shutdown_worker.py b/src/virtualbox_client/app/shutdown_worker.py new file mode 100644 index 0000000..a98f239 --- /dev/null +++ b/src/virtualbox_client/app/shutdown_worker.py @@ -0,0 +1,61 @@ +from PySide6.QtCore import QObject, Signal + + +class ShutdownWorker(QObject): + """后台关闭虚拟机的工作线程——避免阻塞 UI 和跨线程 COM 冲突""" + + finished = Signal() + error = Signal(str) + log = Signal(str, str) + + def __init__(self, config: dict): + super().__init__() + self.config = config + + def run(self): + ssh_only = self.config.get("ssh_only", False) + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + except Exception: + pass + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + self.log.emit("🔌 纯 SSH 模式:正在关闭 SSH 连接...", "info") + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.close_ssh() + self.log.emit("✅ SSH 连接已关闭", "success") + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + self.log.emit("🛑 正在关闭虚拟机...", "warning") + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.stop_vm() + self.log.emit("✅ 虚拟机已关闭", "success") + self.finished.emit() + except Exception as e: + self.log.emit(f"❌ 关闭失败: {e}", "error") + self.error.emit(str(e)) + finally: + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass diff --git a/src/virtualbox_client/app/task_panel.py b/src/virtualbox_client/app/task_panel.py new file mode 100644 index 0000000..35b6103 --- /dev/null +++ b/src/virtualbox_client/app/task_panel.py @@ -0,0 +1,612 @@ +import os +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QTabWidget, + QGroupBox, + QPlainTextEdit, + QLabel, + QLineEdit, + QHBoxLayout, + QPushButton, + QFormLayout, + QMessageBox, + QFileDialog, +) +from PySide6.QtGui import QFont +from PySide6.QtCore import QTimer, QThread +from .log_panel import LogPanel +from .config_panel import ConfigPanel +from .workspace_panel import WorkspacePanel +from .agent_worker import AgentWorker +from .shutdown_worker import ShutdownWorker +from .upload_worker import UploadWorker +from .download_worker import DownloadWorker +from .script_worker import ScriptWorker + + +class TaskPanel(QWidget): + """任务输入和执行面板""" + + def __init__(self, log_panel: LogPanel): + super().__init__() + self.log_panel = log_panel + self._config_panel = None + self._worker_thread = None + self._worker = None + self._shutdown_thread = None + self._shutdown_worker = None + self.setup_ui() + + def set_config_panel(self, panel: ConfigPanel): + self._config_panel = panel + if hasattr(self, "workspace_panel") and self.workspace_panel: + self.workspace_panel._config_panel = panel + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.tab_widget = QTabWidget() + self.tab_widget.setStyleSheet(""" + QTabWidget::pane { border: 1px solid #3c3c3c; border-radius: 4px; background: transparent; } + QTabBar::tab { background: #2d2d2d; color: #ccc; padding: 8px 16px; border: 1px solid #3c3c3c; border-bottom: none; border-top-left-radius: 4px; border-top-right-radius: 4px; } + QTabBar::tab:selected { background: #3c3c3c; color: #569cd6; font-weight: bold; } + QTabBar::tab:hover { background: #4c4c4c; } + """) + + # ====== 任务描述 Tab ====== + task_tab = QWidget() + task_layout = QVBoxLayout(task_tab) + task_layout.setContentsMargins(8, 8, 8, 8) + + input_group = QGroupBox("任务描述") + input_layout = QVBoxLayout(input_group) + + self.task_input = QPlainTextEdit() + self.task_input.setPlaceholderText( + "请输入自然语言任务描述,例如:\n" + " - 在虚拟机中安装 nginx 并确认服务状态\n" + " - 创建一个 /home/test.txt 文件,写入 'Hello World'\n" + " - 查看系统当前的内存使用情况" + ) + self.task_input.setMaximumHeight(100) + self.task_input.setFont(QFont("Microsoft YaHei UI", 10)) + input_layout.addWidget(self.task_input) + + input_layout.addWidget(QLabel("项目路径:")) + self.project_path_edit = QLineEdit() + self.project_path_edit.setPlaceholderText("例如: /home/user/myproject") + input_layout.addWidget(self.project_path_edit) + + task_layout.addWidget(input_group) + + task_btn_layout = QHBoxLayout() + self.run_btn = QPushButton("▶️ 执行任务") + self.run_btn.setStyleSheet(""" + QPushButton { + background-color: #0e639c; color: white; padding: 10px 30px; + font-size: 14px; font-weight: bold; border: none; border-radius: 6px; + } + QPushButton:hover { background-color: #1177bb; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.run_btn.clicked.connect(self.run_task) + task_btn_layout.addWidget(self.run_btn) + + self.cancel_btn = QPushButton("⏹️ 取消") + self.cancel_btn.setStyleSheet(""" + QPushButton { + background-color: #d32f2f; color: white; padding: 10px 20px; + font-size: 14px; font-weight: bold; border: none; border-radius: 6px; + } + QPushButton:hover { background-color: #e53935; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.cancel_btn.clicked.connect(self.cancel_task) + self.cancel_btn.setEnabled(False) + task_btn_layout.addWidget(self.cancel_btn) + task_layout.addLayout(task_btn_layout) + self.tab_widget.addTab(task_tab, "📋 任务执行") + + # ====== 文件传输 Tab ====== + file_tab = QWidget() + file_layout = QVBoxLayout(file_tab) + file_layout.setContentsMargins(8, 8, 8, 8) + + upload_group = QGroupBox("📤 上传文件到虚拟机") + upload_layout = QFormLayout(upload_group) + + upload_local_layout = QHBoxLayout() + self.upload_local_edit = QLineEdit(placeholderText="选择本地文件路径...") + upload_local_layout.addWidget(self.upload_local_edit) + self.upload_browse_btn = QPushButton("📂 浏览") + self.upload_browse_btn.setFixedWidth(80) + self.upload_browse_btn.clicked.connect(self._browse_upload_file) + upload_local_layout.addWidget(self.upload_browse_btn) + upload_layout.addRow("本地文件:", upload_local_layout) + + self.upload_remote_edit = QLineEdit( + placeholderText="例如: ~/workspace/target_file.txt" + ) + self.upload_remote_edit.setText("~/workspace/") + upload_layout.addRow("远程路径:", self.upload_remote_edit) + + self.upload_btn = QPushButton("📤 上传") + self.upload_btn.setStyleSheet(""" + QPushButton { background-color: #2d7d46; color: white; padding: 8px 20px; + font-size: 13px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #389e54; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.upload_btn.clicked.connect(self.upload_file) + upload_layout.addRow("", self.upload_btn) + file_layout.addWidget(upload_group) + + download_group = QGroupBox("📥 从虚拟机下载文件") + download_layout = QFormLayout(download_group) + + self.download_remote_edit = QLineEdit( + placeholderText="例如: ~/workspace/source_file.txt" + ) + download_layout.addRow("远程路径:", self.download_remote_edit) + + download_local_layout = QHBoxLayout() + self.download_local_edit = QLineEdit(placeholderText="选择本地保存路径...") + download_local_layout.addWidget(self.download_local_edit) + self.download_browse_btn = QPushButton("📂 浏览") + self.download_browse_btn.setFixedWidth(80) + self.download_browse_btn.clicked.connect(self._browse_download_file) + download_local_layout.addWidget(self.download_browse_btn) + download_layout.addRow("保存到:", download_local_layout) + + self.download_btn = QPushButton("📥 下载") + self.download_btn.setStyleSheet(""" + QPushButton { background-color: #8b5cf6; color: white; padding: 8px 20px; + font-size: 13px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #a78bfa; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.download_btn.clicked.connect(self.download_file) + download_layout.addRow("", self.download_btn) + file_layout.addWidget(download_group) + + file_layout.addStretch() + self.tab_widget.addTab(file_tab, "📁 文件传输") + + # ====== Script Execution Tab ====== + script_tab = QWidget() + script_layout = QVBoxLayout(script_tab) + script_layout.setContentsMargins(8, 8, 8, 8) + + script_group = QGroupBox("Upload and Execute Script") + script_group_layout = QVBoxLayout(script_group) + + script_path_layout = QHBoxLayout() + self.script_path_edit = QLineEdit( + placeholderText="Select local .sh script file..." + ) + script_path_layout.addWidget(self.script_path_edit) + self.script_browse_btn = QPushButton("Browse") + self.script_browse_btn.setFixedWidth(80) + self.script_browse_btn.clicked.connect(self._browse_script) + script_path_layout.addWidget(self.script_browse_btn) + script_group_layout.addWidget(QLabel("Local Script:")) + script_group_layout.addLayout(script_path_layout) + + self.run_script_btn = QPushButton("Upload and Execute Script") + self.run_script_btn.setStyleSheet(""" + QPushButton { background-color: #8e44ad; color: white; padding: 10px 30px; + font-size: 14px; font-weight: bold; border: none; border-radius: 6px; } + QPushButton:hover { background-color: #9b59b6; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.run_script_btn.clicked.connect(self._run_script) + script_group_layout.addWidget(self.run_script_btn) + + script_layout.addWidget(script_group) + + desc_group = QGroupBox("How it works") + desc_layout = QVBoxLayout(desc_group) + desc_label = QLabel( + "1. Select a local .sh script file\n" + "2. Click button to auto:\n" + " Upload script to /tmp/\n" + " Execute (deploy_* scripts use sudo, others run as normal user)\n" + " Download execution log\n" + " Clean remote temp files" + ) + desc_label.setStyleSheet("color: #aaa; font-size: 12px; line-height: 1.6;") + desc_layout.addWidget(desc_label) + script_layout.addWidget(desc_group) + + script_layout.addStretch() + self.tab_widget.addTab(script_tab, "Script") + + # ====== 工作区 Tab ====== + self.workspace_panel = WorkspacePanel( + self._config_panel or ConfigPanel(), self.log_panel + ) + self.tab_widget.addTab(self.workspace_panel, "📂 工作区") + + self.tab_widget.currentChanged.connect(self._on_tab_changed) + + layout.addWidget(self.tab_widget) + + def _on_tab_changed(self, index: int): + if self.tab_widget.tabText(index) == "📂 工作区": + if self._config_panel and self.workspace_panel._validate_ssh_config(): + QTimer.singleShot(300, self.workspace_panel.refresh_root) + + def _set_ui_running(self, running: bool): + self.run_btn.setEnabled(not running) + self.cancel_btn.setEnabled(running) + self.task_input.setEnabled(not running) + self.run_script_btn.setEnabled(not running) + self.script_browse_btn.setEnabled(not running) + self.script_path_edit.setEnabled(not running) + if self._config_panel: + self._config_panel.set_enabled(not running) + + def _cleanup_worker(self): + if self._worker_thread: + try: + self._worker_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._worker_thread.isRunning(): + self._worker_thread.quit() + if not self._worker_thread.wait(5000): + self._worker_thread.terminate() + self._worker_thread.wait(3000) + self._worker_thread = None + if self._worker: + try: + self._worker.log_signal.disconnect() + self._worker.finished_signal.disconnect() + self._worker.error_signal.disconnect() + self._worker.cancelled_signal.disconnect() + except TypeError, RuntimeError: + pass + self._worker = None + + def _ensure_old_threads_cleaned(self): + self._cleanup_worker() + self._cleanup_shutdown() + + def run_task(self): + if not self._config_panel or not self._config_panel.validate(): + return + task = self.task_input.toPlainText().strip() + if not task: + QMessageBox.warning(self, "提示", "请输入任务描述") + return + + project_path = self.project_path_edit.text().strip() + if not project_path: + QMessageBox.warning(self, "提示", "请输入项目路径") + return + + project_name = os.path.basename(project_path.rstrip("/\\")) + enhanced_task = ( + f'在 {project_path} 下有一个 "{project_name}" 项目,' + f"请仔细阅读 README.md 文档再执行操作," + f"最后将更新记录写入 README.md 中," + f"不允许使用启动或打包项目\n\n{task}" + ) + + config = self._config_panel.get_config() + self._ensure_old_threads_cleaned() + self._set_ui_running(True) + self.log_panel.clear_and_set_header(enhanced_task) + + self._worker = AgentWorker(config, enhanced_task) + self._worker_thread = QThread(self) + self._worker.moveToThread(self._worker_thread) + + self._worker.log_signal.connect(self.log_panel.append_log) + self._worker.finished_signal.connect(self._on_task_finished) + self._worker.error_signal.connect(self._on_task_error) + self._worker.cancelled_signal.connect(self._on_task_cancelled) + self._worker_thread.started.connect(self._worker.run) + self._worker_thread.finished.connect(self._on_worker_thread_finished) + + self._worker_thread.start() + + def _on_worker_thread_finished(self): + self._cleanup_worker() + + def _on_task_finished(self, vm_started_by_us: bool): + self._set_ui_running(False) + self.log_panel.append_log("=" * 60, "info") + self.log_panel.append_log("✅ 任务已成功完成", "success") + + if vm_started_by_us: + reply = QMessageBox.question( + self, + "虚拟机管理", + "虚拟机是由本任务启动的。\n是否要关闭虚拟机?\n\n" + "选「是」→ 关闭虚拟机\n" + "选「否」→ 保持虚拟机运行", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if reply == QMessageBox.StandardButton.Yes: + config = self._config_panel.get_config() if self._config_panel else {} + self._start_shutdown_thread(config) + + def _on_task_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log(f"❌ 任务失败: {error_msg}", "error") + + def _on_task_cancelled(self): + self._set_ui_running(False) + self.log_panel.append_log("⏹️ 任务已取消", "warning") + + def _start_shutdown_thread(self, config: dict): + self._cleanup_shutdown() + self._shutdown_worker = ShutdownWorker(config) + self._shutdown_thread = QThread(self) + self._shutdown_worker.moveToThread(self._shutdown_thread) + + self._shutdown_worker.log.connect(self.log_panel.append_log) + self._shutdown_worker.finished.connect(self._on_shutdown_finished) + self._shutdown_worker.error.connect(self._on_shutdown_error) + self._shutdown_thread.started.connect(self._shutdown_worker.run) + self._shutdown_thread.finished.connect(self._cleanup_shutdown) + + self._shutdown_thread.start() + + def _on_shutdown_finished(self): + self.log_panel.append_log("✅ 虚拟机已关闭", "success") + + def _on_shutdown_error(self, error_msg: str): + self.log_panel.append_log(f"❌ 关机失败: {error_msg}", "error") + + def _cleanup_shutdown(self): + if self._shutdown_thread: + try: + self._shutdown_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._shutdown_thread.isRunning(): + self._shutdown_thread.quit() + if not self._shutdown_thread.wait(5000): + self._shutdown_thread.terminate() + self._shutdown_thread.wait(3000) + self._shutdown_thread = None + if self._shutdown_worker: + try: + self._shutdown_worker.log.disconnect() + self._shutdown_worker.finished.disconnect() + self._shutdown_worker.error.disconnect() + except TypeError, RuntimeError: + pass + self._shutdown_worker = None + + def cancel_task(self): + if self._worker: + self._worker.cancel() + self.log_panel.append_log("⏹️ 正在取消任务...", "warning") + QTimer.singleShot(5000, self._force_cleanup_if_stuck) + + def _force_cleanup_if_stuck(self): + if self._worker_thread and self._worker_thread.isRunning(): + self.log_panel.append_log("⚠️ 任务线程未响应,正在强制终止...", "warning") + self._worker_thread.quit() + if not self._worker_thread.wait(5000): + self._worker_thread.terminate() + self._worker_thread.wait(3000) + self._cleanup_worker() + self._set_ui_running(False) + self.log_panel.append_log("⏹️ 任务已强制终止", "warning") + + _upload_thread = None + _upload_worker = None + + def _cleanup_upload(self): + if self._upload_thread: + try: + self._upload_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._upload_thread.isRunning(): + self._upload_thread.quit() + if not self._upload_thread.wait(5000): + self._upload_thread.terminate() + self._upload_thread.wait(3000) + self._upload_thread = None + if self._upload_worker: + try: + self._upload_worker.log_signal.disconnect() + self._upload_worker.finished_signal.disconnect() + self._upload_worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._upload_worker = None + + def _browse_upload_file(self): + file_path, _ = QFileDialog.getOpenFileName(self, "选择要上传的文件") + if file_path: + self.upload_local_edit.setText(file_path) + name = os.path.basename(file_path) + remote = self.upload_remote_edit.text().strip() + if remote.endswith("/"): + self.upload_remote_edit.setText(remote + name) + else: + self.upload_remote_edit.setText(f"~/workspace/{name}") + + def upload_file(self): + if not self._config_panel or not self._config_panel.validate(): + return + local_path = self.upload_local_edit.text().strip() + remote_path = self.upload_remote_edit.text().strip() + if not local_path: + QMessageBox.warning(self, "提示", "请选择要上传的本地文件") + return + if not remote_path: + QMessageBox.warning(self, "提示", "请输入虚拟机中的目标路径") + return + if not os.path.isfile(local_path): + QMessageBox.warning(self, "提示", f"本地文件不存在: {local_path}") + return + + self._cleanup_upload() + self._set_ui_running(True) + + config = self._config_panel.get_config() + self._upload_worker = UploadWorker(config, local_path, remote_path) + self._upload_thread = QThread(self) + self._upload_worker.moveToThread(self._upload_thread) + + self._upload_worker.log_signal.connect(self.log_panel.append_log) + self._upload_worker.finished_signal.connect(self._on_upload_finished) + self._upload_worker.error_signal.connect(self._on_upload_error) + self._upload_thread.started.connect(self._upload_worker.run) + self._upload_thread.finished.connect(self._cleanup_upload) + + self._upload_thread.start() + + def _on_upload_finished(self, success: bool): + self._set_ui_running(False) + if success: + self.log_panel.append_log("✅ 上传操作已完成", "success") + + def _on_upload_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log(f"❌ 上传失败: {error_msg}", "error") + + _download_thread = None + _download_worker = None + + def _cleanup_download(self): + if self._download_thread: + try: + self._download_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._download_thread.isRunning(): + self._download_thread.quit() + if not self._download_thread.wait(5000): + self._download_thread.terminate() + self._download_thread.wait(3000) + self._download_thread = None + if self._download_worker: + try: + self._download_worker.log_signal.disconnect() + self._download_worker.finished_signal.disconnect() + self._download_worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._download_worker = None + + def _browse_download_file(self): + file_path, _ = QFileDialog.getSaveFileName(self, "选择保存位置") + if file_path: + self.download_local_edit.setText(file_path) + + def download_file(self): + if not self._config_panel or not self._config_panel.validate(): + return + remote_path = self.download_remote_edit.text().strip() + local_path = self.download_local_edit.text().strip() + if not remote_path: + QMessageBox.warning(self, "提示", "请输入虚拟机中的文件路径") + return + if not local_path: + QMessageBox.warning(self, "提示", "请选择本地保存路径") + return + + self._cleanup_download() + self._set_ui_running(True) + + config = self._config_panel.get_config() + self._download_worker = DownloadWorker(config, remote_path, local_path) + self._download_thread = QThread(self) + self._download_worker.moveToThread(self._download_thread) + + self._download_worker.log_signal.connect(self.log_panel.append_log) + self._download_worker.finished_signal.connect(self._on_download_finished) + self._download_worker.error_signal.connect(self._on_download_error) + self._download_thread.started.connect(self._download_worker.run) + self._download_thread.finished.connect(self._cleanup_download) + + self._download_thread.start() + + def _on_download_finished(self, success: bool): + self._set_ui_running(False) + if success: + self.log_panel.append_log("✅ 下载操作已完成", "success") + + def _on_download_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log(f"❌ 下载失败: {error_msg}", "error") + + _script_thread = None + _script_worker = None + + def _cleanup_script(self): + if self._script_thread: + try: + self._script_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._script_thread.isRunning(): + self._script_thread.quit() + if not self._script_thread.wait(5000): + self._script_thread.terminate() + self._script_thread.wait(3000) + self._script_thread = None + if self._script_worker: + try: + self._script_worker.log_signal.disconnect() + self._script_worker.finished_signal.disconnect() + self._script_worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._script_worker = None + + def _browse_script(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "Select script", "", "Shell scripts (*.sh);;All files (*)" + ) + if file_path: + self.script_path_edit.setText(file_path) + + def _run_script(self): + if not self._config_panel or not self._config_panel.validate(): + return + local_path = self.script_path_edit.text().strip() + if not local_path: + QMessageBox.warning(self, "Hint", "Please select a script file") + return + if not os.path.isfile(local_path): + QMessageBox.warning(self, "Hint", "File not found: " + local_path) + return + + self._cleanup_script() + self._set_ui_running(True) + self.tab_widget.setCurrentIndex(0) + self.log_panel.clear_log() + + config = self._config_panel.get_config() + self._script_worker = ScriptWorker(config, local_path) + self._script_thread = QThread(self) + self._script_worker.moveToThread(self._script_thread) + + self._script_worker.log_signal.connect(self.log_panel.append_log) + self._script_worker.finished_signal.connect(self._on_script_finished) + self._script_worker.error_signal.connect(self._on_script_error) + self._script_thread.started.connect(self._script_worker.run) + self._script_thread.finished.connect(self._cleanup_script) + + self._script_thread.start() + + def _on_script_finished(self, success: bool): + self._set_ui_running(False) + if success: + self.log_panel.append_log("Script execution completed", "success") + + def _on_script_error(self, error_msg: str): + self._set_ui_running(False) + self.log_panel.append_log("Script error: " + error_msg, "error") diff --git a/src/virtualbox_client/app/upload_worker.py b/src/virtualbox_client/app/upload_worker.py new file mode 100644 index 0000000..377f5e9 --- /dev/null +++ b/src/virtualbox_client/app/upload_worker.py @@ -0,0 +1,96 @@ +from PySide6.QtCore import QObject, Signal +import threading + + +class UploadWorker(QObject): + """后台线程执行文件上传(支持 VirtualBox 和纯 SSH 模式)""" + + log_signal = Signal(str, str) + finished_signal = Signal(bool) + error_signal = Signal(str) + + def __init__(self, config: dict, local_path: str, remote_path: str): + super().__init__() + self.config = config + self.local_path = local_path + self.remote_path = remote_path + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + self.log_signal.emit( + f"📤 正在上传: {self.local_path} -> {self.remote_path}", "info" + ) + original_print = print + import builtins + + def custom_print(*args, **kwargs): + msg = " ".join(str(a) for a in args) + self.log_signal.emit(msg, "info") + original_print(*args, **kwargs) + + builtins.print = custom_print + + try: + success = vm.upload_file(self.local_path, self.remote_path) + if success: + self.log_signal.emit("✅ 文件上传成功", "success") + self.finished_signal.emit(True) + else: + self.log_signal.emit("❌ 文件上传失败", "error") + self.finished_signal.emit(False) + finally: + builtins.print = original_print + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 上传错误: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass diff --git a/src/virtualbox_client/app/workspace_panel.py b/src/virtualbox_client/app/workspace_panel.py new file mode 100644 index 0000000..8828a54 --- /dev/null +++ b/src/virtualbox_client/app/workspace_panel.py @@ -0,0 +1,834 @@ +import os +import time +from datetime import datetime + +from .workspace_worker import WorkspaceWorker +from .download_worker import DownloadWorker +from .file_size_formatter import FileSizeFormatter +from .config_panel import ConfigPanel +from .log_panel import LogPanel +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QLabel, + QTreeWidget, + QAbstractItemView, + QFrame, + QProgressBar, + QTreeWidgetItem, + QMenu, + QInputDialog, + QFileDialog, + QMessageBox, +) +from PySide6.QtCore import Qt, QThread, QTimer +from PySide6.QtGui import QAction, QColor, QBrush + + +class WorkspacePanel(QWidget): + """工作区面板 - 树形文件浏览器,双击文件夹展开子目录,单击显示文件信息""" + + def __init__(self, config_panel: ConfigPanel, log_panel: "LogPanel"): + super().__init__() + self._config_panel = config_panel + self.log_panel = log_panel + self._busy = False + self._loaded_dirs = set() # 已加载的目录路径 + self._worker_thread = None + self._worker = None + self._pending_expand_item = None # 待展开的树节点 + self._current_info_item = None # 当前显示详细信息的项 + self._download_worker_thread = None + self._download_worker = None + + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + + # ---- 工具栏 ---- + toolbar = QHBoxLayout() + + self.refresh_btn = QPushButton("🔄 刷新") + self.refresh_btn.setStyleSheet(""" + QPushButton { background-color: #0e639c; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #1177bb; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.refresh_btn.clicked.connect(self.refresh_root) + toolbar.addWidget(self.refresh_btn) + + self.upload_btn = QPushButton("📤 上传文件") + self.upload_btn.setStyleSheet(""" + QPushButton { background-color: #2d7d46; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #389e54; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.upload_btn.clicked.connect(self._upload_to_workspace) + toolbar.addWidget(self.upload_btn) + + self.upload_dir_btn = QPushButton("📁 上传文件夹") + self.upload_dir_btn.setStyleSheet(""" + QPushButton { background-color: #16a085; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #1abc9c; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.upload_dir_btn.clicked.connect(self._upload_folder_to_workspace) + toolbar.addWidget(self.upload_dir_btn) + + self.mkdir_btn = QPushButton("📂 新建文件夹") + self.mkdir_btn.setStyleSheet(""" + QPushButton { background-color: #e67e22; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #f39c12; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.mkdir_btn.clicked.connect(self._create_folder) + toolbar.addWidget(self.mkdir_btn) + + self.home_btn = QPushButton("🏠 回到根目录") + self.home_btn.setStyleSheet(""" + QPushButton { background-color: #6c5ce7; color: white; padding: 6px 14px; + font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } + QPushButton:hover { background-color: #7c6cf7; } + QPushButton:disabled { background-color: #555; color: #999; } + """) + self.home_btn.clicked.connect(self._go_home) + toolbar.addWidget(self.home_btn) + + toolbar.addStretch() + + self.status_label = QLabel("就绪") + self.status_label.setStyleSheet("color: #888; font-size: 12px;") + toolbar.addWidget(self.status_label) + + layout.addLayout(toolbar) + + # ---- 文件树(真正的树状结构) ---- + self.file_tree = QTreeWidget() + self.file_tree.setHeaderLabels(["名称", "大小", "类型", "修改时间", "权限"]) + self.file_tree.setColumnWidth(0, 280) + self.file_tree.setColumnWidth(1, 100) + self.file_tree.setColumnWidth(2, 80) + self.file_tree.setColumnWidth(3, 160) + self.file_tree.setColumnWidth(4, 80) + self.file_tree.setAlternatingRowColors(True) + self.file_tree.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.file_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.file_tree.customContextMenuRequested.connect(self._show_context_menu) + self.file_tree.setRootIsDecorated(True) + self.file_tree.setAnimated(True) + + # 双击展开/收起文件夹(懒加载) + self.file_tree.itemDoubleClicked.connect(self._on_item_double_clicked) + # 单击显示文件信息 + self.file_tree.itemClicked.connect(self._on_item_clicked) + + self.file_tree.setStyleSheet(""" + QTreeWidget { + background-color: #1e1e1e; color: #d4d4d4; + border: 1px solid #3c3c3c; border-radius: 4px; + alternate-background-color: #2a2a2a; + font-size: 13px; + } + QTreeWidget::item:hover { background-color: #2d2d2d; } + QTreeWidget::item:selected { background-color: #094771; } + QTreeWidget::branch:has-children:closed:has-siblings { border-image: none; } + QTreeWidget::branch:open:has-children:has-siblings { border-image: none; } + QHeaderView::section { + background-color: #2d2d2d; color: #cccccc; + padding: 6px; border: none; border-bottom: 1px solid #3c3c3c; + font-weight: bold; + } + """) + + layout.addWidget(self.file_tree) + + # ---- 文件详细信息面板(单击文件时显示) ---- + self.info_panel = QFrame() + self.info_panel.setFrameShape(QFrame.Shape.StyledPanel) + self.info_panel.setStyleSheet(""" + QFrame { + background-color: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 4px; + padding: 8px; + } + QLabel { color: #d4d4d4; font-size: 12px; padding: 2px; } + """) + info_layout = QVBoxLayout(self.info_panel) + info_layout.setContentsMargins(8, 4, 8, 4) + self.info_label = QLabel("选择文件以查看详情") + self.info_label.setStyleSheet( + "color: #888; font-style: italic; font-size: 12px;" + ) + info_layout.addWidget(self.info_label) + self.info_panel.setMaximumHeight(60) + self.info_panel.hide() + layout.addWidget(self.info_panel) + + # ---- 进度条 ---- + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 0) + self.progress_bar.setMaximumHeight(4) + self.progress_bar.setTextVisible(False) + self.progress_bar.hide() + layout.addWidget(self.progress_bar) + + # ---- 线程管理 ---- + + def _cleanup_worker(self): + if self._worker_thread: + try: + self._worker_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._worker_thread.isRunning(): + self._worker_thread.quit() + if not self._worker_thread.wait(5000): + self._worker_thread.terminate() + self._worker_thread.wait(3000) + self._worker_thread = None + if self._worker: + try: + self._worker.log_signal.disconnect() + self._worker.finished_signal.disconnect() + self._worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._worker = None + + def _set_busy(self, busy: bool, status_text: str = ""): + self._busy = busy + self.refresh_btn.setEnabled(not busy) + self.upload_btn.setEnabled(not busy) + self.upload_dir_btn.setEnabled(not busy) + self.mkdir_btn.setEnabled(not busy) + self.home_btn.setEnabled(not busy) + self.file_tree.setEnabled(not busy) + if busy: + self.progress_bar.show() + self.status_label.setText(status_text or "操作中...") + else: + self.progress_bar.hide() + self.status_label.setText(status_text or "就绪") + + def _validate_ssh_config(self) -> bool: + if not self._config_panel: + return False + cfg = self._config_panel.get_config() + missing = [] + if not cfg["ssh_host"]: + missing.append("SSH 主机") + if not cfg["ssh_user"]: + missing.append("SSH 用户") + if not cfg["ssh_pass"]: + missing.append("SSH 密码") + if missing: + self.log_panel.append_log( + "❌ SSH 配置不完整: " + ", ".join(missing), "error" + ) + return False + return True + + def _start_worker(self, operation: str, **kwargs): + if not self._config_panel or not self._validate_ssh_config(): + self._set_busy(False, "SSH 配置不完整") + return + + self._cleanup_worker() + self._set_busy(True, "正在连接...") + + config = self._config_panel.get_config() + self._worker = WorkspaceWorker(config, operation, **kwargs) + self._worker_thread = QThread(self) + self._worker.moveToThread(self._worker_thread) + + self._worker.log_signal.connect(self.log_panel.append_log) + self._worker.finished_signal.connect(self._on_worker_finished) + self._worker.error_signal.connect(self._on_worker_error) + self._worker_thread.started.connect(self._worker.run) + self._worker_thread.finished.connect(self._cleanup_worker) + + self._worker_thread.start() + + def _on_worker_finished(self, result): + self._set_busy(False, "就绪") + + if self._worker and self._worker.operation == WorkspaceWorker.OPERATION_LIST: + if self._pending_expand_item: + # 展开子目录的结果 + self._apply_directory_children(self._pending_expand_item, result) + self._pending_expand_item = None + else: + # 根目录刷新结果 + self._refresh_root_tree(result) + count = len(result) if isinstance(result, list) else 0 + self.log_panel.append_log(f"📂 共 {count} 个文件/文件夹", "info") + elif self._worker and self._worker.operation in ( + WorkspaceWorker.OPERATION_UPLOAD, + WorkspaceWorker.OPERATION_UPLOAD_DIR, + ): + if result: + self.log_panel.append_log("✅ 上传成功", "success") + else: + self.log_panel.append_log("❌ 上传失败", "error") + QTimer.singleShot(500, self._soft_refresh) + else: + if result: + self.log_panel.append_log("✅ 操作成功", "success") + else: + self.log_panel.append_log("❌ 操作失败", "error") + QTimer.singleShot(500, self._soft_refresh) + + def _on_worker_error(self, error_msg): + self._set_busy(False, "错误") + self.log_panel.append_log(f"❌ 工作区错误: {error_msg}", "error") + self._pending_expand_item = None + + # ---- 树状结构核心逻辑 ---- + + def refresh_root(self): + """刷新根目录""" + self._loaded_dirs.clear() + self._pending_expand_item = None + self.file_tree.clear() + self.info_panel.hide() + self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path="~/workspace") + + def _soft_refresh(self): + """刷新后保持展开状态""" + self._loaded_dirs.clear() + self._pending_expand_item = None + self.file_tree.clear() + self.info_panel.hide() + self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path="~/workspace") + + def _go_home(self): + """回到根目录:收起所有节点,仅显示根目录""" + self.file_tree.collapseAll() + self.info_panel.hide() + + def _refresh_root_tree(self, files: list): + """用文件列表填充根树""" + if not isinstance(files, list): + return + self.file_tree.clear() + + for f in files: + item = self._create_tree_item(f) + self.file_tree.addTopLevelItem(item) + + self.file_tree.sortItems(0, Qt.SortOrder.AscendingOrder) + + def _apply_directory_children(self, parent_item: QTreeWidgetItem, files: list): + """给一个已展开的文件夹项填充子节点""" + if not isinstance(files, list): + return + + # 移除旧的占位符 + while parent_item.childCount() > 0: + child = parent_item.child(0) + if child and child.data(0, Qt.ItemDataRole.UserRole + 10) == "placeholder": + parent_item.removeChild(child) + else: + break + + # 再清空所有子节点(因为已经懒加载过了) + parent_item.takeChildren() + + # 添加新的子节点 + for f in files: + child_item = self._create_tree_item(f) + # 如果是文件夹,添加一个占位子节点使其可展开 + if f.get("is_dir", False): + placeholder = QTreeWidgetItem() + placeholder.setText(0, "") + placeholder.setData(0, Qt.ItemDataRole.UserRole + 10, "placeholder") + child_item.addChild(placeholder) + parent_item.addChild(child_item) + + parent_item.setExpanded(True) + self.file_tree.sortItems(0, Qt.SortOrder.AscendingOrder) + + def _create_tree_item(self, f: dict) -> QTreeWidgetItem: + """根据文件信息创建树节点项""" + name = f.get("name", "未知") + is_dir = f.get("is_dir", False) + size = f.get("size", 0) + modified = f.get("modified", "") + perm = f.get("permissions", "") + + if isinstance(modified, (int, float)): + try: + modified = datetime.fromtimestamp(modified).strftime( + "%Y-%m-%d %H:%M:%S" + ) + except Exception: + modified = str(modified) + + item = QTreeWidgetItem() + item.setText(0, name) + item.setData(0, Qt.ItemDataRole.UserRole, f.get("path", "")) + item.setData(0, Qt.ItemDataRole.UserRole + 1, is_dir) + + if is_dir: + item.setText(1, "—") + item.setText(2, "📁 文件夹") + item.setForeground(0, QBrush(QColor("#569cd6"))) + # 添加一个占位子节点使其可展开 + placeholder = QTreeWidgetItem() + placeholder.setText(0, "") + placeholder.setData(0, Qt.ItemDataRole.UserRole + 10, "placeholder") + item.addChild(placeholder) + else: + item.setText(1, FileSizeFormatter.format(size)) + ext = os.path.splitext(name)[1].lower() + type_map = { + ".py": "🐍 Python", + ".js": "🟨 JavaScript", + ".ts": "🔵 TypeScript", + ".html": "🌐 HTML", + ".css": "🎨 CSS", + ".json": "📋 JSON", + ".txt": "📄 文本", + ".md": "📝 Markdown", + ".yaml": "⚙️ YAML", + ".yml": "⚙️ YAML", + ".sh": "⚡ Shell", + ".zip": "📦 ZIP", + ".tar": "📦 TAR", + ".gz": "📦 GZip", + ".png": "🖼️ 图片", + ".jpg": "🖼️ 图片", + ".jpeg": "🖼️ 图片", + ".gif": "🖼️ GIF", + ".svg": "🖼️ SVG", + ".pdf": "📕 PDF", + ".log": "📋 日志", + ".conf": "⚙️ 配置", + ".env": "🔒 环境", + ".toml": "⚙️ TOML", + ".xml": "📋 XML", + ".lock": "🔒 锁定", + } + item.setText(2, type_map.get(ext, "📄 文件")) + item.setForeground(0, QBrush(QColor("#d4d4d4"))) + + item.setText(3, str(modified)) + item.setText(4, perm) + + return item + + def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int): + """双击节点:文件夹展开/收起懒加载,文件显示信息""" + is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) + path = item.data(0, Qt.ItemDataRole.UserRole) + + if not is_dir: + # 双击文件显示详细信息 + self._show_file_info(item) + return + + if not path: + return + + # 如果已展开,直接折叠 + if item.isExpanded(): + item.setExpanded(False) + return + + # 检查是否已加载过子目录(通过是否有真实子节点判断) + has_real_children = False + for i in range(item.childCount()): + child = item.child(i) + if child and child.data(0, Qt.ItemDataRole.UserRole + 10) != "placeholder": + has_real_children = True + break + + if has_real_children: + item.setExpanded(True) + return + + # 懒加载:从远程获取子目录内容 + self.log_panel.append_log(f"📂 正在展开: {path}", "info") + self._pending_expand_item = item + self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path=path) + + def _on_item_clicked(self, item: QTreeWidgetItem, column: int): + """单击节点显示文件信息""" + self._show_file_info(item) + + def _show_file_info(self, item: QTreeWidgetItem): + """显示文件/文件夹的详细信息""" + name = item.text(0) + is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) + path = item.data(0, Qt.ItemDataRole.UserRole) or "" + + size = item.text(1) + ftype = item.text(2) + modified = item.text(3) + perm = item.text(4) + + if is_dir: + child_count = 0 + for i in range(item.childCount()): + child = item.child(i) + if ( + child + and child.data(0, Qt.ItemDataRole.UserRole + 10) != "placeholder" + ): + child_count += 1 + info_parts = [ + f"📁 **{name}**", + f"路径: {path}", + f"类型: 文件夹", + f"大小: {size}", + f"修改时间: {modified}" if modified else "", + ] + info_text = " | ".join(p for p in info_parts if p) + else: + info_parts = [ + f"📄 **{name}**", + f"路径: {path}", + f"类型: {ftype}", + f"大小: {size}", + f"修改时间: {modified}" if modified else "", + ] + info_text = " | ".join(p for p in info_parts if p) + + self.info_label.setText(f"📌 {info_text}") + self.info_panel.show() + self._current_info_item = item + + # ---- 右键菜单 ---- + + def _get_selected_paths(self) -> list: + paths = [] + for item in self.file_tree.selectedItems(): + path = item.data(0, Qt.ItemDataRole.UserRole) + if path: + paths.append(path) + return paths + + def _get_selected_names(self) -> list: + names = [] + for item in self.file_tree.selectedItems(): + names.append(item.text(0)) + return names + + def _show_context_menu(self, pos): + items = self.file_tree.selectedItems() + if not items: + return + + menu = QMenu(self) + menu.setStyleSheet(""" + QMenu { background-color: #2d2d2d; color: #ccc; border: 1px solid #3c3c3c; padding: 4px; } + QMenu::item { padding: 6px 24px; border-radius: 3px; } + QMenu::item:selected { background-color: #094771; } + QMenu::separator { height: 1px; background: #3c3c3c; margin: 4px 8px; } + """) + + if len(items) == 1: + item = items[0] + name = item.text(0) + is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) + + rename_action = QAction(f'✏️ 重命名 "{name}"', self) + rename_action.triggered.connect(lambda: self._rename_item(item)) + menu.addAction(rename_action) + + copy_action = QAction(f'📋 复制 "{name}"', self) + copy_action.triggered.connect(lambda: self._copy_item(item)) + menu.addAction(copy_action) + + if is_dir: + download_action = QAction("📥 下载文件夹(打包为 zip)", self) + else: + download_action = QAction("📥 下载文件", self) + download_action.triggered.connect(lambda: self._download_item(item)) + menu.addAction(download_action) + + menu.addSeparator() + + delete_action = QAction(f"🗑️ 删除选中的 {len(items)} 个项目", self) + delete_action.triggered.connect(self._delete_selected) + menu.addAction(delete_action) + + menu.exec(self.file_tree.viewport().mapToGlobal(pos)) + + # ---- 文件操作 ---- + + def _rename_item(self, item): + old_name = item.text(0) + new_name, ok = QInputDialog.getText( + self, "重命名", "请输入新名称:", text=old_name + ) + if not ok or not new_name.strip() or new_name.strip() == old_name: + return + + src_path = item.data(0, Qt.ItemDataRole.UserRole) + if not src_path: + return + + dst_dir = ( + src_path.rsplit("/", 1)[0] + if "/" in src_path + else src_path.rsplit("\\", 1)[0] + ) + dst_path = dst_dir.rstrip("/") + "/" + new_name.strip() + + self._start_worker( + WorkspaceWorker.OPERATION_RENAME, + src=src_path, + dst=dst_path, + ) + + def _copy_item(self, item): + src_path = item.data(0, Qt.ItemDataRole.UserRole) + name = item.text(0) + base, ext = os.path.splitext(name) + new_name = f"{base}_copy{ext}" + dst_dir = ( + src_path.rsplit("/", 1)[0] + if "/" in src_path + else src_path.rsplit("\\", 1)[0] + ) + dst_path = dst_dir.rstrip("/") + "/" + new_name + + self._start_worker( + WorkspaceWorker.OPERATION_COPY, + src=src_path, + dst=dst_path, + ) + + def _download_item(self, item): + path = item.data(0, Qt.ItemDataRole.UserRole) + name = item.text(0) + is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) + + if is_dir: + local_path, _ = QFileDialog.getSaveFileName( + self, "保存文件夹", f"{name}.zip", "ZIP 文件 (*.zip)" + ) + if not local_path: + return + self._download_folder_as_zip(path, local_path) + else: + local_path, _ = QFileDialog.getSaveFileName( + self, "保存文件", name, "所有文件 (*)" + ) + if not local_path: + return + self._download_single_file(path, local_path) + + def _cleanup_download_worker(self): + """清理下载线程和 worker""" + if self._download_worker_thread: + try: + self._download_worker_thread.finished.disconnect() + except TypeError, RuntimeError: + pass + if self._download_worker_thread.isRunning(): + self._download_worker_thread.quit() + if not self._download_worker_thread.wait(5000): + self._download_worker_thread.terminate() + self._download_worker_thread.wait(3000) + self._download_worker_thread = None + if self._download_worker: + try: + self._download_worker.log_signal.disconnect() + self._download_worker.finished_signal.disconnect() + self._download_worker.error_signal.disconnect() + except TypeError, RuntimeError: + pass + self._download_worker = None + + def _download_single_file(self, remote_path, local_path): + if not self._config_panel or not self._validate_ssh_config(): + return + + # 确保本地目标目录存在 + local_dir = os.path.dirname(local_path) + if local_dir: + os.makedirs(local_dir, exist_ok=True) + + self._cleanup_download_worker() + + config = self._config_panel.get_config() + self._download_worker = DownloadWorker(config, remote_path, local_path) + self._download_worker_thread = QThread(self) + self._download_worker.moveToThread(self._download_worker_thread) + + self._download_worker.log_signal.connect(self.log_panel.append_log) + self._download_worker.finished_signal.connect(self._on_download_worker_finished) + self._download_worker.error_signal.connect(self._on_download_worker_error) + self._download_worker_thread.started.connect(self._download_worker.run) + self._download_worker_thread.finished.connect(self._cleanup_download_worker) + + self.log_panel.append_log(f"📥 正在下载: {remote_path} -> {local_path}", "info") + self._download_worker_thread.start() + + def _on_download_worker_finished(self, success: bool): + self.log_panel.append_log( + "✅ 下载完成" if success else "❌ 下载失败", + "success" if success else "error", + ) + # 通知线程退出事件循环,触发 finished 信号从而调用 _cleanup_download_worker + if self._download_worker_thread and self._download_worker_thread.isRunning(): + self._download_worker_thread.quit() + + def _on_download_worker_error(self, error_msg: str): + self.log_panel.append_log(f"❌ 下载失败: {error_msg}", "error") + if self._download_worker_thread and self._download_worker_thread.isRunning(): + self._download_worker_thread.quit() + + def _download_folder_as_zip(self, folder_path: str, local_zip_path: str): + if not self._config_panel or not self._validate_ssh_config(): + return + + config = self._config_panel.get_config() + ssh_only = config.get("ssh_only", False) + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + config["ssh_host"], + config["ssh_port"], + config["ssh_user"], + config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + config["vm_name"], + config["ssh_host"], + config["ssh_port"], + config["ssh_user"], + config["ssh_pass"], + ) + + self.log_panel.append_log( + "📦 正在打包远程文件夹(排除 .venv 和 node_modules)...", "info" + ) + zip_name = f"/tmp/workspace_download_{int(time.time())}.zip" + vm.exec_command( + f'cd "{folder_path}" && zip -r "{zip_name}" . -x ".venv/*" "./src/chongming-web/node_modules/*"' + ) + + self.log_panel.append_log("📥 正在下载打包文件...", "info") + success = vm.download_file(zip_name, local_zip_path) + vm.exec_command(f'rm -f "{zip_name}"') + + if success: + self.log_panel.append_log( + f"✅ 文件夹已下载到: {local_zip_path}", "success" + ) + else: + self.log_panel.append_log("❌ 文件夹下载失败", "error") + + except Exception as e: + self.log_panel.append_log(f"❌ 下载失败: {e}", "error") + + def _delete_selected(self): + paths = self._get_selected_paths() + names = self._get_selected_names() + if not paths: + return + + confirm_msg = "确定要删除以下项目吗?\n" + "\n".join(f" - {n}" for n in names) + reply = QMessageBox.question( + self, + "确认删除", + confirm_msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + self._delete_paths_serial(paths) + + def _delete_paths_serial(self, paths: list): + """批量删除所有选中路径(单条命令一次性删除所有,避免递归线程)""" + if not paths: + self._soft_refresh() + return + + self._cleanup_worker() + self._set_busy(True, "正在删除...") + + config = self._config_panel.get_config() + self._worker = WorkspaceWorker( + config, WorkspaceWorker.OPERATION_DELETE, path=paths + ) + self._worker_thread = QThread(self) + self._worker.moveToThread(self._worker_thread) + + self._worker.log_signal.connect(self.log_panel.append_log) + self._worker.finished_signal.connect(self._on_batch_delete_finished) + self._worker.error_signal.connect(self._on_worker_error) + self._worker_thread.started.connect(self._worker.run) + self._worker_thread.finished.connect(self._cleanup_worker) + + self._worker_thread.start() + + def _on_batch_delete_finished(self, result): + self._set_busy(False, "就绪") + if result: + self.log_panel.append_log("✅ 删除完成", "success") + else: + self.log_panel.append_log("❌ 删除失败", "error") + self._soft_refresh() + + def _upload_folder_to_workspace(self): + if not self._config_panel or not self._validate_ssh_config(): + return + + local_dir = QFileDialog.getExistingDirectory(self, "选择要上传到工作区的文件夹") + if not local_dir: + return + + folder_name = os.path.basename(local_dir.rstrip("/\\")) + remote_path = f"~/workspace/{folder_name}" + self._start_worker( + WorkspaceWorker.OPERATION_UPLOAD_DIR, + local_path=local_dir, + remote_path=remote_path, + ) + + def _upload_to_workspace(self): + if not self._config_panel or not self._validate_ssh_config(): + return + + local_paths, _ = QFileDialog.getOpenFileNames(self, "选择要上传到工作区的文件") + if not local_paths: + return + + # 只上传第一个文件(后续通过多次点击上传更多文件) + local_path = local_paths[0] + name = os.path.basename(local_path) + remote_path = f"~/workspace/{name}" + self._start_worker( + WorkspaceWorker.OPERATION_UPLOAD, + local_path=local_path, + remote_path=remote_path, + ) + + def _create_folder(self): + name, ok = QInputDialog.getText(self, "新建文件夹", "请输入文件夹名称:") + if not ok or not name.strip(): + return + self._start_worker(WorkspaceWorker.OPERATION_MKDIR, name=name.strip()) diff --git a/src/virtualbox_client/app/workspace_worker.py b/src/virtualbox_client/app/workspace_worker.py new file mode 100644 index 0000000..8ae765f --- /dev/null +++ b/src/virtualbox_client/app/workspace_worker.py @@ -0,0 +1,263 @@ +from PySide6.QtCore import QObject, Signal +import threading +from datetime import datetime + + +class WorkspaceWorker(QObject): + """后台线程执行工作区文件操作(列表、删除、复制、重命名、创建目录)""" + + log_signal = Signal(str, str) + finished_signal = Signal(object) + error_signal = Signal(str) + + OPERATION_LIST = "list" + OPERATION_DELETE = "delete" + OPERATION_COPY = "copy" + OPERATION_RENAME = "rename" + OPERATION_MKDIR = "mkdir" + OPERATION_UPLOAD = "upload" + OPERATION_UPLOAD_DIR = "upload_dir" + + def __init__(self, config: dict, operation: str, **kwargs): + super().__init__() + self.config = config + self.operation = operation + self.kwargs = kwargs + self.cancel_event = threading.Event() + + def cancel(self): + self.cancel_event.set() + + def run(self): + ssh_only = self.config.get("ssh_only", False) + _com_initialized = False + if not ssh_only: + try: + import ctypes + + ctypes.windll.ole32.CoInitializeEx(None, 0) + _com_initialized = True + except Exception: + pass + + try: + if ssh_only: + from virtualbox_client.utils.ssh_manager import SSHManager + + vm = SSHManager( + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + else: + from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager + + vm = VBoxSSHManager( + self.config["vm_name"], + self.config["ssh_host"], + self.config["ssh_port"], + self.config["ssh_user"], + self.config["ssh_pass"], + ) + vm.set_cancel_event(self.cancel_event) + + if self.cancel_event.is_set(): + return + + if self.operation == WorkspaceWorker.OPERATION_LIST: + dir_path = self.kwargs.get("dir_path", "~/workspace") + result = self._list_files(vm, dir_path) + elif self.operation == WorkspaceWorker.OPERATION_DELETE: + result = self._delete_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_COPY: + result = self._copy_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_RENAME: + result = self._rename_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_MKDIR: + result = self._mkdir(vm) + elif self.operation == WorkspaceWorker.OPERATION_UPLOAD: + result = self._upload_file(vm) + elif self.operation == WorkspaceWorker.OPERATION_UPLOAD_DIR: + result = self._upload_directory(vm) + else: + raise ValueError(f"未知操作: {self.operation}") + + if self.cancel_event.is_set(): + return + + self.finished_signal.emit(result) + + except ImportError as e: + self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") + self.error_signal.emit(str(e)) + except Exception as e: + self.log_signal.emit(f"❌ 工作区操作失败: {str(e)}", "error") + self.error_signal.emit(str(e)) + finally: + if _com_initialized: + try: + import ctypes + + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + + def _ensure_workspace(self, vm) -> bool: + out, err, code = vm.exec_command("mkdir -p ~/workspace") + return code == 0 + + def _list_files(self, vm, dir_path: str = "~/workspace") -> list: + """列出指定目录中的文件,返回文件信息列表。""" + import base64 + import json as json_mod + + # 对包含 ~ 的路径使用 shell 展开创建目录 + vm.exec_command(f"mkdir -p {dir_path}") + self.log_signal.emit(f"📂 正在扫描: {dir_path}", "info") + + py_script = ( + "import os,sys,json,time\n" + 'ws=os.path.expanduser("' + dir_path.replace('"', '\\"') + '")\n' + "files=[]\n" + "try:\n" + " for f in sorted(os.listdir(ws),key=lambda x:(not os.path.isdir(os.path.join(ws,x)),x.lower())):\n" + " if f.startswith('.'):\n" + " continue\n" + " fp=os.path.join(ws,f)\n" + " try:\n" + " s=os.stat(fp)\n" + " files.append({'name':f,'size':s.st_size,'is_dir':os.path.isdir(fp),'modified':int(s.st_mtime),'path':fp})\n" + " except:\n" + " pass\n" + "except Exception as e:\n" + " sys.stderr.write(str(e))\n" + "print(json.dumps(files))\n" + ) + encoded = base64.b64encode(py_script.encode()).decode() + + out, err, code = vm.exec_command( + f'echo "{encoded}" | base64 -d 2>/dev/null | python3 2>/dev/null' + ) + + if code != 0 or not out or not out.strip(): + out, err, code = vm.exec_command( + f'echo "{encoded}" | base64 -d 2>/dev/null | python 2>/dev/null' + ) + if code != 0 or not out or not out.strip(): + self.log_signal.emit( + "⚠️ Python 不可用,使用 find 命令列出文件", "warning" + ) + # 展开 ~ 为真实路径(find 命令本身不会展开 ~) + resolved_dir = dir_path + if resolved_dir.startswith("~/"): + home_out, _, _ = vm.exec_command("echo ~") + if home_out and home_out.strip(): + resolved_dir = home_out.strip() + resolved_dir[1:] + out2, err2, code2 = vm.exec_command( + f'find {resolved_dir} -maxdepth 1 ! -name "." ! -name ".." -printf "%f|%y|%s\\n" 2>/dev/null | sort' + ) + if code2 != 0 or not out2 or not out2.strip(): + self.log_signal.emit(f"ℹ️ 目录为空或不可访问: {dir_path}", "info") + return [] + + files = [] + for line in out2.strip().split("\n"): + line = line.strip() + if not line: + continue + parts = line.split("|") + if len(parts) < 3: + continue + name, ftype, size_str = parts[0], parts[1], parts[2] + try: + size = int(size_str) + except ValueError: + size = 0 + files.append( + { + "name": name, + "size": size, + "is_dir": ftype == "d", + "modified": "", + "path": f"{resolved_dir}/{name}", + } + ) + return files + + try: + files = json_mod.loads(out.strip()) + for f in files: + f.setdefault("name", "未知") + f.setdefault("size", 0) + f.setdefault("is_dir", False) + f.setdefault("modified", "") + f.setdefault("path", f"~/workspace/{f.get('name', '')}") + if isinstance(f.get("modified"), (int, float)) and f["modified"]: + try: + f["modified"] = datetime.fromtimestamp(f["modified"]).strftime( + "%Y-%m-%d %H:%M:%S" + ) + except Exception: + f["modified"] = str(f["modified"]) + self.log_signal.emit( + f"📂 扫描完成: 找到 {len(files)} 个文件/文件夹", "info" + ) + return files + except (json_mod.JSONDecodeError, Exception) as e: + self.log_signal.emit(f"❌ 解析文件列表失败: {e}", "error") + return [] + + def _delete_file(self, vm) -> bool: + paths = self.kwargs.get("path", []) + if not paths: + return False + if isinstance(paths, str): + paths = [paths] + # 使用空格分隔拼接多个路径,统一用双引号包裹以支持含空格的路径 + joined = " ".join(f'"{p}"' for p in paths) + out, err, code = vm.exec_command(f"rm -rf {joined}") + return code == 0 + + def _copy_file(self, vm) -> bool: + src = self.kwargs.get("src", "") + dst = self.kwargs.get("dst", "") + if not src or not dst: + return False + out, err, code = vm.exec_command(f'cp -r "{src}" "{dst}"') + return code == 0 + + def _rename_file(self, vm) -> bool: + src = self.kwargs.get("src", "") + dst = self.kwargs.get("dst", "") + if not src or not dst: + return False + out, err, code = vm.exec_command(f'mv "{src}" "{dst}"') + return code == 0 + + def _mkdir(self, vm) -> bool: + name = self.kwargs.get("name", "") + if not name: + return False + out, err, code = vm.exec_command(f"mkdir -p ~/workspace/{name}") + return code == 0 + + def _upload_file(self, vm) -> bool: + local_path = self.kwargs.get("local_path", "") + remote_path = self.kwargs.get("remote_path", "") + if not local_path or not remote_path: + return False + remote_dir = ( + remote_path.rsplit("/", 1)[0] + if "/" in remote_path + else remote_path.rsplit("\\", 1)[0] + ) + vm.exec_command(f'mkdir -p "{remote_dir}"') + return vm.upload_file(local_path, remote_path) + + def _upload_directory(self, vm) -> bool: + local_dir = self.kwargs.get("local_path", "") + remote_dir = self.kwargs.get("remote_path", "") + if not local_dir or not remote_dir: + return False + return vm.upload_directory(local_dir, remote_dir) diff --git a/src/virtualbox_client/gui.py b/src/virtualbox_client/gui.py index 48e7fd3..a4b034d 100644 --- a/src/virtualbox_client/gui.py +++ b/src/virtualbox_client/gui.py @@ -3,11 +3,6 @@ VirtualBox Client - PySide6 图形界面 """ import sys -import os -import json -import threading -import time -from datetime import datetime try: from dotenv import load_dotenv @@ -22,2459 +17,17 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, - QGroupBox, - QFormLayout, QLabel, - QLineEdit, - QSpinBox, - QPushButton, - QPlainTextEdit, QSplitter, QStatusBar, - QMessageBox, QFrame, QScrollArea, - QCheckBox, - QFileDialog, - QTabWidget, - QTreeWidget, - QTreeWidgetItem, - QHeaderView, - QMenu, - QInputDialog, - QAbstractItemView, - QProgressBar, ) -from PySide6.QtCore import Qt, QObject, QThread, Signal, QTimer, QSize -from PySide6.QtGui import QFont, QTextCursor, QAction, QIcon, QColor, QBrush +from PySide6.QtCore import Qt - -class AgentWorker(QObject): - """ - 后台线程执行 Agent 任务,通过 Qt Signal 发送日志到主线程。 - 使用 Signal 机制避免了定时器轮询队列带来的延迟和性能开销。 - 支持 VirtualBox 模式和纯 SSH 模式两种运行方式。 - """ - - # 定义信号:日志消息、任务完成(携带 vm_started_by_us)、任务错误、任务取消 - log_signal = Signal(str, str) # (message, level) - finished_signal = Signal(bool) # vm_started_by_us:是否由本任务启动的虚拟机 - error_signal = Signal(str) - cancelled_signal = Signal() - - def __init__(self, config: dict, task: str): - super().__init__() - self.config = config - self.task = task - # 使用共享的 threading.Event,同时控制 AgentWorker 和底层管理器的取消 - self.cancel_event = threading.Event() - # 保存 vm 引用以便在主线程也能取消底层操作 - self._vm = None - - def cancel(self): - """取消任务——设置取消事件,通知所有层级的操作立即停止""" - self.cancel_event.set() - if self._vm is not None: - try: - self._vm.cancel_operations() - except Exception: - pass - - def get_vm(self): - """获取 VM 管理器引用(供主线程关闭 VM 使用)""" - return self._vm - - def is_cancelled(self): - return self.cancel_event.is_set() - - def run(self): - # Windows COM 初始化(仅 VirtualBox 模式需要) - _com_initialized = False - ssh_only = self.config.get("ssh_only", False) - if not ssh_only: - try: - import ctypes - - ctypes.windll.ole32.CoInitializeEx(None, 0) - _com_initialized = True - except Exception: - pass - - try: - from virtualbox_client.utils.vm_agent import VMAgent - - if ssh_only: - from virtualbox_client.utils.ssh_manager import SSHManager - - vm = SSHManager( - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - self.log_signal.emit("🔌 使用纯 SSH 模式(不依赖 VirtualBox)", "info") - else: - from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager - - vm = VBoxSSHManager( - self.config["vm_name"], - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - - vm.set_cancel_event(self.cancel_event) - self._vm = vm - - if self.is_cancelled(): - self.cancelled_signal.emit() - return - - agent = VMAgent( - vm, - api_key=self.config["api_key"], - base_url=self.config.get("base_url", ""), - model=self.config.get("model", "gpt-4o"), - sudo_password=self.config.get("ssh_pass", ""), - ) - - original_print = print - - def custom_print(*args, **kwargs): - msg = " ".join(str(a) for a in args) - self.log_signal.emit(msg, "info") - original_print(*args, **kwargs) - - import builtins - - builtins.print = custom_print - - try: - if self.is_cancelled(): - self.cancelled_signal.emit() - return - - self.log_signal.emit(f"📋 {self.task}", "task") - _, vm_started_by_us = agent.run(self.task) - - if self.is_cancelled(): - self.cancelled_signal.emit() - return - - self.log_signal.emit("✅ 任务完成", "success") - self.finished_signal.emit(vm_started_by_us) - finally: - builtins.print = original_print - - except ImportError as e: - self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") - self.error_signal.emit(str(e)) - except Exception as e: - self.log_signal.emit(f"❌ 错误: {str(e)}", "error") - self.error_signal.emit(str(e)) - finally: - if _com_initialized: - try: - import ctypes - - ctypes.windll.ole32.CoUninitialize() - except Exception: - pass - - -class ShutdownWorker(QObject): - """后台关闭虚拟机的工作线程——避免阻塞 UI 和跨线程 COM 冲突""" - - finished = Signal() - error = Signal(str) - log = Signal(str, str) - - def __init__(self, config: dict): - super().__init__() - self.config = config - - def run(self): - ssh_only = self.config.get("ssh_only", False) - if not ssh_only: - try: - import ctypes - - ctypes.windll.ole32.CoInitializeEx(None, 0) - except Exception: - pass - try: - if ssh_only: - from virtualbox_client.utils.ssh_manager import SSHManager - - self.log.emit("🔌 纯 SSH 模式:正在关闭 SSH 连接...", "info") - vm = SSHManager( - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - vm.close_ssh() - self.log.emit("✅ SSH 连接已关闭", "success") - else: - from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager - - self.log.emit("🛑 正在关闭虚拟机...", "warning") - vm = VBoxSSHManager( - self.config["vm_name"], - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - vm.stop_vm() - self.log.emit("✅ 虚拟机已关闭", "success") - self.finished.emit() - except Exception as e: - self.log.emit(f"❌ 关闭失败: {e}", "error") - self.error.emit(str(e)) - finally: - if not ssh_only: - try: - import ctypes - - ctypes.windll.ole32.CoUninitialize() - except Exception: - pass - - -class ConfigPanel(QWidget): - """配置面板""" - - def __init__(self): - super().__init__() - self.setup_ui() - self.load_from_env() - - def setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - mode_group = QGroupBox("运行模式") - mode_layout = QVBoxLayout(mode_group) - self.ssh_only_check = QCheckBox("🔌 纯 SSH 模式(不依赖 VirtualBox)") - self.ssh_only_check.setToolTip( - "勾选后只需填写 SSH 连接信息,不需要 VirtualBox 环境和虚拟机名称" - ) - self.ssh_only_check.toggled.connect(self._on_mode_changed) - mode_layout.addWidget(self.ssh_only_check) - layout.addWidget(mode_group) - - vm_group = QGroupBox("虚拟机配置") - vm_form = QFormLayout(vm_group) - self.vm_name_edit = QLineEdit(placeholderText="例如: ubuntu20.04") - vm_form.addRow("虚拟机名称:", self.vm_name_edit) - self.ssh_host_edit = QLineEdit(placeholderText="例如: 127.0.0.1") - vm_form.addRow("SSH 主机:", self.ssh_host_edit) - self.ssh_port_spin = QSpinBox() - self.ssh_port_spin.setRange(1, 65535) - self.ssh_port_spin.setValue(2222) - vm_form.addRow("SSH 端口:", self.ssh_port_spin) - self.ssh_user_edit = QLineEdit(placeholderText="SSH 用户名") - vm_form.addRow("SSH 用户:", self.ssh_user_edit) - self.ssh_pass_edit = QLineEdit( - echoMode=QLineEdit.EchoMode.Password, placeholderText="SSH 密码" - ) - vm_form.addRow("SSH 密码:", self.ssh_pass_edit) - layout.addWidget(vm_group) - - llm_group = QGroupBox("大模型配置") - llm_form = QFormLayout(llm_group) - self.api_key_edit = QLineEdit( - echoMode=QLineEdit.EchoMode.Password, placeholderText="API Key" - ) - llm_form.addRow("API Key:", self.api_key_edit) - self.base_url_edit = QLineEdit(placeholderText="例如: https://api.deepseek.com") - llm_form.addRow("Base URL:", self.base_url_edit) - self.model_edit = QLineEdit(placeholderText="例如: deepseek-v4-flash") - llm_form.addRow("模型:", self.model_edit) - layout.addWidget(llm_group) - - btn_layout = QHBoxLayout() - self.load_env_btn = QPushButton("📂 从 .env 加载") - self.load_env_btn.clicked.connect(self.load_from_env) - btn_layout.addWidget(self.load_env_btn) - self.save_config_btn = QPushButton("💾 保存配置") - self.save_config_btn.clicked.connect(self.save_config) - btn_layout.addWidget(self.save_config_btn) - layout.addLayout(btn_layout) - layout.addStretch() - - def load_from_env(self): - self.vm_name_edit.setText(os.getenv("VM_NAME", "")) - self.ssh_host_edit.setText(os.getenv("VM_SSH_HOST", "127.0.0.1")) - try: - self.ssh_port_spin.setValue(int(os.getenv("VM_SSH_PORT", "2222"))) - except ValueError: - self.ssh_port_spin.setValue(2222) - self.ssh_user_edit.setText(os.getenv("VM_SSH_USER", "")) - self.ssh_pass_edit.setText(os.getenv("VM_SSH_PASS", "")) - self.api_key_edit.setText(os.getenv("LLM_API_KEY", "")) - self.base_url_edit.setText(os.getenv("LLM_BASE_URL", "")) - self.model_edit.setText(os.getenv("LLM_MODEL", "")) - - def save_config(self): - config = self.get_config() - try: - config_path = os.path.join( - os.path.dirname(__file__), "..", "..", "gui_config.json" - ) - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - QMessageBox.information(self, "保存成功", f"配置已保存到 {config_path}") - except Exception as e: - QMessageBox.warning(self, "保存失败", f"保存配置失败: {e}") - - def _on_mode_changed(self): - ssh_only = self.ssh_only_check.isChecked() - self.vm_name_edit.setEnabled(not ssh_only) - if ssh_only: - self.vm_name_edit.setPlaceholderText("纯 SSH 模式无需虚拟机名称") - else: - self.vm_name_edit.setPlaceholderText("例如: ubuntu20.04") - - def get_config(self) -> dict: - return { - "vm_name": self.vm_name_edit.text().strip(), - "ssh_host": self.ssh_host_edit.text().strip(), - "ssh_port": self.ssh_port_spin.value(), - "ssh_user": self.ssh_user_edit.text().strip(), - "ssh_pass": self.ssh_pass_edit.text().strip(), - "api_key": self.api_key_edit.text().strip(), - "base_url": self.base_url_edit.text().strip(), - "model": self.model_edit.text().strip(), - "ssh_only": self.ssh_only_check.isChecked(), - } - - def validate(self) -> bool: - cfg = self.get_config() - missing = [] - if not cfg["ssh_only"] and not cfg["vm_name"]: - missing.append("虚拟机名称") - if not cfg["ssh_host"]: - missing.append("SSH 主机") - if not cfg["ssh_user"]: - missing.append("SSH 用户") - if not cfg["ssh_pass"]: - missing.append("SSH 密码") - if not cfg["api_key"]: - missing.append("API Key") - if missing: - QMessageBox.warning( - self, - "配置不完整", - "请填写以下字段:\n" + "\n".join(f" - {m}" for m in missing), - ) - return False - return True - - def set_enabled(self, enabled: bool): - self.ssh_only_check.setEnabled(enabled) - self.vm_name_edit.setEnabled(enabled and not self.ssh_only_check.isChecked()) - self.ssh_host_edit.setEnabled(enabled) - self.ssh_port_spin.setEnabled(enabled) - self.ssh_user_edit.setEnabled(enabled) - self.ssh_pass_edit.setEnabled(enabled) - self.api_key_edit.setEnabled(enabled) - self.base_url_edit.setEnabled(enabled) - self.model_edit.setEnabled(enabled) - self.load_env_btn.setEnabled(enabled) - self.save_config_btn.setEnabled(enabled) - - -class UploadWorker(QObject): - """后台线程执行文件上传(支持 VirtualBox 和纯 SSH 模式)""" - - log_signal = Signal(str, str) - finished_signal = Signal(bool) - error_signal = Signal(str) - - def __init__(self, config: dict, local_path: str, remote_path: str): - super().__init__() - self.config = config - self.local_path = local_path - self.remote_path = remote_path - self.cancel_event = threading.Event() - - def cancel(self): - self.cancel_event.set() - - def run(self): - ssh_only = self.config.get("ssh_only", False) - _com_initialized = False - if not ssh_only: - try: - import ctypes - - ctypes.windll.ole32.CoInitializeEx(None, 0) - _com_initialized = True - except Exception: - pass - - try: - if ssh_only: - from virtualbox_client.utils.ssh_manager import SSHManager - - vm = SSHManager( - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - else: - from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager - - vm = VBoxSSHManager( - self.config["vm_name"], - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - vm.set_cancel_event(self.cancel_event) - - if self.cancel_event.is_set(): - return - - self.log_signal.emit( - f"📤 正在上传: {self.local_path} -> {self.remote_path}", "info" - ) - original_print = print - import builtins - - def custom_print(*args, **kwargs): - msg = " ".join(str(a) for a in args) - self.log_signal.emit(msg, "info") - original_print(*args, **kwargs) - - builtins.print = custom_print - - try: - success = vm.upload_file(self.local_path, self.remote_path) - if success: - self.log_signal.emit("✅ 文件上传成功", "success") - self.finished_signal.emit(True) - else: - self.log_signal.emit("❌ 文件上传失败", "error") - self.finished_signal.emit(False) - finally: - builtins.print = original_print - - except ImportError as e: - self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") - self.error_signal.emit(str(e)) - except Exception as e: - self.log_signal.emit(f"❌ 上传错误: {str(e)}", "error") - self.error_signal.emit(str(e)) - finally: - if _com_initialized: - try: - import ctypes - - ctypes.windll.ole32.CoUninitialize() - except Exception: - pass - - -class DownloadWorker(QObject): - """后台线程执行文件下载(支持 VirtualBox 和纯 SSH 模式)""" - - log_signal = Signal(str, str) - finished_signal = Signal(bool) - error_signal = Signal(str) - - def __init__(self, config: dict, remote_path: str, local_path: str): - super().__init__() - self.config = config - self.remote_path = remote_path - self.local_path = local_path - self.cancel_event = threading.Event() - - def cancel(self): - self.cancel_event.set() - - def run(self): - ssh_only = self.config.get("ssh_only", False) - _com_initialized = False - if not ssh_only: - try: - import ctypes - - ctypes.windll.ole32.CoInitializeEx(None, 0) - _com_initialized = True - except Exception: - pass - - try: - if ssh_only: - from virtualbox_client.utils.ssh_manager import SSHManager - - vm = SSHManager( - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - else: - from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager - - vm = VBoxSSHManager( - self.config["vm_name"], - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - vm.set_cancel_event(self.cancel_event) - - if self.cancel_event.is_set(): - return - - self.log_signal.emit( - f"📥 正在下载: {self.remote_path} -> {self.local_path}", "info" - ) - original_print = print - import builtins - - def custom_print(*args, **kwargs): - msg = " ".join(str(a) for a in args) - self.log_signal.emit(msg, "info") - original_print(*args, **kwargs) - - builtins.print = custom_print - - try: - success = vm.download_file(self.remote_path, self.local_path) - if success: - self.log_signal.emit("✅ 文件下载成功", "success") - self.finished_signal.emit(True) - else: - self.log_signal.emit("❌ 文件下载失败", "error") - self.finished_signal.emit(False) - finally: - builtins.print = original_print - - except ImportError as e: - self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") - self.error_signal.emit(str(e)) - except Exception as e: - self.log_signal.emit(f"❌ 下载错误: {str(e)}", "error") - self.error_signal.emit(str(e)) - finally: - if _com_initialized: - try: - import ctypes - - ctypes.windll.ole32.CoUninitialize() - except Exception: - pass - - -class ScriptWorker(QObject): - log_signal = Signal(str, str) - finished_signal = Signal(bool) - error_signal = Signal(str) - - def __init__(self, config: dict, local_path: str): - super().__init__() - self.config = config - self.local_path = local_path - self.cancel_event = threading.Event() - - def cancel(self): - self.cancel_event.set() - - def run(self): - ssh_only = self.config.get("ssh_only", False) - _com_initialized = False - if not ssh_only: - try: - import ctypes - - ctypes.windll.ole32.CoInitializeEx(None, 0) - _com_initialized = True - except Exception: - pass - - try: - if ssh_only: - from virtualbox_client.utils.ssh_manager import SSHManager - - vm = SSHManager( - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - else: - from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager - - vm = VBoxSSHManager( - self.config["vm_name"], - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - vm.set_cancel_event(self.cancel_event) - - if self.cancel_event.is_set(): - return - - # 非纯 SSH 模式下,先确保虚拟机已启动 - if not ssh_only: - self.log_signal.emit("🚀 检查虚拟机状态...", "info") - vm_started = vm.start_vm(wait_for_ssh=True) - if not vm_started: - self.log_signal.emit("❌ 虚拟机启动失败", "error") - self.error_signal.emit("虚拟机启动失败") - return - self.log_signal.emit("✅ 虚拟机已就绪", "success") - if self.cancel_event.is_set(): - return - - import builtins - - original_print = print - - def custom_print(*args, **kwargs): - msg = " ".join(str(a) for a in args) - self.log_signal.emit(msg, "info") - original_print(*args, **kwargs) - - builtins.print = custom_print - - try: - import os as _os - - script_name = _os.path.basename(self.local_path) - remote_path = "/tmp/" + script_name - log_path = "/tmp/" + script_name + ".log" - local_log_path = script_name + ".log" - - self.log_signal.emit( - "[1/3] Upload: " + self.local_path + " -> " + remote_path, "info" - ) - if not vm.upload_file(self.local_path, remote_path): - self.log_signal.emit("Upload failed", "error") - return - - self.log_signal.emit("[2/3] Execute: " + remote_path, "info") - # deploy_* 脚本需要 sudo 权限(系统级安装),其余脚本以普通用户执行 - if script_name.startswith("deploy_"): - cmd = ( - "chmod +x " + remote_path + " && " - "echo '" - + self.config["ssh_pass"] - + "' | sudo -S " - + remote_path - + " 2>&1 | " - "tee " + log_path - ) - self.log_signal.emit( - " 🔑 检测到部署脚本,以 sudo 权限执行", "info" - ) - else: - cmd = ( - "chmod +x " + remote_path + " && " + remote_path + " 2>&1 | " - "tee " + log_path - ) - self.log_signal.emit( - " 👤 检测到开发脚本,以普通用户权限执行", "info" - ) - out, err, code = vm.exec_command(cmd, get_pty=False) - self.log_signal.emit("Exit code: " + str(code), "info") - - self.log_signal.emit( - "[3/3] Download log: " + log_path + " -> " + local_log_path, "info" - ) - if vm.download_file(log_path, local_log_path): - self.log_signal.emit("Log saved: " + local_log_path, "success") - else: - self.log_signal.emit( - "Tee log download failed, saving terminal output", "warning" - ) - if out: - with open(local_log_path, "w") as f: - f.write(out) - self.log_signal.emit( - "Output saved: " + local_log_path, "success" - ) - - vm.exec_command('rm -f "' + remote_path + '" "' + log_path + '"') - self.log_signal.emit("Remote temp files cleaned", "info") - self.log_signal.emit("Script execution complete!", "success") - self.finished_signal.emit(True) - finally: - builtins.print = original_print - - except ImportError as e: - self.log_signal.emit("Missing dependency: " + str(e), "error") - self.error_signal.emit(str(e)) - except Exception as e: - self.log_signal.emit("Script error: " + str(e), "error") - self.error_signal.emit(str(e)) - finally: - if _com_initialized: - try: - import ctypes - - ctypes.windll.ole32.CoUninitialize() - except Exception: - pass - - -class WorkspaceWorker(QObject): - """后台线程执行工作区文件操作(列表、删除、复制、重命名、创建目录)""" - - log_signal = Signal(str, str) - finished_signal = Signal(object) - error_signal = Signal(str) - - OPERATION_LIST = "list" - OPERATION_DELETE = "delete" - OPERATION_COPY = "copy" - OPERATION_RENAME = "rename" - OPERATION_MKDIR = "mkdir" - OPERATION_UPLOAD = "upload" - OPERATION_UPLOAD_DIR = "upload_dir" - - def __init__(self, config: dict, operation: str, **kwargs): - super().__init__() - self.config = config - self.operation = operation - self.kwargs = kwargs - self.cancel_event = threading.Event() - - def cancel(self): - self.cancel_event.set() - - def run(self): - ssh_only = self.config.get("ssh_only", False) - _com_initialized = False - if not ssh_only: - try: - import ctypes - - ctypes.windll.ole32.CoInitializeEx(None, 0) - _com_initialized = True - except Exception: - pass - - try: - if ssh_only: - from virtualbox_client.utils.ssh_manager import SSHManager - - vm = SSHManager( - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - else: - from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager - - vm = VBoxSSHManager( - self.config["vm_name"], - self.config["ssh_host"], - self.config["ssh_port"], - self.config["ssh_user"], - self.config["ssh_pass"], - ) - vm.set_cancel_event(self.cancel_event) - - if self.cancel_event.is_set(): - return - - if self.operation == WorkspaceWorker.OPERATION_LIST: - dir_path = self.kwargs.get("dir_path", "~/workspace") - result = self._list_files(vm, dir_path) - elif self.operation == WorkspaceWorker.OPERATION_DELETE: - result = self._delete_file(vm) - elif self.operation == WorkspaceWorker.OPERATION_COPY: - result = self._copy_file(vm) - elif self.operation == WorkspaceWorker.OPERATION_RENAME: - result = self._rename_file(vm) - elif self.operation == WorkspaceWorker.OPERATION_MKDIR: - result = self._mkdir(vm) - elif self.operation == WorkspaceWorker.OPERATION_UPLOAD: - result = self._upload_file(vm) - elif self.operation == WorkspaceWorker.OPERATION_UPLOAD_DIR: - result = self._upload_directory(vm) - else: - raise ValueError(f"未知操作: {self.operation}") - - if self.cancel_event.is_set(): - return - - self.finished_signal.emit(result) - - except ImportError as e: - self.log_signal.emit(f"❌ 缺少依赖: {e}", "error") - self.error_signal.emit(str(e)) - except Exception as e: - self.log_signal.emit(f"❌ 工作区操作失败: {str(e)}", "error") - self.error_signal.emit(str(e)) - finally: - if _com_initialized: - try: - import ctypes - - ctypes.windll.ole32.CoUninitialize() - except Exception: - pass - - def _ensure_workspace(self, vm) -> bool: - out, err, code = vm.exec_command("mkdir -p ~/workspace") - return code == 0 - - def _list_files(self, vm, dir_path: str = "~/workspace") -> list: - """列出指定目录中的文件,返回文件信息列表。""" - import base64 - import json as json_mod - - # 对包含 ~ 的路径使用 shell 展开创建目录 - vm.exec_command(f"mkdir -p {dir_path}") - self.log_signal.emit(f"📂 正在扫描: {dir_path}", "info") - - py_script = ( - "import os,sys,json,time\n" - 'ws=os.path.expanduser("' + dir_path.replace('"', '\\"') + '")\n' - "files=[]\n" - "try:\n" - " for f in sorted(os.listdir(ws),key=lambda x:(not os.path.isdir(os.path.join(ws,x)),x.lower())):\n" - " if f.startswith('.'):\n" - " continue\n" - " fp=os.path.join(ws,f)\n" - " try:\n" - " s=os.stat(fp)\n" - " files.append({'name':f,'size':s.st_size,'is_dir':os.path.isdir(fp),'modified':int(s.st_mtime),'path':fp})\n" - " except:\n" - " pass\n" - "except Exception as e:\n" - " sys.stderr.write(str(e))\n" - "print(json.dumps(files))\n" - ) - encoded = base64.b64encode(py_script.encode()).decode() - - out, err, code = vm.exec_command( - f'echo "{encoded}" | base64 -d 2>/dev/null | python3 2>/dev/null' - ) - - if code != 0 or not out or not out.strip(): - out, err, code = vm.exec_command( - f'echo "{encoded}" | base64 -d 2>/dev/null | python 2>/dev/null' - ) - if code != 0 or not out or not out.strip(): - self.log_signal.emit( - "⚠️ Python 不可用,使用 find 命令列出文件", "warning" - ) - # 展开 ~ 为真实路径(find 命令本身不会展开 ~) - resolved_dir = dir_path - if resolved_dir.startswith("~/"): - home_out, _, _ = vm.exec_command("echo ~") - if home_out and home_out.strip(): - resolved_dir = home_out.strip() + resolved_dir[1:] - out2, err2, code2 = vm.exec_command( - f'find {resolved_dir} -maxdepth 1 ! -name "." ! -name ".." -printf "%f|%y|%s\\n" 2>/dev/null | sort' - ) - if code2 != 0 or not out2 or not out2.strip(): - self.log_signal.emit(f"ℹ️ 目录为空或不可访问: {dir_path}", "info") - return [] - - files = [] - for line in out2.strip().split("\n"): - line = line.strip() - if not line: - continue - parts = line.split("|") - if len(parts) < 3: - continue - name, ftype, size_str = parts[0], parts[1], parts[2] - try: - size = int(size_str) - except ValueError: - size = 0 - files.append( - { - "name": name, - "size": size, - "is_dir": ftype == "d", - "modified": "", - "path": f"{resolved_dir}/{name}", - } - ) - return files - - try: - files = json_mod.loads(out.strip()) - for f in files: - f.setdefault("name", "未知") - f.setdefault("size", 0) - f.setdefault("is_dir", False) - f.setdefault("modified", "") - f.setdefault("path", f"~/workspace/{f.get('name', '')}") - if isinstance(f.get("modified"), (int, float)) and f["modified"]: - try: - f["modified"] = datetime.fromtimestamp(f["modified"]).strftime( - "%Y-%m-%d %H:%M:%S" - ) - except Exception: - f["modified"] = str(f["modified"]) - self.log_signal.emit( - f"📂 扫描完成: 找到 {len(files)} 个文件/文件夹", "info" - ) - return files - except (json_mod.JSONDecodeError, Exception) as e: - self.log_signal.emit(f"❌ 解析文件列表失败: {e}", "error") - return [] - - def _delete_file(self, vm) -> bool: - paths = self.kwargs.get("path", []) - if not paths: - return False - if isinstance(paths, str): - paths = [paths] - # 使用空格分隔拼接多个路径,统一用双引号包裹以支持含空格的路径 - joined = " ".join(f'"{p}"' for p in paths) - out, err, code = vm.exec_command(f"rm -rf {joined}") - return code == 0 - - def _copy_file(self, vm) -> bool: - src = self.kwargs.get("src", "") - dst = self.kwargs.get("dst", "") - if not src or not dst: - return False - out, err, code = vm.exec_command(f'cp -r "{src}" "{dst}"') - return code == 0 - - def _rename_file(self, vm) -> bool: - src = self.kwargs.get("src", "") - dst = self.kwargs.get("dst", "") - if not src or not dst: - return False - out, err, code = vm.exec_command(f'mv "{src}" "{dst}"') - return code == 0 - - def _mkdir(self, vm) -> bool: - name = self.kwargs.get("name", "") - if not name: - return False - out, err, code = vm.exec_command(f"mkdir -p ~/workspace/{name}") - return code == 0 - - def _upload_file(self, vm) -> bool: - local_path = self.kwargs.get("local_path", "") - remote_path = self.kwargs.get("remote_path", "") - if not local_path or not remote_path: - return False - remote_dir = ( - remote_path.rsplit("/", 1)[0] - if "/" in remote_path - else remote_path.rsplit("\\", 1)[0] - ) - vm.exec_command(f'mkdir -p "{remote_dir}"') - return vm.upload_file(local_path, remote_path) - - def _upload_directory(self, vm) -> bool: - local_dir = self.kwargs.get("local_path", "") - remote_dir = self.kwargs.get("remote_path", "") - if not local_dir or not remote_dir: - return False - return vm.upload_directory(local_dir, remote_dir) - - -class FileSizeFormatter: - """文件大小格式化工具""" - - @staticmethod - def format(size_bytes: int) -> str: - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - elif size_bytes < 1024 * 1024 * 1024: - return f"{size_bytes / (1024 * 1024):.1f} MB" - else: - return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" - - -class WorkspacePanel(QWidget): - """工作区面板 - 树形文件浏览器,双击文件夹展开子目录,单击显示文件信息""" - - def __init__(self, config_panel: ConfigPanel, log_panel: "LogPanel"): - super().__init__() - self._config_panel = config_panel - self.log_panel = log_panel - self._busy = False - self._loaded_dirs = set() # 已加载的目录路径 - self._worker_thread = None - self._worker = None - self._pending_expand_item = None # 待展开的树节点 - self._current_info_item = None # 当前显示详细信息的项 - self._download_worker_thread = None - self._download_worker = None - - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 8, 8, 8) - - # ---- 工具栏 ---- - toolbar = QHBoxLayout() - - self.refresh_btn = QPushButton("🔄 刷新") - self.refresh_btn.setStyleSheet(""" - QPushButton { background-color: #0e639c; color: white; padding: 6px 14px; - font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } - QPushButton:hover { background-color: #1177bb; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.refresh_btn.clicked.connect(self.refresh_root) - toolbar.addWidget(self.refresh_btn) - - self.upload_btn = QPushButton("📤 上传文件") - self.upload_btn.setStyleSheet(""" - QPushButton { background-color: #2d7d46; color: white; padding: 6px 14px; - font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } - QPushButton:hover { background-color: #389e54; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.upload_btn.clicked.connect(self._upload_to_workspace) - toolbar.addWidget(self.upload_btn) - - self.upload_dir_btn = QPushButton("📁 上传文件夹") - self.upload_dir_btn.setStyleSheet(""" - QPushButton { background-color: #16a085; color: white; padding: 6px 14px; - font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } - QPushButton:hover { background-color: #1abc9c; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.upload_dir_btn.clicked.connect(self._upload_folder_to_workspace) - toolbar.addWidget(self.upload_dir_btn) - - self.mkdir_btn = QPushButton("📂 新建文件夹") - self.mkdir_btn.setStyleSheet(""" - QPushButton { background-color: #e67e22; color: white; padding: 6px 14px; - font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } - QPushButton:hover { background-color: #f39c12; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.mkdir_btn.clicked.connect(self._create_folder) - toolbar.addWidget(self.mkdir_btn) - - self.home_btn = QPushButton("🏠 回到根目录") - self.home_btn.setStyleSheet(""" - QPushButton { background-color: #6c5ce7; color: white; padding: 6px 14px; - font-size: 12px; font-weight: bold; border: none; border-radius: 4px; } - QPushButton:hover { background-color: #7c6cf7; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.home_btn.clicked.connect(self._go_home) - toolbar.addWidget(self.home_btn) - - toolbar.addStretch() - - self.status_label = QLabel("就绪") - self.status_label.setStyleSheet("color: #888; font-size: 12px;") - toolbar.addWidget(self.status_label) - - layout.addLayout(toolbar) - - # ---- 文件树(真正的树状结构) ---- - self.file_tree = QTreeWidget() - self.file_tree.setHeaderLabels(["名称", "大小", "类型", "修改时间", "权限"]) - self.file_tree.setColumnWidth(0, 280) - self.file_tree.setColumnWidth(1, 100) - self.file_tree.setColumnWidth(2, 80) - self.file_tree.setColumnWidth(3, 160) - self.file_tree.setColumnWidth(4, 80) - self.file_tree.setAlternatingRowColors(True) - self.file_tree.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - self.file_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.file_tree.customContextMenuRequested.connect(self._show_context_menu) - self.file_tree.setRootIsDecorated(True) - self.file_tree.setAnimated(True) - - # 双击展开/收起文件夹(懒加载) - self.file_tree.itemDoubleClicked.connect(self._on_item_double_clicked) - # 单击显示文件信息 - self.file_tree.itemClicked.connect(self._on_item_clicked) - - self.file_tree.setStyleSheet(""" - QTreeWidget { - background-color: #1e1e1e; color: #d4d4d4; - border: 1px solid #3c3c3c; border-radius: 4px; - alternate-background-color: #2a2a2a; - font-size: 13px; - } - QTreeWidget::item:hover { background-color: #2d2d2d; } - QTreeWidget::item:selected { background-color: #094771; } - QTreeWidget::branch:has-children:closed:has-siblings { border-image: none; } - QTreeWidget::branch:open:has-children:has-siblings { border-image: none; } - QHeaderView::section { - background-color: #2d2d2d; color: #cccccc; - padding: 6px; border: none; border-bottom: 1px solid #3c3c3c; - font-weight: bold; - } - """) - - layout.addWidget(self.file_tree) - - # ---- 文件详细信息面板(单击文件时显示) ---- - self.info_panel = QFrame() - self.info_panel.setFrameShape(QFrame.Shape.StyledPanel) - self.info_panel.setStyleSheet(""" - QFrame { - background-color: #1e1e1e; - border: 1px solid #3c3c3c; - border-radius: 4px; - padding: 8px; - } - QLabel { color: #d4d4d4; font-size: 12px; padding: 2px; } - """) - info_layout = QVBoxLayout(self.info_panel) - info_layout.setContentsMargins(8, 4, 8, 4) - self.info_label = QLabel("选择文件以查看详情") - self.info_label.setStyleSheet( - "color: #888; font-style: italic; font-size: 12px;" - ) - info_layout.addWidget(self.info_label) - self.info_panel.setMaximumHeight(60) - self.info_panel.hide() - layout.addWidget(self.info_panel) - - # ---- 进度条 ---- - self.progress_bar = QProgressBar() - self.progress_bar.setRange(0, 0) - self.progress_bar.setMaximumHeight(4) - self.progress_bar.setTextVisible(False) - self.progress_bar.hide() - layout.addWidget(self.progress_bar) - - # ---- 线程管理 ---- - - def _cleanup_worker(self): - if self._worker_thread: - try: - self._worker_thread.finished.disconnect() - except TypeError, RuntimeError: - pass - if self._worker_thread.isRunning(): - self._worker_thread.quit() - if not self._worker_thread.wait(5000): - self._worker_thread.terminate() - self._worker_thread.wait(3000) - self._worker_thread = None - if self._worker: - try: - self._worker.log_signal.disconnect() - self._worker.finished_signal.disconnect() - self._worker.error_signal.disconnect() - except TypeError, RuntimeError: - pass - self._worker = None - - def _set_busy(self, busy: bool, status_text: str = ""): - self._busy = busy - self.refresh_btn.setEnabled(not busy) - self.upload_btn.setEnabled(not busy) - self.upload_dir_btn.setEnabled(not busy) - self.mkdir_btn.setEnabled(not busy) - self.home_btn.setEnabled(not busy) - self.file_tree.setEnabled(not busy) - if busy: - self.progress_bar.show() - self.status_label.setText(status_text or "操作中...") - else: - self.progress_bar.hide() - self.status_label.setText(status_text or "就绪") - - def _validate_ssh_config(self) -> bool: - if not self._config_panel: - return False - cfg = self._config_panel.get_config() - missing = [] - if not cfg["ssh_host"]: - missing.append("SSH 主机") - if not cfg["ssh_user"]: - missing.append("SSH 用户") - if not cfg["ssh_pass"]: - missing.append("SSH 密码") - if missing: - self.log_panel.append_log( - "❌ SSH 配置不完整: " + ", ".join(missing), "error" - ) - return False - return True - - def _start_worker(self, operation: str, **kwargs): - if not self._config_panel or not self._validate_ssh_config(): - self._set_busy(False, "SSH 配置不完整") - return - - self._cleanup_worker() - self._set_busy(True, "正在连接...") - - config = self._config_panel.get_config() - self._worker = WorkspaceWorker(config, operation, **kwargs) - self._worker_thread = QThread(self) - self._worker.moveToThread(self._worker_thread) - - self._worker.log_signal.connect(self.log_panel.append_log) - self._worker.finished_signal.connect(self._on_worker_finished) - self._worker.error_signal.connect(self._on_worker_error) - self._worker_thread.started.connect(self._worker.run) - self._worker_thread.finished.connect(self._cleanup_worker) - - self._worker_thread.start() - - def _on_worker_finished(self, result): - self._set_busy(False, "就绪") - - if self._worker and self._worker.operation == WorkspaceWorker.OPERATION_LIST: - if self._pending_expand_item: - # 展开子目录的结果 - self._apply_directory_children(self._pending_expand_item, result) - self._pending_expand_item = None - else: - # 根目录刷新结果 - self._refresh_root_tree(result) - count = len(result) if isinstance(result, list) else 0 - self.log_panel.append_log(f"📂 共 {count} 个文件/文件夹", "info") - elif self._worker and self._worker.operation in ( - WorkspaceWorker.OPERATION_UPLOAD, - WorkspaceWorker.OPERATION_UPLOAD_DIR, - ): - if result: - self.log_panel.append_log("✅ 上传成功", "success") - else: - self.log_panel.append_log("❌ 上传失败", "error") - QTimer.singleShot(500, self._soft_refresh) - else: - if result: - self.log_panel.append_log("✅ 操作成功", "success") - else: - self.log_panel.append_log("❌ 操作失败", "error") - QTimer.singleShot(500, self._soft_refresh) - - def _on_worker_error(self, error_msg): - self._set_busy(False, "错误") - self.log_panel.append_log(f"❌ 工作区错误: {error_msg}", "error") - self._pending_expand_item = None - - # ---- 树状结构核心逻辑 ---- - - def refresh_root(self): - """刷新根目录""" - self._loaded_dirs.clear() - self._pending_expand_item = None - self.file_tree.clear() - self.info_panel.hide() - self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path="~/workspace") - - def _soft_refresh(self): - """刷新后保持展开状态""" - self._loaded_dirs.clear() - self._pending_expand_item = None - self.file_tree.clear() - self.info_panel.hide() - self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path="~/workspace") - - def _go_home(self): - """回到根目录:收起所有节点,仅显示根目录""" - self.file_tree.collapseAll() - self.info_panel.hide() - - def _refresh_root_tree(self, files: list): - """用文件列表填充根树""" - if not isinstance(files, list): - return - self.file_tree.clear() - - for f in files: - item = self._create_tree_item(f) - self.file_tree.addTopLevelItem(item) - - self.file_tree.sortItems(0, Qt.SortOrder.AscendingOrder) - - def _apply_directory_children(self, parent_item: QTreeWidgetItem, files: list): - """给一个已展开的文件夹项填充子节点""" - if not isinstance(files, list): - return - - # 移除旧的占位符 - while parent_item.childCount() > 0: - child = parent_item.child(0) - if child and child.data(0, Qt.ItemDataRole.UserRole + 10) == "placeholder": - parent_item.removeChild(child) - else: - break - - # 再清空所有子节点(因为已经懒加载过了) - parent_item.takeChildren() - - # 添加新的子节点 - for f in files: - child_item = self._create_tree_item(f) - # 如果是文件夹,添加一个占位子节点使其可展开 - if f.get("is_dir", False): - placeholder = QTreeWidgetItem() - placeholder.setText(0, "") - placeholder.setData(0, Qt.ItemDataRole.UserRole + 10, "placeholder") - child_item.addChild(placeholder) - parent_item.addChild(child_item) - - parent_item.setExpanded(True) - self.file_tree.sortItems(0, Qt.SortOrder.AscendingOrder) - - def _create_tree_item(self, f: dict) -> QTreeWidgetItem: - """根据文件信息创建树节点项""" - name = f.get("name", "未知") - is_dir = f.get("is_dir", False) - size = f.get("size", 0) - modified = f.get("modified", "") - perm = f.get("permissions", "") - - if isinstance(modified, (int, float)): - try: - modified = datetime.fromtimestamp(modified).strftime( - "%Y-%m-%d %H:%M:%S" - ) - except Exception: - modified = str(modified) - - item = QTreeWidgetItem() - item.setText(0, name) - item.setData(0, Qt.ItemDataRole.UserRole, f.get("path", "")) - item.setData(0, Qt.ItemDataRole.UserRole + 1, is_dir) - - if is_dir: - item.setText(1, "—") - item.setText(2, "📁 文件夹") - item.setForeground(0, QBrush(QColor("#569cd6"))) - # 添加一个占位子节点使其可展开 - placeholder = QTreeWidgetItem() - placeholder.setText(0, "") - placeholder.setData(0, Qt.ItemDataRole.UserRole + 10, "placeholder") - item.addChild(placeholder) - else: - item.setText(1, FileSizeFormatter.format(size)) - ext = os.path.splitext(name)[1].lower() - type_map = { - ".py": "🐍 Python", - ".js": "🟨 JavaScript", - ".ts": "🔵 TypeScript", - ".html": "🌐 HTML", - ".css": "🎨 CSS", - ".json": "📋 JSON", - ".txt": "📄 文本", - ".md": "📝 Markdown", - ".yaml": "⚙️ YAML", - ".yml": "⚙️ YAML", - ".sh": "⚡ Shell", - ".zip": "📦 ZIP", - ".tar": "📦 TAR", - ".gz": "📦 GZip", - ".png": "🖼️ 图片", - ".jpg": "🖼️ 图片", - ".jpeg": "🖼️ 图片", - ".gif": "🖼️ GIF", - ".svg": "🖼️ SVG", - ".pdf": "📕 PDF", - ".log": "📋 日志", - ".conf": "⚙️ 配置", - ".env": "🔒 环境", - ".toml": "⚙️ TOML", - ".xml": "📋 XML", - ".lock": "🔒 锁定", - } - item.setText(2, type_map.get(ext, "📄 文件")) - item.setForeground(0, QBrush(QColor("#d4d4d4"))) - - item.setText(3, str(modified)) - item.setText(4, perm) - - return item - - def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int): - """双击节点:文件夹展开/收起懒加载,文件显示信息""" - is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) - path = item.data(0, Qt.ItemDataRole.UserRole) - - if not is_dir: - # 双击文件显示详细信息 - self._show_file_info(item) - return - - if not path: - return - - # 如果已展开,直接折叠 - if item.isExpanded(): - item.setExpanded(False) - return - - # 检查是否已加载过子目录(通过是否有真实子节点判断) - has_real_children = False - for i in range(item.childCount()): - child = item.child(i) - if child and child.data(0, Qt.ItemDataRole.UserRole + 10) != "placeholder": - has_real_children = True - break - - if has_real_children: - item.setExpanded(True) - return - - # 懒加载:从远程获取子目录内容 - self.log_panel.append_log(f"📂 正在展开: {path}", "info") - self._pending_expand_item = item - self._start_worker(WorkspaceWorker.OPERATION_LIST, dir_path=path) - - def _on_item_clicked(self, item: QTreeWidgetItem, column: int): - """单击节点显示文件信息""" - self._show_file_info(item) - - def _show_file_info(self, item: QTreeWidgetItem): - """显示文件/文件夹的详细信息""" - name = item.text(0) - is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) - path = item.data(0, Qt.ItemDataRole.UserRole) or "" - - size = item.text(1) - ftype = item.text(2) - modified = item.text(3) - perm = item.text(4) - - if is_dir: - child_count = 0 - for i in range(item.childCount()): - child = item.child(i) - if ( - child - and child.data(0, Qt.ItemDataRole.UserRole + 10) != "placeholder" - ): - child_count += 1 - info_parts = [ - f"📁 **{name}**", - f"路径: {path}", - f"类型: 文件夹", - f"大小: {size}", - f"修改时间: {modified}" if modified else "", - ] - info_text = " | ".join(p for p in info_parts if p) - else: - info_parts = [ - f"📄 **{name}**", - f"路径: {path}", - f"类型: {ftype}", - f"大小: {size}", - f"修改时间: {modified}" if modified else "", - ] - info_text = " | ".join(p for p in info_parts if p) - - self.info_label.setText(f"📌 {info_text}") - self.info_panel.show() - self._current_info_item = item - - # ---- 右键菜单 ---- - - def _get_selected_paths(self) -> list: - paths = [] - for item in self.file_tree.selectedItems(): - path = item.data(0, Qt.ItemDataRole.UserRole) - if path: - paths.append(path) - return paths - - def _get_selected_names(self) -> list: - names = [] - for item in self.file_tree.selectedItems(): - names.append(item.text(0)) - return names - - def _show_context_menu(self, pos): - items = self.file_tree.selectedItems() - if not items: - return - - menu = QMenu(self) - menu.setStyleSheet(""" - QMenu { background-color: #2d2d2d; color: #ccc; border: 1px solid #3c3c3c; padding: 4px; } - QMenu::item { padding: 6px 24px; border-radius: 3px; } - QMenu::item:selected { background-color: #094771; } - QMenu::separator { height: 1px; background: #3c3c3c; margin: 4px 8px; } - """) - - if len(items) == 1: - item = items[0] - name = item.text(0) - is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) - - rename_action = QAction(f'✏️ 重命名 "{name}"', self) - rename_action.triggered.connect(lambda: self._rename_item(item)) - menu.addAction(rename_action) - - copy_action = QAction(f'📋 复制 "{name}"', self) - copy_action.triggered.connect(lambda: self._copy_item(item)) - menu.addAction(copy_action) - - if is_dir: - download_action = QAction("📥 下载文件夹(打包为 zip)", self) - else: - download_action = QAction("📥 下载文件", self) - download_action.triggered.connect(lambda: self._download_item(item)) - menu.addAction(download_action) - - menu.addSeparator() - - delete_action = QAction(f"🗑️ 删除选中的 {len(items)} 个项目", self) - delete_action.triggered.connect(self._delete_selected) - menu.addAction(delete_action) - - menu.exec(self.file_tree.viewport().mapToGlobal(pos)) - - # ---- 文件操作 ---- - - def _rename_item(self, item): - old_name = item.text(0) - new_name, ok = QInputDialog.getText( - self, "重命名", "请输入新名称:", text=old_name - ) - if not ok or not new_name.strip() or new_name.strip() == old_name: - return - - src_path = item.data(0, Qt.ItemDataRole.UserRole) - if not src_path: - return - - dst_dir = ( - src_path.rsplit("/", 1)[0] - if "/" in src_path - else src_path.rsplit("\\", 1)[0] - ) - dst_path = dst_dir.rstrip("/") + "/" + new_name.strip() - - self._start_worker( - WorkspaceWorker.OPERATION_RENAME, - src=src_path, - dst=dst_path, - ) - - def _copy_item(self, item): - src_path = item.data(0, Qt.ItemDataRole.UserRole) - name = item.text(0) - base, ext = os.path.splitext(name) - new_name = f"{base}_copy{ext}" - dst_dir = ( - src_path.rsplit("/", 1)[0] - if "/" in src_path - else src_path.rsplit("\\", 1)[0] - ) - dst_path = dst_dir.rstrip("/") + "/" + new_name - - self._start_worker( - WorkspaceWorker.OPERATION_COPY, - src=src_path, - dst=dst_path, - ) - - def _download_item(self, item): - path = item.data(0, Qt.ItemDataRole.UserRole) - name = item.text(0) - is_dir = item.data(0, Qt.ItemDataRole.UserRole + 1) - - if is_dir: - local_path, _ = QFileDialog.getSaveFileName( - self, "保存文件夹", f"{name}.zip", "ZIP 文件 (*.zip)" - ) - if not local_path: - return - self._download_folder_as_zip(path, local_path) - else: - local_path, _ = QFileDialog.getSaveFileName( - self, "保存文件", name, "所有文件 (*)" - ) - if not local_path: - return - self._download_single_file(path, local_path) - - def _cleanup_download_worker(self): - """清理下载线程和 worker""" - if self._download_worker_thread: - try: - self._download_worker_thread.finished.disconnect() - except TypeError, RuntimeError: - pass - if self._download_worker_thread.isRunning(): - self._download_worker_thread.quit() - if not self._download_worker_thread.wait(5000): - self._download_worker_thread.terminate() - self._download_worker_thread.wait(3000) - self._download_worker_thread = None - if self._download_worker: - try: - self._download_worker.log_signal.disconnect() - self._download_worker.finished_signal.disconnect() - self._download_worker.error_signal.disconnect() - except TypeError, RuntimeError: - pass - self._download_worker = None - - def _download_single_file(self, remote_path, local_path): - if not self._config_panel or not self._validate_ssh_config(): - return - - # 确保本地目标目录存在 - local_dir = os.path.dirname(local_path) - if local_dir: - os.makedirs(local_dir, exist_ok=True) - - self._cleanup_download_worker() - - config = self._config_panel.get_config() - self._download_worker = DownloadWorker(config, remote_path, local_path) - self._download_worker_thread = QThread(self) - self._download_worker.moveToThread(self._download_worker_thread) - - self._download_worker.log_signal.connect(self.log_panel.append_log) - self._download_worker.finished_signal.connect(self._on_download_worker_finished) - self._download_worker.error_signal.connect(self._on_download_worker_error) - self._download_worker_thread.started.connect(self._download_worker.run) - self._download_worker_thread.finished.connect(self._cleanup_download_worker) - - self.log_panel.append_log(f"📥 正在下载: {remote_path} -> {local_path}", "info") - self._download_worker_thread.start() - - def _on_download_worker_finished(self, success: bool): - self.log_panel.append_log( - "✅ 下载完成" if success else "❌ 下载失败", - "success" if success else "error", - ) - # 通知线程退出事件循环,触发 finished 信号从而调用 _cleanup_download_worker - if self._download_worker_thread and self._download_worker_thread.isRunning(): - self._download_worker_thread.quit() - - def _on_download_worker_error(self, error_msg: str): - self.log_panel.append_log(f"❌ 下载失败: {error_msg}", "error") - if self._download_worker_thread and self._download_worker_thread.isRunning(): - self._download_worker_thread.quit() - - def _download_folder_as_zip(self, folder_path: str, local_zip_path: str): - if not self._config_panel or not self._validate_ssh_config(): - return - - config = self._config_panel.get_config() - ssh_only = config.get("ssh_only", False) - - try: - if ssh_only: - from virtualbox_client.utils.ssh_manager import SSHManager - - vm = SSHManager( - config["ssh_host"], - config["ssh_port"], - config["ssh_user"], - config["ssh_pass"], - ) - else: - from virtualbox_client.utils.vbox_ssh_manager import VBoxSSHManager - - vm = VBoxSSHManager( - config["vm_name"], - config["ssh_host"], - config["ssh_port"], - config["ssh_user"], - config["ssh_pass"], - ) - - self.log_panel.append_log( - "📦 正在打包远程文件夹(排除 .venv 和 node_modules)...", "info" - ) - zip_name = f"/tmp/workspace_download_{int(time.time())}.zip" - vm.exec_command( - f'cd "{folder_path}" && zip -r "{zip_name}" . -x ".venv/*" "./src/chongming-web/node_modules/*"' - ) - - self.log_panel.append_log("📥 正在下载打包文件...", "info") - success = vm.download_file(zip_name, local_zip_path) - vm.exec_command(f'rm -f "{zip_name}"') - - if success: - self.log_panel.append_log( - f"✅ 文件夹已下载到: {local_zip_path}", "success" - ) - else: - self.log_panel.append_log("❌ 文件夹下载失败", "error") - - except Exception as e: - self.log_panel.append_log(f"❌ 下载失败: {e}", "error") - - def _delete_selected(self): - paths = self._get_selected_paths() - names = self._get_selected_names() - if not paths: - return - - confirm_msg = "确定要删除以下项目吗?\n" + "\n".join(f" - {n}" for n in names) - reply = QMessageBox.question( - self, - "确认删除", - confirm_msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - - self._delete_paths_serial(paths) - - def _delete_paths_serial(self, paths: list): - """批量删除所有选中路径(单条命令一次性删除所有,避免递归线程)""" - if not paths: - self._soft_refresh() - return - - self._cleanup_worker() - self._set_busy(True, "正在删除...") - - config = self._config_panel.get_config() - self._worker = WorkspaceWorker( - config, WorkspaceWorker.OPERATION_DELETE, path=paths - ) - self._worker_thread = QThread(self) - self._worker.moveToThread(self._worker_thread) - - self._worker.log_signal.connect(self.log_panel.append_log) - self._worker.finished_signal.connect(self._on_batch_delete_finished) - self._worker.error_signal.connect(self._on_worker_error) - self._worker_thread.started.connect(self._worker.run) - self._worker_thread.finished.connect(self._cleanup_worker) - - self._worker_thread.start() - - def _on_batch_delete_finished(self, result): - self._set_busy(False, "就绪") - if result: - self.log_panel.append_log("✅ 删除完成", "success") - else: - self.log_panel.append_log("❌ 删除失败", "error") - self._soft_refresh() - - def _upload_folder_to_workspace(self): - if not self._config_panel or not self._validate_ssh_config(): - return - - local_dir = QFileDialog.getExistingDirectory(self, "选择要上传到工作区的文件夹") - if not local_dir: - return - - folder_name = os.path.basename(local_dir.rstrip("/\\")) - remote_path = f"~/workspace/{folder_name}" - self._start_worker( - WorkspaceWorker.OPERATION_UPLOAD_DIR, - local_path=local_dir, - remote_path=remote_path, - ) - - def _upload_to_workspace(self): - if not self._config_panel or not self._validate_ssh_config(): - return - - local_paths, _ = QFileDialog.getOpenFileNames(self, "选择要上传到工作区的文件") - if not local_paths: - return - - # 只上传第一个文件(后续通过多次点击上传更多文件) - local_path = local_paths[0] - name = os.path.basename(local_path) - remote_path = f"~/workspace/{name}" - self._start_worker( - WorkspaceWorker.OPERATION_UPLOAD, - local_path=local_path, - remote_path=remote_path, - ) - - def _create_folder(self): - name, ok = QInputDialog.getText(self, "新建文件夹", "请输入文件夹名称:") - if not ok or not name.strip(): - return - self._start_worker(WorkspaceWorker.OPERATION_MKDIR, name=name.strip()) - - -class LogPanel(QWidget): - """高性能日志面板(使用 QPlainTextEdit 替代 QTextEdit)""" - - def __init__(self): - super().__init__() - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - tool_layout = QHBoxLayout() - self.clear_btn = QPushButton("🗑️ 清空") - self.clear_btn.clicked.connect(self.clear_log) - tool_layout.addWidget(self.clear_btn) - tool_layout.addStretch() - self.auto_scroll_check = QCheckBox("📌 自动滚动") - self.auto_scroll_check.setChecked(True) - tool_layout.addWidget(self.auto_scroll_check) - layout.addLayout(tool_layout) - - self.log_output = QPlainTextEdit() - self.log_output.setReadOnly(True) - self.log_output.setMaximumBlockCount(10000) - self.log_output.setFont(QFont("Consolas", 10)) - self.log_output.setStyleSheet(""" - QPlainTextEdit { - background-color: #1e1e1e; - color: #d4d4d4; - border: 1px solid #333; - border-radius: 4px; - padding: 8px; - } - """) - layout.addWidget(self.log_output) - - self._prefix_map = { - "error": "❌ ", - "warning": "⚠️ ", - "success": "✅ ", - "task": "📋 ", - "info": "", - } - - def append_log(self, message: str, level: str = "info"): - timestamp = datetime.now().strftime("%H:%M:%S") - level = self._detect_level(message, level) - prefix = self._prefix_map.get(level, "") - line = f"[{timestamp}] {prefix}{message}" - self.log_output.appendPlainText(line) - if self.auto_scroll_check.isChecked(): - self.log_output.moveCursor(QTextCursor.MoveOperation.End) - - @staticmethod - def _detect_level(message: str, given_level: str) -> str: - if given_level != "info": - return given_level - msg_upper = message.upper() - if any( - x in msg_upper - for x in ["❌", "ERROR", "EXCEPTION", "FAILED", "失败", "错误"] - ): - return "error" - if any(x in msg_upper for x in ["⚠️", "WARN", "跳过", "重试"]): - return "warning" - if any(x in msg_upper for x in ["✅", "SUCCESS", "完成", "成功"]): - return "success" - return "info" - - def clear_log(self): - self.log_output.clear() - - def clear_and_set_header(self, task: str): - self.clear_log() - self.append_log("=" * 60, "info") - self.append_log(f"📋 开始执行任务: {task}", "info") - self.append_log("=" * 60, "info") - - -class TaskPanel(QWidget): - """任务输入和执行面板""" - - def __init__(self, log_panel: LogPanel): - super().__init__() - self.log_panel = log_panel - self._config_panel = None - self._worker_thread = None - self._worker = None - self._shutdown_thread = None - self._shutdown_worker = None - self.setup_ui() - - def set_config_panel(self, panel: ConfigPanel): - self._config_panel = panel - if hasattr(self, "workspace_panel") and self.workspace_panel: - self.workspace_panel._config_panel = panel - - def setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - self.tab_widget = QTabWidget() - self.tab_widget.setStyleSheet(""" - QTabWidget::pane { border: 1px solid #3c3c3c; border-radius: 4px; background: transparent; } - QTabBar::tab { background: #2d2d2d; color: #ccc; padding: 8px 16px; border: 1px solid #3c3c3c; border-bottom: none; border-top-left-radius: 4px; border-top-right-radius: 4px; } - QTabBar::tab:selected { background: #3c3c3c; color: #569cd6; font-weight: bold; } - QTabBar::tab:hover { background: #4c4c4c; } - """) - - # ====== 任务描述 Tab ====== - task_tab = QWidget() - task_layout = QVBoxLayout(task_tab) - task_layout.setContentsMargins(8, 8, 8, 8) - - input_group = QGroupBox("任务描述") - input_layout = QVBoxLayout(input_group) - - self.task_input = QPlainTextEdit() - self.task_input.setPlaceholderText( - "请输入自然语言任务描述,例如:\n" - " - 在虚拟机中安装 nginx 并确认服务状态\n" - " - 创建一个 /home/test.txt 文件,写入 'Hello World'\n" - " - 查看系统当前的内存使用情况" - ) - self.task_input.setMaximumHeight(100) - self.task_input.setFont(QFont("Microsoft YaHei UI", 10)) - input_layout.addWidget(self.task_input) - - input_layout.addWidget(QLabel("项目路径:")) - self.project_path_edit = QLineEdit() - self.project_path_edit.setPlaceholderText("例如: /home/user/myproject") - input_layout.addWidget(self.project_path_edit) - - task_layout.addWidget(input_group) - - task_btn_layout = QHBoxLayout() - self.run_btn = QPushButton("▶️ 执行任务") - self.run_btn.setStyleSheet(""" - QPushButton { - background-color: #0e639c; color: white; padding: 10px 30px; - font-size: 14px; font-weight: bold; border: none; border-radius: 6px; - } - QPushButton:hover { background-color: #1177bb; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.run_btn.clicked.connect(self.run_task) - task_btn_layout.addWidget(self.run_btn) - - self.cancel_btn = QPushButton("⏹️ 取消") - self.cancel_btn.setStyleSheet(""" - QPushButton { - background-color: #d32f2f; color: white; padding: 10px 20px; - font-size: 14px; font-weight: bold; border: none; border-radius: 6px; - } - QPushButton:hover { background-color: #e53935; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.cancel_btn.clicked.connect(self.cancel_task) - self.cancel_btn.setEnabled(False) - task_btn_layout.addWidget(self.cancel_btn) - task_layout.addLayout(task_btn_layout) - self.tab_widget.addTab(task_tab, "📋 任务执行") - - # ====== 文件传输 Tab ====== - file_tab = QWidget() - file_layout = QVBoxLayout(file_tab) - file_layout.setContentsMargins(8, 8, 8, 8) - - upload_group = QGroupBox("📤 上传文件到虚拟机") - upload_layout = QFormLayout(upload_group) - - upload_local_layout = QHBoxLayout() - self.upload_local_edit = QLineEdit(placeholderText="选择本地文件路径...") - upload_local_layout.addWidget(self.upload_local_edit) - self.upload_browse_btn = QPushButton("📂 浏览") - self.upload_browse_btn.setFixedWidth(80) - self.upload_browse_btn.clicked.connect(self._browse_upload_file) - upload_local_layout.addWidget(self.upload_browse_btn) - upload_layout.addRow("本地文件:", upload_local_layout) - - self.upload_remote_edit = QLineEdit( - placeholderText="例如: ~/workspace/target_file.txt" - ) - self.upload_remote_edit.setText("~/workspace/") - upload_layout.addRow("远程路径:", self.upload_remote_edit) - - self.upload_btn = QPushButton("📤 上传") - self.upload_btn.setStyleSheet(""" - QPushButton { background-color: #2d7d46; color: white; padding: 8px 20px; - font-size: 13px; font-weight: bold; border: none; border-radius: 4px; } - QPushButton:hover { background-color: #389e54; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.upload_btn.clicked.connect(self.upload_file) - upload_layout.addRow("", self.upload_btn) - file_layout.addWidget(upload_group) - - download_group = QGroupBox("📥 从虚拟机下载文件") - download_layout = QFormLayout(download_group) - - self.download_remote_edit = QLineEdit( - placeholderText="例如: ~/workspace/source_file.txt" - ) - download_layout.addRow("远程路径:", self.download_remote_edit) - - download_local_layout = QHBoxLayout() - self.download_local_edit = QLineEdit(placeholderText="选择本地保存路径...") - download_local_layout.addWidget(self.download_local_edit) - self.download_browse_btn = QPushButton("📂 浏览") - self.download_browse_btn.setFixedWidth(80) - self.download_browse_btn.clicked.connect(self._browse_download_file) - download_local_layout.addWidget(self.download_browse_btn) - download_layout.addRow("保存到:", download_local_layout) - - self.download_btn = QPushButton("📥 下载") - self.download_btn.setStyleSheet(""" - QPushButton { background-color: #8b5cf6; color: white; padding: 8px 20px; - font-size: 13px; font-weight: bold; border: none; border-radius: 4px; } - QPushButton:hover { background-color: #a78bfa; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.download_btn.clicked.connect(self.download_file) - download_layout.addRow("", self.download_btn) - file_layout.addWidget(download_group) - - file_layout.addStretch() - self.tab_widget.addTab(file_tab, "📁 文件传输") - - # ====== Script Execution Tab ====== - script_tab = QWidget() - script_layout = QVBoxLayout(script_tab) - script_layout.setContentsMargins(8, 8, 8, 8) - - script_group = QGroupBox("Upload and Execute Script") - script_group_layout = QVBoxLayout(script_group) - - script_path_layout = QHBoxLayout() - self.script_path_edit = QLineEdit( - placeholderText="Select local .sh script file..." - ) - script_path_layout.addWidget(self.script_path_edit) - self.script_browse_btn = QPushButton("Browse") - self.script_browse_btn.setFixedWidth(80) - self.script_browse_btn.clicked.connect(self._browse_script) - script_path_layout.addWidget(self.script_browse_btn) - script_group_layout.addWidget(QLabel("Local Script:")) - script_group_layout.addLayout(script_path_layout) - - self.run_script_btn = QPushButton("Upload and Execute Script") - self.run_script_btn.setStyleSheet(""" - QPushButton { background-color: #8e44ad; color: white; padding: 10px 30px; - font-size: 14px; font-weight: bold; border: none; border-radius: 6px; } - QPushButton:hover { background-color: #9b59b6; } - QPushButton:disabled { background-color: #555; color: #999; } - """) - self.run_script_btn.clicked.connect(self._run_script) - script_group_layout.addWidget(self.run_script_btn) - - script_layout.addWidget(script_group) - - desc_group = QGroupBox("How it works") - desc_layout = QVBoxLayout(desc_group) - desc_label = QLabel( - "1. Select a local .sh script file\n" - "2. Click button to auto:\n" - " Upload script to /tmp/\n" - " Execute (deploy_* scripts use sudo, others run as normal user)\n" - " Download execution log\n" - " Clean remote temp files" - ) - desc_label.setStyleSheet("color: #aaa; font-size: 12px; line-height: 1.6;") - desc_layout.addWidget(desc_label) - script_layout.addWidget(desc_group) - - script_layout.addStretch() - self.tab_widget.addTab(script_tab, "Script") - - # ====== 工作区 Tab ====== - self.workspace_panel = WorkspacePanel( - self._config_panel or ConfigPanel(), self.log_panel - ) - self.tab_widget.addTab(self.workspace_panel, "📂 工作区") - - self.tab_widget.currentChanged.connect(self._on_tab_changed) - - layout.addWidget(self.tab_widget) - - def _on_tab_changed(self, index: int): - if self.tab_widget.tabText(index) == "📂 工作区": - if self._config_panel and self.workspace_panel._validate_ssh_config(): - QTimer.singleShot(300, self.workspace_panel.refresh_root) - - def _set_ui_running(self, running: bool): - self.run_btn.setEnabled(not running) - self.cancel_btn.setEnabled(running) - self.task_input.setEnabled(not running) - self.run_script_btn.setEnabled(not running) - self.script_browse_btn.setEnabled(not running) - self.script_path_edit.setEnabled(not running) - if self._config_panel: - self._config_panel.set_enabled(not running) - - def _cleanup_worker(self): - if self._worker_thread: - try: - self._worker_thread.finished.disconnect() - except TypeError, RuntimeError: - pass - if self._worker_thread.isRunning(): - self._worker_thread.quit() - if not self._worker_thread.wait(5000): - self._worker_thread.terminate() - self._worker_thread.wait(3000) - self._worker_thread = None - if self._worker: - try: - self._worker.log_signal.disconnect() - self._worker.finished_signal.disconnect() - self._worker.error_signal.disconnect() - self._worker.cancelled_signal.disconnect() - except TypeError, RuntimeError: - pass - self._worker = None - - def _ensure_old_threads_cleaned(self): - self._cleanup_worker() - self._cleanup_shutdown() - - def run_task(self): - if not self._config_panel or not self._config_panel.validate(): - return - task = self.task_input.toPlainText().strip() - if not task: - QMessageBox.warning(self, "提示", "请输入任务描述") - return - - project_path = self.project_path_edit.text().strip() - if not project_path: - QMessageBox.warning(self, "提示", "请输入项目路径") - return - - project_name = os.path.basename(project_path.rstrip("/\\")) - enhanced_task = ( - f'在 {project_path} 下有一个 "{project_name}" 项目,' - f"请仔细阅读 README.md 文档再执行操作," - f"最后将更新记录写入 README.md 中," - f"不允许使用启动或打包项目\n\n{task}" - ) - - config = self._config_panel.get_config() - self._ensure_old_threads_cleaned() - self._set_ui_running(True) - self.log_panel.clear_and_set_header(enhanced_task) - - self._worker = AgentWorker(config, enhanced_task) - self._worker_thread = QThread(self) - self._worker.moveToThread(self._worker_thread) - - self._worker.log_signal.connect(self.log_panel.append_log) - self._worker.finished_signal.connect(self._on_task_finished) - self._worker.error_signal.connect(self._on_task_error) - self._worker.cancelled_signal.connect(self._on_task_cancelled) - self._worker_thread.started.connect(self._worker.run) - self._worker_thread.finished.connect(self._on_worker_thread_finished) - - self._worker_thread.start() - - def _on_worker_thread_finished(self): - self._cleanup_worker() - - def _on_task_finished(self, vm_started_by_us: bool): - self._set_ui_running(False) - self.log_panel.append_log("=" * 60, "info") - self.log_panel.append_log("✅ 任务已成功完成", "success") - - if vm_started_by_us: - reply = QMessageBox.question( - self, - "虚拟机管理", - "虚拟机是由本任务启动的。\n是否要关闭虚拟机?\n\n" - "选「是」→ 关闭虚拟机\n" - "选「否」→ 保持虚拟机运行", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes, - ) - if reply == QMessageBox.StandardButton.Yes: - config = self._config_panel.get_config() if self._config_panel else {} - self._start_shutdown_thread(config) - - def _on_task_error(self, error_msg: str): - self._set_ui_running(False) - self.log_panel.append_log(f"❌ 任务失败: {error_msg}", "error") - - def _on_task_cancelled(self): - self._set_ui_running(False) - self.log_panel.append_log("⏹️ 任务已取消", "warning") - - def _start_shutdown_thread(self, config: dict): - self._cleanup_shutdown() - self._shutdown_worker = ShutdownWorker(config) - self._shutdown_thread = QThread(self) - self._shutdown_worker.moveToThread(self._shutdown_thread) - - self._shutdown_worker.log.connect(self.log_panel.append_log) - self._shutdown_worker.finished.connect(self._on_shutdown_finished) - self._shutdown_worker.error.connect(self._on_shutdown_error) - self._shutdown_thread.started.connect(self._shutdown_worker.run) - self._shutdown_thread.finished.connect(self._cleanup_shutdown) - - self._shutdown_thread.start() - - def _on_shutdown_finished(self): - self.log_panel.append_log("✅ 虚拟机已关闭", "success") - - def _on_shutdown_error(self, error_msg: str): - self.log_panel.append_log(f"❌ 关机失败: {error_msg}", "error") - - def _cleanup_shutdown(self): - if self._shutdown_thread: - try: - self._shutdown_thread.finished.disconnect() - except TypeError, RuntimeError: - pass - if self._shutdown_thread.isRunning(): - self._shutdown_thread.quit() - if not self._shutdown_thread.wait(5000): - self._shutdown_thread.terminate() - self._shutdown_thread.wait(3000) - self._shutdown_thread = None - if self._shutdown_worker: - try: - self._shutdown_worker.log.disconnect() - self._shutdown_worker.finished.disconnect() - self._shutdown_worker.error.disconnect() - except TypeError, RuntimeError: - pass - self._shutdown_worker = None - - def cancel_task(self): - if self._worker: - self._worker.cancel() - self.log_panel.append_log("⏹️ 正在取消任务...", "warning") - QTimer.singleShot(5000, self._force_cleanup_if_stuck) - - def _force_cleanup_if_stuck(self): - if self._worker_thread and self._worker_thread.isRunning(): - self.log_panel.append_log("⚠️ 任务线程未响应,正在强制终止...", "warning") - self._worker_thread.quit() - if not self._worker_thread.wait(5000): - self._worker_thread.terminate() - self._worker_thread.wait(3000) - self._cleanup_worker() - self._set_ui_running(False) - self.log_panel.append_log("⏹️ 任务已强制终止", "warning") - - _upload_thread = None - _upload_worker = None - - def _cleanup_upload(self): - if self._upload_thread: - try: - self._upload_thread.finished.disconnect() - except TypeError, RuntimeError: - pass - if self._upload_thread.isRunning(): - self._upload_thread.quit() - if not self._upload_thread.wait(5000): - self._upload_thread.terminate() - self._upload_thread.wait(3000) - self._upload_thread = None - if self._upload_worker: - try: - self._upload_worker.log_signal.disconnect() - self._upload_worker.finished_signal.disconnect() - self._upload_worker.error_signal.disconnect() - except TypeError, RuntimeError: - pass - self._upload_worker = None - - def _browse_upload_file(self): - file_path, _ = QFileDialog.getOpenFileName(self, "选择要上传的文件") - if file_path: - self.upload_local_edit.setText(file_path) - name = os.path.basename(file_path) - remote = self.upload_remote_edit.text().strip() - if remote.endswith("/"): - self.upload_remote_edit.setText(remote + name) - else: - self.upload_remote_edit.setText(f"~/workspace/{name}") - - def upload_file(self): - if not self._config_panel or not self._config_panel.validate(): - return - local_path = self.upload_local_edit.text().strip() - remote_path = self.upload_remote_edit.text().strip() - if not local_path: - QMessageBox.warning(self, "提示", "请选择要上传的本地文件") - return - if not remote_path: - QMessageBox.warning(self, "提示", "请输入虚拟机中的目标路径") - return - if not os.path.isfile(local_path): - QMessageBox.warning(self, "提示", f"本地文件不存在: {local_path}") - return - - self._cleanup_upload() - self._set_ui_running(True) - - config = self._config_panel.get_config() - self._upload_worker = UploadWorker(config, local_path, remote_path) - self._upload_thread = QThread(self) - self._upload_worker.moveToThread(self._upload_thread) - - self._upload_worker.log_signal.connect(self.log_panel.append_log) - self._upload_worker.finished_signal.connect(self._on_upload_finished) - self._upload_worker.error_signal.connect(self._on_upload_error) - self._upload_thread.started.connect(self._upload_worker.run) - self._upload_thread.finished.connect(self._cleanup_upload) - - self._upload_thread.start() - - def _on_upload_finished(self, success: bool): - self._set_ui_running(False) - if success: - self.log_panel.append_log("✅ 上传操作已完成", "success") - - def _on_upload_error(self, error_msg: str): - self._set_ui_running(False) - self.log_panel.append_log(f"❌ 上传失败: {error_msg}", "error") - - _download_thread = None - _download_worker = None - - def _cleanup_download(self): - if self._download_thread: - try: - self._download_thread.finished.disconnect() - except TypeError, RuntimeError: - pass - if self._download_thread.isRunning(): - self._download_thread.quit() - if not self._download_thread.wait(5000): - self._download_thread.terminate() - self._download_thread.wait(3000) - self._download_thread = None - if self._download_worker: - try: - self._download_worker.log_signal.disconnect() - self._download_worker.finished_signal.disconnect() - self._download_worker.error_signal.disconnect() - except TypeError, RuntimeError: - pass - self._download_worker = None - - def _browse_download_file(self): - file_path, _ = QFileDialog.getSaveFileName(self, "选择保存位置") - if file_path: - self.download_local_edit.setText(file_path) - - def download_file(self): - if not self._config_panel or not self._config_panel.validate(): - return - remote_path = self.download_remote_edit.text().strip() - local_path = self.download_local_edit.text().strip() - if not remote_path: - QMessageBox.warning(self, "提示", "请输入虚拟机中的文件路径") - return - if not local_path: - QMessageBox.warning(self, "提示", "请选择本地保存路径") - return - - self._cleanup_download() - self._set_ui_running(True) - - config = self._config_panel.get_config() - self._download_worker = DownloadWorker(config, remote_path, local_path) - self._download_thread = QThread(self) - self._download_worker.moveToThread(self._download_thread) - - self._download_worker.log_signal.connect(self.log_panel.append_log) - self._download_worker.finished_signal.connect(self._on_download_finished) - self._download_worker.error_signal.connect(self._on_download_error) - self._download_thread.started.connect(self._download_worker.run) - self._download_thread.finished.connect(self._cleanup_download) - - self._download_thread.start() - - def _on_download_finished(self, success: bool): - self._set_ui_running(False) - if success: - self.log_panel.append_log("✅ 下载操作已完成", "success") - - def _on_download_error(self, error_msg: str): - self._set_ui_running(False) - self.log_panel.append_log(f"❌ 下载失败: {error_msg}", "error") - - _script_thread = None - _script_worker = None - - def _cleanup_script(self): - if self._script_thread: - try: - self._script_thread.finished.disconnect() - except TypeError, RuntimeError: - pass - if self._script_thread.isRunning(): - self._script_thread.quit() - if not self._script_thread.wait(5000): - self._script_thread.terminate() - self._script_thread.wait(3000) - self._script_thread = None - if self._script_worker: - try: - self._script_worker.log_signal.disconnect() - self._script_worker.finished_signal.disconnect() - self._script_worker.error_signal.disconnect() - except TypeError, RuntimeError: - pass - self._script_worker = None - - def _browse_script(self): - file_path, _ = QFileDialog.getOpenFileName( - self, "Select script", "", "Shell scripts (*.sh);;All files (*)" - ) - if file_path: - self.script_path_edit.setText(file_path) - - def _run_script(self): - if not self._config_panel or not self._config_panel.validate(): - return - local_path = self.script_path_edit.text().strip() - if not local_path: - QMessageBox.warning(self, "Hint", "Please select a script file") - return - if not os.path.isfile(local_path): - QMessageBox.warning(self, "Hint", "File not found: " + local_path) - return - - self._cleanup_script() - self._set_ui_running(True) - self.tab_widget.setCurrentIndex(0) - self.log_panel.clear_log() - - config = self._config_panel.get_config() - self._script_worker = ScriptWorker(config, local_path) - self._script_thread = QThread(self) - self._script_worker.moveToThread(self._script_thread) - - self._script_worker.log_signal.connect(self.log_panel.append_log) - self._script_worker.finished_signal.connect(self._on_script_finished) - self._script_worker.error_signal.connect(self._on_script_error) - self._script_thread.started.connect(self._script_worker.run) - self._script_thread.finished.connect(self._cleanup_script) - - self._script_thread.start() - - def _on_script_finished(self, success: bool): - self._set_ui_running(False) - if success: - self.log_panel.append_log("Script execution completed", "success") - - def _on_script_error(self, error_msg: str): - self._set_ui_running(False) - self.log_panel.append_log("Script error: " + error_msg, "error") +from .app.config_panel import ConfigPanel +from .app.log_panel import LogPanel +from .app.task_panel import TaskPanel class MainWindow(QMainWindow):