Merge branch 'dev'

v1.1.0
This commit is contained in:
beibeizi committed 2026-03-16 21:21:24 +08:00
commit 59f4f89ac9
35 files changed
+4272 -2706

No files matched your search

+3
View File
@@ -42,3 +42,6 @@ frontend/data/
.coverage
coverage/
htmlcov/
# Tasks (local only)
tasks/
+10
View File
@@ -11,6 +11,15 @@ One of its core capabilities is the persistent session design for long-running w
![WebSSH Gateway Overview](./docs/images/preview-overview.png)
![WebSSH Gateway Terminal and File Manager](./docs/images/preview-terminal.png)
## Mobile Experience
A dedicated mobile layout provides touch-friendly interactions for terminal work, file management, and system monitoring in phone browsers.
<img src="./docs/images/mobile-overview.jpg" alt="Mobile Overview" width="320" style="max-width: 100%;" />
<img src="./docs/images/mobile-terminal.jpg" alt="Mobile Terminal" width="320" style="max-width: 100%;" />
<img src="./docs/images/mobile-fileManagement.jpg" alt="Mobile File Management" width="320" style="max-width: 100%;" />
<img src="./docs/images/mobile-systemStatus.jpg" alt="Mobile System Status" width="320" style="max-width: 100%;" />
## Community Edition Notice
- This repository is the **Community Edition (CE)** of WebSSH Gateway.
@@ -24,6 +33,7 @@ One of its core capabilities is the persistent session design for long-running w
- Connection asset management with encrypted credentials
- Session lifecycle management with reconnect and notes
- Enhanced persistent session design powered by `tmux`, with keepalive and auto-retry
- Mobile-friendly experience for phone browsers with touch-first layouts and controls
- System monitoring for CPU, memory, network, process, and disk
- File management: browse, upload/download, rename, delete, chmod, batch upload
- Security baseline: JWT auth, password policy, lockout policy, request-id logging
+10
View File
@@ -11,6 +11,15 @@ WebSSH Gateway 是一个面向运维和开发场景的浏览器 SSH 网关。你
![WebSSH Gateway 项目总览](./docs/images/preview-overview.png)
![WebSSH Gateway 终端与文件管理](./docs/images/preview-terminal.png)
## 移动端适配
移动端提供专门的触摸友好布局与交互,支持在手机浏览器中完成终端操作、文件管理与系统监控。
<img src="./docs/images/mobile-overview.jpg" alt="移动端概览" width="320" style="max-width: 100%;" />
<img src="./docs/images/mobile-terminal.jpg" alt="移动端终端" width="320" style="max-width: 100%;" />
<img src="./docs/images/mobile-fileManagement.jpg" alt="移动端文件管理" width="320" style="max-width: 100%;" />
<img src="./docs/images/mobile-systemStatus.jpg" alt="移动端系统状态" width="320" style="max-width: 100%;" />
## 社区版声明
- 本仓库为 **WebSSH Gateway 社区版(Community Edition)**。
@@ -24,6 +33,7 @@ WebSSH Gateway 是一个面向运维和开发场景的浏览器 SSH 网关。你
- 连接资产管理:支持保存主机连接信息,密码/私钥凭据加密存储。
- 会话生命周期管理:会话状态跟踪、断开重连、会话备注。
- 增强会话持久化:支持基于 `tmux` 的持久会话设计、增强保活与自动重试机制。
- 移动端适配:面向手机浏览器的触摸友好操作与布局。
- 系统监控面板:CPU、内存、网络、进程、磁盘等实时查看。
- 文件管理能力:浏览、上传、下载、重命名、删除、权限修改、批量上传。
- 基础安全能力:JWT 鉴权、密码复杂度校验、登录失败锁定、请求追踪 ID 日志。
+60 -5
View File
@@ -8,7 +8,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import select, func
from app.api.dependencies import AppState, get_current_user, get_current_user_from_ws, get_db, get_state
from app.core.db import utc_now
@@ -18,6 +18,7 @@ from app.models.connection import Connection
from app.models.session import SessionRecord
from app.schemas.api import (
SessionCreateRequest,
SessionOrderUpdateRequest,
SessionNoteUpdateRequest,
SessionResponse,
SessionStatusResponse,
@@ -140,6 +141,7 @@ def _build_session_response(
username=conn.username,
name=conn.name,
note=record.note,
session_order=record.session_order or 0,
enhanced_enabled=bool(record.enhanced_enabled),
remote_arch=conn.remote_arch,
remote_os=conn.remote_os,
@@ -165,7 +167,11 @@ def list_sessions(
user=Depends(get_current_user),
db=Depends(get_db),
) -> list[SessionResponse]:
records = db.execute(select(SessionRecord).where(SessionRecord.user_id == user.id)).scalars().all()
records = db.execute(
select(SessionRecord)
.where(SessionRecord.user_id == user.id)
.order_by(SessionRecord.session_order.asc(), SessionRecord.started_at.asc())
).scalars().all()
responses = []
for record in records:
conn = db.execute(select(Connection).where(Connection.id == record.connection_id)).scalar_one_or_none()
@@ -310,6 +316,11 @@ async def create_session(
if conn.enhance_prompt_shown is False and payload.enable_enhanced_persistence and binary_match is not None:
conn.enhance_prompt_shown = True
max_order = db.execute(
select(func.max(SessionRecord.session_order)).where(SessionRecord.user_id == user.id)
).scalar_one()
next_order = (max_order or 0) + 1
managed = await session_manager.create_session(
connection=conn,
auth_payload=auth_payload,
@@ -328,6 +339,7 @@ async def create_session(
last_activity=utc_now(),
pty_info=session_manager.serialize_pty(pty),
note=None,
session_order=next_order,
enhanced_enabled=enable_enhanced,
enhanced_fingerprint=enhanced_fingerprint,
tmux_binary_path=tmux_binary_path,
@@ -376,7 +388,8 @@ async def retry_enhanced_session(
pty = session_manager.deserialize_pty(record.pty_info)
last_error: Exception | None = None
for attempt in range(1, 4):
retry_schedule = [2, 4, 8, 16, 32]
for attempt in range(1, len(retry_schedule) + 1):
try:
await session_manager.close_session(session_id)
managed = await session_manager.create_session(
@@ -400,7 +413,8 @@ async def retry_enhanced_session(
return response
except Exception as exc:
last_error = exc
await asyncio.sleep(5)
if attempt < len(retry_schedule):
await asyncio.sleep(retry_schedule[attempt - 1])
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(last_error) if last_error else "重试失败")
@@ -495,6 +509,48 @@ async def update_session_note(
return response
@router.patch("/order")
def update_session_order(
payload: SessionOrderUpdateRequest,
user=Depends(get_current_user),
db=Depends(get_db),
) -> dict:
records = db.execute(select(SessionRecord).where(SessionRecord.user_id == user.id)).scalars().all()
if not records:
return {"status": "ok"}
record_map = {record.id: record for record in records}
ordered_ids: list[str] = []
seen: set[str] = set()
for session_id in payload.ordered_ids:
if session_id in seen:
continue
if session_id not in record_map:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session not found")
ordered_ids.append(session_id)
seen.add(session_id)
existing_sorted = sorted(
records,
key=lambda record: (
1 if (record.session_order is None or record.session_order <= 0) else 0,
record.session_order or 0,
record.started_at,
),
)
for record in existing_sorted:
if record.id in seen:
continue
ordered_ids.append(record.id)
seen.add(record.id)
for index, session_id in enumerate(ordered_ids, start=1):
record_map[session_id].session_order = index
return {"status": "ok"}
@router.websocket("/ws/terminal/{session_id}")
async def terminal_socket(
websocket: WebSocket,
@@ -622,4 +678,3 @@ async def terminal_socket(
await websocket.close(code=1008)
except Exception:
pass
+334 -333
View File
@@ -1,19 +1,26 @@
from __future__ import annotations
import asyncio
import json
import re
import tempfile
import os
import tarfile
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List
from typing import List, AsyncIterator
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.api.dependencies import AppState, get_current_user, get_state
from sqlalchemy import select
from app.api.dependencies import AppState, get_current_user, get_state, get_db
from app.models.connection import Connection
from app.models.session import SessionRecord
from app.services.crypto import CryptoService, EncryptedPayload
router = APIRouter(prefix="/system", tags=["system"])
@@ -153,11 +160,48 @@ def build_upload_result(
return UploadResult(uploaded=uploaded, skipped=skipped, failed=failed, errors=errors or []).model_dump()
async def run_ssh_command(session, command: str, timeout: float = 5.0) -> str:
@asynccontextmanager
async def resolve_ssh_client(
session_id: str,
state: AppState,
user,
db,
) -> AsyncIterator:
session = state.session_manager.get_session(session_id)
if session:
yield session.client
return
record = db.execute(
select(SessionRecord).where(SessionRecord.id == session_id, SessionRecord.user_id == user.id)
).scalar_one_or_none()
if not record or record.status != "active":
raise HTTPException(status_code=404, detail="会话不存在")
conn = db.execute(
select(Connection).where(Connection.id == record.connection_id, Connection.user_id == user.id)
).scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="连接不存在")
crypto = CryptoService(state.config.secret_keys)
auth_data = json.loads(conn.auth_data)
decrypted = crypto.decrypt(EncryptedPayload(nonce=auth_data["nonce"], ciphertext=auth_data["ciphertext"]))
auth_payload = json.loads(decrypted)
client = await state.session_manager.connect_client(conn, auth_payload)
try:
yield client
finally:
client.close()
async def run_ssh_command(session_or_client, command: str, timeout: float = 5.0) -> str:
"""在 SSH 会话中执行命令并返回输出"""
try:
client = getattr(session_or_client, "client", session_or_client)
result = await asyncio.wait_for(
session.client.run(command, check=False),
client.run(command, check=False),
timeout=timeout
)
return result.stdout or ""
@@ -401,74 +445,62 @@ async def collect_system_overview(session) -> SystemOverview:
async def get_system_stats(
session_id: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
) -> SystemStats:
"""获取远程服务器系统状态信息(CPU、内存、交换区)"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
stat1, meminfo, cpuinfo = await asyncio.gather(
run_ssh_command(client, "cat /proc/stat"),
run_ssh_command(client, "cat /proc/meminfo"),
run_ssh_command(client, "cat /proc/cpuinfo"),
)
# 骞惰鑾峰彇绗竴娆℃暟鎹?
stat1, meminfo, cpuinfo = await asyncio.gather(
run_ssh_command(session, "cat /proc/stat"),
run_ssh_command(session, "cat /proc/meminfo"),
run_ssh_command(session, "cat /proc/cpuinfo"),
)
interval = 0.3
await asyncio.sleep(interval)
stat2 = await run_ssh_command(client, "cat /proc/stat")
# 等待一小段时间再次获取 CPU 统计
interval = 0.3
await asyncio.sleep(interval)
stat2 = await run_ssh_command(session, "cat /proc/stat")
memory, swap = parse_memory_info(meminfo)
cpu = parse_cpu_info(stat1, stat2, cpuinfo)
# 解析数据
memory, swap = parse_memory_info(meminfo)
cpu = parse_cpu_info(stat1, stat2, cpuinfo)
return SystemStats(cpu=cpu, memory=memory, swap=swap)
return SystemStats(cpu=cpu, memory=memory, swap=swap)
@router.get("/network/{session_id}", response_model=NetworkInfo)
async def get_network_stats(
session_id: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
) -> NetworkInfo:
"""获取远程服务器网络速度"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
net1 = await run_ssh_command(client, "cat /proc/net/dev")
interval = 0.5
await asyncio.sleep(interval)
net2 = await run_ssh_command(client, "cat /proc/net/dev")
# 获取两次网络统计
net1 = await run_ssh_command(session, "cat /proc/net/dev")
interval = 0.5
await asyncio.sleep(interval)
net2 = await run_ssh_command(session, "cat /proc/net/dev")
return parse_network_stats(net1, net2, interval)
return parse_network_stats(net1, net2, interval)
@router.get("/processes/{session_id}", response_model=ProcessList)
async def get_processes(
session_id: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
) -> ProcessList:
"""获取远程服务器进程列表"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
ps_output = await run_ssh_command(
client,
"ps aux 2>/dev/null || ps -ef 2>/dev/null",
timeout=5.0
)
# 使用 ps 命令获取进程信息
ps_output = await run_ssh_command(
session,
"ps aux 2>/dev/null || ps -ef 2>/dev/null",
timeout=5.0
)
processes = parse_processes(ps_output)
# 鎸夊唴瀛樻帓搴忓彇鍓?20
processes.sort(key=lambda x: x.memory_bytes, reverse=True)
return ProcessList(processes=processes[:20])
processes = parse_processes(ps_output)
processes.sort(key=lambda x: x.memory_bytes, reverse=True)
return ProcessList(processes=processes[:20])
def parse_ls_output(ls_output: str, base_path: str) -> List[FileInfo]:
@@ -557,59 +589,52 @@ async def list_directory(
session_id: str,
path: str = "/",
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
) -> DirectoryListing:
"""列出远程服务器目录内容"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
if not clean_path.startswith("/"):
clean_path = "/" + clean_path
# 规范化路径,防止路径遍历攻击
clean_path = sanitize_shell_path(path)
if not clean_path.startswith("/"):
clean_path = "/" + clean_path
ls_output = await run_ssh_command(
client,
f"ls -la '{clean_path}' 2>/dev/null",
timeout=10.0
)
# 使用 ls -la 获取目录内容
ls_output = await run_ssh_command(
session,
f"ls -la '{clean_path}' 2>/dev/null",
timeout=10.0
)
if not ls_output:
raise HTTPException(status_code=404, detail="目录不存在或无权访问")
if not ls_output:
raise HTTPException(status_code=404, detail="目录不存在或无权访问")
files = parse_ls_output(ls_output, clean_path)
return DirectoryListing(path=clean_path, files=files)
files = parse_ls_output(ls_output, clean_path)
return DirectoryListing(path=clean_path, files=files)
@router.get("/disks/{session_id}", response_model=DiskList)
async def get_disks(
session_id: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
) -> DiskList:
"""获取磁盘挂载信息"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
df_output = await run_ssh_command(client, "df -B1 | grep '^/'", timeout=5.0)
df_output = await run_ssh_command(session, "df -B1 | grep '^/'", timeout=5.0)
return DiskList(disks=parse_disks(df_output))
return DiskList(disks=parse_disks(df_output))
@router.get("/overview/{session_id}", response_model=SystemOverview)
async def get_system_overview(
session_id: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
) -> SystemOverview:
"""一次返回系统状态、网络、进程和磁盘信息。"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
return await collect_system_overview(session)
async with resolve_ssh_client(session_id, state, user, db) as client:
return await collect_system_overview(client)
@router.get("/file/{session_id}")
@@ -618,24 +643,21 @@ async def read_file(
path: str,
force: bool = False,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
) -> dict:
"""读取文件内容"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_path = sanitize_shell_path(path)
size_check = await run_ssh_command(client, f"stat -c %s '{clean_path}' 2>/dev/null || echo 0")
size = int(size_check.strip() or 0)
# 检查文件大小
size_check = await run_ssh_command(session, f"stat -c %s '{clean_path}' 2>/dev/null || echo 0")
size = int(size_check.strip() or 0)
if size > 1024 * 1024 and not force:
return {"size": size, "too_large": True}
if size > 1024 * 1024 and not force:
return {"size": size, "too_large": True}
content = await run_ssh_command(session, f"cat '{clean_path}' 2>/dev/null", timeout=30.0)
return {"content": content, "size": size}
content = await run_ssh_command(client, f"cat '{clean_path}' 2>/dev/null", timeout=30.0)
return {"content": content, "size": size}
@router.post("/file/{session_id}")
@@ -644,26 +666,24 @@ async def write_file(
path: str,
payload: FileContent,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""写入文件内容"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_path = sanitize_shell_path(path)
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
tmp.write(payload.content)
tmp_path = tmp.name
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
tmp.write(payload.content)
tmp_path = tmp.name
try:
async with client.start_sftp_client() as sftp:
await sftp.put(tmp_path, clean_path)
finally:
os.unlink(tmp_path)
try:
async with session.client.start_sftp_client() as sftp:
await sftp.put(tmp_path, clean_path)
finally:
os.unlink(tmp_path)
return {"status": "ok"}
return {"status": "ok"}
@router.post("/upload/{session_id}")
@@ -673,46 +693,44 @@ async def upload_file(
compress: bool = False,
file: UploadFile = File(...),
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""上传文件到远程服务器"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_path = sanitize_shell_path(path)
safe_filename = sanitize_upload_filename(file.filename)
safe_filename = sanitize_upload_filename(file.filename)
with tempfile.TemporaryDirectory() as tmpdir:
local_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, safe_filename))
content = await file.read()
with open(local_path, "wb") as f:
f.write(content)
with tempfile.TemporaryDirectory() as tmpdir:
local_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, safe_filename))
content = await file.read()
with open(local_path, "wb") as f:
f.write(content)
errors: List[str] = []
uploaded = 0
errors: List[str] = []
uploaded = 0
try:
if compress:
tar_path = os.path.join(tmpdir, f"{safe_filename}.tar.gz")
create_tar_gz(tar_path, local_path, safe_filename)
try:
if compress:
tar_path = os.path.join(tmpdir, f"{safe_filename}.tar.gz")
create_tar_gz(tar_path, local_path, safe_filename)
async with client.start_sftp_client() as sftp:
await sftp.put(tar_path, f"{clean_path}/{safe_filename}.tar.gz")
async with session.client.start_sftp_client() as sftp:
await sftp.put(tar_path, f"{clean_path}/{safe_filename}.tar.gz")
await run_ssh_command(
session,
f"cd '{clean_path}' && tar -xzf '{safe_filename}.tar.gz' && rm -f '{safe_filename}.tar.gz'",
timeout=30.0,
)
uploaded = 1
else:
async with session.client.start_sftp_client() as sftp:
await sftp.put(local_path, f"{clean_path}/{safe_filename}")
uploaded = 1
except Exception as exc:
errors.append(f"{safe_filename}: {exc}")
await run_ssh_command(
client,
f"cd '{clean_path}' && tar -xzf '{safe_filename}.tar.gz' && rm -f '{safe_filename}.tar.gz'",
timeout=30.0,
)
uploaded = 1
else:
async with client.start_sftp_client() as sftp:
await sftp.put(local_path, f"{clean_path}/{safe_filename}")
uploaded = 1
except Exception as exc:
errors.append(f"{safe_filename}: {exc}")
if errors:
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
@@ -726,37 +744,35 @@ async def upload_targz(
path: str,
file: UploadFile = File(...),
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""上传 tar.gz 压缩包并解压"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_path = sanitize_shell_path(path)
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
errors: List[str] = []
uploaded = 0
errors: List[str] = []
uploaded = 0
try:
async with client.start_sftp_client() as sftp:
await sftp.put(tmp_path, f"{clean_path}/upload.tar.gz")
await run_ssh_command(client, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=120.0)
uploaded = 1
except Exception as exc:
errors.append(f"upload.tar.gz: {exc}")
finally:
os.unlink(tmp_path)
try:
async with session.client.start_sftp_client() as sftp:
await sftp.put(tmp_path, f"{clean_path}/upload.tar.gz")
await run_ssh_command(session, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=120.0)
uploaded = 1
except Exception as exc:
errors.append(f"upload.tar.gz: {exc}")
finally:
os.unlink(tmp_path)
if errors:
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
if errors:
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
return build_upload_result(uploaded=uploaded)
return build_upload_result(uploaded=uploaded)
@router.post("/upload-zip/{session_id}")
@@ -765,37 +781,35 @@ async def upload_zip(
path: str,
file: UploadFile = File(...),
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""上传 zip 压缩包并解压"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_path = sanitize_shell_path(path)
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
errors: List[str] = []
uploaded = 0
errors: List[str] = []
uploaded = 0
try:
async with client.start_sftp_client() as sftp:
await sftp.put(tmp_path, f"{clean_path}/upload.zip")
await run_ssh_command(client, f"cd '{clean_path}' && unzip -q upload.zip && rm -f upload.zip", timeout=120.0)
uploaded = 1
except Exception as exc:
errors.append(f"upload.zip: {exc}")
finally:
os.unlink(tmp_path)
try:
async with session.client.start_sftp_client() as sftp:
await sftp.put(tmp_path, f"{clean_path}/upload.zip")
await run_ssh_command(session, f"cd '{clean_path}' && unzip -q upload.zip && rm -f upload.zip", timeout=120.0)
uploaded = 1
except Exception as exc:
errors.append(f"upload.zip: {exc}")
finally:
os.unlink(tmp_path)
if errors:
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
if errors:
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
return build_upload_result(uploaded=uploaded)
return build_upload_result(uploaded=uploaded)
@router.post("/upload-batch/{session_id}")
@@ -806,72 +820,70 @@ async def upload_batch(
concurrent: int = 3,
files: List[UploadFile] = File(...),
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""批量上传文件(支持文件夹结构)"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_path = sanitize_shell_path(path)
with tempfile.TemporaryDirectory() as tmpdir:
written_files = 0
staged_files: List[tuple[str, str]] = []
errors: List[str] = []
with tempfile.TemporaryDirectory() as tmpdir:
written_files = 0
staged_files: List[tuple[str, str]] = []
errors: List[str] = []
for file in files:
relative_path = sanitize_upload_relative_path(file.filename)
file_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, relative_path))
os.makedirs(os.path.dirname(file_path), exist_ok=True)
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
for file in files:
relative_path = sanitize_upload_relative_path(file.filename)
file_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, relative_path))
os.makedirs(os.path.dirname(file_path), exist_ok=True)
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
staged_files.append((relative_path, file_path))
written_files += 1
staged_files.append((relative_path, file_path))
written_files += 1
if compress:
tar_path = os.path.join(tmpdir, "upload.tar.gz")
with tarfile.open(tar_path, "w:gz") as tar:
for relative_path, file_path in staged_files:
tar.add(file_path, arcname=relative_path)
if compress:
tar_path = os.path.join(tmpdir, "upload.tar.gz")
with tarfile.open(tar_path, "w:gz") as tar:
for relative_path, file_path in staged_files:
tar.add(file_path, arcname=relative_path)
try:
async with client.start_sftp_client() as sftp:
await sftp.put(tar_path, f"{clean_path}/upload.tar.gz")
try:
async with session.client.start_sftp_client() as sftp:
await sftp.put(tar_path, f"{clean_path}/upload.tar.gz")
await run_ssh_command(client, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=60.0)
except Exception as exc:
errors.append(f"upload.tar.gz: {exc}")
return build_upload_result(uploaded=0, failed=written_files, errors=errors)
await run_ssh_command(session, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=60.0)
except Exception as exc:
errors.append(f"upload.tar.gz: {exc}")
return build_upload_result(uploaded=0, failed=written_files, errors=errors)
return build_upload_result(uploaded=written_files)
return build_upload_result(uploaded=written_files)
uploaded = 0
queue = list(staged_files)
concurrency = max(1, min(concurrent, 8))
semaphore = asyncio.Semaphore(concurrency)
uploaded = 0
queue = list(staged_files)
concurrency = max(1, min(concurrent, 8))
semaphore = asyncio.Semaphore(concurrency)
async def upload_one(relative_path: str, file_path: str) -> bool:
remote_path = f"{clean_path}/{relative_path}"
remote_dir = os.path.dirname(remote_path)
async def upload_one(relative_path: str, file_path: str) -> bool:
remote_path = f"{clean_path}/{relative_path}"
remote_dir = os.path.dirname(remote_path)
try:
async with semaphore:
await run_ssh_command(client, f"mkdir -p '{remote_dir}'")
async with client.start_sftp_client() as sftp:
await sftp.put(file_path, remote_path)
return True
except Exception as exc:
errors.append(f"{relative_path}: {exc}")
return False
try:
async with semaphore:
await run_ssh_command(session, f"mkdir -p '{remote_dir}'")
async with session.client.start_sftp_client() as sftp:
await sftp.put(file_path, remote_path)
return True
except Exception as exc:
errors.append(f"{relative_path}: {exc}")
return False
results = await asyncio.gather(*(upload_one(relative_path, file_path) for relative_path, file_path in queue))
uploaded = sum(1 for ok in results if ok)
results = await asyncio.gather(*(upload_one(relative_path, file_path) for relative_path, file_path in queue))
uploaded = sum(1 for ok in results if ok)
failed = len(errors)
return build_upload_result(uploaded=uploaded, failed=failed, errors=errors)
failed = len(errors)
return build_upload_result(uploaded=uploaded, failed=failed, errors=errors)
@router.post("/mkdir/{session_id}")
@@ -879,15 +891,14 @@ async def make_directory(
session_id: str,
path: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""创建目录"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
clean_path = sanitize_shell_path(path)
await run_ssh_command(session, f"mkdir -p '{clean_path}'")
return {"status": "ok"}
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
await run_ssh_command(client, f"mkdir -p '{clean_path}'")
return {"status": "ok"}
@router.post("/touch/{session_id}")
@@ -895,15 +906,14 @@ async def touch_file(
session_id: str,
path: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""创建文件"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
clean_path = sanitize_shell_path(path)
await run_ssh_command(session, f"touch '{clean_path}'")
return {"status": "ok"}
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
await run_ssh_command(client, f"touch '{clean_path}'")
return {"status": "ok"}
@router.post("/rename/{session_id}")
@@ -912,16 +922,15 @@ async def rename_file(
old_path: str,
new_path: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""重命名文件或目录"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
clean_old = sanitize_shell_path(old_path)
clean_new = sanitize_shell_path(new_path)
await run_ssh_command(session, f"mv '{clean_old}' '{clean_new}'")
return {"status": "ok"}
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_old = sanitize_shell_path(old_path)
clean_new = sanitize_shell_path(new_path)
await run_ssh_command(client, f"mv '{clean_old}' '{clean_new}'")
return {"status": "ok"}
@router.post("/chmod/{session_id}")
@@ -931,17 +940,16 @@ async def change_mode(
mode: str,
recursive: bool = False,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""修改文件权限"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
clean_path = sanitize_shell_path(path)
clean_mode = sanitize_shell_path(mode)
recursive_flag = "-R " if recursive else ""
await run_ssh_command(session, f"chmod {recursive_flag}{clean_mode} '{clean_path}'")
return {"status": "ok"}
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_mode = sanitize_shell_path(mode)
recursive_flag = "-R " if recursive else ""
await run_ssh_command(client, f"chmod {recursive_flag}{clean_mode} '{clean_path}'")
return {"status": "ok"}
@router.delete("/delete/{session_id}")
@@ -949,15 +957,14 @@ async def delete_file(
session_id: str,
path: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""删除文件或目录"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
clean_path = sanitize_shell_path(path)
await run_ssh_command(session, f"rm -rf '{clean_path}'")
return {"status": "ok"}
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
await run_ssh_command(client, f"rm -rf '{clean_path}'")
return {"status": "ok"}
@router.get("/download/{session_id}")
@@ -965,71 +972,65 @@ async def download_file(
session_id: str,
path: str,
state: AppState = Depends(get_state),
_user: dict = Depends(get_current_user)
user=Depends(get_current_user),
db=Depends(get_db),
):
"""从远程服务器下载文件"""
session = state.session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="会话不存在")
async with resolve_ssh_client(session_id, state, user, db) as client:
clean_path = sanitize_shell_path(path)
clean_path = sanitize_shell_path(path)
check_result = await run_ssh_command(client, f"[ -d '{clean_path}' ] && echo 'dir' || echo 'file'")
is_dir = check_result.strip() == "dir"
# 检查是否为目录
check_result = await run_ssh_command(session, f"[ -d '{clean_path}' ] && echo 'dir' || echo 'file'")
is_dir = check_result.strip() == "dir"
filename = os.path.basename(clean_path)
filename = os.path.basename(clean_path)
if is_dir:
tar_name = f"{filename}.tar.gz"
remote_tar = f"/tmp/{tar_name}"
await run_ssh_command(client, f"tar -czf {remote_tar} -C {os.path.dirname(clean_path)} {filename}", timeout=60.0)
if is_dir:
# 压缩目录
tar_name = f"{filename}.tar.gz"
remote_tar = f"/tmp/{tar_name}"
await run_ssh_command(session, f"tar -czf {remote_tar} -C {os.path.dirname(clean_path)} {filename}", timeout=60.0)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
async with client.start_sftp_client() as sftp:
await sftp.get(remote_tar, tmp_path)
try:
async with session.client.start_sftp_client() as sftp:
await sftp.get(remote_tar, tmp_path)
await run_ssh_command(client, f"rm {remote_tar}")
await run_ssh_command(session, f"rm {remote_tar}")
with open(tmp_path, "rb") as f:
content = f.read()
with open(tmp_path, "rb") as f:
content = f.read()
encoded_filename = quote(tar_name)
return StreamingResponse(
iter([content]),
media_type="application/gzip",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Length": str(len(content)),
},
)
finally:
os.unlink(tmp_path)
else:
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
encoded_filename = quote(tar_name)
return StreamingResponse(
iter([content]),
media_type="application/gzip",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Length": str(len(content)),
},
)
finally:
os.unlink(tmp_path)
else:
# 直接下载文件
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
async with client.start_sftp_client() as sftp:
await sftp.get(clean_path, tmp_path)
try:
async with session.client.start_sftp_client() as sftp:
await sftp.get(clean_path, tmp_path)
with open(tmp_path, "rb") as f:
content = f.read()
encoded_filename = quote(filename)
return StreamingResponse(
iter([content]),
media_type="application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Length": str(len(content)),
},
)
finally:
os.unlink(tmp_path)
with open(tmp_path, "rb") as f:
content = f.read()
encoded_filename = quote(filename)
return StreamingResponse(
iter([content]),
media_type="application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Length": str(len(content)),
},
)
finally:
os.unlink(tmp_path)
+5 -4
View File
@@ -35,10 +35,11 @@ def _serialize_session_status(record: SessionRecord) -> str:
def _required_elapsed_seconds_for_next_retry(retry_cycle_count: int) -> int:
# Retry spacing: 5s, 10s, 15s...
# Retry spacing (seconds): 2, 4, 8, 16, 32
# retry_cycle_count is the number of attempts already made in current cycle.
next_attempt = retry_cycle_count + 1
return 5 * next_attempt * (next_attempt + 1) // 2
schedule = [2, 4, 8, 16, 32]
index = min(retry_cycle_count, len(schedule) - 1)
return schedule[index]
def build_state() -> tuple[
@@ -95,7 +96,7 @@ def build_state() -> tuple[
SessionRecord.allow_auto_retry.is_(True),
SessionRecord.enhanced_fingerprint.is_not(None),
SessionRecord.tmux_binary_path.is_not(None),
SessionRecord.retry_cycle_count < 3,
SessionRecord.retry_cycle_count < 5,
)
).scalars().all()
+3 -1
View File
@@ -21,6 +21,7 @@ from app.services.bootstrap import (
ensure_connection_arch_columns,
ensure_session_enhanced_columns,
ensure_session_note_column,
ensure_session_order_column,
)
logger = get_logger(__name__)
@@ -44,6 +45,7 @@ def create_app() -> FastAPI:
ensure_session_note_column(database)
ensure_connection_arch_columns(database)
ensure_session_enhanced_columns(database)
ensure_session_order_column(database)
with database.session() as db_session:
password = ensure_admin_user(db_session, auth_service)
@@ -158,7 +160,7 @@ def create_app() -> FastAPI:
if response.status_code != 404 or request.method != "GET":
return response
path = request.url.path
if path.startswith("/api") or path.startswith("/auth") or path.startswith("/sessions"):
if path.startswith("/api") or path.startswith("/auth") or path.startswith("/sessions") or path.startswith("/system"):
return response
return index_response
+1
View File
@@ -20,6 +20,7 @@ class SessionRecord(TimestampMixin, Base):
last_activity: Mapped[datetime] = mapped_column(UTCDateTime())
pty_info: Mapped[str] = mapped_column(Text)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
session_order: Mapped[int] = mapped_column(Integer, default=0)
enhanced_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
enhanced_fingerprint: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
tmux_binary_path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
+5
View File
@@ -94,6 +94,7 @@ class SessionResponse(BaseModel):
username: str
name: str
note: Optional[str] = None
session_order: int = 0
enhanced_enabled: bool = False
remote_arch: Optional[str] = None
remote_os: Optional[str] = None
@@ -119,6 +120,10 @@ class SessionNoteUpdateRequest(BaseModel):
note: Optional[str] = Field(default=None, max_length=1000)
class SessionOrderUpdateRequest(BaseModel):
ordered_ids: list[str] = Field(default_factory=list)
class TerminalMessage(BaseModel):
type: str
data: Optional[str] = None
+32
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from app.core.config import AppConfig
from app.core.db import Database
from app.models.session import SessionRecord
from app.models.user import User
from app.services.auth import AuthService
@@ -94,6 +95,37 @@ def ensure_session_enhanced_columns(database: Database) -> None:
connection.execute(text("ALTER TABLE sessions ADD COLUMN allow_auto_retry BOOLEAN DEFAULT 1"))
def ensure_session_order_column(database: Database) -> None:
inspector = inspect(database._engine)
if "sessions" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("sessions")}
with database._engine.begin() as connection:
if "session_order" not in columns:
connection.execute(text("ALTER TABLE sessions ADD COLUMN session_order INTEGER"))
# 为历史数据补齐顺序(按 started_at 排序)
with database.session() as db_session:
records = db_session.execute(
select(SessionRecord).order_by(SessionRecord.user_id, SessionRecord.started_at)
).scalars().all()
last_order_by_user: dict[int, int] = {}
for record in records:
if record.session_order and record.session_order > 0:
last_order_by_user[record.user_id] = max(
last_order_by_user.get(record.user_id, 0),
record.session_order,
)
for record in records:
if record.session_order and record.session_order > 0:
continue
next_order = last_order_by_user.get(record.user_id, 0) + 1
record.session_order = next_order
last_order_by_user[record.user_id] = next_order
def is_user_locked(user: User, auth_service: AuthService) -> bool:
return auth_service.is_locked(user)
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

+242 -108
View File
@@ -59,6 +59,7 @@ type FileBrowserProps = {
currentDir?: string;
onFileSelect?: (path: string | null) => void;
networkProfile?: NetworkProfile;
compact?: boolean;
};
type UploadStats = {
@@ -106,10 +107,11 @@ function createRootTree(): TreeNode[] {
return [{ name: "/", path: "/", expanded: true, loaded: false }];
}
export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, networkProfile }: FileBrowserProps) {
export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, networkProfile, compact }: FileBrowserProps) {
const { networkProfile: globalNetworkProfile, language } = useApp();
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
const effectiveNetworkProfile = networkProfile ?? globalNetworkProfile;
const isCompact = compact === true;
const directoryCacheTtlMs =
effectiveNetworkProfile === "poor" ? 12000 : effectiveNetworkProfile === "degraded" ? 6000 : 3000;
const currentDirDebounceMs =
@@ -146,6 +148,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
const [dialogInput, setDialogInput] = React.useState("");
const [chmodRecursive, setChmodRecursive] = React.useState(false);
const [chmodLoading, setChmodLoading] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const directoryCacheRef = React.useRef<Map<string, DirectoryCacheItem>>(new Map());
const treeRef = React.useRef<TreeNode[]>(tree);
const selectedPathRef = React.useRef<string>(selectedPath);
@@ -515,11 +518,11 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
[mapBatchFailures, uploadBatchWithRetry, t]
);
// 处理文件拖拽上传
const handleDrop = React.useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const handleUploadFiles = React.useCallback(async (allFiles: Array<{ file: File; path: string }>) => {
if (allFiles.length === 0) {
push(t("没有可上传的文件", "No files to upload"));
return;
}
setUploading(true);
setUploadProgress([]);
setLastUploadFailures([]);
@@ -528,15 +531,6 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
setUploadStats(INITIAL_UPLOAD_STATS);
try {
// 读取所有文件(包括文件夹内容)
const dropSnapshot = snapshotDropPayload(e.dataTransfer);
const allFiles = await collectDroppedFiles(dropSnapshot);
if (allFiles.length === 0) {
push(t("没有可上传的文件", "No files to upload"));
return;
}
const totalBytes = calculateTotalBytes(allFiles);
setUploadStats({ current: 0, total: allFiles.length, uploadedBytes: 0, totalBytes });
@@ -637,6 +631,34 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
}
}, [sessionId, selectedPath, compress, push, loadDirectory, uploadFilesByBatch, t, language]);
// 处理文件拖拽上传
const handleDrop = React.useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
try {
// 读取所有文件(包括文件夹内容)
const dropSnapshot = snapshotDropPayload(e.dataTransfer);
const allFiles = await collectDroppedFiles(dropSnapshot);
await handleUploadFiles(allFiles);
} catch (err) {
push(err instanceof Error ? err.message : t("上传失败", "Upload failed"));
}
}, [handleUploadFiles, push, t]);
const handleFileInputChange = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const filesList = event.target.files;
if (!filesList || filesList.length === 0) {
return;
}
const allFiles = Array.from(filesList).map((file) => ({
file,
path: file.webkitRelativePath || file.name,
}));
await handleUploadFiles(allFiles);
event.target.value = "";
}, [handleUploadFiles]);
const handleCancelUpload = React.useCallback(() => {
uploadCancelledRef.current = true;
}, []);
@@ -776,45 +798,51 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
}
}, [sessionId, push, t]);
const handleContextDownload = React.useCallback(async () => {
if (!contextMenu) return;
setContextMenu(null);
const getSelectedFiles = React.useCallback(() => {
if (selectedFiles.size === 0) {
return [];
}
return Array.from(selectedFiles)
.map(path => files.find(f => f.path === path))
.filter(Boolean) as FileInfo[];
}, [files, selectedFiles]);
const filesToDownload =
selectedFiles.size > 0
? (Array.from(selectedFiles).map(path => files.find(f => f.path === path)).filter(Boolean) as FileInfo[])
: contextMenu.files;
if (filesToDownload.length === 0) return;
await downloadItemsWithProgress(filesToDownload);
}, [contextMenu, selectedFiles, files, downloadItemsWithProgress]);
const handleActionForFiles = React.useCallback((
action: "mkdir" | "touch" | "rename" | "chmod" | "delete" | "download",
filesForAction: FileInfo[]
) => {
if (action === "delete") {
if (filesForAction.length === 0) return;
if (confirm(t(`确定删除 ${filesForAction.length} 个项目?`, `Delete ${filesForAction.length} item(s)?`))) {
Promise.all(filesForAction.map(file => deletePath(sessionId, file.path))).then(() => {
push(t("删除成功", "Deleted successfully"));
setSelectedFiles(new Set());
loadDirectory(selectedPathRef.current, false, { forceRefresh: true, preferCache: false });
}).catch(() => push(t("删除失败", "Delete failed")));
}
return;
}
if (action === "download") {
if (filesForAction.length === 0) return;
void downloadItemsWithProgress(filesForAction);
return;
}
setDialog({ type: action, files: filesForAction });
if (action === "rename") {
setDialogInput(filesForAction[0]?.name || "");
} else if (action === "chmod") {
setDialogInput(permissionsToOctal(filesForAction[0]?.permissions || ""));
} else {
setDialogInput("");
}
}, [sessionId, push, t, downloadItemsWithProgress, loadDirectory]);
const handleContextAction = React.useCallback((action: "mkdir" | "touch" | "rename" | "chmod" | "delete" | "download") => {
if (!contextMenu) return;
const { files } = contextMenu;
setContextMenu(null);
if (action === "delete") {
if (files.length === 0) return;
if (confirm(t(`确定删除 ${files.length} 个项目?`, `Delete ${files.length} item(s)?`))) {
Promise.all(files.map(file => deletePath(sessionId, file.path))).then(() => {
push(t("删除成功", "Deleted successfully"));
setSelectedFiles(new Set());
loadDirectory(selectedPath, false, { forceRefresh: true, preferCache: false });
}).catch(() => push(t("删除失败", "Delete failed")));
}
} else if (action === "download") {
handleContextDownload();
} else {
setDialog({ type: action, files });
if (action === "rename") {
setDialogInput(files[0]?.name || "");
} else if (action === "chmod") {
setDialogInput(permissionsToOctal(files[0]?.permissions || ""));
} else {
setDialogInput("");
}
}
}, [contextMenu, sessionId, push, loadDirectory, selectedPath, t]);
handleActionForFiles(action, contextMenu.files);
}, [contextMenu, handleActionForFiles]);
const handleDialogSubmit = React.useCallback(async () => {
if (!dialog || !dialogInput.trim()) return;
@@ -982,6 +1010,12 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
onFileSelect?.(null);
}, [onFileSelect]);
const handleClearSelection = React.useCallback(() => {
setSelectedFile(null);
setSelectedFiles(new Set());
onFileSelect?.(null);
}, [onFileSelect]);
const loadDirectoryRef = React.useRef(loadDirectory);
const toggleNodeRef = React.useRef(toggleNode);
@@ -1018,7 +1052,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
return (
<div key={node.path}>
<div
className={`flex items-center gap-1 py-0.5 px-1 rounded cursor-pointer text-xs truncate ${
className={`flex items-center gap-1 ${isCompact ? "py-1 text-sm" : "py-0.5 text-xs"} px-1 rounded cursor-pointer truncate ${
isSelected
? (isDark ? "bg-indigo-600/50 text-white" : "bg-indigo-100 text-indigo-700")
: (isDark ? "hover:bg-slate-700 text-slate-300" : "hover:bg-slate-100 text-slate-600")
@@ -1072,9 +1106,17 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
downloadStats && downloadStats.totalBytes > 0
? calculatePercent(downloadStats.loadedBytes, downloadStats.totalBytes)
: null;
const selectedItems = getSelectedFiles();
const selectedCount = selectedItems.length;
const canRename = selectedCount === 1;
const hasSelection = selectedCount > 0;
const rowPaddingClass = isCompact ? "py-2" : "py-1";
const rowTextClass = isCompact ? "text-sm" : "text-xs";
const tableColSpan = isCompact ? 2 : 5;
return (
<div className="space-y-2 h-full flex flex-col">
<div className={`space-y-2 h-full flex flex-col ${isCompact ? "text-sm" : ""}`}>
<div className="flex items-center gap-2">
<h3 className={`text-sm font-semibold ${isDark ? "text-slate-200" : "text-slate-700"}`}>
{t("文件浏览", "File Browser")}
@@ -1082,48 +1124,127 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
</div>
{/* 路径输入框 */}
<div className="flex items-center gap-2">
<div className={`flex ${isCompact ? "flex-col items-stretch gap-2" : "items-center gap-2"}`}>
<input
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit}
placeholder={t("输入路径,按回车跳转", "Input path and press Enter")}
className={`flex-1 px-2 py-1 text-xs rounded border ${
className={`flex-1 px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border ${
isDark
? "bg-slate-800 border-slate-600 text-slate-200 placeholder:text-slate-500 focus:border-indigo-500"
: "bg-white border-slate-300 text-slate-700 placeholder:text-slate-400 focus:border-indigo-400"
} focus:outline-none`}
/>
<button
onClick={handleRefresh}
disabled={refreshing}
className={`px-2 py-1 text-xs rounded border ${
<div className={`flex items-center gap-2 ${isCompact ? "flex-wrap" : ""}`}>
<button
onClick={handleRefresh}
disabled={refreshing}
className={`px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border ${
isDark
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
} ${refreshing ? "opacity-50 cursor-not-allowed" : ""}`}
title={t("刷新", "Refresh")}
>
{refreshing ? <span className="animate-spin inline-block">⟳</span> : "↻"}
</button>
<label className={`flex items-center gap-1 px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border cursor-pointer ${
isDark
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
} ${refreshing ? "opacity-50 cursor-not-allowed" : ""}`}
title={t("刷新", "Refresh")}
>
{refreshing ? <span className="animate-spin inline-block">⟳</span> : "↻"}
</button>
<label className={`flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer ${
isDark
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
}`}>
<input
type="checkbox"
checked={compress}
onChange={(e) => setCompress(e.target.checked)}
className="w-3 h-3"
/>
<span>{t("压缩上传", "Compressed upload")}</span>
</label>
}`}>
<input
type="checkbox"
checked={compress}
onChange={(e) => setCompress(e.target.checked)}
className="w-3 h-3"
/>
<span>{t("压缩上传", "Compressed upload")}</span>
</label>
</div>
</div>
{isCompact ? (
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded-full px-2 py-1 ${isDark ? "bg-slate-800 text-slate-300" : "bg-slate-200 text-slate-600"}`}>
{t("已选", "Selected")} {selectedCount}
</span>
<button
type="button"
onClick={() => handleActionForFiles("mkdir", [])}
className={`rounded-full px-3 py-2 ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
>
{t("新建文件夹", "New folder")}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className={`rounded-full px-3 py-2 ${uploading ? "opacity-50 cursor-not-allowed" : ""} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
>
{t("上传文件", "Upload")}
</button>
<button
type="button"
onClick={() => handleActionForFiles("touch", [])}
className={`rounded-full px-3 py-2 ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
>
{t("新建文件", "New file")}
</button>
<button
type="button"
onClick={() => handleActionForFiles("rename", selectedItems)}
disabled={!canRename}
className={`rounded-full px-3 py-2 ${canRename ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
>
{t("重命名", "Rename")}
</button>
<button
type="button"
onClick={() => handleActionForFiles("chmod", selectedItems)}
disabled={!hasSelection}
className={`rounded-full px-3 py-2 ${hasSelection ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
>
{t("权限", "Permissions")}
</button>
<button
type="button"
onClick={() => handleActionForFiles("download", selectedItems)}
disabled={!hasSelection}
className={`rounded-full px-3 py-2 ${hasSelection ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
>
{t("下载", "Download")}
</button>
<button
type="button"
onClick={() => handleActionForFiles("delete", selectedItems)}
disabled={!hasSelection}
className={`rounded-full px-3 py-2 ${hasSelection ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-rose-900/60 text-rose-200" : "bg-rose-50 text-rose-600 border border-rose-200"}`}
>
{t("删除", "Delete")}
</button>
<button
type="button"
onClick={handleClearSelection}
disabled={!hasSelection}
className={`rounded-full px-3 py-2 ${hasSelection ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
>
{t("取消选择", "Clear")}
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileInputChange}
/>
</div>
) : null}
<div
className={`flex rounded border flex-1 min-h-0 ${isDark ? "border-slate-700" : "border-slate-200"} ${(uploading || downloading) ? "opacity-50" : ""} relative`}
className={`flex rounded border flex-1 min-h-0 ${isCompact ? "flex-col" : ""} ${isDark ? "border-slate-700" : "border-slate-200"} ${(uploading || downloading) ? "opacity-50" : ""} relative`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -1251,7 +1372,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
</div>
)}
{/* 左侧树形目录 */}
<div className={`w-1/3 overflow-auto border-r ${isDark ? "border-slate-700 bg-slate-800/50 dark-scrollbar" : "border-slate-200 bg-slate-50 light-scrollbar"}`}>
<div className={`${isCompact ? "w-full max-h-40 border-b" : "w-1/3 border-r"} overflow-auto ${isDark ? "border-slate-700 bg-slate-800/50 dark-scrollbar" : "border-slate-200 bg-slate-50 light-scrollbar"}`}>
<div className="p-1">
{tree.map(node => renderTreeNode(node))}
</div>
@@ -1276,23 +1397,29 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
{error}
</div>
) : (
<table className="w-full text-xs">
<table className={`w-full ${rowTextClass}`}>
<thead className={`sticky top-0 ${isDark ? "bg-slate-800" : "bg-slate-100"}`}>
<tr className={isDark ? "text-slate-400" : "text-slate-500"}>
<th className="text-left py-1 px-2 font-medium">{t("名称", "Name")}</th>
<th className="text-right py-1 px-2 font-medium relative" style={{ width: colWidths.size }}>
<th className={`text-left ${rowPaddingClass} px-2 font-medium`}>{t("名称", "Name")}</th>
<th className={`text-right ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.size }}>
{t("大小", "Size")}
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("size")} />
{!isCompact ? (
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("size")} />
) : null}
</th>
<th className="text-left py-1 px-2 font-medium relative" style={{ width: colWidths.permissions }}>
{t("权限", "Permissions")}
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("permissions")} />
</th>
<th className="text-left py-1 px-2 font-medium relative" style={{ width: colWidths.owner }}>
{t("用户", "Owner")}
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("owner")} />
</th>
<th className="text-left py-1 px-2 font-medium" style={{ width: colWidths.modified }}>{t("修改时间", "Modified Time")}</th>
{!isCompact ? (
<>
<th className={`text-left ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.permissions }}>
{t("权限", "Permissions")}
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("permissions")} />
</th>
<th className={`text-left ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.owner }}>
{t("用户", "Owner")}
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("owner")} />
</th>
<th className={`text-left ${rowPaddingClass} px-2 font-medium`} style={{ width: colWidths.modified }}>{t("修改时间", "Modified Time")}</th>
</>
) : null}
</tr>
</thead>
<tbody>
@@ -1303,14 +1430,18 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
onClick={handleParentItemClick}
onDoubleClick={handleNavigateToParent}
>
<td className="py-1 px-2 truncate max-w-[120px]" title={t(`返回上一级: ${parentPath}`, `Go to parent: ${parentPath}`)}>
<td className={`${rowPaddingClass} px-2 truncate max-w-[120px]`} title={t(`返回上一级: ${parentPath}`, `Go to parent: ${parentPath}`)}>
<span className="mr-1">📁</span>
...
</td>
<td className={`py-1 px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
<td className={`py-1 px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
<td className={`py-1 px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
<td className={`py-1 px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>{t("返回上一级", "Go to parent")}</td>
<td className={`${rowPaddingClass} px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
{!isCompact ? (
<>
<td className={`${rowPaddingClass} px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
<td className={`${rowPaddingClass} px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
<td className={`${rowPaddingClass} px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>{t("返回上一级", "Go to parent")}</td>
</>
) : null}
</tr>
)}
{files.map((file) => (
@@ -1327,27 +1458,31 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
handleContextMenu(e, file);
}}
>
<td className="py-1 px-2 truncate max-w-[120px]" title={file.name}>
<td className={`${rowPaddingClass} px-2 truncate max-w-[120px]`} title={file.name}>
<span className="mr-1">{file.is_dir ? "📁" : "📄"}</span>
{file.name}
</td>
<td className={`py-1 px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
<td className={`${rowPaddingClass} px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{file.is_dir ? "-" : formatFileSize(file.size)}
</td>
<td className={`py-1 px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{file.permissions}
</td>
<td className={`py-1 px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`} title={`${file.owner}:${file.group}`}>
{file.owner}
</td>
<td className={`py-1 px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{file.modified}
</td>
{!isCompact ? (
<>
<td className={`${rowPaddingClass} px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{file.permissions}
</td>
<td className={`${rowPaddingClass} px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`} title={`${file.owner}:${file.group}`}>
{file.owner}
</td>
<td className={`${rowPaddingClass} px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{file.modified}
</td>
</>
) : null}
</tr>
))}
{files.length === 0 && !filesLoading && (
<tr>
<td colSpan={5} className={`py-4 text-center ${isDark ? "text-slate-500" : "text-slate-400"}`}>
<td colSpan={tableColSpan} className={`py-4 text-center ${isDark ? "text-slate-500" : "text-slate-400"}`}>
{t("空目录", "Empty directory")}
</td>
</tr>
@@ -1475,4 +1610,3 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
</div>
);
}
+349 -170
View File
@@ -1,5 +1,5 @@
import React from "react";
import { getSystemOverview, readFile, writeFile, SystemStats, NetworkInfo, ProcessInfo, DiskInfo } from "../lib/api";
import { downloadFile, getSystemOverview, readFile, writeFile, SystemStats, NetworkInfo, ProcessInfo, DiskInfo } from "../lib/api";
import { useApp } from "../context/AppContext";
import type { NetworkProfile } from "../context/AppContext";
import { useToast } from "./Toast";
@@ -27,6 +27,16 @@ function formatSpeed(bytesPerSec: number): string {
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "tif", "tiff"]);
function isImagePath(path: string): boolean {
const normalized = path.split("?")[0].split("#")[0];
const lastDot = normalized.lastIndexOf(".");
if (lastDot === -1) return false;
const ext = normalized.slice(lastDot + 1).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
}
type ProgressBarProps = {
percent: number;
isDark: boolean;
@@ -65,18 +75,28 @@ type SystemMonitorProps = {
isDark: boolean;
selectedFilePath?: string;
networkProfile?: NetworkProfile;
compact?: boolean;
};
export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProfile }: SystemMonitorProps) {
export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProfile, compact }: SystemMonitorProps) {
const { networkProfile: globalNetworkProfile, language } = useApp();
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
const effectiveNetworkProfile = networkProfile ?? globalNetworkProfile;
const isCompact = compact === true;
const [collapsedSections, setCollapsedSections] = React.useState({
status: false,
processes: isCompact,
disks: isCompact,
file: false,
});
const [stats, setStats] = React.useState<SystemStats | null>(null);
const [network, setNetwork] = React.useState<NetworkInfo | null>(null);
const [processes, setProcesses] = React.useState<ProcessInfo[]>([]);
const [disks, setDisks] = React.useState<DiskInfo[]>([]);
const [fileContent, setFileContent] = React.useState<string>("");
const [originalContent, setOriginalContent] = React.useState<string>("");
const [imageUrl, setImageUrl] = React.useState<string | null>(null);
const [imageError, setImageError] = React.useState<string | null>(null);
const [fileLoading, setFileLoading] = React.useState(false);
const [fileTooLarge, setFileTooLarge] = React.useState(false);
const [fileSize, setFileSize] = React.useState(0);
@@ -108,15 +128,83 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
}
}, [sessionId, t]);
// 读取文件内容
const isImageFile = React.useMemo(() => {
return selectedFilePath ? isImagePath(selectedFilePath) : false;
}, [selectedFilePath]);
const revokeImageUrl = React.useCallback((url: string | null) => {
if (url) {
URL.revokeObjectURL(url);
}
}, []);
// 读取文件内容或加载图片
React.useEffect(() => {
let cancelled = false;
if (!selectedFilePath) {
setFileContent("");
setOriginalContent("");
setFileTooLarge(false);
return;
setImageError(null);
setFileSize(0);
setImageUrl((prev) => {
revokeImageUrl(prev);
return null;
});
return () => {
cancelled = true;
};
}
if (isImageFile) {
setFileLoading(true);
setFileTooLarge(false);
setFileContent("");
setOriginalContent("");
setImageError(null);
const loadImage = async () => {
try {
const blob = await downloadFile(sessionId, selectedFilePath);
if (cancelled) {
return;
}
const url = URL.createObjectURL(blob);
setImageUrl((prev) => {
revokeImageUrl(prev);
return url;
});
setFileSize(blob.size);
} catch (err) {
if (cancelled) {
return;
}
setImageError(err instanceof Error ? err.message : t("图片加载失败", "Failed to load image"));
setImageUrl((prev) => {
revokeImageUrl(prev);
return null;
});
} finally {
if (!cancelled) {
setFileLoading(false);
}
}
};
loadImage();
return () => {
cancelled = true;
};
}
setImageError(null);
setImageUrl((prev) => {
revokeImageUrl(prev);
return null;
});
const loadFile = async (force = false) => {
setFileLoading(true);
try {
@@ -136,15 +224,28 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
setFileContent("");
setOriginalContent("");
} finally {
setFileLoading(false);
if (!cancelled) {
setFileLoading(false);
}
}
};
loadFile();
}, [sessionId, selectedFilePath, push, t]);
return () => {
cancelled = true;
};
}, [sessionId, selectedFilePath, isImageFile, push, t, revokeImageUrl, downloadFile]);
React.useEffect(() => {
return () => {
revokeImageUrl(imageUrl);
};
}, [imageUrl, revokeImageUrl]);
const handleLoadLargeFile = React.useCallback(async () => {
if (!selectedFilePath) return;
if (isImageFile) return;
setFileLoading(true);
try {
const result = await readFile(sessionId, selectedFilePath, true);
@@ -156,7 +257,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
} finally {
setFileLoading(false);
}
}, [sessionId, selectedFilePath, push, t]);
}, [sessionId, selectedFilePath, push, t, isImageFile]);
// 保存文件
const handleSaveFile = React.useCallback(async () => {
@@ -208,8 +309,37 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
const bVal = sortField === "cpu" ? b.cpu_percent : b.memory_percent;
return sortOrder === "desc" ? bVal - aVal : aVal - bVal;
});
return sorted.slice(0, 5);
}, [processes, sortField, sortOrder]);
const maxCount = isCompact ? 3 : 5;
return sorted.slice(0, maxCount);
}, [processes, sortField, sortOrder, isCompact]);
const toggleSection = React.useCallback((key: keyof typeof collapsedSections) => {
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const renderSectionHeader = (key: keyof typeof collapsedSections, title: string) => {
if (!isCompact) {
return (
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
{title}
</h3>
);
}
return (
<button
type="button"
onClick={() => toggleSection(key)}
className={`w-full flex items-center justify-between text-sm font-semibold mb-2 ${
isDark ? "text-slate-200" : "text-slate-700"
}`}
>
<span>{title}</span>
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{collapsedSections[key] ? t("展开", "Expand") : t("收起", "Collapse")}
</span>
</button>
);
};
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) return <span className="opacity-30">↕</span>;
@@ -235,191 +365,240 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
if (!stats) return null;
return (
<div className="space-y-4">
<div className={isCompact ? "space-y-3" : "space-y-4"}>
<div>
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
{t("系统状态", "System Status")}
</h3>
{renderSectionHeader("status", t("系统状态", "System Status"))}
{/* CPU */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{t(`CPU (${stats.cpu.count} 核)`, `CPU (${stats.cpu.count} Cores)`)}
</span>
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{stats.cpu.percent.toFixed(1)}%
</span>
</div>
<ProgressBar percent={stats.cpu.percent} isDark={isDark} color="blue" />
</div>
{/* 内存 */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{t("内存", "Memory")}
</span>
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{stats.memory.percent.toFixed(1)}% ({formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)})
</span>
</div>
<ProgressBar percent={stats.memory.percent} isDark={isDark} color="green" />
</div>
{/* 交换区 */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{t("交换区", "Swap")}
</span>
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{stats.swap.total > 0
? `${stats.swap.percent.toFixed(1)}% (${formatBytes(stats.swap.used)} / ${formatBytes(stats.swap.total)})`
: t("未启用", "Disabled")
}
</span>
</div>
{stats.swap.total > 0 && (
<ProgressBar percent={stats.swap.percent} isDark={isDark} color="yellow" />
)}
</div>
{/* 网络速度 */}
<div className={`pt-3 mt-3 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
<div className="flex justify-between items-center">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{t("网络", "Network")}
</span>
<div className="flex items-center gap-3">
<span className={`text-xs font-medium ${isDark ? "text-purple-400" : "text-purple-600"}`}>
↑ {network ? formatSpeed(network.upload_speed) : "-"}
</span>
<span className={`text-xs font-medium ${isDark ? "text-cyan-400" : "text-cyan-600"}`}>
↓ {network ? formatSpeed(network.download_speed) : "-"}
</span>
{!isCompact || !collapsedSections.status ? (
<>
{/* CPU */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{t(`CPU (${stats.cpu.count} 核)`, `CPU (${stats.cpu.count} Cores)`)}
</span>
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{stats.cpu.percent.toFixed(1)}%
</span>
</div>
<ProgressBar percent={stats.cpu.percent} isDark={isDark} color="blue" />
</div>
</div>
</div>
{/* 内存 */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{t("内存", "Memory")}
</span>
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{stats.memory.percent.toFixed(1)}% ({formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)})
</span>
</div>
<ProgressBar percent={stats.memory.percent} isDark={isDark} color="green" />
</div>
{/* 交换区 */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{t("交换区", "Swap")}
</span>
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{stats.swap.total > 0
? `${stats.swap.percent.toFixed(1)}% (${formatBytes(stats.swap.used)} / ${formatBytes(stats.swap.total)})`
: t("未启用", "Disabled")
}
</span>
</div>
{stats.swap.total > 0 && (
<ProgressBar percent={stats.swap.percent} isDark={isDark} color="yellow" />
)}
</div>
{/* 网络速度 */}
<div className={`pt-3 mt-3 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
<div className="flex justify-between items-center">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{t("网络", "Network")}
</span>
<div className="flex items-center gap-3">
<span className={`text-xs font-medium ${isDark ? "text-purple-400" : "text-purple-600"}`}>
↑ {network ? formatSpeed(network.upload_speed) : "-"}
</span>
<span className={`text-xs font-medium ${isDark ? "text-cyan-400" : "text-cyan-600"}`}>
↓ {network ? formatSpeed(network.download_speed) : "-"}
</span>
</div>
</div>
</div>
</>
) : null}
</div>
{/* 进程列表 */}
<div className={`pt-4 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
{t("进程 TOP 5", "Top 5 Processes")}
</h3>
{renderSectionHeader("processes", t("进程 TOP 5", "Top 5 Processes"))}
{/* 表头 */}
<div className={`flex text-xs mb-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
<div className="flex-1">{t("进程名", "Process")}</div>
<div
className="w-16 text-right cursor-pointer hover:text-indigo-400 select-none"
onClick={() => handleSort("cpu")}
>
CPU <SortIcon field="cpu" />
</div>
<div
className="w-20 text-right cursor-pointer hover:text-indigo-400 select-none"
onClick={() => handleSort("memory")}
>
{t("内存", "Memory")} <SortIcon field="memory" />
</div>
</div>
{/* 进程表 */}
<div className="space-y-1">
{sortedProcesses.map((proc) => (
<div
key={proc.pid}
className={`flex text-xs py-1 rounded px-1 ${isDark ? "hover:bg-slate-800" : "hover:bg-slate-100"} group relative`}
title={proc.command}
>
<div className={`flex-1 truncate ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{proc.name}
{!isCompact || !collapsedSections.processes ? (
<>
{/* 表头 */}
<div className={`flex text-xs mb-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
<div className="flex-1">{t("进程名", "Process")}</div>
<div
className="w-16 text-right cursor-pointer hover:text-indigo-400 select-none"
onClick={() => handleSort("cpu")}
>
CPU <SortIcon field="cpu" />
</div>
<div className={`w-16 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{proc.cpu_percent.toFixed(1)}%
</div>
<div className={`w-20 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{formatBytes(proc.memory_bytes)}
<div
className="w-20 text-right cursor-pointer hover:text-indigo-400 select-none"
onClick={() => handleSort("memory")}
>
{t("内存", "Memory")} <SortIcon field="memory" />
</div>
</div>
))}
{sortedProcesses.length === 0 && (
<div className={`text-xs ${isDark ? "text-slate-500" : "text-slate-400"}`}>
{t("暂无数据", "No data")}
{/* 进程表 */}
<div className="space-y-1">
{sortedProcesses.map((proc) => (
<div
key={proc.pid}
className={`flex text-xs py-1 rounded px-1 ${isDark ? "hover:bg-slate-800" : "hover:bg-slate-100"} group relative`}
title={proc.command}
>
<div className={`flex-1 truncate ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{proc.name}
</div>
<div className={`w-16 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{proc.cpu_percent.toFixed(1)}%
</div>
<div className={`w-20 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{formatBytes(proc.memory_bytes)}
</div>
</div>
))}
{sortedProcesses.length === 0 && (
<div className={`text-xs ${isDark ? "text-slate-500" : "text-slate-400"}`}>
{t("暂无数据", "No data")}
</div>
)}
</div>
)}
</div>
</>
) : null}
</div>
{/* 磁盘信息 */}
<div className={`pt-4 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
{t("磁盘挂载", "Disk Mounts")}
</h3>
<div className="space-y-2">
{disks.map((disk) => (
<div key={disk.mount}>
<div className="flex justify-between items-center mb-1">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{disk.mount}
</span>
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{disk.percent.toFixed(1)}% ({formatBytes(disk.used)} / {formatBytes(disk.total)})
</span>
{renderSectionHeader("disks", t("磁盘挂载", "Disk Mounts"))}
{!isCompact || !collapsedSections.disks ? (
<div className="space-y-2">
{disks.map((disk) => (
<div key={disk.mount}>
<div className="flex justify-between items-center mb-1">
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{disk.mount}
</span>
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
{disk.percent.toFixed(1)}% ({formatBytes(disk.used)} / {formatBytes(disk.total)})
</span>
</div>
<ProgressBar percent={disk.percent} isDark={isDark} color="cyan" />
</div>
<ProgressBar percent={disk.percent} isDark={isDark} color="cyan" />
</div>
))}
</div>
))}
</div>
) : null}
</div>
{/* 文件预览 */}
{selectedFilePath && (
<div className={`pt-4 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
<div className="flex justify-between items-center mb-2">
<h3 className={`text-sm font-semibold ${isDark ? "text-slate-200" : "text-slate-700"}`}>
{t("文件预览", "File Preview")}
</h3>
<button
onClick={handleSaveFile}
disabled={fileContent === originalContent || fileTooLarge}
className={`px-2 py-1 text-xs rounded ${
fileContent === originalContent || fileTooLarge
? "opacity-50 cursor-not-allowed"
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
} text-white`}
>
{t("保存", "Save")}
</button>
</div>
{fileLoading ? (
<div className={`flex items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
<span className="animate-spin mr-2">⟳</span> {t("加载中...", "Loading...")}
</div>
) : fileTooLarge ? (
<div className={`flex flex-col items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
<p className="mb-2">{t(`文件过大 (${(fileSize / 1024 / 1024).toFixed(2)} MB)`, `File too large (${(fileSize / 1024 / 1024).toFixed(2)} MB)`)}</p>
<button
onClick={handleLoadLargeFile}
className={`px-3 py-1 rounded ${isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400"} text-white`}
>
{t("仍然加载", "Load anyway")}
</button>
</div>
{isCompact ? (
renderSectionHeader("file", isImageFile ? t("图片预览", "Image Preview") : t("文件预览", "File Preview"))
) : (
<textarea
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
className={`w-full text-xs font-mono p-2 rounded border ${
isDark
? "bg-slate-900 border-slate-700 text-slate-200"
: "bg-white border-slate-300 text-slate-700"
}`}
style={{ height: "172px" }}
/>
<div className="flex justify-between items-center mb-2">
<h3 className={`text-sm font-semibold ${isDark ? "text-slate-200" : "text-slate-700"}`}>
{isImageFile ? t("图片预览", "Image Preview") : t("文件预览", "File Preview")}
</h3>
{!isImageFile ? (
<button
onClick={handleSaveFile}
disabled={fileContent === originalContent || fileTooLarge}
className={`px-2 py-1 text-xs rounded ${
fileContent === originalContent || fileTooLarge
? "opacity-50 cursor-not-allowed"
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
} text-white`}
>
{t("保存", "Save")}
</button>
) : null}
</div>
)}
{!isCompact || !collapsedSections.file ? (
<>
{isCompact ? (
<div className="flex justify-end mb-2">
{!isImageFile ? (
<button
onClick={handleSaveFile}
disabled={fileContent === originalContent || fileTooLarge}
className={`px-3 py-2 text-sm rounded ${
fileContent === originalContent || fileTooLarge
? "opacity-50 cursor-not-allowed"
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
} text-white`}
>
{t("保存", "Save")}
</button>
) : null}
</div>
) : null}
{fileLoading ? (
<div className={`flex items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
<span className="animate-spin mr-2">⟳</span> {t("加载中...", "Loading...")}
</div>
) : isImageFile ? (
imageError ? (
<div className={`flex items-center justify-center text-xs ${isDark ? "text-rose-400" : "text-rose-500"}`} style={{ height: "172px" }}>
{imageError}
</div>
) : imageUrl ? (
<div className={`flex items-center justify-center rounded border ${isDark ? "border-slate-700 bg-slate-900/40" : "border-slate-200 bg-slate-50"}`} style={{ height: "172px" }}>
<img
src={imageUrl}
alt={selectedFilePath}
className="max-h-full max-w-full object-contain"
/>
</div>
) : (
<div className={`flex items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
{t("暂无图片内容", "No image preview")}
</div>
)
) : fileTooLarge ? (
<div className={`flex flex-col items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
<p className="mb-2">{t(`文件过大 (${(fileSize / 1024 / 1024).toFixed(2)} MB)`, `File too large (${(fileSize / 1024 / 1024).toFixed(2)} MB)`)}</p>
<button
onClick={handleLoadLargeFile}
className={`px-3 py-1 rounded ${isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400"} text-white`}
>
{t("仍然加载", "Load anyway")}
</button>
</div>
) : (
<textarea
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
className={`w-full text-xs font-mono p-2 rounded border ${
isDark
? "bg-slate-900 border-slate-700 text-slate-200"
: "bg-white border-slate-300 text-slate-700"
}`}
style={{ height: "172px" }}
/>
)}
</>
) : null}
</div>
)}
</div>
+14
View File
@@ -22,6 +22,7 @@ const CLIENT_TEXT_MAP: Record<string, { zh: string; en: string }> = {
"重试连接失败": { zh: "重试连接失败", en: "Failed to retry connection" },
"断开失败": { zh: "断开失败", en: "Failed to disconnect" },
"保存备注失败": { zh: "保存备注失败", en: "Failed to save note" },
"保存排序失败": { zh: "保存排序失败", en: "Failed to save order" },
"删除会话失败": { zh: "删除会话失败", en: "Failed to delete session" },
"删除连接失败": { zh: "删除连接失败", en: "Failed to delete connection" },
"更新连接失败": { zh: "更新连接失败", en: "Failed to update connection" },
@@ -163,6 +164,7 @@ export type Session = {
username: string;
name: string;
note?: string | null;
session_order?: number;
enhanced_enabled?: boolean;
remote_arch?: string | null;
remote_os?: string | null;
@@ -413,6 +415,18 @@ export async function updateSessionNote(sessionId: string, note: string | null):
return response.json();
}
export async function updateSessionOrder(orderedIds: string[]): Promise<void> {
const response = await fetch(`${HTTP_BASE}/sessions/order`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...getAuthHeader() },
body: JSON.stringify({ ordered_ids: orderedIds })
});
if (!response.ok) {
const detail = await safeError(response, "保存排序失败");
throw new Error(detail);
}
}
export async function deleteSession(sessionId: string): Promise<void> {
const response = await fetch(`${HTTP_BASE}/sessions/${sessionId}`, {
method: "DELETE",
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
const DEFAULT_BREAKPOINT = 768;
export function useIsMobile(breakpoint: number = DEFAULT_BREAKPOINT): boolean {
const getMatch = React.useCallback(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches;
}, [breakpoint]);
const [isMobile, setIsMobile] = React.useState(getMatch);
React.useEffect(() => {
if (typeof window === "undefined") {
return;
}
const media = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const handleChange = () => setIsMobile(media.matches);
handleChange();
media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange);
}, [breakpoint]);
return isMobile;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,236 @@
import React from "react";
import { Button } from "../../components/Button";
import { Card } from "../../components/Card";
import { Input } from "../../components/Input";
import type { SessionsState } from "./useSessionsState";
type SessionsConnectionsPanelProps = {
state: SessionsState;
};
export function SessionsConnectionsPanel({ state }: SessionsConnectionsPanelProps) {
return (
<Card
title={state.t("新增 SSH 连接", "New SSH Connection")}
description={state.t("保存连接信息并发起会话", "Save connection details and start sessions")}
className={!state.isDark ? "bg-white border-slate-200 shadow-sm" : ""}
titleClassName={!state.isDark ? "text-slate-900" : ""}
descClassName={!state.isDark ? "text-slate-500" : ""}
>
<div className="space-y-4">
<Button
variant={state.showCreateForm ? "secondary" : "primary"}
lightMode={!state.isDark}
onClick={() => state.setShowCreateForm((prev) => !prev)}
className="w-full"
>
{state.showCreateForm ? state.t("收起表单", "Hide form") : state.t("新增连接", "New connection")}
</Button>
{state.showCreateForm ? (
<form className="space-y-4" onSubmit={state.handleCreateConnection}>
<div
className={`rounded-md border px-3 py-2 text-sm ${
state.isDark
? "border-amber-500/40 bg-amber-500/10 text-amber-100"
: "border-amber-200 bg-amber-50 text-amber-800"
}`}
>
{state.t(
"请确保该项目所运行的机器可以访问到目标连接。",
"Make sure the machine running this project can reach the target connection."
)}
</div>
<Input
placeholder={state.t("连接名称", "Connection name")}
value={state.form.name}
onChange={(event) => state.setForm({ ...state.form, name: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<Input
placeholder={state.t("主机 IP", "Host IP")}
value={state.form.host}
onChange={(event) => state.setForm({ ...state.form, host: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<Input
placeholder={state.t("端口", "Port")}
type="number"
value={state.form.port}
onChange={(event) => state.setForm({ ...state.form, port: Number(event.target.value) })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<Input
placeholder={state.t("用户名", "Username")}
value={state.form.username}
onChange={(event) => state.setForm({ ...state.form, username: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<div className="flex gap-2">
<Button
type="button"
variant={state.form.auth_type === "password" ? "primary" : "secondary"}
lightMode={!state.isDark}
onClick={() => state.setForm({ ...state.form, auth_type: "password" })}
>
{state.t("密码", "Password")}
</Button>
<Button
type="button"
variant={state.form.auth_type === "private_key" ? "primary" : "secondary"}
lightMode={!state.isDark}
onClick={() => state.setForm({ ...state.form, auth_type: "private_key" })}
>
{state.t("私钥", "Private key")}
</Button>
</div>
{state.form.auth_type === "password" ? (
<Input
placeholder={state.t("密码", "Password")}
type="password"
value={state.form.password}
onChange={(event) => state.setForm({ ...state.form, password: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
) : (
<>
<textarea
className={`min-h-[120px] w-full rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
placeholder={state.t("私钥内容", "Private key content")}
value={state.form.private_key}
onChange={(event) => state.setForm({ ...state.form, private_key: event.target.value })}
/>
<Input
placeholder={state.t("私钥密码(可选)", "Private key passphrase (optional)")}
type="password"
value={state.form.key_passphrase}
onChange={(event) => state.setForm({ ...state.form, key_passphrase: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
</>
)}
<Button type="submit" lightMode={!state.isDark} className="w-full">
{state.t("保存连接", "Save connection")}
</Button>
</form>
) : null}
</div>
<div className={`mt-6 pt-6 space-y-3 ${state.isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
<p className={`text-sm font-semibold ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("已保存连接", "Saved connections")}</p>
{state.connections.map((conn) => (
<div key={conn.id} className={`rounded-md border p-3 text-sm ${state.isDark ? "border-slate-700 bg-slate-900/60 text-slate-300" : "border-slate-200 bg-slate-50 text-slate-600"}`}>
{state.editingConnection?.id === conn.id ? (
<form className="space-y-3" onSubmit={state.handleUpdateConnection}>
<Input
placeholder={state.t("连接名称", "Connection name")}
value={state.editForm.name}
onChange={(event) => state.setEditForm({ ...state.editForm, name: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<Input
placeholder={state.t("主机 IP", "Host IP")}
value={state.editForm.host}
onChange={(event) => state.setEditForm({ ...state.editForm, host: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<Input
placeholder={state.t("端口", "Port")}
type="number"
value={state.editForm.port}
onChange={(event) => state.setEditForm({ ...state.editForm, port: Number(event.target.value) })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<Input
placeholder={state.t("用户名", "Username")}
value={state.editForm.username}
onChange={(event) => state.setEditForm({ ...state.editForm, username: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<div className="flex gap-2">
<Button
type="button"
variant={state.editForm.auth_type === "password" ? "primary" : "secondary"}
lightMode={!state.isDark}
onClick={() => state.setEditForm({ ...state.editForm, auth_type: "password" })}
>
{state.t("密码", "Password")}
</Button>
<Button
type="button"
variant={state.editForm.auth_type === "private_key" ? "primary" : "secondary"}
lightMode={!state.isDark}
onClick={() => state.setEditForm({ ...state.editForm, auth_type: "private_key" })}
>
{state.t("私钥", "Private key")}
</Button>
</div>
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("留空则保持原凭据不变", "Leave empty to keep original credentials")}</p>
{state.editForm.auth_type === "password" ? (
<Input
placeholder={state.t("新密码(可选)", "New password (optional)")}
type="password"
value={state.editForm.password}
onChange={(event) => state.setEditForm({ ...state.editForm, password: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
) : (
<>
<textarea
className={`min-h-[80px] w-full rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
placeholder={state.t("新私钥(可选)", "New private key (optional)")}
value={state.editForm.private_key}
onChange={(event) => state.setEditForm({ ...state.editForm, private_key: event.target.value })}
/>
<Input
placeholder={state.t("私钥密码(可选)", "Private key passphrase (optional)")}
type="password"
value={state.editForm.key_passphrase}
onChange={(event) => state.setEditForm({ ...state.editForm, key_passphrase: event.target.value })}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
</>
)}
<div className="flex gap-2">
<Button type="submit" variant="primary" lightMode={!state.isDark}>
{state.t("保存", "Save")}
</Button>
<Button type="button" variant="ghost" lightMode={!state.isDark} onClick={() => state.setEditingConnection(null)}>
{state.t("取消", "Cancel")}
</Button>
</div>
</form>
) : (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<p className={`text-base font-semibold ${state.isDark ? "text-slate-100" : "text-slate-800"}`}>{conn.name}</p>
<p className={`text-xs ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
{conn.username}@{conn.host}:{conn.port}
</p>
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
{state.t("创建", "Created")}: {new Date(conn.created_at).toLocaleString()} | {state.t("更新", "Updated")}: {new Date(conn.updated_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
lightMode={!state.isDark}
loading={state.connectingId === conn.id}
disabled={state.connectingId !== null}
onClick={() => state.handleCreateSession(conn.id)}
>
{state.t("创建新的连接", "Create new connection")}
</Button>
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.handleEditConnection(conn)}>
{state.t("编辑", "Edit")}
</Button>
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.setDeleteConfirm({ type: "connection", id: conn.id, name: conn.name })}>
{state.t("删除", "Delete")}
</Button>
</div>
</div>
)}
</div>
))}
</div>
</Card>
);
}
@@ -0,0 +1,230 @@
import React from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "../../components/Button";
import { Input } from "../../components/Input";
import type { SessionsState } from "./useSessionsState";
import { SessionsConnectionsPanel } from "./SessionsConnectionsPanel";
import { SessionsDialogs } from "./SessionsDialogs";
import { clearAuthStorage } from "../../lib/api";
type SessionsDesktopProps = {
state: SessionsState;
};
export function SessionsDesktop({ state }: SessionsDesktopProps) {
if (state.loading) {
return <div className={`p-6 ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("加载中...", "Loading...")}</div>;
}
return (
<div className={`min-h-screen px-6 py-8 transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
<div className="mx-auto max-w-6xl space-y-8">
<div className={`flex items-center justify-between pb-6 ${state.isDark ? "border-b border-slate-700" : "border-b border-slate-200"}`}>
<div>
<h1 className="text-2xl font-semibold">{state.t("会话管理", "Session Management")}</h1>
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
{state.t("管理 SSH 连接与持久在线会话", "Manage SSH connections and persistent sessions")}
<a
href="https://github.com/beibeizi/WebSSHGateway"
target="_blank"
rel="noreferrer"
className={`ml-2 inline-flex items-center gap-2 align-middle ${
state.isDark ? "text-slate-300 hover:text-slate-200" : "text-slate-600 hover:text-slate-700"
}`}
aria-label={state.t("打开 GitHub 仓库", "Open GitHub repository")}
title={state.t("打开 GitHub 仓库", "Open GitHub repository")}
>
<img
src="https://img.shields.io/github/stars/beibeizi/WebSSHGateway?style=social"
alt={state.t("GitHub Star 数", "GitHub stars")}
className="h-4"
loading="lazy"
/>
</a>
</p>
<p className={`text-xs mt-1 ${state.networkProfileTone}`}>{state.networkProfileLabel}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={state.toggleLanguage}
>
{state.language === "en-US" ? "中文" : "EN"}
</Button>
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={state.toggleTheme}
>
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
</Button>
<Button
variant="secondary"
lightMode={!state.isDark}
onClick={() => state.setPasswordDialogOpen(true)}
>
{state.t("修改密码", "Change password")}
</Button>
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={() => {
clearAuthStorage();
window.location.href = "/";
}}
>
{state.t("退出登录", "Sign out")}
</Button>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Input
placeholder={state.t("搜索会话名称", "Search session name")}
value={state.search}
onChange={(event) => state.setSearch(event.target.value)}
className={`max-w-xs ${state.isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
/>
<div className="flex gap-2">
{[
{ value: "all", label: state.t("全部", "All") },
{ value: "active", label: state.t("在线", "Active") },
{ value: "disconnected", label: state.t("离线", "Disconnected") },
].map((item) => (
<Button
key={item.value}
variant={state.filter === item.value ? "primary" : "secondary"}
lightMode={!state.isDark}
onClick={() => state.setFilter(item.value)}
>
{item.label}
</Button>
))}
</div>
</div>
<div className="grid gap-4">
{state.filteredSessions.map((session) => {
const noteValue = state.noteDrafts[session.id] ?? "";
return (
<div
key={session.id}
ref={(element) => {
if (element) {
state.ordering.cardRefs.current.set(session.id, element);
} else {
state.ordering.cardRefs.current.delete(session.id);
}
}}
onDragOver={(event) => state.ordering.handleDragOver(session.id, event)}
onDrop={(event) => event.preventDefault()}
className={`relative rounded-lg border p-4 pl-14 transition-transform duration-200 ease-out will-change-transform ${
state.ordering.draggingSessionId === session.id ? "ring-2 ring-indigo-400/60" : ""
} ${state.isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"}`}
>
<button
type="button"
draggable={!state.ordering.savingOrder}
onDragStart={(event) => state.ordering.handleDragStart(session.id, event)}
onDragEnd={state.ordering.handleDragEnd}
disabled={state.ordering.savingOrder}
aria-label={state.t("拖动调整排序", "Drag to reorder")}
title={state.t("拖动调整排序", "Drag to reorder")}
className={`absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border p-2 shadow-sm transition ${
state.isDark
? "border-slate-700 bg-slate-900 text-slate-400 hover:text-slate-200"
: "border-slate-200 bg-white text-slate-500 hover:text-slate-700"
} ${state.ordering.savingOrder ? "cursor-not-allowed opacity-50" : "cursor-grab active:cursor-grabbing"}`}
>
<span className="flex flex-col items-center leading-none">
<ChevronUp className="h-4 w-4 -mb-1" />
<ChevronDown className="h-4 w-4 -mt-1" />
</span>
</button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-lg font-semibold">{session.name}</p>
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
{session.username}@{session.host}
</p>
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("状态", "Status")}: {state.mapSessionStatus(session.status)}</p>
{session.enhanced_enabled ? (
<p className={`text-xs font-medium ${state.isDark ? "text-indigo-300" : "text-indigo-600"}`}>
{state.t("增强持久化连接", "Enhanced persistent connection")}
</p>
) : null}
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}</p>
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}</p>
{session.disconnected_at ? (
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
{state.t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
</p>
) : null}
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
{state.t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/5
</p>
) : null}
</div>
<div className="flex gap-2">
{session.status === "active" ? (
<Button variant="secondary" lightMode={!state.isDark} onClick={() => window.open(`/terminal/${session.id}`, "_blank")}>
{state.t("打开会话", "Open session")}
</Button>
) : null}
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
<Button
variant="secondary"
lightMode={!state.isDark}
loading={state.isSystemRetrying(session) || !!state.retryingSessionIds[session.id]}
onClick={() => state.handleRetryEnhancedSession(session.id)}
>
{state.t("重试连接", "Retry")}
</Button>
) : null}
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.handleDisconnectOrDelete(session)}>
{session.status === "active" ? state.t("断开", "Disconnect") : state.t("删除", "Delete")}
</Button>
</div>
</div>
<div className="mt-4 space-y-2">
<Input
placeholder={state.t("备注", "Note")}
value={noteValue}
maxLength={1000}
onChange={(event) => state.handleNoteChange(session.id, event.target.value)}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<div className={`flex items-center justify-between text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
<span>{state.t("最多 1000 字", "Up to 1000 characters")}</span>
{noteValue.trim() !== (session.note ?? "") ? (
<Button
variant="secondary"
lightMode={!state.isDark}
onClick={() => state.handleSaveNote(session)}
>
{state.t("保存备注", "Save note")}
</Button>
) : null}
</div>
</div>
</div>
);
})}
{state.filteredSessions.length === 0 ? (
<div className={`text-sm ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("暂无会话", "No sessions")}</div>
) : null}
</div>
</div>
<SessionsConnectionsPanel state={state} />
</div>
</div>
<SessionsDialogs state={state} />
</div>
);
}
@@ -0,0 +1,122 @@
import React from "react";
import { Button } from "../../components/Button";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { Input } from "../../components/Input";
import type { SessionsState } from "./useSessionsState";
type SessionsDialogsProps = {
state: SessionsState;
};
export function SessionsDialogs({ state }: SessionsDialogsProps) {
return (
<>
<ConfirmDialog
open={state.deleteConfirm !== null}
title={state.deleteConfirm?.type === "session" ? state.t("删除会话", "Delete session") : state.t("删除连接", "Delete connection")}
message={state.t(`确定要删除 "${state.deleteConfirm?.name ?? ""}" 吗?此操作不可撤销。`, `Are you sure you want to delete "${state.deleteConfirm?.name ?? ""}"? This action cannot be undone.`)}
confirmText={state.t("删除", "Delete")}
variant="danger"
loading={state.deleteLoading}
onConfirm={state.confirmDelete}
onCancel={() => state.setDeleteConfirm(null)}
/>
{state.passwordDialogOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div className={`w-full max-w-md rounded-xl border p-6 shadow-xl ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-200 bg-white text-slate-900"}`}>
<h3 className="mb-2 text-lg font-semibold">{state.t("修改密码", "Change password")}</h3>
<p className={`mb-4 text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
{state.t("请输入当前密码,并设置新的登录密码。", "Enter your current password and set a new login password.")}
</p>
<div className="space-y-3">
<Input
placeholder={state.t("当前密码", "Current password")}
type="password"
value={state.passwordForm.currentPassword}
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<Input
placeholder={state.t("新密码", "New password")}
type="password"
value={state.passwordForm.newPassword}
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, newPassword: event.target.value }))}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<Input
placeholder={state.t("确认新密码", "Confirm new password")}
type="password"
value={state.passwordForm.confirmPassword}
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, confirmPassword: event.target.value }))}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={state.handleClosePasswordDialog}
disabled={state.passwordSaving}
>
{state.t("取消", "Cancel")}
</Button>
<Button
variant="primary"
lightMode={!state.isDark}
loading={state.passwordSaving}
onClick={state.handleSubmitPasswordChange}
>
{state.t("确认修改", "Update password")}
</Button>
</div>
</div>
</div>
) : null}
{state.enhancePrompt?.open ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div className={`w-full max-w-lg rounded-xl border p-6 shadow-xl ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-200 bg-white text-slate-900"}`}>
<h3 className="text-lg font-semibold mb-2">{state.t("是否开启增强持久化连接", "Enable enhanced persistent connection?")}</h3>
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
{state.t(
`检测到远端系统为 ${state.enhancePrompt.remoteOs || "unknown"} (${state.enhancePrompt.remoteArch || "unknown"}),建议开启增强持久化连接以提高稳定性。`,
`Detected remote system ${state.enhancePrompt.remoteOs || "unknown"} (${state.enhancePrompt.remoteArch || "unknown"}). Enable enhanced persistence for better stability.`
)}
</p>
<label className={`mt-4 flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-800/60" : "border-slate-200 bg-slate-50"}`}>
<input
type="checkbox"
checked={state.enhancePrompt.checked}
onChange={(event) => state.setEnhancePrompt((prev) => (prev ? { ...prev, checked: event.target.checked } : prev))}
/>
<span>{state.t("开启增强持久化连接", "Enable enhanced persistent connection")}</span>
</label>
<div className={`mt-3 text-xs ${state.isDark ? "text-slate-500" : "text-slate-500"}`}>
{state.t(
"普通连接的可靠性由项目所在机器和目标之间的网络决定。开启此功能会向目标机器搭建 tmux 通道,防止网络波动造成的连接断开。",
"Normal connections depend on network stability between this host and the target. Enabling this will create a tmux channel to keep the session alive during network fluctuations."
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={() => state.setEnhancePrompt(null)}
>
{state.t("取消", "Cancel")}
</Button>
<Button
variant="primary"
lightMode={!state.isDark}
onClick={state.handleConfirmEnhance}
>
{state.t("确认连接", "Confirm connection")}
</Button>
</div>
</div>
</div>
) : null}
</>
);
}
@@ -0,0 +1,223 @@
import React from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "../../components/Button";
import { Input } from "../../components/Input";
import type { SessionsState } from "./useSessionsState";
import { SessionsConnectionsPanel } from "./SessionsConnectionsPanel";
import { SessionsDialogs } from "./SessionsDialogs";
import { clearAuthStorage } from "../../lib/api";
type SessionsMobileProps = {
state: SessionsState;
};
export function SessionsMobile({ state }: SessionsMobileProps) {
if (state.loading) {
return <div className={`p-6 ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("加载中...", "Loading...")}</div>;
}
return (
<div className={`min-h-screen px-4 py-6 transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
<div className="space-y-6">
<div className={`space-y-3 pb-4 ${state.isDark ? "border-b border-slate-800" : "border-b border-slate-200"}`}>
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">{state.t("会话管理", "Session Management")}</h1>
<p className={`text-xs ${state.networkProfileTone}`}>{state.networkProfileLabel}</p>
</div>
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={state.toggleLanguage}
className="px-3 py-2 text-xs"
>
{state.language === "en-US" ? "中文" : "EN"}
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={state.toggleTheme}
className="px-3 py-2 text-xs"
>
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
</Button>
<Button
variant="secondary"
lightMode={!state.isDark}
onClick={() => state.setPasswordDialogOpen(true)}
className="px-3 py-2 text-xs"
>
{state.t("修改密码", "Change password")}
</Button>
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={() => {
clearAuthStorage();
window.location.href = "/";
}}
className="px-3 py-2 text-xs"
>
{state.t("退出登录", "Sign out")}
</Button>
</div>
</div>
<div className="space-y-3">
<Input
placeholder={state.t("搜索会话名称", "Search session name")}
value={state.search}
onChange={(event) => state.setSearch(event.target.value)}
className={`${state.isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
/>
<div className="flex gap-2 overflow-x-auto pb-1">
{[
{ value: "all", label: state.t("全部", "All") },
{ value: "active", label: state.t("在线", "Active") },
{ value: "disconnected", label: state.t("离线", "Disconnected") },
].map((item) => (
<Button
key={item.value}
variant={state.filter === item.value ? "primary" : "secondary"}
lightMode={!state.isDark}
onClick={() => state.setFilter(item.value)}
className="px-3 py-2 text-xs whitespace-nowrap"
>
{item.label}
</Button>
))}
</div>
</div>
<div className="grid gap-4">
{state.filteredSessions.map((session, index) => {
const noteValue = state.noteDrafts[session.id] ?? "";
const canMoveUp = index > 0 && !state.ordering.savingOrder;
const canMoveDown = index < state.filteredSessions.length - 1 && !state.ordering.savingOrder;
return (
<div
key={session.id}
ref={(element) => {
if (element) {
state.ordering.cardRefs.current.set(session.id, element);
} else {
state.ordering.cardRefs.current.delete(session.id);
}
}}
className={`relative rounded-lg border p-4 pl-12 transition-transform duration-200 ease-out will-change-transform ${
state.isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"
}`}
>
<div className="absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className={`flex flex-col items-center gap-1 rounded-full border p-1 ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-400" : "border-slate-200 bg-white text-slate-500"}`}>
<button
type="button"
onClick={() => state.ordering.handleMoveSession(session.id, "up")}
disabled={!canMoveUp}
className={`flex h-9 w-9 items-center justify-center rounded-full transition ${
canMoveUp
? (state.isDark ? "hover:bg-slate-800 text-slate-200" : "hover:bg-slate-100 text-slate-700")
: "opacity-40 cursor-not-allowed"
}`}
aria-label={state.t("上移会话", "Move session up")}
>
<ChevronUp className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => state.ordering.handleMoveSession(session.id, "down")}
disabled={!canMoveDown}
className={`flex h-9 w-9 items-center justify-center rounded-full transition ${
canMoveDown
? (state.isDark ? "hover:bg-slate-800 text-slate-200" : "hover:bg-slate-100 text-slate-700")
: "opacity-40 cursor-not-allowed"
}`}
aria-label={state.t("下移会话", "Move session down")}
>
<ChevronDown className="h-5 w-5" />
</button>
</div>
</div>
<div className="space-y-2">
<div>
<p className="text-base font-semibold">{session.name}</p>
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
{session.username}@{session.host}
</p>
</div>
<div className="space-y-1">
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("状态", "Status")}: {state.mapSessionStatus(session.status)}</p>
{session.enhanced_enabled ? (
<p className={`text-xs font-medium ${state.isDark ? "text-indigo-300" : "text-indigo-600"}`}>
{state.t("增强持久化连接", "Enhanced persistent connection")}
</p>
) : null}
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}</p>
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}</p>
{session.disconnected_at ? (
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
{state.t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
{session.status === "active" ? (
<Button variant="secondary" lightMode={!state.isDark} className="w-full" onClick={() => window.open(`/terminal/${session.id}`, "_blank")}>
{state.t("打开会话", "Open session")}
</Button>
) : null}
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
<Button
variant="secondary"
lightMode={!state.isDark}
loading={state.isSystemRetrying(session) || !!state.retryingSessionIds[session.id]}
className="w-full"
onClick={() => state.handleRetryEnhancedSession(session.id)}
>
{state.t("重试连接", "Retry")}
</Button>
) : null}
<Button variant="ghost" lightMode={!state.isDark} className="w-full" onClick={() => state.handleDisconnectOrDelete(session)}>
{session.status === "active" ? state.t("断开", "Disconnect") : state.t("删除", "Delete")}
</Button>
</div>
<div className="space-y-2">
<Input
placeholder={state.t("备注", "Note")}
value={noteValue}
maxLength={1000}
onChange={(event) => state.handleNoteChange(session.id, event.target.value)}
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
/>
<div className={`flex items-center justify-between text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
<span>{state.t("最多 1000 字", "Up to 1000 characters")}</span>
{noteValue.trim() !== (session.note ?? "") ? (
<Button
variant="secondary"
lightMode={!state.isDark}
onClick={() => state.handleSaveNote(session)}
className="px-3 py-2 text-xs"
>
{state.t("保存备注", "Save note")}
</Button>
) : null}
</div>
</div>
</div>
</div>
);
})}
{state.filteredSessions.length === 0 ? (
<div className={`text-sm ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("暂无会话", "No sessions")}</div>
) : null}
</div>
<SessionsConnectionsPanel state={state} />
</div>
<SessionsDialogs state={state} />
</div>
);
}
@@ -0,0 +1,47 @@
export function localizeText(language: string, zh: string, en: string): string {
return language === "en-US" ? en : zh;
}
const NETWORK_PROFILE_RANK: Record<"good" | "degraded" | "poor", number> = {
good: 0,
degraded: 1,
poor: 2,
};
export function pickWorseProfile(
a: "good" | "degraded" | "poor",
b: "good" | "degraded" | "poor"
): "good" | "degraded" | "poor" {
return NETWORK_PROFILE_RANK[a] >= NETWORK_PROFILE_RANK[b] ? a : b;
}
export function normalizeTargetProfile(raw: string | undefined | null): "good" | "degraded" | "poor" | "unknown" {
if (raw === "good" || raw === "degraded" || raw === "poor") {
return raw;
}
return "unknown";
}
export function moveItem<T>(list: T[], fromIndex: number, toIndex: number): T[] {
const next = [...list];
const [item] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, item);
return next;
}
export function mergeVisibleOrder(
fullOrder: string[],
visibleOrder: string[],
nextVisibleOrder: string[]
): string[] {
const visibleSet = new Set(visibleOrder);
let visibleIndex = 0;
return fullOrder.map((id) => {
if (!visibleSet.has(id)) {
return id;
}
const nextId = nextVisibleOrder[visibleIndex];
visibleIndex += 1;
return nextId;
});
}
@@ -0,0 +1,61 @@
import React from "react";
import { changePassword } from "../../lib/api";
type UsePasswordDialogOptions = {
push: (message: string) => void;
t: (zh: string, en: string) => string;
};
export function usePasswordDialog({ push, t }: UsePasswordDialogOptions) {
const [passwordDialogOpen, setPasswordDialogOpen] = React.useState(false);
const [passwordSaving, setPasswordSaving] = React.useState(false);
const [passwordForm, setPasswordForm] = React.useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
const resetPasswordForm = React.useCallback(() => {
setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
}, []);
const handleClosePasswordDialog = React.useCallback(() => {
if (passwordSaving) {
return;
}
setPasswordDialogOpen(false);
resetPasswordForm();
}, [passwordSaving, resetPasswordForm]);
const handleSubmitPasswordChange = async () => {
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
push(t("请完整填写密码信息", "Please complete all password fields"));
return;
}
setPasswordSaving(true);
try {
await changePassword(
passwordForm.currentPassword,
passwordForm.newPassword,
passwordForm.confirmPassword,
);
push(t("密码已更新", "Password updated"));
setPasswordDialogOpen(false);
resetPasswordForm();
} catch (error) {
push(error instanceof Error ? error.message : t("修改失败", "Update failed"));
} finally {
setPasswordSaving(false);
}
};
return {
passwordDialogOpen,
setPasswordDialogOpen,
passwordSaving,
passwordForm,
setPasswordForm,
handleClosePasswordDialog,
handleSubmitPasswordChange,
};
}
@@ -0,0 +1,165 @@
import React from "react";
import type { Session } from "../../lib/api";
import { updateSessionOrder } from "../../lib/api";
import { mergeVisibleOrder, moveItem } from "./sessionsUtils";
type UseSessionsOrderingOptions = {
orderedSessions: Session[];
filteredSessions: Session[];
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
loadData: () => Promise<void>;
push: (message: string) => void;
t: (zh: string, en: string) => string;
draggingRef: React.MutableRefObject<boolean>;
};
export function useSessionsOrdering({
orderedSessions,
filteredSessions,
setSessions,
loadData,
push,
t,
draggingRef,
}: UseSessionsOrderingOptions) {
const [draggingSessionId, setDraggingSessionId] = React.useState<string | null>(null);
const [savingOrder, setSavingOrder] = React.useState(false);
const orderDirtyRef = React.useRef(false);
const orderedIdsRef = React.useRef<string[]>([]);
const cardRefs = React.useRef(new Map<string, HTMLDivElement>());
const previousPositionsRef = React.useRef(new Map<string, DOMRect>());
React.useEffect(() => {
orderedIdsRef.current = orderedSessions.map((session) => session.id);
}, [orderedSessions]);
React.useLayoutEffect(() => {
const nextPositions = new Map<string, DOMRect>();
filteredSessions.forEach((session) => {
const element = cardRefs.current.get(session.id);
if (element) {
nextPositions.set(session.id, element.getBoundingClientRect());
}
});
const previousPositions = previousPositionsRef.current;
nextPositions.forEach((nextRect, sessionId) => {
const previousRect = previousPositions.get(sessionId);
if (!previousRect) {
return;
}
const deltaY = previousRect.top - nextRect.top;
if (deltaY === 0) {
return;
}
const element = cardRefs.current.get(sessionId);
if (!element) {
return;
}
element.style.transform = `translateY(${deltaY}px)`;
element.getBoundingClientRect();
element.style.transform = "";
});
previousPositionsRef.current = nextPositions;
}, [filteredSessions]);
const persistSessionOrder = React.useCallback(async (orderedIds: string[]) => {
if (savingOrder) {
return;
}
setSavingOrder(true);
try {
await updateSessionOrder(orderedIds);
push(t("排序已保存", "Order saved"));
} catch (error) {
push(error instanceof Error ? error.message : t("保存失败", "Save failed"));
await loadData();
} finally {
setSavingOrder(false);
}
}, [loadData, push, savingOrder, t]);
const applySessionOrder = React.useCallback((orderedIds: string[]) => {
orderedIdsRef.current = orderedIds;
const orderMap = new Map(orderedIds.map((id, index) => [id, index + 1]));
setSessions((prev) => prev.map((session) => (
orderMap.has(session.id)
? { ...session, session_order: orderMap.get(session.id) }
: session
)));
}, [setSessions]);
const handleDragStart = React.useCallback((sessionId: string, event: React.DragEvent<HTMLButtonElement>) => {
if (savingOrder) {
event.preventDefault();
return;
}
draggingRef.current = true;
orderDirtyRef.current = false;
setDraggingSessionId(sessionId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", sessionId);
}, [savingOrder]);
const handleDragOver = React.useCallback((sessionId: string, event: React.DragEvent<HTMLDivElement>) => {
if (!draggingSessionId || draggingSessionId === sessionId) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const visibleOrderIds = filteredSessions.map((session) => session.id);
if (!visibleOrderIds.includes(draggingSessionId) || !visibleOrderIds.includes(sessionId)) {
return;
}
const fromIndex = visibleOrderIds.indexOf(draggingSessionId);
const toIndex = visibleOrderIds.indexOf(sessionId);
if (fromIndex === toIndex) {
return;
}
const nextVisibleOrder = moveItem(visibleOrderIds, fromIndex, toIndex);
const nextFullOrder = mergeVisibleOrder(orderedIdsRef.current, visibleOrderIds, nextVisibleOrder);
applySessionOrder(nextFullOrder);
orderDirtyRef.current = true;
}, [applySessionOrder, draggingSessionId, filteredSessions]);
const handleDragEnd = React.useCallback(async () => {
draggingRef.current = false;
setDraggingSessionId(null);
if (!orderDirtyRef.current) {
return;
}
orderDirtyRef.current = false;
await persistSessionOrder(orderedIdsRef.current);
}, [persistSessionOrder]);
const handleMoveSession = React.useCallback(async (sessionId: string, direction: "up" | "down") => {
if (savingOrder) {
return;
}
const visibleOrderIds = filteredSessions.map((session) => session.id);
const currentIndex = visibleOrderIds.indexOf(sessionId);
if (currentIndex < 0) {
return;
}
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= visibleOrderIds.length) {
return;
}
const nextVisibleOrder = moveItem(visibleOrderIds, currentIndex, targetIndex);
const nextFullOrder = mergeVisibleOrder(orderedIdsRef.current, visibleOrderIds, nextVisibleOrder);
applySessionOrder(nextFullOrder);
await persistSessionOrder(nextFullOrder);
}, [applySessionOrder, filteredSessions, persistSessionOrder, savingOrder]);
return {
draggingSessionId,
savingOrder,
orderedIdsRef,
cardRefs,
handleDragStart,
handleDragOver,
handleDragEnd,
handleMoveSession,
};
}
@@ -0,0 +1,128 @@
import React from "react";
import type { Session } from "../../lib/api";
import { clearAuthStorage, getStoredToken, listSessions, openSessionSocket } from "../../lib/api";
type UseSessionsPollingOptions = {
sessionsPollIntervalMs: number;
preserveOrderIfDragging: (list: Session[]) => Session[];
sessionsRef: React.MutableRefObject<Session[]>;
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
setNoteDrafts: React.Dispatch<React.SetStateAction<Record<string, string>>>;
push: (message: string) => void;
t: (zh: string, en: string) => string;
reportNetworkHint: (profile: "good" | "degraded" | "poor", durationMs: number) => void;
};
export function useSessionsPolling({
sessionsPollIntervalMs,
preserveOrderIfDragging,
sessionsRef,
setSessions,
setNoteDrafts,
push,
t,
reportNetworkHint,
}: UseSessionsPollingOptions) {
const sessionPollInFlightRef = React.useRef(false);
React.useEffect(() => {
let cancelled = false;
let timer: number | null = null;
const scheduleNext = () => {
if (cancelled) return;
timer = window.setTimeout(run, sessionsPollIntervalMs);
};
const run = async () => {
if (sessionPollInFlightRef.current) {
scheduleNext();
return;
}
sessionPollInFlightRef.current = true;
try {
const sessionList = await listSessions();
setSessions(preserveOrderIfDragging(sessionList));
setNoteDrafts((prev) => {
const previousSessions = sessionsRef.current;
const next = { ...prev };
sessionList.forEach((session) => {
if (!(session.id in next)) {
next[session.id] = session.note ?? "";
return;
}
const previousSession = previousSessions.find((item) => item.id === session.id);
if (next[session.id] === (previousSession?.note ?? "")) {
next[session.id] = session.note ?? "";
}
});
return next;
});
} catch {
// 静默处理刷新错误
} finally {
sessionPollInFlightRef.current = false;
scheduleNext();
}
};
run();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [sessionsPollIntervalMs, preserveOrderIfDragging, setSessions, setNoteDrafts, sessionsRef]);
React.useEffect(() => {
const userId = localStorage.getItem("user_id") || sessionStorage.getItem("user_id");
const token = getStoredToken();
if (!userId || !token) {
clearAuthStorage();
window.location.href = "/";
return;
}
const socket = openSessionSocket(Number(userId));
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as Partial<Session> & { status?: string };
if (payload.id && payload.status === "deleted") {
setSessions((prev) => prev.filter((item) => item.id !== payload.id));
return;
}
setSessions((prev) => {
const existing = prev.find((item) => item.id === payload.id);
if (existing) {
return prev.map((item) => (item.id === payload.id ? { ...item, ...payload } as Session : item));
}
if (payload.id) {
return [...prev, payload as Session];
}
return prev;
});
if (payload.id && "note" in payload) {
setNoteDrafts((prev) => ({ ...prev, [payload.id as string]: payload.note ?? "" }));
}
} catch {
// ignore malformed payloads
}
};
let warned = false;
socket.onerror = () => {
if (!warned) {
push(t("会话状态连接失败", "Session status connection failed"));
warned = true;
}
reportNetworkHint("degraded", 15000);
};
socket.onclose = (event) => {
if (!event.wasClean && !warned) {
push(t("会话状态连接已断开", "Session status connection closed"));
warned = true;
}
if (!event.wasClean) {
reportNetworkHint("poor", 25000);
}
};
return () => socket.close();
}, [push, reportNetworkHint, setSessions, setNoteDrafts, t]);
}
@@ -0,0 +1,490 @@
import React from "react";
import { useToast } from "../../components/Toast";
import { useApp } from "../../context/AppContext";
import {
Connection,
Session,
createConnection,
createSession,
deleteConnection,
deleteSession,
disconnectSession,
listConnections,
listSessions,
prepareSession,
retrySession,
updateConnection,
updateSessionNote,
} from "../../lib/api";
import { localizeText, normalizeTargetProfile, pickWorseProfile } from "./sessionsUtils";
import { useSessionsOrdering } from "./useSessionsOrdering";
import { useSessionsPolling } from "./useSessionsPolling";
import { usePasswordDialog } from "./usePasswordDialog";
export function useSessionsState() {
const [connections, setConnections] = React.useState<Connection[]>([]);
const [sessions, setSessions] = React.useState<Session[]>([]);
const [filter, setFilter] = React.useState("all");
const [search, setSearch] = React.useState("");
const [loading, setLoading] = React.useState(true);
const [form, setForm] = React.useState({
name: "",
host: "",
port: 22,
username: "",
auth_type: "password" as "password" | "private_key",
password: "",
private_key: "",
key_passphrase: "",
});
const [noteDrafts, setNoteDrafts] = React.useState<Record<string, string>>({});
const [showCreateForm, setShowCreateForm] = React.useState(false);
const [editingConnection, setEditingConnection] = React.useState<Connection | null>(null);
const [editForm, setEditForm] = React.useState({
name: "",
host: "",
port: 22,
username: "",
auth_type: "password" as "password" | "private_key",
password: "",
private_key: "",
key_passphrase: "",
});
const [deleteConfirm, setDeleteConfirm] = React.useState<{
type: "session" | "connection";
id: string | number;
name: string;
} | null>(null);
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [connectingId, setConnectingId] = React.useState<number | null>(null);
const [retryingSessionIds, setRetryingSessionIds] = React.useState<Record<string, boolean>>({});
const [enhancePrompt, setEnhancePrompt] = React.useState<{
open: boolean;
connectionId: number;
remoteArch: string;
remoteOs: string;
checked: boolean;
} | null>(null);
const { push } = useToast();
const { isDark, toggleTheme, toggleLanguage, language, networkProfile, reportNetworkHint } = useApp();
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
const passwordDialog = usePasswordDialog({ push, t });
const sessionsRef = React.useRef<Session[]>([]);
const draggingRef = React.useRef(false);
const preserveOrderIfDragging = React.useCallback((sessionList: Session[]) => {
if (!draggingRef.current) {
return sessionList;
}
const orderMap = new Map(sessionsRef.current.map((session) => [session.id, session.session_order]));
return sessionList.map((session) => (
orderMap.has(session.id)
? { ...session, session_order: orderMap.get(session.id) }
: session
));
}, []);
const loadData = React.useCallback(async () => {
try {
const [connectionList, sessionList] = await Promise.all([listConnections(), listSessions()]);
setConnections(connectionList);
setSessions(preserveOrderIfDragging(sessionList));
setNoteDrafts((prev) => {
const next = { ...prev };
sessionList.forEach((session) => {
if (!(session.id in next)) {
next[session.id] = session.note ?? "";
}
});
return next;
});
} catch (error) {
push(error instanceof Error ? error.message : t("加载失败", "Failed to load data"));
} finally {
setLoading(false);
}
}, [preserveOrderIfDragging, push, t]);
React.useEffect(() => {
loadData();
}, [loadData, language]);
React.useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
const orderedSessions = React.useMemo(() => {
const normalizeOrder = (value?: number) => (value && value > 0 ? value : Number.MAX_SAFE_INTEGER);
return [...sessions].sort((a, b) => {
const orderA = normalizeOrder(a.session_order);
const orderB = normalizeOrder(b.session_order);
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.started_at).getTime() - new Date(b.started_at).getTime();
});
}, [sessions]);
const filteredSessions = orderedSessions.filter((session) => {
const matchStatus = filter === "all" || session.status === filter;
const matchSearch = (session.name || "").toLowerCase().includes(search.toLowerCase());
return matchStatus && matchSearch;
});
const ordering = useSessionsOrdering({
orderedSessions,
filteredSessions,
setSessions,
loadData,
push,
t,
draggingRef,
});
const targetOverviewProfile = React.useMemo<"good" | "degraded" | "poor" | "unknown">(() => {
let current: "good" | "degraded" | "poor" | "unknown" = "unknown";
for (const session of sessions) {
const profile = normalizeTargetProfile(session.target_profile);
if (profile === "unknown") {
continue;
}
current = current === "unknown" ? profile : pickWorseProfile(current, profile);
}
return current;
}, [sessions]);
const effectiveNetworkProfile = React.useMemo<"good" | "degraded" | "poor">(() => {
if (targetOverviewProfile === "unknown") {
return networkProfile;
}
return pickWorseProfile(networkProfile, targetOverviewProfile);
}, [networkProfile, targetOverviewProfile]);
const sessionsPollIntervalMs =
effectiveNetworkProfile === "poor" ? 25000 : effectiveNetworkProfile === "degraded" ? 15000 : 10000;
useSessionsPolling({
sessionsPollIntervalMs,
preserveOrderIfDragging,
sessionsRef,
setSessions,
setNoteDrafts,
push,
t,
reportNetworkHint,
});
const networkProfileLabel =
effectiveNetworkProfile === "good"
? t("网络良好", "Network Good")
: effectiveNetworkProfile === "degraded"
? t("网络波动", "Network Fluctuating")
: t("弱网模式", "Poor Network Mode");
const networkProfileTone =
effectiveNetworkProfile === "good" ? "text-emerald-400" : effectiveNetworkProfile === "degraded" ? "text-amber-400" : "text-rose-400";
const mapSessionStatus = React.useCallback(
(status: string) => {
if (status === "active") {
return t("在线", "Active");
}
if (status === "disconnected") {
return t("离线", "Disconnected");
}
return status;
},
[t]
);
const isSystemRetrying = React.useCallback((session: Session) => {
return (
session.enhanced_enabled === true
&& session.status !== "active"
&& session.allow_auto_retry !== false
&& (session.retry_cycle_count ?? 0) < 5
);
}, []);
const handleCreateConnection = async (event: React.FormEvent) => {
event.preventDefault();
if (!form.name || !form.host || !form.username) {
push(t("请填写完整连接信息", "Please fill in all required connection fields"));
return;
}
try {
const payload = {
name: form.name,
host: form.host,
port: Number(form.port),
username: form.username,
auth_type: form.auth_type,
password: form.auth_type === "password" ? form.password : undefined,
private_key: form.auth_type === "private_key" ? form.private_key : undefined,
key_passphrase: form.auth_type === "private_key" ? form.key_passphrase : undefined,
};
await createConnection(payload);
push(t("连接已保存", "Connection saved"));
setForm({ name: "", host: "", port: 22, username: "", auth_type: "password", password: "", private_key: "", key_passphrase: "" });
setShowCreateForm(false);
loadData();
} catch (error) {
push(error instanceof Error ? error.message : t("创建失败", "Create failed"));
}
};
const createSessionWithOption = async (
connectionId: number,
signal: AbortSignal,
enableEnhancedPersistence: boolean
) => {
const session = await createSession(
{
connection_id: connectionId,
rows: 24,
cols: 80,
enable_enhanced_persistence: enableEnhancedPersistence,
},
{ signal }
);
push(t("会话已启动", "Session started"));
window.open(`/terminal/${session.id}`, "_blank");
await loadData();
};
const handleCreateSession = async (connectionId: number) => {
setConnectingId(connectionId);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 10000);
try {
const prepared = await prepareSession(connectionId, { signal: controller.signal });
if (prepared.should_prompt_enhance) {
setEnhancePrompt({
open: true,
connectionId,
remoteArch: prepared.remote_arch,
remoteOs: prepared.remote_os,
checked: false,
});
clearTimeout(timeoutId);
setConnectingId(null);
return;
}
await createSessionWithOption(connectionId, controller.signal, false);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
push(t("连接超时,请检查与目标机器网络是否通畅", "Connection timeout. Please verify network reachability to the target host."));
} else {
push(error instanceof Error ? error.message : t("启动失败", "Start failed"));
}
} finally {
clearTimeout(timeoutId);
setConnectingId(null);
}
};
const handleConfirmEnhance = async () => {
if (!enhancePrompt) {
return;
}
const { connectionId, checked } = enhancePrompt;
setEnhancePrompt(null);
setConnectingId(connectionId);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 10000);
try {
await createSessionWithOption(connectionId, controller.signal, checked);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
push(t("连接超时,请检查与目标机器网络是否通畅", "Connection timeout. Please verify network reachability to the target host."));
} else {
push(error instanceof Error ? error.message : t("启动失败", "Start failed"));
}
} finally {
clearTimeout(timeoutId);
setConnectingId(null);
}
};
const handleRetryEnhancedSession = async (sessionId: string) => {
if (retryingSessionIds[sessionId]) {
return;
}
setRetryingSessionIds((prev) => ({ ...prev, [sessionId]: true }));
try {
await retrySession(sessionId);
push(t("重试连接已发起", "Retry request started"));
await loadData();
} catch (error) {
push(error instanceof Error ? error.message : t("重试失败", "Retry failed"));
} finally {
setRetryingSessionIds((prev) => {
const next = { ...prev };
delete next[sessionId];
return next;
});
}
};
const handleDisconnectOrDelete = async (session: Session) => {
if (session.status === "active") {
if (!confirm(t("断开后此会话后台正在执行的程序会停止运行,确定要断开吗?", "Disconnecting will stop running processes in this session. Continue?"))) {
return;
}
try {
await disconnectSession(session.id);
push(t("会话已断开", "Session disconnected"));
loadData();
} catch (error) {
push(error instanceof Error ? error.message : t("断开失败", "Disconnect failed"));
}
} else {
setDeleteConfirm({ type: "session", id: session.id, name: session.name });
}
};
const handleDeleteSession = async (sessionId: string) => {
setDeleteLoading(true);
try {
await deleteSession(sessionId);
push(t("会话已删除", "Session deleted"));
loadData();
} catch (error) {
push(error instanceof Error ? error.message : t("删除失败", "Delete failed"));
} finally {
setDeleteLoading(false);
setDeleteConfirm(null);
}
};
const handleDeleteConnection = async (connectionId: number) => {
setDeleteLoading(true);
try {
await deleteConnection(connectionId);
push(t("连接已删除", "Connection deleted"));
loadData();
} catch (error) {
push(error instanceof Error ? error.message : t("删除失败", "Delete failed"));
} finally {
setDeleteLoading(false);
setDeleteConfirm(null);
}
};
const confirmDelete = () => {
if (!deleteConfirm) return;
if (deleteConfirm.type === "session") {
handleDeleteSession(deleteConfirm.id as string);
} else {
handleDeleteConnection(deleteConfirm.id as number);
}
};
const handleEditConnection = (conn: Connection) => {
setEditingConnection(conn);
setEditForm({
name: conn.name,
host: conn.host,
port: conn.port,
username: conn.username,
auth_type: conn.auth_type as "password" | "private_key",
password: "",
private_key: "",
key_passphrase: "",
});
};
const handleUpdateConnection = async (event: React.FormEvent) => {
event.preventDefault();
if (!editingConnection) return;
try {
const payload: Parameters<typeof updateConnection>[1] = {
name: editForm.name,
host: editForm.host,
port: Number(editForm.port),
username: editForm.username,
};
if (editForm.password || editForm.private_key || editForm.key_passphrase) {
payload.auth_type = editForm.auth_type;
payload.password = editForm.auth_type === "password" ? editForm.password : undefined;
payload.private_key = editForm.auth_type === "private_key" ? editForm.private_key : undefined;
payload.key_passphrase = editForm.auth_type === "private_key" ? editForm.key_passphrase : undefined;
}
await updateConnection(editingConnection.id, payload);
push(t("连接已更新", "Connection updated"));
setEditingConnection(null);
loadData();
} catch (error) {
push(error instanceof Error ? error.message : t("更新失败", "Update failed"));
}
};
const handleNoteChange = (sessionId: string, value: string) => {
setNoteDrafts((prev) => ({ ...prev, [sessionId]: value }));
};
const handleSaveNote = async (session: Session) => {
try {
const note = (noteDrafts[session.id] ?? "").trim();
const response = await updateSessionNote(session.id, note.length > 0 ? note : null);
setSessions((prev) => prev.map((item) => (item.id === response.id ? { ...item, ...response } : item)));
setNoteDrafts((prev) => ({ ...prev, [session.id]: response.note ?? "" }));
push(t("备注已保存", "Note saved"));
} catch (error) {
push(error instanceof Error ? error.message : t("保存失败", "Save failed"));
}
};
return {
isDark,
toggleTheme,
toggleLanguage,
language,
t,
connections,
sessions,
orderedSessions,
filteredSessions,
filter,
setFilter,
search,
setSearch,
loading,
form,
setForm,
showCreateForm,
setShowCreateForm,
editingConnection,
setEditingConnection,
editForm,
setEditForm,
noteDrafts,
handleNoteChange,
handleSaveNote,
deleteConfirm,
setDeleteConfirm,
deleteLoading,
confirmDelete,
...passwordDialog,
handleCreateConnection,
handleUpdateConnection,
handleEditConnection,
handleCreateSession,
connectingId,
handleRetryEnhancedSession,
retryingSessionIds,
handleDisconnectOrDelete,
enhancePrompt,
setEnhancePrompt,
handleConfirmEnhance,
mapSessionStatus,
isSystemRetrying,
networkProfileLabel,
networkProfileTone,
ordering,
};
}
export type SessionsState = ReturnType<typeof useSessionsState>;
@@ -0,0 +1,193 @@
import React from "react";
import { Button } from "../../components/Button";
import { FileBrowser } from "../../components/FileBrowser";
import { SystemMonitor } from "../../components/SystemMonitor";
import type { TerminalSessionState } from "./useTerminalSession";
type TerminalDesktopProps = {
state: TerminalSessionState;
onBack: () => void;
};
export function TerminalDesktop({ state, onBack }: TerminalDesktopProps) {
const { syncTerminalSize, terminalInstance } = state;
const [sidebarWidth, setSidebarWidth] = React.useState(() => {
const saved = localStorage.getItem("terminal-sidebar-width");
return saved ? Number(saved) : 256;
});
const [isDragging, setIsDragging] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const handleMouseDown = React.useCallback((event: React.MouseEvent) => {
event.preventDefault();
setIsDragging(true);
}, []);
React.useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (event: MouseEvent) => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newWidth = containerRect.right - event.clientX;
const maxWidth = containerRect.width * 0.7;
const clampedWidth = Math.max(50, Math.min(maxWidth, newWidth));
setSidebarWidth(clampedWidth);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging]);
React.useEffect(() => {
localStorage.setItem("terminal-sidebar-width", String(sidebarWidth));
const timer = window.setTimeout(() => {
const term = terminalInstance.current;
if (term) {
syncTerminalSize(term);
}
}, 10);
return () => clearTimeout(timer);
}, [sidebarWidth, syncTerminalSize, terminalInstance]);
return (
<div className={`flex min-h-screen flex-col transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
<div className={`border-b px-4 py-3 ${state.isDark ? "border-slate-800" : "border-slate-200 bg-white"}`}>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center gap-3">
<span className={`text-sm font-medium ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>
{state.sessionInfo?.name || state.t("会话", "Session")} | {state.sessionInfo ? new Date(state.sessionInfo.started_at).toLocaleString() : ""}
</span>
<span className={`text-xs ${state.connectionTone}`}>
{state.reconnectCountdown !== null ? state.t(`重连中 (${state.reconnectCountdown}s)`, `Reconnecting (${state.reconnectCountdown}s)`) : state.connectionLabel}
</span>
<span className={`text-xs ${state.networkProfileTone}`}>{state.networkProfileLabel}</span>
{state.targetLatencyMs !== null ? (
<div className="flex items-center gap-1">
<span className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.targetLatencyMs}ms</span>
<div
className={`flex h-4 w-[100px] items-end gap-px rounded px-1 ${state.isDark ? "bg-slate-900/60" : "bg-slate-200/70"}`}
title={state.t("近30次网络延迟波动", "Latency trend (last 30 samples)")}
aria-label={state.t("近30次网络延迟波动", "Latency trend (last 30 samples)")}
>
{state.latencyBarHeights.map((height, index) => (
<span
key={index}
className={`flex-1 rounded-sm ${height > 0 ? (state.isDark ? "bg-cyan-400/80" : "bg-cyan-600/80") : "bg-transparent"}`}
style={height > 0 ? { height: `${height}px` } : undefined}
/>
))}
</div>
{state.latencyHistoryMaxMs !== null ? (
<span
className={`text-[10px] whitespace-nowrap ${state.isDark ? "text-slate-500" : "text-slate-400"}`}
title={state.t("近30次最大延迟", "Max latency in last 30 samples")}
>
{state.t("最大", "Max")} {state.latencyHistoryMaxMs}ms
</span>
) : null}
</div>
) : null}
</div>
{state.sessionInfo?.note ? (
<span className={`text-xs mt-1 ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.sessionInfo.note}</span>
) : null}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" lightMode={!state.isDark} onClick={state.toggleLanguage}>
{state.language === "en-US" ? "中文" : "EN"}
</Button>
<Button variant="secondary" lightMode={!state.isDark} onClick={onBack}>
{state.t("返回会话管理", "Back to Sessions")}
</Button>
<Button variant="ghost" lightMode={!state.isDark} onClick={state.toggleTheme}>
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
</Button>
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleClear}>
{state.t("清屏", "Clear")}
</Button>
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleSelectAll}>
{state.t("全选", "Select all")}
</Button>
{state.connectionState === "closed" && state.reconnectCountdown !== null ? (
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleCancelReconnect}>
{state.t("取消重连", "Cancel reconnect")}
</Button>
) : null}
{state.connectionState === "closed" && state.reconnectCountdown === null ? (
<Button variant="secondary" lightMode={!state.isDark} onClick={state.handleReconnect}>
{state.t("重连", "Reconnect")}
</Button>
) : null}
<Button variant="secondary" lightMode={!state.isDark} onClick={state.handleCopySelection}>
{state.t("复制", "Copy")}
</Button>
</div>
</div>
</div>
<div ref={containerRef} className="flex flex-1 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 p-4 pb-2 min-h-0">
<div
ref={state.terminalRef}
className={`h-full w-full rounded-lg border ${state.isDark ? "border-slate-800" : "border-slate-300 xterm-light"} ${state.sessionInfo?.enhanced_enabled ? "xterm-tmux-enhanced" : ""}`}
tabIndex={-1}
onClick={() => state.terminalInstance.current?.focus()}
/>
</div>
<div className={`p-4 pt-2 ${state.isDark ? "border-t border-slate-800" : "border-t border-slate-200"}`} style={{ height: "280px" }}>
{state.sessionId ? (
<FileBrowser
sessionId={state.sessionId}
isDark={state.isDark}
currentDir={state.currentDir}
onFileSelect={state.setSelectedFilePath}
networkProfile={state.sessionNetworkProfile}
/>
) : null}
</div>
</div>
<div
onMouseDown={handleMouseDown}
className={`w-1 cursor-col-resize transition-colors hidden lg:block ${
isDragging
? (state.isDark ? "bg-indigo-500" : "bg-indigo-400")
: (state.isDark ? "bg-slate-800 hover:bg-slate-700" : "bg-slate-200 hover:bg-slate-300")
}`}
/>
<div
style={{ width: sidebarWidth }}
className={`p-4 hidden lg:block flex-shrink-0 overflow-y-auto ${state.isDark ? "bg-slate-900/50 dark-scrollbar" : "bg-white border-l border-slate-200 light-scrollbar"}`}
>
{state.sessionId ? (
<SystemMonitor
sessionId={state.sessionId}
isDark={state.isDark}
selectedFilePath={state.selectedFilePath || undefined}
networkProfile={state.sessionNetworkProfile}
/>
) : null}
</div>
</div>
{isDragging ? <div className="fixed inset-0 z-50 cursor-col-resize" /> : null}
{state.sessionDisconnected?.disconnected && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950">
<div className="text-center">
<div className="text-2xl mb-4 text-rose-400">{state.t("会话已断开", "Session disconnected")}</div>
<div className="text-sm text-slate-400">{state.t("断开时间", "Disconnected at")}: {state.sessionDisconnected.time}</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,237 @@
import React from "react";
import { Button } from "../../components/Button";
import { FileBrowser } from "../../components/FileBrowser";
import { SystemMonitor } from "../../components/SystemMonitor";
import type { TerminalSessionState } from "./useTerminalSession";
type TerminalMobileProps = {
state: TerminalSessionState;
onBack: () => void;
};
type MobileTab = "terminal" | "files" | "system";
export function TerminalMobile({ state, onBack }: TerminalMobileProps) {
const [activeTab, setActiveTab] = React.useState<MobileTab>("terminal");
const { syncTerminalSize, terminalInstance, scrollTerminal } = state;
React.useEffect(() => {
if (activeTab !== "terminal") {
return;
}
const term = terminalInstance.current;
if (!term) {
return;
}
syncTerminalSize(term, { force: true });
term.focus();
}, [activeTab, syncTerminalSize, terminalInstance]);
const actions = [
{
key: "language",
label: state.language === "en-US" ? "中文" : "EN",
onClick: state.toggleLanguage,
variant: "ghost" as const,
},
{
key: "theme",
label: state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark"),
onClick: state.toggleTheme,
variant: "ghost" as const,
},
{
key: "copy",
label: state.t("复制", "Copy"),
onClick: state.handleCopySelection,
variant: "secondary" as const,
},
{
key: "clear",
label: state.t("清屏", "Clear"),
onClick: state.handleClear,
variant: "ghost" as const,
},
{
key: "select",
label: state.t("全选", "Select all"),
onClick: state.handleSelectAll,
variant: "ghost" as const,
},
];
const shouldShowReconnect = state.connectionState === "closed" && state.reconnectCountdown === null;
const shouldShowCancelReconnect = state.connectionState === "closed" && state.reconnectCountdown !== null;
return (
<div className={`flex min-h-screen flex-col transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
<div className={`border-b px-4 py-3 ${state.isDark ? "border-slate-800" : "border-slate-200 bg-white"}`}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className={`text-base font-semibold truncate ${state.isDark ? "text-slate-100" : "text-slate-800"}`}>
{state.sessionInfo?.name || state.t("会话", "Session")}
</div>
<div className={`text-xs truncate ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
{state.sessionInfo ? new Date(state.sessionInfo.started_at).toLocaleString() : ""}
</div>
</div>
<Button variant="secondary" lightMode={!state.isDark} onClick={onBack} className="px-3 py-2 text-xs whitespace-nowrap">
{state.t("返回", "Back")}
</Button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className={`${state.connectionTone}`}>
{state.reconnectCountdown !== null ? state.t(`重连中 (${state.reconnectCountdown}s)`, `Reconnecting (${state.reconnectCountdown}s)`) : state.connectionLabel}
</span>
<span className={`${state.networkProfileTone}`}>{state.networkProfileLabel}</span>
{state.targetLatencyMs !== null ? (
<span className={state.isDark ? "text-slate-500" : "text-slate-400"}>
{state.targetLatencyMs}ms
</span>
) : null}
</div>
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
{actions.map((action) => (
<Button
key={action.key}
variant={action.variant}
lightMode={!state.isDark}
onClick={action.onClick}
className="px-3 py-2 text-xs whitespace-nowrap"
>
{action.label}
</Button>
))}
{shouldShowCancelReconnect ? (
<Button
variant="ghost"
lightMode={!state.isDark}
onClick={state.handleCancelReconnect}
className="px-3 py-2 text-xs whitespace-nowrap"
>
{state.t("取消重连", "Cancel reconnect")}
</Button>
) : null}
{shouldShowReconnect ? (
<Button
variant="secondary"
lightMode={!state.isDark}
onClick={state.handleReconnect}
className="px-3 py-2 text-xs whitespace-nowrap"
>
{state.t("重连", "Reconnect")}
</Button>
) : null}
</div>
{state.sessionInfo?.note ? (
<div className={`mt-2 text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
{state.sessionInfo.note}
</div>
) : null}
</div>
<div className="flex-1 min-h-0 pb-16">
<div className={`${activeTab === "terminal" ? "flex" : "hidden"} h-full flex-col`}>
<div className="flex-1 p-3 min-h-0 relative">
<div
ref={state.terminalRef}
className={`h-full w-full rounded-lg border ${state.isDark ? "border-slate-800" : "border-slate-300 xterm-light"} ${state.sessionInfo?.enhanced_enabled ? "xterm-tmux-enhanced" : ""}`}
tabIndex={-1}
onClick={() => state.terminalInstance.current?.focus()}
/>
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
<button
type="button"
onClick={() => scrollTerminal("up", "remote")}
className={`h-10 w-10 rounded-full border text-base ${
state.isDark
? "border-slate-700 bg-slate-900/90 text-slate-200"
: "border-slate-200 bg-white text-slate-700"
}`}
aria-label={state.t("向上滚动终端", "Scroll terminal up")}
>
↑
</button>
<button
type="button"
onClick={() => scrollTerminal("down", "remote")}
className={`h-10 w-10 rounded-full border text-base ${
state.isDark
? "border-slate-700 bg-slate-900/90 text-slate-200"
: "border-slate-200 bg-white text-slate-700"
}`}
aria-label={state.t("向下滚动终端", "Scroll terminal down")}
>
↓
</button>
</div>
</div>
</div>
<div className={`${activeTab === "files" ? "flex" : "hidden"} h-full flex-col`}>
<div className="flex-1 p-3 min-h-0">
{state.sessionId ? (
<FileBrowser
sessionId={state.sessionId}
isDark={state.isDark}
currentDir={state.currentDir}
onFileSelect={state.setSelectedFilePath}
networkProfile={state.sessionNetworkProfile}
compact
/>
) : null}
</div>
</div>
<div className={`${activeTab === "system" ? "flex" : "hidden"} h-full flex-col`}>
<div className="flex-1 p-3 min-h-0 overflow-y-auto">
{state.sessionId ? (
<SystemMonitor
sessionId={state.sessionId}
isDark={state.isDark}
selectedFilePath={state.selectedFilePath || undefined}
networkProfile={state.sessionNetworkProfile}
compact
/>
) : null}
</div>
</div>
</div>
<div className={`fixed bottom-0 left-0 right-0 z-50 border-t ${state.isDark ? "border-slate-800 bg-slate-950" : "border-slate-200 bg-white"}`}>
<div className="flex">
{[
{ key: "terminal", label: state.t("终端", "Terminal") },
{ key: "files", label: state.t("文件", "Files") },
{ key: "system", label: state.t("系统", "System") },
].map((tab) => {
const active = activeTab === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key as MobileTab)}
className={`flex-1 py-3 text-sm font-medium transition ${
active
? (state.isDark ? "text-indigo-300" : "text-indigo-600")
: (state.isDark ? "text-slate-400" : "text-slate-500")
}`}
>
{tab.label}
</button>
);
})}
</div>
</div>
{state.sessionDisconnected?.disconnected && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950">
<div className="text-center">
<div className="text-2xl mb-4 text-rose-400">{state.t("会话已断开", "Session disconnected")}</div>
<div className="text-sm text-slate-400">{state.t("断开时间", "Disconnected at")}: {state.sessionDisconnected.time}</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,160 @@
import type { NetworkProfile } from "../../context/AppContext";
export type TargetNetworkProfile = "good" | "degraded" | "poor" | "unknown";
export const NETWORK_PROFILE_RANK: Record<NetworkProfile, number> = {
good: 0,
degraded: 1,
poor: 2,
};
export function pickWorseProfile(a: NetworkProfile, b: NetworkProfile): NetworkProfile {
return NETWORK_PROFILE_RANK[a] >= NETWORK_PROFILE_RANK[b] ? a : b;
}
export function normalizeTargetNetworkProfile(raw: string | undefined | null): TargetNetworkProfile {
if (raw === "good" || raw === "degraded" || raw === "poor") {
return raw;
}
return "unknown";
}
export const darkTerminalTheme = {
background: "#020617",
foreground: "#e2e8f0",
cursor: "#e2e8f0",
cursorAccent: "#020617",
selectionBackground: "#334155",
black: "#1e293b",
red: "#f87171",
green: "#4ade80",
yellow: "#facc15",
blue: "#60a5fa",
magenta: "#c084fc",
cyan: "#22d3ee",
white: "#f1f5f9",
brightBlack: "#475569",
brightRed: "#fca5a5",
brightGreen: "#86efac",
brightYellow: "#fde047",
brightBlue: "#93c5fd",
brightMagenta: "#d8b4fe",
brightCyan: "#67e8f9",
brightWhite: "#ffffff",
};
export const lightTerminalTheme = {
background: "#ffffff",
foreground: "#1e293b",
cursor: "#1e293b",
cursorAccent: "#ffffff",
selectionBackground: "#bfdbfe",
black: "#1e293b",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#f1f5f9",
brightBlack: "#64748b",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#eab308",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#ffffff",
};
export const WHEEL_PIXEL_PER_LINE = 40;
export const LATENCY_HISTORY_SIZE = 30;
const ALT_SCREEN_SEQUENCE = /\x1b\[\?(?:1049|1047|47)[hl]/g;
const HARD_RESET_SEQUENCE = /\x1bc/g;
const CLEAR_SCROLLBACK_SEQUENCE = /\x1b\[(?:3|2;3)J/g;
const SANITIZE_TARGET_SEQUENCES = [
"\x1b[?1049h",
"\x1b[?1049l",
"\x1b[?1047h",
"\x1b[?1047l",
"\x1b[?47h",
"\x1b[?47l",
"\x1bc",
"\x1b[3J",
"\x1b[2;3J",
];
const SANITIZE_TARGET_MAX_LENGTH = SANITIZE_TARGET_SEQUENCES.reduce(
(max, sequence) => Math.max(max, sequence.length),
0
);
export function normalizeWheelDeltaToLines(event: WheelEvent, terminalRows: number): number {
let lineDelta = event.deltaY;
if (event.deltaMode === 0) {
lineDelta = lineDelta / WHEEL_PIXEL_PER_LINE;
} else if (event.deltaMode === 2) {
lineDelta = lineDelta * Math.max(terminalRows, 1);
}
if (!Number.isFinite(lineDelta)) {
return 0;
}
return lineDelta;
}
export function normalizeWheelDeltaToPixels(event: WheelEvent, viewportHeight: number): number {
let pixelDelta = event.deltaY;
if (event.deltaMode === 1) {
pixelDelta = pixelDelta * WHEEL_PIXEL_PER_LINE;
} else if (event.deltaMode === 2) {
pixelDelta = pixelDelta * Math.max(viewportHeight, 1);
}
if (!Number.isFinite(pixelDelta)) {
return 0;
}
return pixelDelta;
}
function stripTerminalControlSequences(data: string): string {
// 在增强 tmux 会话中屏蔽会重置本地滚动缓冲的控制序列,保证 xterm scrollback 可持续积累。
return data
.replace(ALT_SCREEN_SEQUENCE, "")
.replace(HARD_RESET_SEQUENCE, "")
.replace(CLEAR_SCROLLBACK_SEQUENCE, "");
}
function splitSanitizePendingSuffix(data: string): { safeOutput: string; pendingSuffix: string } {
const maxSuffixLength = Math.min(data.length, SANITIZE_TARGET_MAX_LENGTH - 1);
let pendingSuffix = "";
for (let length = 1; length <= maxSuffixLength; length += 1) {
const suffix = data.slice(-length);
const isSequencePrefix = SANITIZE_TARGET_SEQUENCES.some(
(sequence) => sequence.length > suffix.length && sequence.startsWith(suffix)
);
if (isSequencePrefix) {
pendingSuffix = suffix;
}
}
if (!pendingSuffix) {
return { safeOutput: data, pendingSuffix: "" };
}
return {
safeOutput: data.slice(0, -pendingSuffix.length),
pendingSuffix,
};
}
export function sanitizeTerminalOutputChunk(
data: string,
forceNormalBuffer: boolean,
pendingSuffix: string
): { output: string; pendingSuffix: string } {
if (!forceNormalBuffer || data.length === 0) {
return { output: data, pendingSuffix: "" };
}
const merged = pendingSuffix + data;
const split = splitSanitizePendingSuffix(merged);
return {
output: stripTerminalControlSequences(split.safeOutput),
pendingSuffix: split.pendingSuffix,
};
}
@@ -0,0 +1,178 @@
import React from "react";
import { useToast } from "../../components/Toast";
import { useApp } from "../../context/AppContext";
import { darkTerminalTheme, lightTerminalTheme } from "./terminalUtils";
import { useTerminalSocket } from "./useTerminalSocket";
import { useTerminalSessionInfo } from "./useTerminalSessionInfo";
function localizeText(language: string, zh: string, en: string): string {
return language === "en-US" ? en : zh;
}
export function useTerminalSession(sessionId?: string) {
const { push } = useToast();
const {
isDark,
toggleTheme,
toggleLanguage,
language,
networkProfile: globalNetworkProfile,
reportNetworkHint,
clearNetworkHint,
} = useApp();
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
const [currentDir, setCurrentDir] = React.useState<string>("/");
const [selectedFilePath, setSelectedFilePath] = React.useState<string | null>(null);
const enhancedSessionRef = React.useRef(false);
const socketState = useTerminalSocket({
sessionId,
t,
push,
reportNetworkHint,
clearNetworkHint,
enhancedSessionRef,
setCurrentDir,
});
const sessionInfoState = useTerminalSessionInfo({
sessionId,
globalNetworkProfile,
connectionState: socketState.connectionState,
reconnectCountdown: socketState.reconnectCountdown,
enhancedSessionRef,
});
React.useEffect(() => {
setSelectedFilePath(null);
setCurrentDir("/");
}, [sessionId]);
React.useEffect(() => {
const term = socketState.terminalInstance.current;
if (!term) return;
term.options.theme = isDark ? darkTerminalTheme : lightTerminalTheme;
term.refresh(0, term.rows - 1);
}, [isDark, socketState.terminalInstance]);
const handleClear = React.useCallback(() => {
const term = socketState.terminalInstance.current;
if (!term) {
return;
}
term.clear();
push(t("已清屏", "Screen cleared"));
}, [push, t, socketState.terminalInstance]);
const handleSelectAll = React.useCallback(() => {
const term = socketState.terminalInstance.current;
if (!term) {
return;
}
term.selectAll();
push(t("已全选", "Selected all"));
}, [push, t, socketState.terminalInstance]);
const handleCopySelection = React.useCallback(async () => {
const termSelection = socketState.terminalInstance.current?.getSelection() ?? "";
const domSelection = window.getSelection()?.toString() ?? "";
const selection = termSelection || domSelection;
if (!selection) {
push(t("没有可复制内容", "No selection to copy"));
return;
}
const fallbackCopy = () => {
const textarea = document.createElement("textarea");
textarea.value = selection;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
document.body.removeChild(textarea);
return copied;
};
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(selection);
push(t("已复制选中内容", "Copied selection"));
return;
}
} catch {
// 使用降级方案处理复制失败。
}
if (fallbackCopy()) {
push(t("已复制选中内容", "Copied selection"));
return;
}
push(t("复制失败,请使用右键菜单", "Copy failed, use context menu"));
}, [push, t, socketState.terminalInstance]);
const connectionLabel =
socketState.connectionState === "open"
? t("已连接", "Connected")
: socketState.connectionState === "connecting"
? t("连接中", "Connecting")
: t("已断开", "Disconnected");
const connectionTone =
socketState.connectionState === "open"
? "text-emerald-400"
: socketState.connectionState === "connecting"
? "text-amber-400"
: "text-rose-400";
const networkProfileLabel =
sessionInfoState.sessionNetworkProfile === "good"
? t("网络良好", "Network Good")
: sessionInfoState.sessionNetworkProfile === "degraded"
? t("网络波动", "Network Fluctuating")
: t("弱网模式", "Poor Network Mode");
const networkProfileTone =
sessionInfoState.sessionNetworkProfile === "good"
? "text-emerald-400"
: sessionInfoState.sessionNetworkProfile === "degraded"
? "text-amber-400"
: "text-rose-400";
return {
t,
isDark,
toggleTheme,
toggleLanguage,
language,
sessionId,
currentDir,
selectedFilePath,
setSelectedFilePath,
terminalRef: socketState.terminalRef,
terminalInstance: socketState.terminalInstance,
fitAddon: socketState.fitAddon,
connectionState: socketState.connectionState,
reconnectCountdown: socketState.reconnectCountdown,
autoReconnect: socketState.autoReconnect,
setAutoReconnect: socketState.setAutoReconnect,
syncTerminalSize: socketState.syncTerminalSize,
scrollTerminal: socketState.scrollTerminal,
handleReconnect: socketState.handleReconnect,
handleCancelReconnect: socketState.handleCancelReconnect,
sessionInfo: sessionInfoState.sessionInfo,
targetLatencyMs: sessionInfoState.targetLatencyMs,
latencyBarHeights: sessionInfoState.latencyBarHeights,
latencyHistoryMaxMs: sessionInfoState.latencyHistoryMaxMs,
sessionNetworkProfile: sessionInfoState.sessionNetworkProfile,
sessionDisconnected: sessionInfoState.sessionDisconnected,
connectionLabel,
connectionTone,
networkProfileLabel,
networkProfileTone,
handleClear,
handleSelectAll,
handleCopySelection,
};
}
export type TerminalSessionState = ReturnType<typeof useTerminalSession>;
@@ -0,0 +1,162 @@
import React from "react";
import type { NetworkProfile } from "../../context/AppContext";
import { getSession, Session } from "../../lib/api";
import {
LATENCY_HISTORY_SIZE,
normalizeTargetNetworkProfile,
pickWorseProfile,
TargetNetworkProfile,
} from "./terminalUtils";
type TerminalSessionInfoOptions = {
sessionId?: string;
globalNetworkProfile: NetworkProfile;
connectionState: "connecting" | "open" | "closed";
reconnectCountdown: number | null;
enhancedSessionRef: React.MutableRefObject<boolean>;
};
export function useTerminalSessionInfo({
sessionId,
globalNetworkProfile,
connectionState,
reconnectCountdown,
enhancedSessionRef,
}: TerminalSessionInfoOptions) {
const [sessionInfo, setSessionInfo] = React.useState<Session | null>(null);
const [targetLatencyHistory, setTargetLatencyHistory] = React.useState<number[]>([]);
const [sessionDisconnected, setSessionDisconnected] = React.useState<{ disconnected: boolean; time: string } | null>(null);
const sessionCheckInFlightRef = React.useRef(false);
const sessionTargetNetworkProfile = React.useMemo<TargetNetworkProfile>(
() => normalizeTargetNetworkProfile(sessionInfo?.target_profile),
[sessionInfo?.target_profile]
);
const targetLatencyMs = React.useMemo(() => {
if (!sessionInfo) return null;
const avg = sessionInfo.target_avg_rtt_ms ?? null;
const current = sessionInfo.target_rtt_ms ?? null;
return avg ?? current;
}, [sessionInfo]);
const mergedSessionNetworkProfile = React.useMemo<NetworkProfile>(() => {
if (sessionTargetNetworkProfile === "unknown") {
return globalNetworkProfile;
}
return pickWorseProfile(globalNetworkProfile, sessionTargetNetworkProfile);
}, [globalNetworkProfile, sessionTargetNetworkProfile]);
const sessionNetworkProfile = React.useMemo<NetworkProfile>(() => {
if (reconnectCountdown !== null || connectionState === "closed") {
return "poor";
}
return mergedSessionNetworkProfile;
}, [reconnectCountdown, connectionState, mergedSessionNetworkProfile]);
const sessionCheckIntervalMs =
sessionNetworkProfile === "poor" ? 12000 : sessionNetworkProfile === "degraded" ? 6000 : 3000;
const latencyBarHeights = React.useMemo(() => {
const history = targetLatencyHistory.slice(-LATENCY_HISTORY_SIZE);
if (history.length === 0) {
return new Array<number>(LATENCY_HISTORY_SIZE).fill(0);
}
const min = Math.min(...history);
const max = Math.max(...history);
const range = Math.max(max - min, 1);
const bars = history.map((value) => {
const ratio = (value - min) / range;
return Math.round(3 + ratio * 13);
});
const padding = new Array<number>(Math.max(0, LATENCY_HISTORY_SIZE - bars.length)).fill(0);
return [...padding, ...bars].slice(-LATENCY_HISTORY_SIZE);
}, [targetLatencyHistory]);
const latencyHistoryMaxMs = React.useMemo(() => {
const history = targetLatencyHistory.slice(-LATENCY_HISTORY_SIZE);
if (history.length === 0) {
return targetLatencyMs;
}
return Math.max(...history);
}, [targetLatencyHistory, targetLatencyMs]);
React.useEffect(() => {
setTargetLatencyHistory([]);
}, [sessionId]);
React.useEffect(() => {
enhancedSessionRef.current = sessionInfo?.enhanced_enabled === true;
}, [sessionInfo?.enhanced_enabled, enhancedSessionRef]);
React.useEffect(() => {
if (targetLatencyMs === null) {
return;
}
setTargetLatencyHistory((prev) => [...prev.slice(-(LATENCY_HISTORY_SIZE - 1)), targetLatencyMs]);
}, [targetLatencyMs]);
React.useEffect(() => {
if (!sessionId) return;
getSession(sessionId)
.then(setSessionInfo)
.catch(() => {
// 静默处理错误
});
}, [sessionId]);
React.useEffect(() => {
if (!sessionId) return;
let cancelled = false;
let timer: number | null = null;
const scheduleNext = () => {
if (cancelled) return;
timer = window.setTimeout(checkSession, sessionCheckIntervalMs);
};
const checkSession = async () => {
if (sessionCheckInFlightRef.current) {
scheduleNext();
return;
}
sessionCheckInFlightRef.current = true;
try {
const session = await getSession(sessionId);
setSessionInfo(session);
if (session.status !== "active") {
const disconnectedTime = session.disconnected_at ? new Date(session.disconnected_at) : new Date();
const timeStr = `${disconnectedTime.getFullYear()}-${String(disconnectedTime.getMonth() + 1).padStart(2, "0")}-${String(disconnectedTime.getDate()).padStart(2, "0")} ${String(disconnectedTime.getHours()).padStart(2, "0")}:${String(disconnectedTime.getMinutes()).padStart(2, "0")}:${String(disconnectedTime.getSeconds()).padStart(2, "0")}`;
setSessionDisconnected((prev) => {
if (prev?.disconnected && prev.time === timeStr) {
return prev;
}
return { disconnected: true, time: timeStr };
});
} else {
setSessionDisconnected(null);
}
} catch {
// 静默处理错误
} finally {
sessionCheckInFlightRef.current = false;
scheduleNext();
}
};
checkSession();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [sessionId, sessionCheckIntervalMs]);
return {
sessionInfo,
setSessionInfo,
targetLatencyMs,
latencyBarHeights,
latencyHistoryMaxMs,
sessionNetworkProfile,
sessionDisconnected,
};
}
@@ -0,0 +1,526 @@
import React from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import type { NetworkProfile } from "../../context/AppContext";
import { clearAuthStorage, getSession, getStoredToken, openTerminalSocket } from "../../lib/api";
import {
darkTerminalTheme,
lightTerminalTheme,
normalizeWheelDeltaToLines,
normalizeWheelDeltaToPixels,
sanitizeTerminalOutputChunk,
} from "./terminalUtils";
type TerminalSocketOptions = {
sessionId?: string;
t: (zh: string, en: string) => string;
push: (message: string) => void;
reportNetworkHint: (profile: NetworkProfile, durationMs: number) => void;
clearNetworkHint: () => void;
enhancedSessionRef: React.MutableRefObject<boolean>;
setCurrentDir: (dir: string) => void;
};
type ResizeState = { socket: WebSocket; rows: number; cols: number };
export function useTerminalSocket({
sessionId,
t,
push,
reportNetworkHint,
clearNetworkHint,
enhancedSessionRef,
setCurrentDir,
}: TerminalSocketOptions) {
const terminalRef = React.useRef<HTMLDivElement | null>(null);
const terminalInstance = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);
const socketRef = React.useRef<WebSocket | null>(null);
const reconnectTimerRef = React.useRef<number | null>(null);
const reconnectCountdownIntervalRef = React.useRef<number | null>(null);
const reconnectAttemptRef = React.useRef(0);
const lastResizeStateRef = React.useRef<ResizeState | null>(null);
const suppressMouseReportRef = React.useRef(false);
const [connectionState, setConnectionState] = React.useState<"connecting" | "open" | "closed">("connecting");
const [autoReconnect, setAutoReconnect] = React.useState(true);
const [reconnectCountdown, setReconnectCountdown] = React.useState<number | null>(null);
const clearReconnectTimer = React.useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (reconnectCountdownIntervalRef.current) {
clearInterval(reconnectCountdownIntervalRef.current);
reconnectCountdownIntervalRef.current = null;
}
setReconnectCountdown(null);
}, []);
const sendResize = React.useCallback((term: Terminal, options?: { force?: boolean }) => {
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) {
return false;
}
const { rows, cols } = term;
const lastResize = lastResizeStateRef.current;
if (
!options?.force &&
lastResize &&
lastResize.socket === socket &&
lastResize.rows === rows &&
lastResize.cols === cols
) {
return false;
}
socket.send(JSON.stringify({ type: "resize", rows, cols }));
lastResizeStateRef.current = { socket, rows, cols };
return true;
}, []);
const syncTerminalSize = React.useCallback(
(term: Terminal, options?: { force?: boolean }) => {
fitAddon.current?.fit();
sendResize(term, options);
},
[sendResize]
);
const sendInput = React.useCallback(
(data: string, notifyOnError: boolean = false) => {
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) {
if (notifyOnError) {
push(t("终端未连接", "Terminal is not connected"));
}
return false;
}
socket.send(JSON.stringify({ type: "input", data }));
return true;
},
[push, t]
);
const connectSocket = React.useCallback(
(term: Terminal, isReconnect: boolean = false) => {
if (!sessionId) {
return;
}
const token = getStoredToken();
if (!token) {
clearAuthStorage();
window.location.href = "/";
return;
}
clearReconnectTimer();
setConnectionState("connecting");
if (isReconnect) {
term.write(`\r\n\x1b[33m${t("正在重新连接...", "Reconnecting...")}\x1b[0m\r\n`);
}
const socket = openTerminalSocket(sessionId);
socketRef.current = socket;
let sanitizePendingSuffix = "";
socket.onmessage = (event) => {
if (socketRef.current !== socket) {
return;
}
const sanitized = sanitizeTerminalOutputChunk(
String(event.data ?? ""),
enhancedSessionRef.current,
sanitizePendingSuffix
);
sanitizePendingSuffix = sanitized.pendingSuffix;
const output = sanitized.output;
term.write(output);
// 检测目录变化:解析提示符中的路径
const text = output.replace(/\x1b\[[0-9;]*m/g, "");
const promptMatch = text.match(/:([\/][^\s$#]+)[\s$#]/);
if (promptMatch && promptMatch[1] && !promptMatch[1].startsWith("//")) {
setCurrentDir(promptMatch[1]);
}
};
socket.onopen = () => {
if (socketRef.current !== socket) {
return;
}
reconnectAttemptRef.current = 0;
clearNetworkHint();
syncTerminalSize(term, { force: true });
[300, 1200].forEach((delay) => {
window.setTimeout(() => {
if (socketRef.current === socket && socket.readyState === WebSocket.OPEN) {
syncTerminalSize(term);
}
}, delay);
});
setConnectionState("open");
term.focus();
if (isReconnect) {
push(t("重新连接成功", "Reconnected successfully"));
}
};
socket.onerror = () => {
if (socketRef.current !== socket) {
return;
}
setConnectionState("closed");
reportNetworkHint("poor", 15000);
};
socket.onclose = (event) => {
if (socketRef.current !== socket) {
return;
}
setConnectionState("closed");
if (!event.wasClean) {
reportNetworkHint("poor", 25000);
}
if (autoReconnect && !event.wasClean && reconnectAttemptRef.current < 5) {
const schedule = [2, 4, 8, 16, 32];
const attempt = reconnectAttemptRef.current + 1;
const delaySeconds = schedule[attempt - 1] ?? schedule[schedule.length - 1];
const delay = delaySeconds * 1000;
reconnectAttemptRef.current = attempt;
const reconnectMessage = t(
`连接已断开,${delaySeconds}秒后自动重连 (${reconnectAttemptRef.current}/5)...`,
`Connection lost. Auto reconnect in ${delaySeconds}s (${reconnectAttemptRef.current}/5)...`
);
term.write(`\r\n\x1b[31m${reconnectMessage}\x1b[0m\r\n`);
setReconnectCountdown(Math.round(delay / 1000));
let countdown = Math.round(delay / 1000);
if (reconnectCountdownIntervalRef.current) {
clearInterval(reconnectCountdownIntervalRef.current);
reconnectCountdownIntervalRef.current = null;
}
reconnectCountdownIntervalRef.current = window.setInterval(() => {
countdown -= 1;
if (countdown > 0) {
setReconnectCountdown(countdown);
} else if (reconnectCountdownIntervalRef.current) {
clearInterval(reconnectCountdownIntervalRef.current);
reconnectCountdownIntervalRef.current = null;
}
}, 1000);
reconnectTimerRef.current = window.setTimeout(() => {
if (reconnectCountdownIntervalRef.current) {
clearInterval(reconnectCountdownIntervalRef.current);
reconnectCountdownIntervalRef.current = null;
}
connectSocket(term, true);
}, delay);
} else if (!event.wasClean) {
term.write(`\r\n\x1b[31m${t("连接已断开", "Connection lost")}\x1b[0m\r\n`);
push(t("终端连接已断开", "Terminal connection closed"));
}
};
},
[sessionId, clearReconnectTimer, t, syncTerminalSize, push, reportNetworkHint, clearNetworkHint, autoReconnect, enhancedSessionRef, setCurrentDir]
);
const handleReconnect = React.useCallback(() => {
const term = terminalInstance.current;
if (!term) {
return;
}
reconnectAttemptRef.current = 0;
socketRef.current?.close();
connectSocket(term, true);
}, [connectSocket]);
const handleCancelReconnect = React.useCallback(() => {
clearReconnectTimer();
setAutoReconnect(false);
terminalInstance.current?.write(`\r\n\x1b[33m${t("已取消自动重连", "Auto reconnect canceled")}\x1b[0m\r\n`);
}, [clearReconnectTimer, t]);
const connectSocketRef = React.useRef(connectSocket);
const sendInputRef = React.useRef(sendInput);
const syncTerminalSizeRef = React.useRef(syncTerminalSize);
const scrollTerminal = React.useCallback((direction: "up" | "down", mode: "auto" | "remote" = "auto") => {
const term = terminalInstance.current;
const container = terminalRef.current;
if (!term || !container) {
return;
}
const lineDelta = direction === "up" ? -8 : 8;
const sendTmuxWheelPage = (delta: number) => {
if (delta === 0) {
return;
}
const sequence = delta < 0 ? "\x1b[5~" : "\x1b[6~";
sendInput(sequence);
};
const sendTmuxMouseWheel = (delta: number) => {
if (delta === 0) {
return;
}
const col = Math.max(1, Math.floor(term.cols / 2));
const row = Math.max(1, Math.floor(term.rows / 2));
const code = delta < 0 ? 64 : 65;
sendInput(`\x1b[<${code};${col};${row}M`);
};
if (mode === "remote") {
if (enhancedSessionRef.current && term.modes.mouseTrackingMode !== "none") {
sendTmuxMouseWheel(lineDelta);
return;
}
if (enhancedSessionRef.current) {
term.scrollLines(lineDelta);
return;
}
term.scrollLines(lineDelta);
return;
}
const activeBuffer = term.buffer.active;
const hasLocalScrollback = activeBuffer.type === "normal" && activeBuffer.baseY > 0;
if (!hasLocalScrollback) {
sendTmuxWheelPage(lineDelta);
return;
}
const viewport = container.querySelector<HTMLElement>(".xterm-viewport");
if (viewport) {
const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
const isAtTop = viewport.scrollTop <= 0;
const isAtBottom = viewport.scrollTop >= maxScrollTop;
const isEnhancedSession = enhancedSessionRef.current;
if (isEnhancedSession && lineDelta < 0 && isAtTop) {
sendTmuxWheelPage(lineDelta);
return;
}
if (isEnhancedSession && lineDelta > 0 && isAtBottom) {
sendTmuxWheelPage(lineDelta);
return;
}
}
term.scrollLines(lineDelta);
}, [sendInput, enhancedSessionRef]);
React.useEffect(() => {
connectSocketRef.current = connectSocket;
sendInputRef.current = sendInput;
syncTerminalSizeRef.current = syncTerminalSize;
}, [connectSocket, sendInput, syncTerminalSize]);
React.useEffect(() => {
const terminalContainer = terminalRef.current;
if (!terminalContainer || !sessionId) {
return;
}
let disposed = false;
let term: Terminal | null = null;
let handleTerminalWheel: ((event: WheelEvent) => void) | null = null;
let handleTerminalMouseDown: ((event: MouseEvent) => void) | null = null;
let handleTerminalMouseUp: ((event: MouseEvent) => void) | null = null;
let handleResize: (() => void) | null = null;
const initTerminal = async () => {
try {
const session = await getSession(sessionId);
if (!disposed) {
enhancedSessionRef.current = session.enhanced_enabled === true;
}
} catch {
// 忽略初始化会话信息失败
}
if (disposed) {
return;
}
const savedTheme = localStorage.getItem("theme");
const initialTheme = savedTheme === "light" ? "light" : "dark";
term = new Terminal({
theme: initialTheme === "light" ? lightTerminalTheme : darkTerminalTheme,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontSize: 14,
cursorBlink: true,
scrollback: 100000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(terminalContainer);
fit.fit();
terminalInstance.current = term;
fitAddon.current = fit;
connectSocketRef.current(term);
const coreMouseService = (term as unknown as { _core?: { coreMouseService?: { triggerMouseEvent: (event: unknown) => boolean } } })._core
?.coreMouseService as { triggerMouseEvent: (event: unknown) => boolean; _wsghTriggerMouseEvent?: (event: unknown) => boolean } | undefined;
if (coreMouseService && !coreMouseService._wsghTriggerMouseEvent) {
coreMouseService._wsghTriggerMouseEvent = coreMouseService.triggerMouseEvent.bind(coreMouseService);
coreMouseService.triggerMouseEvent = (event) => {
if (suppressMouseReportRef.current) {
return false;
}
return coreMouseService._wsghTriggerMouseEvent?.(event) ?? false;
};
}
term.onData((data) => {
sendInputRef.current(data);
});
let wheelRemainder = 0;
const sendTmuxWheelPage = (lineDelta: number) => {
if (lineDelta === 0) {
return;
}
const sequence = lineDelta < 0 ? "\x1b[5~" : "\x1b[6~";
sendInputRef.current(sequence);
};
handleTerminalWheel = (event: WheelEvent) => {
if (!term) {
return;
}
if (term.modes.mouseTrackingMode !== "none") {
return;
}
const isEnhancedSession = enhancedSessionRef.current;
const activeBuffer = term.buffer.active;
const hasLocalScrollback = activeBuffer.type === "normal" && activeBuffer.baseY > 0;
if (!hasLocalScrollback) {
const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
if (lineDelta === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
sendTmuxWheelPage(lineDelta);
return;
}
event.preventDefault();
event.stopPropagation();
const viewport = terminalContainer.querySelector<HTMLElement>(".xterm-viewport");
if (viewport) {
const pixelDelta = normalizeWheelDeltaToPixels(event, viewport.clientHeight);
const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
const isAtTop = viewport.scrollTop <= 0;
const isAtBottom = viewport.scrollTop >= maxScrollTop;
const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
if (isEnhancedSession && pixelDelta < 0 && isAtTop) {
sendTmuxWheelPage(lineDelta);
return;
}
if (isEnhancedSession && pixelDelta > 0 && isAtBottom) {
sendTmuxWheelPage(lineDelta);
return;
}
if (pixelDelta !== 0) {
const nextScrollTop = Math.min(maxScrollTop, Math.max(0, viewport.scrollTop + pixelDelta));
viewport.scrollTop = nextScrollTop;
}
return;
}
const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
if (lineDelta === 0) {
return;
}
wheelRemainder += lineDelta;
const wholeLines = wheelRemainder > 0 ? Math.floor(wheelRemainder) : Math.ceil(wheelRemainder);
if (wholeLines === 0) {
return;
}
term.scrollLines(wholeLines);
wheelRemainder -= wholeLines;
};
terminalContainer.addEventListener("wheel", handleTerminalWheel, { capture: true, passive: false });
handleTerminalMouseDown = (event: MouseEvent) => {
if (!term || term.modes.mouseTrackingMode === "none") {
return;
}
if (event.button === 0) {
suppressMouseReportRef.current = true;
const selectionService = (term as unknown as { _core?: { _selectionService?: { enable?: () => void; handleMouseDown?: (e: MouseEvent) => void } } })._core
?._selectionService;
if (selectionService?.enable && selectionService?.handleMouseDown) {
selectionService.enable();
selectionService.handleMouseDown(event);
term.focus();
event.stopPropagation();
}
return;
}
if (event.button === 2) {
suppressMouseReportRef.current = true;
event.stopPropagation();
}
};
handleTerminalMouseUp = () => {
if (!suppressMouseReportRef.current) {
return;
}
window.setTimeout(() => {
suppressMouseReportRef.current = false;
}, 0);
};
terminalContainer.addEventListener("mousedown", handleTerminalMouseDown, { capture: true });
document.addEventListener("mouseup", handleTerminalMouseUp, { capture: true });
handleResize = () => {
if (!term) {
return;
}
syncTerminalSizeRef.current(term);
};
window.addEventListener("resize", handleResize);
};
void initTerminal();
return () => {
disposed = true;
if (handleTerminalWheel) {
terminalContainer.removeEventListener("wheel", handleTerminalWheel, { capture: true });
}
if (handleTerminalMouseDown) {
terminalContainer.removeEventListener("mousedown", handleTerminalMouseDown, { capture: true });
}
if (handleTerminalMouseUp) {
document.removeEventListener("mouseup", handleTerminalMouseUp, { capture: true });
}
if (handleResize) {
window.removeEventListener("resize", handleResize);
}
clearReconnectTimer();
socketRef.current?.close();
term?.dispose();
};
}, [sessionId, clearReconnectTimer, enhancedSessionRef]);
return {
terminalRef,
terminalInstance,
fitAddon,
connectionState,
reconnectCountdown,
autoReconnect,
setAutoReconnect,
syncTerminalSize,
scrollTerminal,
handleReconnect,
handleCancelReconnect,
};
}