diff --git a/package-lock.json b/package-lock.json index 60210b3..a72d983 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1357,6 +1357,22 @@ "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", "license": "MIT" }, + "node_modules/@xterm/addon-search": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", + "integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT", + "peer": true + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -6369,6 +6385,7 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", "@simplewebauthn/browser": "^13.1.0", + "@xterm/addon-search": "^0.15.0", "axios": "^1.8.4", "monaco-editor": "^0.52.2", "pinia": "^3.0.2", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e067806..c761237 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -10,6 +10,7 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", "@simplewebauthn/browser": "^13.1.0", + "@xterm/addon-search": "^0.15.0", "axios": "^1.8.4", "monaco-editor": "^0.52.2", "pinia": "^3.0.2", diff --git a/packages/frontend/src/components/CommandInputBar.vue b/packages/frontend/src/components/CommandInputBar.vue index 9dbffe3..86623dd 100644 --- a/packages/frontend/src/components/CommandInputBar.vue +++ b/packages/frontend/src/components/CommandInputBar.vue @@ -1,34 +1,120 @@ @@ -36,41 +122,83 @@ const sendCommand = () => { .command-input-bar { display: flex; align-items: center; - padding: 5px 0px; - background-color: var(--app-bg-color); /* Use theme variable */ - min-height: 30px; /* 保证一定高度 */ + padding: 5px 10px; /* 增加左右 padding */ + background-color: var(--app-bg-color); + min-height: 30px; + gap: 10px; /* 在输入框和控件之间添加间隙 */ } .input-wrapper { - flex-grow: 1; /* 让输入框容器占据大部分空间 */ + flex-grow: 1; display: flex; - justify-content: center; /* 水平居中输入框 */ + align-items: center; /* 垂直居中对齐 */ background-color: transparent; + position: relative; /* 为了按钮定位 */ } -.command-input { +.command-input, +.search-input { padding: 6px 10px; - border: 1px solid var(--border-color); /* Use theme variable */ + border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.9em; - background-color: var(--app-bg-color); /* Use theme variable */ - color: var(--text-color); /* Use theme variable */ - width: 60%; /* 输入框宽度,可调整 */ - max-width: 800px; /* 最大宽度 */ + background-color: var(--input-bg-color, var(--app-bg-color)); /* Use specific or fallback theme variable */ + color: var(--text-color); + flex-grow: 1; /* 让输入框占据可用空间 */ outline: none; transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + margin-right: 5px; /* 与右侧控件保持距离 */ } -.command-input:focus { - border-color: var(--button-bg-color); /* Use theme variable */ - box-shadow: 0 0 5px var(--button-bg-color, #007bff); /* Use theme variable for glow */ +.command-input:focus, +.search-input:focus { + border-color: var(--button-bg-color); + box-shadow: 0 0 5px var(--button-bg-color, #007bff); } -/* 可以添加按钮样式 */ +.search-controls { + display: flex; + align-items: center; + gap: 5px; /* 控件之间的间隙 */ + background-color: var(--app-bg-color); /* 确保背景色一致 */ +} + +.icon-button { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-color); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; +} + +.icon-button:hover { + background-color: var(--hover-bg-color, #eee); /* Use theme variable */ +} + +.icon-button span { /* 临时 emoji 样式 */ + font-size: 1.1em; +} + +/* 实际使用图标库时可以这样设置大小 */ /* -.command-input-bar button { - margin-left: 10px; - padding: 6px 12px; +.icon-button svg { + width: 18px; + height: 18px; } */ + +.search-results { + font-size: 0.8em; + color: var(--text-secondary-color, #666); /* Use theme variable */ + margin-left: 5px; + white-space: nowrap; /* 防止换行 */ +} +.search-results.no-results { + color: var(--warning-color, #ffc107); /* Use theme variable */ +} diff --git a/packages/frontend/src/components/LayoutRenderer.vue b/packages/frontend/src/components/LayoutRenderer.vue index cb18f38..d0638ec 100644 --- a/packages/frontend/src/components/LayoutRenderer.vue +++ b/packages/frontend/src/components/LayoutRenderer.vue @@ -29,6 +29,7 @@ const props = defineProps({ type: String as PropType, default: null, }, + // Removed terminalManager prop definition }); // --- Emits --- @@ -47,7 +48,12 @@ const emit = defineEmits({ 'request-edit-connection': null, // (conn: any) // *** 修正:更新 terminal-ready 事件的 payload 类型 *** 'terminal-ready': (payload: { sessionId: string; terminal: any }) => // 使用 any 简化类型检查,或导入 Terminal - typeof payload === 'object' && typeof payload.sessionId === 'string' && typeof payload.terminal === 'object' + typeof payload === 'object' && typeof payload.sessionId === 'string' && typeof payload.terminal === 'object', + // *** 新增:声明搜索相关事件 *** + 'search': null, // (searchTerm: string) + 'find-next': null, // () + 'find-previous': null, // () + 'close-search': null, // () }); // --- Setup --- @@ -146,9 +152,15 @@ const componentProps = computed(() => { }; case 'commandBar': // CommandInputBar 需要转发 send-command 事件 + // searchResultCount 和 currentSearchResultIndex 将在模板中直接从 terminalManager 绑定 return { class: 'pane-content', onSendCommand: (command: string) => emit('sendCommand', command), + // 转发搜索事件 + onSearch: (term: string) => emit('search', term), + onFindNext: () => emit('find-next'), + onFindPrevious: () => emit('find-previous'), + onCloseSearch: () => emit('close-search'), }; case 'connections': // WorkspaceConnectionList 需要转发 connect-request 等事件 @@ -238,6 +250,10 @@ const handlePaneResize = (eventData: { panes: Array<{ size: number; [key: string emit('request-add-connection'); }" @request-edit-connection="emit('request-edit-connection', $event)" + @search="emit('search', $event)" + @find-next="emit('find-next')" + @find-previous="emit('find-previous')" + @close-search="emit('close-search')" /> @@ -309,6 +325,12 @@ const handlePaneResize = (eventData: { panes: Array<{ size: number; [key: string emit('request-add-connection'); }" /> + + (); const terminalRef = ref(null); // 终端容器的引用 let terminal: Terminal | null = null; let fitAddon: FitAddon | null = null; +let searchAddon: SearchAddon | null = null; // *** 添加 searchAddon 变量 *** let resizeObserver: ResizeObserver | null = null; let debounceTimer: number | null = null; // 用于防抖的计时器 ID // const fontSize = ref(14); // 移除本地字体大小状态,将由 store 管理 @@ -111,8 +115,10 @@ onMounted(() => { // 加载插件 fitAddon = new FitAddon(); + searchAddon = new SearchAddon(); // *** 创建 SearchAddon 实例 *** terminal.loadAddon(fitAddon); terminal.loadAddon(new WebLinksAddon()); + terminal.loadAddon(searchAddon); // *** 加载 SearchAddon *** // 将终端附加到 DOM terminal.open(terminalRef.value); @@ -199,11 +205,11 @@ onMounted(() => { } }, { immediate: true }); // 立即执行一次 watch - // 触发 ready 事件,传递 sessionId 和 terminal 实例 + // 触发 ready 事件,传递 sessionId, terminal 和 searchAddon 实例 if (terminal) { - emit('ready', { sessionId: props.sessionId, terminal: terminal }); + emit('ready', { sessionId: props.sessionId, terminal: terminal, searchAddon: searchAddon }); } - + // --- 监听外观变化 --- watch(currentTerminalTheme, (newTheme) => { if (terminal) { @@ -311,7 +317,28 @@ onBeforeUnmount(() => { const write = (data: string | Uint8Array) => { terminal?.write(data); }; -defineExpose({ write }); + +// *** 暴露搜索方法 *** +const findNext = (term: string, options?: ISearchOptions): boolean => { + if (searchAddon) { + return searchAddon.findNext(term, options); + } + return false; +}; + +const findPrevious = (term: string, options?: ISearchOptions): boolean => { + if (searchAddon) { + return searchAddon.findPrevious(term, options); + } + return false; +}; + +const clearSearch = () => { + searchAddon?.clearDecorations(); +}; + +defineExpose({ write, findNext, findPrevious, clearSearch }); + // --- 应用终端背景 --- const applyTerminalBackground = () => { diff --git a/packages/frontend/src/composables/useSshTerminal.ts b/packages/frontend/src/composables/useSshTerminal.ts index ff05810..8d13837 100644 --- a/packages/frontend/src/composables/useSshTerminal.ts +++ b/packages/frontend/src/composables/useSshTerminal.ts @@ -1,6 +1,7 @@ -import { ref, readonly, type Ref, ComputedRef } from 'vue'; // 修正导入,移除大写 Readonly +import { ref, readonly, type Ref, ComputedRef } from 'vue'; // import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入 import type { Terminal } from 'xterm'; +import type { SearchAddon, ISearchOptions } from '@xterm/addon-search'; // *** 移除 ISearchResult 导入 *** import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 定义与 WebSocket 相关的依赖接口 @@ -22,6 +23,10 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD const { sendMessage, onMessage, isConnected } = wsDeps; const terminalInstance = ref(null); + const searchAddon = ref(null); // Keep searchAddon ref + // Removed search result state refs + // const searchResultCount = ref(0); + // const currentSearchResultIndex = ref(-1); const terminalOutputBuffer = ref([]); // 缓冲 WebSocket 消息直到终端准备好 const isSshConnected = ref(false); // 新增:跟踪 SSH 连接状态 @@ -35,9 +40,38 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD // --- 终端事件处理 --- - const handleTerminalReady = (term: Terminal) => { - console.log(`[会话 ${sessionId}][SSH终端模块] 终端实例已就绪。`); + // *** 更新 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 实例 *** + + // *** 监听搜索结果变化 *** + if (searchAddon.value) { + // *** 移除错误的类型注解,让 TS 推断 *** + searchAddon.value.onDidChangeResults((results) => { + // *** 添加更详细的日志 *** + console.log(`[会话 ${sessionId}][SearchAddon] onDidChangeResults 事件触发! results:`, JSON.stringify(results)); // 使用 JSON.stringify 查看完整结构 + if (results && typeof results.resultIndex === 'number' && typeof results.resultCount === 'number') { + // 确认 results 包含预期的数字属性 + searchResultCount.value = results.resultCount; + currentSearchResultIndex.value = results.resultIndex; // xterm 的索引是从 0 开始的 + console.log(`[会话 ${sessionId}][SearchAddon] 状态已更新: index=${currentSearchResultIndex.value}, count=${searchResultCount.value}`); + } else { + // 没有结果、搜索被清除或 results 结构不符合预期 + console.log(`[会话 ${sessionId}][SearchAddon] 清除搜索状态或结果无效。 results:`, JSON.stringify(results)); // 使用 JSON.stringify 查看完整结构 + searchResultCount.value = 0; + currentSearchResultIndex.value = -1; + // console.log(`[会话 ${sessionId}][SearchAddon] 搜索结果清除或无匹配。`); // 这行日志有点重复,可以注释掉 + } + }); + // *** 添加确认日志 *** + console.log(`[会话 ${sessionId}][SearchAddon] onDidChangeResults 监听器已附加。`); + } else { + console.warn(`[会话 ${sessionId}][SearchAddon] 无法附加 onDidChangeResults 监听器,searchAddon 实例为空。`); + } + // --- 添加日志:检查缓冲区处理 --- console.log(`[会话 ${sessionId}][SSH前端] handleTerminalReady: 准备处理缓冲区,缓冲区长度: ${terminalOutputBuffer.value.length}`); if (terminalOutputBuffer.value.length > 0) { @@ -313,6 +347,44 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD 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; + } + 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.warn(`[会话 ${sessionId}][SSH终端模块] searchPrevious 调用失败,searchAddon 不可用。`); + // Removed state reset on failure + return false; + }; + + const clearTerminalSearch = () => { + if (searchAddon.value) { + console.log(`[会话 ${sessionId}][SSH终端模块] 清除搜索高亮。`); + searchAddon.value.clearDecorations(); + } + // Removed state reset + console.log(`[会话 ${sessionId}][SSH终端模块] 搜索高亮已清除 (状态不再管理)。`); + }; + + // 返回工厂实例 return { // 公共接口 @@ -321,9 +393,16 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD handleTerminalResize, sendData, // 新增:允许外部直接发送数据 cleanup, - // --- 新增暴露 --- + // --- 搜索方法 --- + searchNext, + searchPrevious, + clearTerminalSearch, + // --- 暴露状态 --- isSshConnected: readonly(isSshConnected), // 暴露 SSH 连接状态 (只读) - terminalInstance // 暴露 terminal 实例,以便 WorkspaceView 可以写入提示信息 + terminalInstance, // 暴露 terminal 实例,以便 WorkspaceView 可以写入提示信息 + // Removed search result state exposure + // searchResultCount: readonly(searchResultCount), + // currentSearchResultIndex: readonly(currentSearchResultIndex), }; } diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index d4cfc3a..d36ed4b 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -682,5 +682,14 @@ "errorCommandRequired": "Command cannot be empty", "add": "Add" } +}, +"commandInputBar": { + "placeholder": "Enter command here...", + "searchPlaceholder": "Search in terminal...", + "openSearch": "Open terminal search", + "closeSearch": "Close terminal search", + "findPrevious": "Find previous", + "findNext": "Find next", + "noResults": "No results" } } diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index df8ac65..abae0ae 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -687,5 +687,14 @@ "errorCommandRequired": "指令内容不能为空", "add": "添加" } +}, +"commandInputBar": { + "placeholder": "在此输入命令...", + "searchPlaceholder": "在终端中搜索...", + "openSearch": "打开终端搜索", + "closeSearch": "关闭终端搜索", + "findPrevious": "查找上一个", + "findNext": "查找下一个", + "noResults": "无结果" } } diff --git a/packages/frontend/src/views/WorkspaceView.vue b/packages/frontend/src/views/WorkspaceView.vue index 9d3da8f..6b281cf 100644 --- a/packages/frontend/src/views/WorkspaceView.vue +++ b/packages/frontend/src/views/WorkspaceView.vue @@ -14,6 +14,7 @@ import { useLayoutStore } from '../stores/layout.store'; import { useCommandHistoryStore } from '../stores/commandHistory.store'; import type { ConnectionInfo } from '../stores/connections.store'; import type { Terminal } from 'xterm'; // *** 导入 Terminal 类型 *** +import type { ISearchOptions } from '@xterm/addon-search'; // *** 导入搜索选项类型 *** // --- Setup --- const { t } = useI18n(); @@ -53,6 +54,9 @@ const showAddEditForm = ref(false); const connectionToEdit = ref(null); const showLayoutConfigurator = ref(false); // 控制布局配置器可见性 +// --- 搜索状态 --- +const currentSearchTerm = ref(''); // 当前搜索的关键词 + // --- 生命周期钩子 --- onMounted(() => { console.log('[工作区视图] 组件已挂载。'); @@ -163,14 +167,76 @@ onBeforeUnmount(() => { // 处理终端就绪 (用于 Terminal) // 注意:LayoutRenderer 内部的 Terminal 组件需要 emit('terminal-ready', payload) - const handleTerminalReady = (payload: { sessionId: string; terminal: Terminal }) => { // *** 修正:接收包含 sessionId 和 terminal 的 payload *** - console.log(`[工作区视图 ${payload.sessionId}] 收到 terminal-ready 事件。`); // 添加日志 - sessionStore.sessions.get(payload.sessionId)?.terminalManager.handleTerminalReady(payload.terminal); // *** 修正:传递 terminal 实例 *** - }; + // *** 修正:更新 payload 类型以包含 searchAddon *** + const handleTerminalReady = (payload: { sessionId: string; terminal: Terminal; searchAddon: any | null }) => { // 使用 any 避免导入 SearchAddon 类型 + console.log(`[工作区视图 ${payload.sessionId}] 收到 terminal-ready 事件。Payload:`, payload); // *** 添加 Payload 日志 *** + // *** 检查 payload 中 searchAddon 是否存在 *** + if (payload && payload.searchAddon) { + console.log(`[工作区视图 ${payload.sessionId}] Payload 包含 searchAddon 实例。`); + } else { + console.warn(`[工作区视图 ${payload.sessionId}] Payload 未包含 searchAddon 实例! Payload:`, payload); + } + // *** 修正:传递包含 terminal 和 searchAddon 的完整 payload *** + sessionStore.sessions.get(payload.sessionId)?.terminalManager.handleTerminalReady(payload); +}; +// --- 搜索事件处理 --- +const handleSearch = (term: string) => { + currentSearchTerm.value = term; + if (!term) { + // 如果搜索词为空,清除搜索 + handleCloseSearch(); + return; + } + console.log(`[WorkspaceView] Received search event: "${term}"`); + // 默认向前搜索 + handleFindNext(); +}; - // --- 编辑器操作处理 (用于 FileEditorContainer) --- - const handleCloseEditorTab = (tabId: string) => { +const handleFindNext = () => { + const manager = activeSession.value?.terminalManager; + if (manager && currentSearchTerm.value) { + console.log(`[WorkspaceView] Calling findNext for term: "${currentSearchTerm.value}"`); + const found = manager.searchNext(currentSearchTerm.value, { incremental: true }); + console.log(`[WorkspaceView] findNext returned: ${found}`); // 打印返回值 + if (!found) { + console.log(`[WorkspaceView] findNext: No more results for "${currentSearchTerm.value}"`); + // 可以添加 UI 提示,例如短暂高亮搜索框 + } + } else { + console.warn(`[WorkspaceView] Cannot findNext, no active session manager or search term.`); + } +}; + +const handleFindPrevious = () => { + const manager = activeSession.value?.terminalManager; + if (manager && currentSearchTerm.value) { + console.log(`[WorkspaceView] Calling findPrevious for term: "${currentSearchTerm.value}"`); + const found = manager.searchPrevious(currentSearchTerm.value, { incremental: true }); + console.log(`[WorkspaceView] findPrevious returned: ${found}`); // 打印返回值 + if (!found) { + console.log(`[WorkspaceView] findPrevious: No previous results for "${currentSearchTerm.value}"`); + // 可以添加 UI 提示 + } + } else { + console.warn(`[WorkspaceView] Cannot findPrevious, no active session manager or search term.`); + } +}; + +const handleCloseSearch = () => { + console.log(`[WorkspaceView] Received close-search event.`); + currentSearchTerm.value = ''; // 清空搜索词 + const manager = activeSession.value?.terminalManager; + if (manager) { + manager.clearTerminalSearch(); + } else { + console.warn(`[WorkspaceView] Cannot clear search, no active session manager.`); + } +}; + +// Removed computed properties for search results, will pass manager directly +// --- 编辑器操作处理 (用于 FileEditorContainer) --- +const handleCloseEditorTab = (tabId: string) => { const isShared = shareFileEditorTabsBoolean.value; console.log(`[WorkspaceView] handleCloseEditorTab: ${tabId}, Shared mode: ${isShared}`); if (isShared) { @@ -261,6 +327,7 @@ onBeforeUnmount(() => { class="layout-renderer-wrapper" :editor-tabs="editorTabs" :active-editor-tab-id="activeEditorTabId" + @send-command="handleSendCommand" @terminal-input="handleTerminalInput" @terminal-resize="handleTerminalResize" @@ -273,6 +340,10 @@ onBeforeUnmount(() => { @open-new-session="handleOpenNewSession" @request-add-connection="handleRequestAddConnection" @request-edit-connection="handleRequestEditConnection" + @search="handleSearch" + @find-next="handleFindNext" + @find-previous="handleFindPrevious" + @close-search="handleCloseSearch" >
{{ t('layout.loading', '加载布局中...') }}