移动端适配:会话与终端分支布局

This commit is contained in:
beibeizi committed 2026-03-16 01:16:43 +08:00
1 parent df97f71cc5
commit a5b4f4bb5d
20 files changed
+3202 -2331

No files matched your search

+81 -60
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 =
@@ -1018,7 +1020,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")
@@ -1073,8 +1075,12 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
? calculatePercent(downloadStats.loadedBytes, downloadStats.totalBytes)
: null;
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 +1088,50 @@ 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>
<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 +1259,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 +1284,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 +1317,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 +1345,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 +1497,3 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
</div>
);
}
+5 -3
View File
@@ -65,12 +65,14 @@ 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 [stats, setStats] = React.useState<SystemStats | null>(null);
const [network, setNetwork] = React.useState<NetworkInfo | null>(null);
const [processes, setProcesses] = React.useState<ProcessInfo[]>([]);
@@ -235,7 +237,7 @@ 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")}
@@ -385,7 +387,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
<button
onClick={handleSaveFile}
disabled={fileContent === originalContent || fileTooLarge}
className={`px-2 py-1 text-xs rounded ${
className={`${isCompact ? "px-3 py-2 text-sm" : "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")
+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}/3
</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) < 3
);
}, []);
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,211 @@
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 } = 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">
<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>
<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,177 @@
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,
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,465 @@
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 attempt = reconnectAttemptRef.current + 1;
const delay = attempt * 5000;
reconnectAttemptRef.current = attempt;
const reconnectMessage = t(
`连接已断开,${Math.round(delay / 1000)}秒后自动重连 (${reconnectAttemptRef.current}/5)...`,
`Connection lost. Auto reconnect in ${Math.round(delay / 1000)}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);
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,
handleReconnect,
handleCancelReconnect,
};
}