1 Commits
Author SHA1 Message Date
chakcy 3391978b1f modify: gui.py 拆分 2026-05-13 11:36:16 +08:00
15 changed files with 2530 additions and 2462 deletions
+1
View File
@@ -11,4 +11,5 @@ output.md
# Virtual environments
.venv
.sdk
.env*
+11 -11
View File
@@ -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]
+136
View File
@@ -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
+158
View File
@@ -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)
@@ -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
@@ -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"
+89
View File
@@ -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")
+156
View File
@@ -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
@@ -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
+612
View File
@@ -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")
@@ -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
@@ -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())
@@ -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)
File diff suppressed because it is too large Load Diff