feat(frontend): 添加 SSH 终端运行中标记

为 SSH 顶部服务器标签和内部终端标签补充 `%` 运行中提示,
并基于发送命令、shell prompt、断连与错误链路派生运行态。
This commit is contained in:
yinjianm
2026-04-19 21:33:24 +08:00
parent 940c8babc2
commit 6253b90151
15 changed files with 530 additions and 230 deletions
@@ -654,6 +654,13 @@ onBeforeUnmount(() => {
<span class="whitespace-nowrap">
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }}
</span>
<span
v-if="session.isCommandRunning"
class="ml-2 rounded-sm px-1 text-[10px] font-semibold uppercase tracking-wide text-amber-400"
:title="t('terminalTabBar.commandRunningIndicator')"
>
%
</span>
<button
type="button"
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-header hover:text-foreground group-hover:opacity-100"
@@ -100,6 +100,14 @@ const getRepresentativeSessionId = (connectionId: string, fallbackSessionId: str
const getConnectionSessionCount = (connectionId: string) => getConnectionSessions(connectionId).length;
const getConnectionRunningSessionCount = (connectionId: string) =>
getConnectionSessions(connectionId).filter((session) => session.isCommandRunning).length;
const isConnectionRunning = (connectionId: string) => getConnectionRunningSessionCount(connectionId) > 0;
const getConnectionRunningIndicatorTitle = (connectionId: string) =>
t('terminalTabBar.commandRunningIndicatorCount', { count: getConnectionRunningSessionCount(connectionId) });
const shouldRenderTopLevelItem = (session: SessionTabInfoWithStatus, index: number) => {
if (!isSshConnection(session.connectionId)) {
return true;
@@ -564,6 +572,13 @@ onBeforeUnmount(() => {
<span class="max-w-[180px] truncate text-xs font-semibold tracking-wide">
{{ session.connectionName }}
</span>
<span
v-if="isConnectionRunning(session.connectionId)"
class="rounded-sm px-1 text-[10px] font-semibold uppercase tracking-wide text-amber-400"
:title="getConnectionRunningIndicatorTitle(session.connectionId)"
>
%
</span>
<span
:class="[
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
@@ -1,18 +1,129 @@
import { ref, readonly, type Ref, ComputedRef } from 'vue';
import { ref, readonly, ComputedRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { sessions as globalSessionsRef } from '../stores/session/state'; // +++ 导入全局 sessions state +++
// import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
import { sessions as globalSessionsRef } from '../stores/session/state';
import type { Terminal } from 'xterm';
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search'; // *** 移除 ISearchResult 导入 ***
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
// 定义与 WebSocket 相关的依赖接口
export interface SshTerminalDependencies {
sendMessage: (message: WebSocketMessage) => void;
onMessage: (type: string, handler: (payload: any, fullMessage?: WebSocketMessage) => void) => () => void;
isConnected: ComputedRef<boolean>;
}
const OSC_SEQUENCE_RE = /\x1B\][^\x07]*(?:\x07|\x1B\\)/g;
const CSI_SEQUENCE_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g;
const SINGLE_ESC_RE = /\x1B[@-Z\\-_]/g;
const INPUT_ESCAPE_RE = /(?:\x1B\[[0-?]*[ -/]*[@-~])|(?:\x1B[@-Z\\-_])/g;
const SHELL_PROMPT_PATTERNS = [
/^(?:\[[^\]]+\]\s*)?[\w.-]+@[\w.-]+(?::[^\r\n]*)?[#$%>] ?$/,
/^(?:[A-Za-z]:)?[\\/][^>\r\n]*> ?$/,
/^PS [^>\r\n]+> ?$/,
/^(?:~|\/)[^#$%>\r\n]*[#$%>] ?$/,
/^[\w.-]+\s[%#] ?$/,
/^[#$%>] ?$/,
];
const stripTerminalControlSequences = (text: string): string =>
text
.replace(OSC_SEQUENCE_RE, '')
.replace(CSI_SEQUENCE_RE, '')
.replace(SINGLE_ESC_RE, '');
const getSessionState = (sessionId: string) => globalSessionsRef.value.get(sessionId);
const resetSessionCommandRuntime = (sessionId: string) => {
const session = getSessionState(sessionId);
if (!session) {
return;
}
session.isCommandRunning.value = false;
session.terminalInputBuffer.value = '';
};
const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) => {
const session = getSessionState(sessionId);
if (!session) {
return { submittedCommand: false, interrupted: false };
}
const normalizedData = data.replace(INPUT_ESCAPE_RE, '');
if (!normalizedData) {
return { submittedCommand: false, interrupted: false };
}
let nextBuffer = session.terminalInputBuffer.value;
let submittedCommand = false;
let interrupted = false;
for (const char of normalizedData) {
if (char === '\x03') {
session.isCommandRunning.value = false;
nextBuffer = '';
interrupted = true;
continue;
}
if (char === '\r' || char === '\n') {
if (nextBuffer.trim().length > 0) {
session.isCommandRunning.value = true;
submittedCommand = true;
}
nextBuffer = '';
continue;
}
if (char === '\b' || char === '\u007f') {
nextBuffer = nextBuffer.slice(0, -1);
continue;
}
if (char < ' ') {
continue;
}
if (nextBuffer.length === 0 && session.isCommandRunning.value) {
session.isCommandRunning.value = false;
}
nextBuffer += char;
}
session.terminalInputBuffer.value = nextBuffer;
return { submittedCommand, interrupted };
};
const getPromptProbeText = (outputData: string | Uint8Array): string => {
if (typeof outputData === 'string') {
return outputData;
}
try {
return new TextDecoder().decode(outputData);
} catch {
return '';
}
};
const isPromptTail = (tail: string): boolean => {
const normalizedTail = stripTerminalControlSequences(tail);
const lines = normalizedTail
.split(/\r?\n/)
.map((line) => line.replace(/\r/g, '').trimEnd());
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index].trim();
if (!line) {
continue;
}
return SHELL_PROMPT_PATTERNS.some((pattern) => pattern.test(line));
}
return false;
};
/**
* 创建一个 SSH 终端管理器实例
* @param sessionId 会话唯一标识符
@@ -20,74 +131,57 @@ export interface SshTerminalDependencies {
* @param t i18n 翻译函数,从父组件传入
* @returns SSH 终端管理器实例
*/
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: ReturnType<typeof useI18n>['t']) { // +++ Update type of t +++
// 使用依赖注入的 WebSocket 函数
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: ReturnType<typeof useI18n>['t']) {
const { sendMessage, onMessage, isConnected } = wsDeps;
const terminalInstance = ref<Terminal | null>(null);
const searchAddon = ref<SearchAddon | null>(null); // Keep searchAddon ref
// Removed search result state refs
// const searchResultCount = ref(0);
// const currentSearchResultIndex = ref(-1);
const terminalOutputBuffer = ref<(string | Uint8Array)[]>([]); // 缓冲 WebSocket 消息直到终端准备好
const isSshConnected = ref(false); // 跟踪 SSH 连接状态
const searchAddon = ref<SearchAddon | null>(null);
const terminalOutputBuffer = ref<(string | Uint8Array)[]>([]);
const isSshConnected = ref(false);
const promptProbeBuffer = ref('');
// 辅助函数:获取终端消息文本
const getTerminalText = (key: string, params?: Record<string, any>): string => {
// 确保 i18n key 存在,否则返回原始 key
const getTerminalText = (key: string, params?: Record<string, unknown>): string => {
const translationKey = `workspace.terminal.${key}`;
const translated = t(translationKey, params || {});
return translated === translationKey ? key : translated;
};
// --- 终端事件处理 ---
// *** 更新 handleTerminalReady 签名以接收 searchAddon ***
const handleTerminalReady = (payload: { terminal: Terminal; searchAddon: SearchAddon | null }) => {
const { terminal: term, searchAddon: addon } = payload;
console.log(`[会话 ${sessionId}][SSH终端模块] 终端实例已就绪。SearchAddon 实例:`, addon ? '存在' : '不存在');
terminalInstance.value = term;
searchAddon.value = addon; // *** 存储 searchAddon 实例 ***
searchAddon.value = addon;
// 1. 处理 SessionState.pendingOutput (来自 SSH_OUTPUT_CACHED_CHUNK 的早期数据)
const currentSessionState = globalSessionsRef.value.get(sessionId);
if (currentSessionState && currentSessionState.pendingOutput && currentSessionState.pendingOutput.length > 0) {
// console.log(`[会话 ${sessionId}][SSH终端模块] 发现 SessionState.pendingOutput,长度: ${currentSessionState.pendingOutput.length}。正在写入...`);
currentSessionState.pendingOutput.forEach(data => {
if (currentSessionState?.pendingOutput?.length) {
currentSessionState.pendingOutput.forEach((data) => {
term.write(data);
});
currentSessionState.pendingOutput = []; // 清空
// console.log(`[会话 ${sessionId}][SSH终端模块] SessionState.pendingOutput 处理完毕。`);
// 如果之前因为 pendingOutput 而将 isResuming 保持为 true,现在可以考虑更新
currentSessionState.pendingOutput = [];
if (currentSessionState.isResuming) {
// 检查 isLastChunk 是否已收到 (这部分逻辑在 handleSshOutputCachedChunk 中,这里仅作标记清除)
// 假设所有缓存块都已处理完毕
// console.log(`[会话 ${sessionId}][SSH终端模块] 所有 pendingOutput 已写入,清除 isResuming 标记。`);
currentSessionState.isResuming = false;
}
}
// 2. 将此管理器内部缓冲的输出 (terminalOutputBuffer, 来自 ssh:output) 写入终端
if (terminalOutputBuffer.value.length > 0) {
terminalOutputBuffer.value.forEach(data => {
term.write(data);
terminalOutputBuffer.value.forEach((data) => {
term.write(data);
});
terminalOutputBuffer.value = []; // 清空内部缓冲区
terminalOutputBuffer.value = [];
}
// 可以在这里自动聚焦或执行其他初始化操作
// term.focus(); // 也许在 ssh:connected 时聚焦更好
};
const handleTerminalData = (data: string) => {
// console.debug(`[会话 ${sessionId}][SSH终端模块] 接收到终端输入:`, data);
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
promptProbeBuffer.value = '';
}
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
};
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
console.log(`[SSH ${sessionId}] handleTerminalResize called with:`, dimensions);
// 只有在连接状态下才发送 resize 命令给后端
if (isConnected.value) {
sendMessage({ type: 'ssh:resize', sessionId, payload: dimensions });
} else {
@@ -95,76 +189,62 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
}
};
// --- WebSocket 消息处理 ---
const handleSshOutput = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
return;
}
let outputData = payload;
// 检查是否为 Base64 编码 (需要后端配合发送 encoding 字段)
if (message?.encoding === 'base64' && typeof outputData === 'string') {
try {
// 使用更安全的Base64解码方式,保证中文字符正确解码
const base64String = outputData;
// 先用atob获取二进制字符串
const binaryString = atob(base64String);
// 创建Uint8Array存储二进制数据
const binaryString = atob(outputData);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
for (let index = 0; index < binaryString.length; index += 1) {
bytes[index] = binaryString.charCodeAt(index);
}
// 直接使用原始二进制数据作为 Uint8Array 写入终端,避免编码转换问题
outputData = bytes;
} catch (e) {
console.error(`[会话 ${sessionId}][SSH终端模块] Base64 解码失败:`, e, '原始数据:', message.payload);
outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误
} catch (error) {
console.error(`[会话 ${sessionId}][SSH终端模块] Base64 解码失败:`, error, '原始数据:', message.payload);
outputData = `\r\n[解码错误: ${error}]\r\n`;
}
} else if (typeof outputData !== 'string') {
console.warn(`[会话 ${sessionId}][SSH终端模块] 收到非字符串 ssh:output payload:`, outputData);
try {
outputData = JSON.stringify(outputData);
} catch {
outputData = String(outputData);
}
}
// 如果不是 base64 或解码失败,确保它是字符串
else if (typeof outputData !== 'string') {
console.warn(`[会话 ${sessionId}][SSH终端模块] 收到非字符串 ssh:output payload:`, outputData);
try {
outputData = JSON.stringify(outputData); // 尝试序列化
} catch {
outputData = String(outputData); // 最后手段:强制转字符串
}
}
// 由于直接使用原始二进制数据,不再需要过滤 OSC 184 序列
// 相关代码已移除
// --- 添加前端日志 ---
// console.log(`[会话 ${sessionId}][SSH前端] 收到 ssh:output 原始 payload (解码前):`, payload);
// console.log(`[会话 ${sessionId}][SSH前端] 解码后的数据 (尝试写入):`, outputData);
// --------------------
if (terminalInstance.value) {
// console.log(`[会话 ${sessionId}][SSH前端] 终端实例存在,尝试写入...`);
terminalInstance.value.write(outputData);
// console.log(`[会话 ${sessionId}][SSH前端] 写入完成。`);
} else {
// 如果终端还没准备好,先缓冲输出
terminalOutputBuffer.value.push(outputData);
}
if (getSessionState(sessionId)?.isCommandRunning.value) {
const promptProbeText = getPromptProbeText(outputData);
if (promptProbeText) {
promptProbeBuffer.value = `${promptProbeBuffer.value}${promptProbeText}`.slice(-320);
if (isPromptTail(promptProbeBuffer.value)) {
resetSessionCommandRuntime(sessionId);
}
}
}
};
const handleSshConnected = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
return;
}
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。 Payload:`, payload, 'Full message:', message); // 更详细的日志
isSshConnected.value = true; // 更新状态
// 连接成功后聚焦终端
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。 Payload:`, payload, 'Full message:', message);
isSshConnected.value = true;
promptProbeBuffer.value = '';
terminalInstance.value?.focus();
if (terminalInstance.value) {
const currentDimensions = { cols: terminalInstance.value.cols, rows: terminalInstance.value.rows };
// 检查尺寸是否有效
if (currentDimensions.cols > 0 && currentDimensions.rows > 0) {
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 连接成功,主动发送初始尺寸:`, currentDimensions);
sendMessage({ type: 'ssh:resize', sessionId, payload: currentDimensions });
@@ -172,62 +252,55 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接成功,但获取到的初始尺寸无效,跳过发送 resize:`, currentDimensions);
}
} else {
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接成功,但 terminalInstance 不可用,无法发送初始 resize。`);
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接成功,但 terminalInstance 不可用,无法发送初始 resize。`);
}
// 清空可能存在的旧缓冲(虽然理论上此时应该已经 ready 了)
if (terminalOutputBuffer.value.length > 0) {
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...`);
terminalOutputBuffer.value.forEach(data => terminalInstance.value?.write(data));
terminalOutputBuffer.value = [];
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...`);
terminalOutputBuffer.value.forEach((data) => terminalInstance.value?.write(data));
terminalOutputBuffer.value = [];
}
};
const handleSshDisconnected = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
return;
}
const reason = payload || t('workspace.terminal.unknownReason'); // 使用 i18n 获取未知原因文本
const reason = payload || t('workspace.terminal.unknownReason');
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
isSshConnected.value = false; // 更新状态
isSshConnected.value = false;
promptProbeBuffer.value = '';
resetSessionCommandRuntime(sessionId);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
// 可以在这里添加其他清理逻辑,例如禁用输入
};
const handleSshError = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
return;
}
const errorMsg = payload || t('workspace.terminal.unknownSshError'); // 使用 i18n
const errorMsg = payload || t('workspace.terminal.unknownSshError');
console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
isSshConnected.value = false; // 更新状态
isSshConnected.value = false;
promptProbeBuffer.value = '';
resetSessionCommandRuntime(sessionId);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
};
const handleSshStatus = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
return;
}
// 这个消息现在由 useWebSocketConnection 处理以更新全局状态栏消息
// 这里可以保留日志或用于其他特定于终端的 UI 更新(如果需要)
const statusKey = payload?.key || 'unknown';
const statusParams = payload?.params || {};
console.log(`[会话 ${sessionId}][SSH终端模块] 收到 SSH 状态更新:`, statusKey, statusParams);
// 可以在终端打印一些状态信息吗?
// terminalInstance.value?.writeln(`\r\n\x1b[34m[状态: ${statusKey}]\x1b[0m`);
};
const handleInfoMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
return;
}
console.log(`[会话 ${sessionId}][SSH终端模块] 收到后端信息:`, payload);
@@ -235,19 +308,15 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
};
const handleErrorMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
return;
}
// 通用错误也可能需要显示在终端
const errorMsg = payload || t('workspace.terminal.unknownGenericError'); // 使用 i18n
const errorMsg = payload || t('workspace.terminal.unknownGenericError');
console.error(`[会话 ${sessionId}][SSH终端模块] 收到后端通用错误:`, errorMsg);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${errorMsg}\x1b[0m`);
};
// --- 注册 WebSocket 消息处理器 ---
const unregisterHandlers: (() => void)[] = [];
const registerSshHandlers = () => {
@@ -257,61 +326,53 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
unregisterHandlers.push(onMessage('ssh:error', handleSshError));
unregisterHandlers.push(onMessage('ssh:status', handleSshStatus));
unregisterHandlers.push(onMessage('info', handleInfoMessage));
unregisterHandlers.push(onMessage('error', handleErrorMessage)); // 也处理通用错误
unregisterHandlers.push(onMessage('error', handleErrorMessage));
console.log(`[会话 ${sessionId}][SSH终端模块] 已注册 SSH 相关消息处理器。`);
};
const unregisterAllSshHandlers = () => {
console.log(`[会话 ${sessionId}][SSH终端模块] 注销 SSH 相关消息处理器...`);
unregisterHandlers.forEach(unregister => unregister?.());
unregisterHandlers.length = 0; // 清空数组
unregisterHandlers.forEach((unregister) => unregister?.());
unregisterHandlers.length = 0;
};
// 初始化时自动注册处理程序
registerSshHandlers();
// --- 清理函数 ---
const cleanup = () => {
unregisterAllSshHandlers();
// terminalInstance.value?.dispose(); // 终端实例的销毁由 TerminalComponent 负责
terminalInstance.value = null;
console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
};
/**
* 直接发送数据到 SSH 会话 (例如,从命令输入栏)
* 直接发送数据到 SSH 会话例如,从命令输入栏
* @param data 要发送的字符串数据
*/
const sendData = (data: string) => {
// console.debug(`[会话 ${sessionId}][SSH终端模块] 直接发送数据:`, data);
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
promptProbeBuffer.value = '';
}
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
};
// --- 搜索相关方法 (移除计数逻辑) ---
// Removed countOccurrences helper function
const searchNext = (term: string, options?: ISearchOptions): boolean => {
if (searchAddon.value) {
console.log(`[会话 ${sessionId}][SSH终端模块] 执行 searchNext: "${term}"`);
const found = searchAddon.value.findNext(term, options);
// Removed manual count and state update
return found;
return searchAddon.value.findNext(term, options);
}
console.warn(`[会话 ${sessionId}][SSH终端模块] searchNext 调用失败,searchAddon 不可用。`);
// Removed state reset on failure
return false;
};
const searchPrevious = (term: string, options?: ISearchOptions): boolean => {
if (searchAddon.value) {
console.log(`[会话 ${sessionId}][SSH终端模块] 执行 searchPrevious: "${term}"`);
const found = searchAddon.value.findPrevious(term, options);
// Removed manual count and state update
return found;
console.log(`[会话 ${sessionId}][SSH终端模块] 执行 searchPrevious: "${term}"`);
return searchAddon.value.findPrevious(term, options);
}
console.warn(`[会话 ${sessionId}][SSH终端模块] searchPrevious 调用失败,searchAddon 不可用。`);
// Removed state reset on failure
console.warn(`[会话 ${sessionId}][SSH终端模块] searchPrevious 调用失败,searchAddon 不可用。`);
return false;
};
@@ -320,49 +381,42 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
console.log(`[会话 ${sessionId}][SSH终端模块] 清除搜索高亮。`);
searchAddon.value.clearDecorations();
}
// Removed state reset
console.log(`[会话 ${sessionId}][SSH终端模块] 搜索高亮已清除 (状态不再管理)`);
console.log(`[会话 ${sessionId}][SSH终端模块] 搜索高亮已清除状态不再管理`);
};
// 返回工厂实例
return {
// 公共接口
handleTerminalReady,
handleTerminalData, // 这个处理来自 xterm.js 的输入
handleTerminalData,
handleTerminalResize,
sendData, // 允许外部直接发送数据
sendData,
cleanup,
// --- 搜索方法 ---
searchNext,
searchPrevious,
clearTerminalSearch,
// --- 暴露状态 ---
isSshConnected: readonly(isSshConnected), // 暴露 SSH 连接状态 (只读)
terminalInstance, // 暴露 terminal 实例,以便 WorkspaceView 可以写入提示信息
isSshConnected: readonly(isSshConnected),
terminalInstance,
};
}
// 保留兼容旧代码的函数(将在完全迁移后移除)
export function useSshTerminal(t: (key: string) => string) {
export function useSshTerminal() {
console.warn('⚠️ 使用已弃用的 useSshTerminal() 全局单例。请迁移到 createSshTerminalManager() 工厂函数。');
const terminalInstance = ref<Terminal | null>(null);
const handleTerminalReady = (term: Terminal) => {
console.log('[SSH终端模块][旧] 终端实例已就绪,但使用了已弃用的单例模式。');
terminalInstance.value = term;
};
const handleTerminalData = (data: string) => {
const handleTerminalData = () => {
console.warn('[SSH终端模块][旧] 收到终端数据,但使用了已弃用的单例模式,无法发送。');
};
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
const handleTerminalResize = () => {
console.warn('[SSH终端模块][旧] 收到终端大小调整,但使用了已弃用的单例模式,无法发送。');
};
// 返回与旧接口兼容的空函数,以避免错误
return {
terminalInstance,
handleTerminalReady,
+3 -1
View File
@@ -1700,7 +1700,9 @@
"openConnectionPickerTooltip": "Choose another server",
"terminalBadge": "Terminal {index}",
"serverEntryTitle": "{name} · {count} terminals",
"terminalCount": "{count} terminals"
"terminalCount": "{count} terminals",
"commandRunningIndicator": "Command running",
"commandRunningIndicatorCount": "{count} terminal(s) running"
},
"globalConnectionSearch": {
"shortcut": "Ctrl+Shift+F",
+3 -1
View File
@@ -1624,7 +1624,9 @@
"newTerminalTooltip": "現在のサーバーに新しいターミナルを追加",
"closeConnectionGroupTooltip": "{name} の端末をすべて閉じる ({count} 件)",
"openConnectionPickerTooltip": "別のサーバーを選択",
"terminalBadge": "端末 {index}"
"terminalBadge": "端末 {index}",
"commandRunningIndicator": "コマンド実行中",
"commandRunningIndicatorCount": "{count} 個の端末でコマンド実行中"
},
"globalConnectionSearch": {
"shortcut": "Ctrl+Shift+F",
+3 -1
View File
@@ -1704,7 +1704,9 @@
"openConnectionPickerTooltip": "选择其他服务器",
"terminalBadge": "终端 {index}",
"serverEntryTitle": "{name} · {count} 个终端",
"terminalCount": "{count} 个终端"
"terminalCount": "{count} 个终端",
"commandRunningIndicator": "命令正在运行中",
"commandRunningIndicatorCount": "{count} 个终端正在运行中"
},
"globalConnectionSearch": {
"shortcut": "Ctrl+Shift+F",
@@ -148,6 +148,8 @@ export const openNewSession = (
editorTabs: ref([]),
activeEditorTabId: ref(null),
commandInputContent: ref(''),
isCommandRunning: ref(false),
terminalInputBuffer: ref(''),
isMarkedForSuspend: false,
createdAt: Date.now(),
disposables: [],
+39 -40
View File
@@ -1,11 +1,9 @@
// packages/frontend/src/stores/session/getters.ts
import { computed } from 'vue';
import { sessions, activeSessionId } from './state';
import type { SessionState, SessionTabInfoWithStatus } from './types';
export const sessionTabs = computed(() => {
return Array.from(sessions.value.values()).map(session => ({
return Array.from(sessions.value.values()).map((session) => ({
sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName,
@@ -13,56 +11,57 @@ export const sessionTabs = computed(() => {
}));
});
// 包含状态的标签页信息
export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] => {
const sessionOrderStr = localStorage.getItem('sessionOrder');
let sessionOrder: string[] = [];
if (sessionOrderStr) {
try {
sessionOrder = JSON.parse(sessionOrderStr);
console.log('[SessionGetters] 使用本地存储的用户自定义标签顺序');
} catch (e) {
console.error('[SessionGetters] 解析本地存储的标签顺序失败:', e);
console.log('[SessionGetters] 使用本地存储的用户自定义标签顺序');
} catch (error) {
console.error('[SessionGetters] 解析本地存储的标签顺序失败', error);
sessionOrder = [];
}
}
const sessionList = Array.from(sessions.value.values());
if (sessionOrder.length > 0) {
// 按照用户自定义顺序排序
return sessionList
.sort((a, b) => {
const indexA = sessionOrder.indexOf(a.sessionId);
const indexB = sessionOrder.indexOf(b.sessionId);
if (indexA === -1 && indexB === -1) return a.createdAt - b.createdAt;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
const orderedSessions = sessionOrder.length > 0
? sessionList.sort((left, right) => {
const leftIndex = sessionOrder.indexOf(left.sessionId);
const rightIndex = sessionOrder.indexOf(right.sessionId);
if (leftIndex === -1 && rightIndex === -1) {
return left.createdAt - right.createdAt;
}
if (leftIndex === -1) {
return 1;
}
if (rightIndex === -1) {
return -1;
}
return leftIndex - rightIndex;
})
.map(session => ({
sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
isMarkedForSuspend: session.isMarkedForSuspend,
}));
} else {
// 如果没有自定义顺序,则按照创建时间排序
return sessionList
.sort((a, b) => a.createdAt - b.createdAt)
.map(session => ({
sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
isMarkedForSuspend: session.isMarkedForSuspend,
}));
}
: sessionList.sort((left, right) => left.createdAt - right.createdAt);
return orderedSessions.map((session) => ({
sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
status: session.wsManager.connectionStatus.value,
isMarkedForSuspend: session.isMarkedForSuspend,
isCommandRunning: session.isCommandRunning.value,
}));
});
export const activeSession = computed((): SessionState | null => {
if (!activeSessionId.value) return null;
if (!activeSessionId.value) {
return null;
}
return sessions.value.get(activeSessionId.value) || null;
});
+21 -32
View File
@@ -1,58 +1,47 @@
import type { Ref } from 'vue';
import type { FileTab as OriginalFileTab } from '../fileEditor.store';
import type { WsConnectionStatus } from '../../composables/useWebSocketConnection';
import type { DockerManagerInstance as OriginalDockerManagerInstance } from '../../composables/useDockerManager';
// 导入工厂函数仅用于通过 ReturnType 推导实例类型
// 这些导入仅用于类型推断,不在运行时使用
import type { FileTab as OriginalFileTab } from '../fileEditor.store';
import type { WsConnectionStatus } from '../../composables/useWebSocketConnection';
import type { DockerManagerInstance as OriginalDockerManagerInstance } from '../../composables/useDockerManager';
import type { createWebSocketConnectionManager } from '../../composables/useWebSocketConnection';
import type { createSftpActionsManager } from '../../composables/useSftpActions';
import type { createSshTerminalManager } from '../../composables/useSshTerminal';
import type { createStatusMonitorManager } from '../../composables/useStatusMonitor';
// 使用 ReturnType 定义其他管理器实例类型
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
export type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
export type SshTerminalInstance = ReturnType<typeof createSshTerminalManager>;
export type StatusMonitorInstance = ReturnType<typeof createStatusMonitorManager>;
// 为 DockerManagerInstance 创建一个本地类型别名,并导出它
export type DockerManagerInstance = OriginalDockerManagerInstance;
// 重新导出 FileTab 类型,使其可用于其他模块
export type FileTab = OriginalFileTab;
export interface SessionState {
sessionId: string;
connectionId: string; // 数据库中的连接 ID
connectionName: string; // 用于显示
terminalIndex: number; // 同一连接下的终端序号,从 1 开始
connectionId: string;
connectionName: string;
terminalIndex: number;
wsManager: WsManagerInstance;
sftpManagers: Map<string, SftpManagerInstance>; // 使用 Map 管理多个实例
sftpManagers: Map<string, SftpManagerInstance>;
terminalManager: SshTerminalInstance;
statusMonitorManager: StatusMonitorInstance;
dockerManager: DockerManagerInstance; // 现在应该可以找到 DockerManagerInstance
// --- 独立编辑器状态 ---
editorTabs: Ref<FileTab[]>; // 编辑器标签页列表
activeEditorTabId: Ref<string | null>; // 当前活动的编辑器标签页 ID
// --- 命令输入框内容 ---
commandInputContent: Ref<string>; // 当前会话的命令输入框内容
isResuming?: boolean; // 标记会话是否正在从挂起状态恢复
isMarkedForSuspend?: boolean; // +++ 标记会话是否已被用户请求标记为待挂起 +++
createdAt: number; // 记录会话创建的时间戳,用于排序
disposables?: (() => void)[]; // 用于存储清理函数,例如取消注册消息处理器
pendingOutput?: string[]; // 用于暂存恢复会话时,在终端实例准备好之前收到的输出
dockerManager: DockerManagerInstance;
editorTabs: Ref<FileTab[]>;
activeEditorTabId: Ref<string | null>;
commandInputContent: Ref<string>;
isCommandRunning: Ref<boolean>;
terminalInputBuffer: Ref<string>;
isResuming?: boolean;
isMarkedForSuspend?: boolean;
createdAt: number;
disposables?: (() => void)[];
pendingOutput?: string[];
}
// 为标签栏定义包含状态的类型
export interface SessionTabInfoWithStatus {
sessionId: string;
connectionId: string;
connectionName: string;
terminalIndex: number;
status: WsConnectionStatus; // 添加状态字段
isMarkedForSuspend?: boolean; // +++ 用于UI指示会话是否已标记待挂起 +++
status: WsConnectionStatus;
isMarkedForSuspend?: boolean;
isCommandRunning: boolean;
}