diff --git a/package-lock.json b/package-lock.json index 7d96961..054782f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1825,6 +1825,12 @@ "vscode-uri": "^3.0.8" } }, + "node_modules/@vscode/iconv-lite-umd": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.0.tgz", + "integrity": "sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg==", + "license": "MIT" + }, "node_modules/@vue/compiler-core": { "version": "3.5.13", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", @@ -8847,11 +8853,14 @@ "@fortawesome/fontawesome-free": "^6.7.2", "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@tailwindcss/vite": "^4.1.4", + "@vscode/iconv-lite-umd": "^0.7.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "axios": "^1.8.4", + "buffer": "^6.0.3", "date-fns": "^4.1.0", "guacamole-common-js": "^1.5.0", + "iconv-lite": "^0.6.3", "monaco-editor": "^0.52.2", "pinia": "^3.0.2", "pinia-plugin-persistedstate": "^4.2.0", @@ -9266,6 +9275,30 @@ "node": ">=18" } }, + "packages/frontend/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "packages/frontend/node_modules/esbuild": { "version": "0.25.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", diff --git a/packages/backend/src/services/sftp.service.ts b/packages/backend/src/services/sftp.service.ts index 087572f..f35758d 100644 --- a/packages/backend/src/services/sftp.service.ts +++ b/packages/backend/src/services/sftp.service.ts @@ -194,15 +194,15 @@ export class SftpService { } } - /** 读取文件内容 */ - async readFile(sessionId: string, path: string, requestId: string): Promise { - const state = this.clientStates.get(sessionId); - if (!state || !state.sftp) { - console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 readFile (ID: ${requestId})`); - state?.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); - return; - } - console.debug(`[SFTP ${sessionId}] Received readFile request for ${path} (ID: ${requestId})`); + /** 读取文件内容 (支持指定编码) */ + async readFile(sessionId: string, path: string, requestId: string, requestedEncoding?: string): Promise { + const state = this.clientStates.get(sessionId); + if (!state || !state.sftp) { + console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 readFile (ID: ${requestId})`); + state?.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); + return; + } + console.debug(`[SFTP ${sessionId}] Received readFile request for ${path} (ID: ${requestId}, Requested Encoding: ${requestedEncoding ?? 'auto'})`); try { const readStream = state.sftp.createReadStream(path); let fileData = Buffer.alloc(0); @@ -215,74 +215,122 @@ export class SftpService { state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `读取文件流错误: ${err.message}`, requestId: requestId })); }); readStream.on('end', () => { - if (!errorOccurred) { - console.log(`[SFTP ${sessionId}] readFile ${path} success, size: ${fileData.length} bytes (ID: ${requestId}). Detecting encoding...`); - let contentUtf8: string; - try { - // 1. Detect encoding + if (errorOccurred) return; + + console.log(`[SFTP ${sessionId}] readFile ${path} success, size: ${fileData.length} bytes (ID: ${requestId}). Processing content...`); + let encodingUsed: string = 'utf-8'; // Default encoding + let decodedContent: string = ''; + let decodeError: string | null = null; + + try { + if (requestedEncoding) { + // 用户指定了编码 + encodingUsed = requestedEncoding; + console.log(`[SFTP ${sessionId}] Using requested encoding: ${encodingUsed} (ID: ${requestId})`); + const normalizedEncoding = encodingUsed.toLowerCase().replace(/[^a-z0-9]/g, ''); // Normalize more aggressively + if (iconv.encodingExists(normalizedEncoding)) { + decodedContent = iconv.decode(fileData, normalizedEncoding); + encodingUsed = normalizedEncoding; // Use the normalized name if valid + } else { + console.warn(`[SFTP ${sessionId}] Requested encoding "${requestedEncoding}" is not supported by iconv-lite. Falling back to UTF-8. (ID: ${requestId})`); + encodingUsed = 'utf-8'; // Fallback + decodedContent = iconv.decode(fileData, encodingUsed); + // Optionally add a warning? + } + } else { + // 自动检测编码 + console.log(`[SFTP ${sessionId}] Detecting encoding for ${path} (ID: ${requestId})`); const detection = jschardet.detect(fileData); - const detectedEncoding = detection.encoding.toLowerCase(); - const confidence = detection.confidence; - console.log(`[SFTP ${sessionId}] Detected encoding for ${path}: ${detectedEncoding} (confidence: ${confidence})`); + const detectedEncodingRaw = detection.encoding ? detection.encoding.toLowerCase() : 'utf-8'; // Default to utf-8 if detection fails + const confidence = detection.confidence || 0; + console.log(`[SFTP ${sessionId}] Detected encoding: ${detectedEncodingRaw} (confidence: ${confidence})`); - // 2. Decode to UTF-8 with improved logic for low confidence and Chinese encodings - const chineseEncodings = ['gbk', 'gb2312', 'gb18030', 'big5', 'euc-tw']; // Common Chinese/Taiwanese encodings + const chineseEncodings = ['gbk', 'gb2312', 'gb18030', 'big5', 'euc-tw']; + let normalizedDetected = detectedEncodingRaw.replace(/[^a-z0-9]/g, ''); + if (normalizedDetected === 'windows1252') normalizedDetected = 'cp1252'; + else if (normalizedDetected === 'gb2312') normalizedDetected = 'gbk'; // Prefer gbk - if (detectedEncoding === 'utf-8' || detectedEncoding === 'ascii') { - contentUtf8 = fileData.toString('utf8'); + if (normalizedDetected === 'utf8' || normalizedDetected === 'ascii') { + encodingUsed = 'utf-8'; + decodedContent = fileData.toString('utf8'); console.log(`[SFTP ${sessionId}] Decoded ${path} as UTF-8/ASCII.`); - } else if (chineseEncodings.includes(detectedEncoding)) { + } else if (chineseEncodings.includes(normalizedDetected)) { // If detected as a common Chinese encoding, trust it and use gb18030 for broader compatibility - contentUtf8 = iconv.decode(fileData, 'gb18030'); - console.log(`[SFTP ${sessionId}] Decoded ${path} from detected Chinese encoding (${detectedEncoding}) as gb18030.`); - } else if (confidence < 0.90) { // Low confidence threshold (adjustable, e.g., 0.90 or 0.85) - console.warn(`[SFTP ${sessionId}] Low confidence detection (${detectedEncoding}, ${confidence}) for ${path}. Attempting GB18030 decode first.`); + encodingUsed = 'gb18030'; // Report gb18030 as used + decodedContent = iconv.decode(fileData, encodingUsed); + console.log(`[SFTP ${sessionId}] Decoded ${path} from detected Chinese encoding (${normalizedDetected}) as ${encodingUsed}.`); + } else if (confidence < 0.90) { // Low confidence threshold + console.warn(`[SFTP ${sessionId}] Low confidence detection (${normalizedDetected}, ${confidence}) for ${path}. Attempting GB18030 decode first.`); try { - // Try decoding as GB18030 first for low confidence cases, common for Chinese Windows ANSI - contentUtf8 = iconv.decode(fileData, 'gb18030'); - // Basic check for Mojibake (presence of replacement char � U+FFFD) - if (contentUtf8.includes('\uFFFD')) { - console.warn(`[SFTP ${sessionId}] GB18030 decoding resulted in replacement characters. Falling back to original detection (${detectedEncoding}) or UTF-8.`); + // Try decoding as GB18030 first + const tempContent = iconv.decode(fileData, 'gb18030'); + // Basic check for Mojibake + if (tempContent.includes('\uFFFD')) { + console.warn(`[SFTP ${sessionId}] GB18030 decoding resulted in replacement characters. Falling back to original detection (${normalizedDetected}) or UTF-8.`); // Fallback: Try the originally detected encoding if supported, otherwise UTF-8 - if (iconv.encodingExists(detectedEncoding)) { - contentUtf8 = iconv.decode(fileData, detectedEncoding); - console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as originally detected ${detectedEncoding}.`); + if (iconv.encodingExists(normalizedDetected)) { + encodingUsed = normalizedDetected; + decodedContent = iconv.decode(fileData, encodingUsed); + console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as originally detected ${encodingUsed}.`); } else { - contentUtf8 = fileData.toString('utf8'); + encodingUsed = 'utf-8'; + decodedContent = fileData.toString('utf8'); console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as UTF-8.`); } } else { - console.log(`[SFTP ${sessionId}] Decoded ${path} as GB18030 due to low confidence detection.`); + encodingUsed = 'gb18030'; // Success with GB18030 + decodedContent = tempContent; + console.log(`[SFTP ${sessionId}] Decoded ${path} as ${encodingUsed} due to low confidence detection.`); } } catch (gbkError) { - console.warn(`[SFTP ${sessionId}] Error decoding as GB18030, falling back to original detection (${detectedEncoding}) or UTF-8:`, gbkError); + console.warn(`[SFTP ${sessionId}] Error decoding as GB18030, falling back to original detection (${normalizedDetected}) or UTF-8:`, gbkError); // Fallback: Try the originally detected encoding if supported, otherwise UTF-8 - if (iconv.encodingExists(detectedEncoding)) { - contentUtf8 = iconv.decode(fileData, detectedEncoding); - console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as originally detected ${detectedEncoding}.`); + if (iconv.encodingExists(normalizedDetected)) { + encodingUsed = normalizedDetected; + decodedContent = iconv.decode(fileData, encodingUsed); + console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as originally detected ${encodingUsed}.`); } else { - contentUtf8 = fileData.toString('utf8'); + encodingUsed = 'utf-8'; + decodedContent = fileData.toString('utf8'); console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as UTF-8.`); } } - } else if (iconv.encodingExists(detectedEncoding)) { + } else if (iconv.encodingExists(normalizedDetected)) { // Higher confidence, non-Chinese, supported encoding - contentUtf8 = iconv.decode(fileData, detectedEncoding); - console.log(`[SFTP ${sessionId}] Decoded ${path} from ${detectedEncoding} to UTF-8 using iconv-lite (high confidence).`); + encodingUsed = normalizedDetected; + decodedContent = iconv.decode(fileData, encodingUsed); + console.log(`[SFTP ${sessionId}] Decoded ${path} from ${encodingUsed} using iconv-lite (high confidence).`); } else { - console.warn(`[SFTP ${sessionId}] Unsupported or unknown encoding detected for ${path}: ${detectedEncoding}. Falling back to UTF-8.`); - contentUtf8 = fileData.toString('utf8'); // Final fallback + console.warn(`[SFTP ${sessionId}] Unsupported or unknown encoding detected for ${path}: ${normalizedDetected}. Falling back to UTF-8.`); + encodingUsed = 'utf-8'; // Final fallback + decodedContent = fileData.toString('utf8'); } - } catch (decodeError: any) { - console.error(`[SFTP ${sessionId}] Error detecting/decoding file ${path} (ID: ${requestId}):`, decodeError); - // Send error if decoding fails - state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `文件编码检测或转换失败: ${decodeError.message}`, requestId: requestId })); - return; // Stop further processing } - // 3. Send UTF-8 content to frontend - state.ws.send(JSON.stringify({ type: 'sftp:readfile:success', path: path, payload: { content: contentUtf8 }, requestId: requestId })); // Send UTF-8 string directly + // Final check for replacement characters after deciding the encoding + if (decodedContent.includes('\uFFFD')) { + console.warn(`[SFTP ${sessionId}] Final decoded content for ${path} (using ${encodingUsed}) contains replacement characters (U+FFFD). Decoding might be incorrect. (ID: ${requestId})`); + // decodeError = `解码内容可能不正确 (使用 ${encodingUsed}),检测到无效字符。`; // Optionally set error + } + + } catch (err: any) { + console.error(`[SFTP ${sessionId}] Error detecting/decoding file content for ${path} (ID: ${requestId}):`, err); + decodeError = `文件编码检测或转换失败: ${err.message}`; + state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: decodeError, requestId: requestId })); + return; // Stop processing } + + // 发送 Base64 编码的原始数据和实际使用的编码 + console.log(`[SFTP ${sessionId}] Sending raw content (Base64) and encoding used (${encodingUsed}) for ${path} (ID: ${requestId})`); + state.ws.send(JSON.stringify({ + type: 'sftp:readfile:success', + path: path, + payload: { + rawContentBase64: fileData.toString('base64'), // 发送 Base64 字符串 + encodingUsed: encodingUsed // 发送实际使用的编码 + }, + requestId: requestId + })); }); } catch (error: any) { console.error(`[SFTP ${sessionId}] readFile ${path} caught unexpected error (ID: ${requestId}):`, error); @@ -290,17 +338,30 @@ export class SftpService { } } - /** 写入文件内容 */ - async writefile(sessionId: string, path: string, data: string, requestId: string): Promise { + /** 写入文件内容 (支持指定编码) */ + // --- 修改:添加 encoding 参数 --- + async writefile(sessionId: string, path: string, data: string, requestId: string, encoding?: string): Promise { const state = this.clientStates.get(sessionId); if (!state || !state.sftp) { console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 writefile (ID: ${requestId})`); state?.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); return; } - console.debug(`[SFTP ${sessionId}] Received writefile request for ${path} (ID: ${requestId})`); + // --- 修改:使用传入的 encoding 或默认 utf-8 --- + const targetEncoding = encoding || 'utf-8'; + console.debug(`[SFTP ${sessionId}] Received writefile request for ${path} (ID: ${requestId}, Encoding: ${targetEncoding})`); try { - const buffer = Buffer.from(data, 'utf8'); + // --- 修改:使用 iconv-lite 根据指定编码创建 Buffer --- + let buffer: Buffer; + try { + buffer = iconv.encode(data, targetEncoding); + console.log(`[SFTP ${sessionId}] Encoded content for ${path} using ${targetEncoding} (Buffer size: ${buffer.length})`); + } catch (encodeError: any) { + console.error(`[SFTP ${sessionId}] Failed to encode content for ${path} with encoding ${targetEncoding} (ID: ${requestId}):`, encodeError); + state.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: `无效的编码或编码失败: ${targetEncoding}`, requestId: requestId })); + return; + } + console.debug(`[SFTP ${sessionId}] Creating write stream for ${path} (ID: ${requestId})`); const writeStream = state.sftp.createWriteStream(path); let errorOccurred = false; diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 47255ad..948197a 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -1080,16 +1080,26 @@ connectionName: connInfo?.name || 'Unknown', // 添加连接名称 (使用可选 else throw new Error("Missing 'path' in payload for stat"); break; case 'sftp:readfile': - if (payload?.path) sftpService.readFile(sessionId, payload.path, requestId); - else throw new Error("Missing 'path' in payload for readfile"); + // --- 修改:提取并传递可选的 encoding 参数 --- + if (payload?.path) { + const requestedEncoding = payload?.encoding; // 获取可选的 encoding + sftpService.readFile(sessionId, payload.path, requestId, requestedEncoding); // 传递给 service 方法 + } else { + throw new Error("Missing 'path' in payload for readfile"); + } break; + // --- 结束修改 --- case 'sftp:writefile': const fileContent = payload?.content ?? payload?.data ?? ''; + // --- 修改:提取可选的 encoding 参数 --- + const encoding = payload?.encoding; // 获取可选的 encoding if (payload?.path) { const dataToSend = (typeof fileContent === 'string') ? fileContent : ''; - sftpService.writefile(sessionId, payload.path, dataToSend, requestId); + // --- 修改:将 encoding 传递给 service 方法 --- + sftpService.writefile(sessionId, payload.path, dataToSend, requestId, encoding); } else throw new Error("Missing 'path' in payload for writefile"); break; + // --- 结束修改 --- case 'sftp:mkdir': if (payload?.path) sftpService.mkdir(sessionId, payload.path, requestId); else throw new Error("Missing 'path' in payload for mkdir"); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 2119bcc..c184423 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -12,10 +12,14 @@ "@fortawesome/fontawesome-free": "^6.7.2", "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@tailwindcss/vite": "^4.1.4", + "@vscode/iconv-lite-umd": "^0.7.0", + "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "axios": "^1.8.4", + "buffer": "^6.0.3", "date-fns": "^4.1.0", "guacamole-common-js": "^1.5.0", + "iconv-lite": "^0.6.3", "monaco-editor": "^0.52.2", "pinia": "^3.0.2", "pinia-plugin-persistedstate": "^4.2.0", @@ -28,7 +32,6 @@ "vue3-recaptcha2": "^1.8.0", "vuedraggable": "^4.1.0", "xterm": "^5.3.0", - "@xterm/addon-fit": "^0.10.0", "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { @@ -40,4 +43,3 @@ "vue-tsc": "^2.2.8" } } - diff --git a/packages/frontend/src/components/FileEditorContainer.vue b/packages/frontend/src/components/FileEditorContainer.vue index 3d0b366..46fa210 100644 --- a/packages/frontend/src/components/FileEditorContainer.vue +++ b/packages/frontend/src/components/FileEditorContainer.vue @@ -33,6 +33,7 @@ const emit = defineEmits<{ (e: 'close-tab', tabId: string): void; (e: 'request-save', tabId: string): void; // 发送保存请求,携带 tabId (e: 'update:content', payload: { tabId: string; content: string }): void; // 用于 v-model 同步 + (e: 'change-encoding', payload: { tabId: string; encoding: string }): void; // +++ 新增:编码更改事件 +++ }>(); @@ -51,14 +52,7 @@ watch(activeTab, (newTab) => { localEditorContent.value = newTab?.content ?? ''; }, { immediate: true }); -// 监听 activeTab 内容的变化 (处理异步加载完成的情况) -watch(() => activeTab.value?.content, (newContent) => { - // console.log('[EditorContainer] Active tab content changed, updating local content.'); - if (localEditorContent.value !== newContent) { - localEditorContent.value = newContent ?? ''; - } -}); - +// 移除用于调试的 watch 函数 // 当本地编辑器内容变化时,通知父组件 (WorkspaceView) watch(localEditorContent, (newContent) => { // console.log('[EditorContainer] Local content changed, checking if emit needed.'); @@ -82,6 +76,61 @@ const currentTabSaveError = computed(() => activeTab.value?.saveError ?? null); const currentTabLanguage = computed(() => activeTab.value?.language ?? 'plaintext'); const currentTabFilePath = computed(() => activeTab.value?.filePath ?? ''); const currentTabIsModified = computed(() => activeTab.value?.isModified ?? false); // 用于显示修改状态 +// +++ 新增:计算当前选择的编码 +++ +const currentSelectedEncoding = computed(() => activeTab.value?.selectedEncoding ?? 'utf-8'); + +// +++ 新增:编码选项 +++ +// 注意:这里的 value 需要与 iconv-lite 支持的标签匹配 (后端使用) +// 扩展编码列表以包含更多常用选项 +const encodingOptions = ref([ + // Unicode + { value: 'utf-8', text: 'UTF-8' }, + { value: 'utf-16le', text: 'UTF-16 LE' }, + { value: 'utf-16be', text: 'UTF-16 BE' }, + // Chinese + { value: 'gbk', text: 'GBK' }, + { value: 'gb18030', text: 'GB18030' }, + { value: 'big5', text: 'Big5 (Traditional Chinese)' }, + // Japanese + { value: 'shift_jis', text: 'Shift-JIS' }, + { value: 'euc-jp', text: 'EUC-JP' }, + // Korean + { value: 'euc-kr', text: 'EUC-KR' }, + // Western European + { value: 'iso-8859-1', text: 'ISO-8859-1 (Latin-1)' }, + { value: 'iso-8859-15', text: 'ISO-8859-15 (Latin-9)' }, + { value: 'cp1252', text: 'Windows-1252' }, // Western European + // Central European + { value: 'iso-8859-2', text: 'ISO-8859-2 (Latin-2)' }, + { value: 'cp1250', text: 'Windows-1250' }, // Central European + // Cyrillic + { value: 'iso-8859-5', text: 'ISO-8859-5 (Cyrillic)' }, + { value: 'cp1251', text: 'Windows-1251 (Cyrillic)' }, + { value: 'koi8-r', text: 'KOI8-R' }, + { value: 'koi8-u', text: 'KOI8-U' }, + // Greek + { value: 'iso-8859-7', text: 'ISO-8859-7 (Greek)' }, + { value: 'cp1253', text: 'Windows-1253 (Greek)' }, + // Turkish + { value: 'iso-8859-9', text: 'ISO-8859-9 (Turkish)' }, + { value: 'cp1254', text: 'Windows-1254 (Turkish)' }, + // Hebrew + { value: 'iso-8859-8', text: 'ISO-8859-8 (Hebrew)' }, + { value: 'cp1255', text: 'Windows-1255 (Hebrew)' }, + // Arabic + { value: 'iso-8859-6', text: 'ISO-8859-6 (Arabic)' }, + { value: 'cp1256', text: 'Windows-1256 (Arabic)' }, + // Baltic + { value: 'iso-8859-4', text: 'ISO-8859-4 (Baltic)' }, // Latin-4 + { value: 'iso-8859-13', text: 'ISO-8859-13 (Baltic)' }, // Latin-7 + { value: 'cp1257', text: 'Windows-1257 (Baltic)' }, + // Vietnamese + { value: 'cp1258', text: 'Windows-1258 (Vietnamese)' }, + // Thai + { value: 'tis-620', text: 'TIS-620 (Thai)' }, // Often cp874 + { value: 'cp874', text: 'Windows-874 (Thai)' }, +]); + // --- 事件处理 --- const handleSaveRequest = () => { @@ -90,6 +139,17 @@ const handleSaveRequest = () => { } }; +// +++ 新增:处理编码更改事件 +++ +const handleEncodingChange = (event: Event) => { + const target = event.target as HTMLSelectElement; + const newEncoding = target.value; + if (activeTab.value && newEncoding && newEncoding !== currentSelectedEncoding.value) { + console.log(`[EditorContainer] Encoding changed to ${newEncoding} for tab ${activeTab.value.id}`); + emit('change-encoding', { tabId: activeTab.value.id, encoding: newEncoding }); + } +}; + + // 注意:关闭/最小化按钮现在应该在 WorkspaceView 控制 Pane,而不是这里 // const handleCloseContainer = () => { ... }; // const handleMinimizeContainer = () => { ... }; @@ -180,6 +240,21 @@ const handleKeyDown = (event: KeyboardEvent) => { *
+ + + {{ t('fileManager.loadingEncoding', '加载中...') }} + + {{ t('fileManager.saving') }}... ✅ {{ t('fileManager.saveSuccess') }} ❌ {{ t('fileManager.saveError') }}: {{ currentTabSaveError }} @@ -276,7 +351,7 @@ const handleKeyDown = (event: KeyboardEvent) => { .editor-actions { display: flex; align-items: center; - gap: 1rem; + gap: 0.8rem; /* 稍微减小间距以容纳下拉菜单 */ } .save-btn { @@ -306,3 +381,34 @@ const handleKeyDown = (event: KeyboardEvent) => { min-height: 0; } + + + diff --git a/packages/frontend/src/components/LayoutRenderer.vue b/packages/frontend/src/components/LayoutRenderer.vue index 29a5a9f..18772b6 100644 --- a/packages/frontend/src/components/LayoutRenderer.vue +++ b/packages/frontend/src/components/LayoutRenderer.vue @@ -65,6 +65,7 @@ const emit = defineEmits({ 'find-previous': null, // () 'close-search': null, // () 'clear-terminal': null, // () +++ 添加 clear-terminal 事件 +++ + 'change-encoding': null, // +++ 添加 change-encoding 事件 +++ // --- 移除 RDP 事件 --- }); @@ -203,6 +204,8 @@ const componentProps = computed(() => { onActivateTab: (tabId: string) => emit('activateEditorTab', tabId), 'onUpdate:content': (payload: { tabId: string; content: string }) => emit('updateEditorContent', payload), // 注意事件名 onRequestSave: (tabId: string) => emit('saveEditorTab', tabId), + // +++ 添加:转发 change-encoding 事件 +++ + onChangeEncoding: (payload: { tabId: string; encoding: string }) => emit('change-encoding', payload), }; case 'commandBar': // CommandInputBar 需要转发 send-command 事件 @@ -510,6 +513,7 @@ onMounted(() => { @find-previous="emit('find-previous')" @close-search="emit('close-search')" @clear-terminal="() => emit('clear-terminal')" + @change-encoding="emit('change-encoding', $event)" class="flex-grow overflow-auto" /> diff --git a/packages/frontend/src/composables/useSftpActions.ts b/packages/frontend/src/composables/useSftpActions.ts index 1ff8915..ff2f67a 100644 --- a/packages/frontend/src/composables/useSftpActions.ts +++ b/packages/frontend/src/composables/useSftpActions.ts @@ -1,5 +1,5 @@ import { ref, readonly, reactive, computed, type Ref, type ComputedRef } from 'vue'; // 引入 reactive 和 computed -import type { FileListItem, FileAttributes, EditorFileContent } from '../types/sftp.types'; // 修正导入为 FileAttributes +import type { FileListItem, FileAttributes, EditorFileContent, SftpReadFileSuccessPayload, SftpReadFileRequestPayload } from '../types/sftp.types'; // +++ 添加 SftpReadFileRequestPayload 导入 +++ import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types'; // 导入 UI 通知 store import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // 更正导入 @@ -307,11 +307,12 @@ export function createSftpActionsManager( }; // readFile 和 writeFile 仍然返回 Promise,并在内部处理自己的消息监听器注销 - const readFile = (path: string): Promise => { + // --- 修改:接受可选 encoding 参数,返回包含 content 和 encodingUsed 的 Payload --- + const readFile = (path: string, encoding?: string): Promise => { return new Promise((resolve, reject) => { if (!isSftpReady.value) { const errMsg = t('fileManager.errors.sftpNotReady'); - console.warn(`[SFTP ${instanceSessionId}] 尝试读取文件 ${path} 但 SFTP 未就绪。`); // 日志改为中文 + console.warn(`[SFTP ${instanceSessionId}] 尝试读取文件 ${path} 但 SFTP 未就绪。`); uiNotificationsStore.showError(errMsg); return reject(new Error(errMsg)); } @@ -328,34 +329,40 @@ export function createSftpActionsManager( }, 20000); // 20 秒超时 unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => { - // 确保 payload 是期望的类型 - const successPayload = payload as { content: string; encoding: 'utf8' | 'base64' }; + // +++ 修改:处理包含 rawContentBase64 和 encodingUsed 的新 payload +++ + const successPayload = payload as SftpReadFileSuccessPayload; // Type assertion remains valid if (message.requestId === requestId && message.path === path) { clearTimeout(timeoutId); unregisterSuccess?.(); unregisterError?.(); - resolve({ content: successPayload.content, encoding: successPayload.encoding }); + // Resolve with the new payload structure + resolve({ rawContentBase64: successPayload.rawContentBase64, encodingUsed: successPayload.encodingUsed }); } }); unregisterError = onMessage('sftp:readfile:error', (payload: MessagePayload, message: WebSocketMessage) => { - // 确保 payload 是期望的类型 (string) const errorPayload = payload as string; if (message.requestId === requestId && message.path === path) { clearTimeout(timeoutId); unregisterSuccess?.(); unregisterError?.(); - const errorMsg = errorPayload || t('fileManager.errors.readFileFailed'); // 使用 i18n + const errorMsg = errorPayload || t('fileManager.errors.readFileFailed'); uiNotificationsStore.showError(`${t('fileManager.errors.readFileError')}: ${errorMsg}`); reject(new Error(errorMsg)); } }); - sendMessage({ type: 'sftp:readfile', requestId: requestId, payload: { path } }); + // --- 修改:在 payload 中包含可选的 encoding --- + const requestPayload: SftpReadFileRequestPayload = { path }; + if (encoding) { + requestPayload.encoding = encoding; + } + sendMessage({ type: 'sftp:readfile', requestId: requestId, payload: requestPayload }); }); }; - const writeFile = (path: string, content: string): Promise => { + // --- 修改:接受可选 encoding 参数 --- + const writeFile = (path: string, content: string, encoding?: string): Promise => { return new Promise((resolve, reject) => { if (!isSftpReady.value) { const errMsg = t('fileManager.errors.sftpNotReady'); @@ -364,7 +371,8 @@ export function createSftpActionsManager( return reject(new Error(errMsg)); } const requestId = generateRequestId(); - const encoding: 'utf8' | 'base64' = 'utf8'; // 假设总是 utf8 + // --- 修改:使用传入的 encoding,默认为 utf8 --- + const finalEncoding = encoding || 'utf8'; let unregisterSuccess: (() => void) | null = null; let unregisterError: (() => void) | null = null; @@ -401,7 +409,8 @@ export function createSftpActionsManager( sendMessage({ type: 'sftp:writefile', requestId: requestId, - payload: { path, content, encoding } + // --- 修改:在 payload 中包含最终的编码 --- + payload: { path, content, encoding: finalEncoding } }); }); }; diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 32e9939..e5ce733 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -333,7 +333,9 @@ "noOpenFile": "No file open", "selectFileToEdit": "Select a file from the file manager to start editing.", "searchPlaceholder": "Search files...", - "dropFilesHere": "Drop files here to upload" + "dropFilesHere": "Drop files here to upload", + "changeEncodingTooltip": "Change file encoding", + "loadingEncoding": "Loading..." }, "statusMonitor": { "title": "Server Status", diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 9f74caa..8a9d6ad 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -327,7 +327,9 @@ "uploadTasks": "アップロードタスク", "warnings": { "moveSameDirectory": "同じディレクトリ内で切り取りと貼り付けはできません。" - } + }, + "changeEncodingTooltip": "ファイルエンコーディングを変更", + "loadingEncoding": "読み込み中..." }, "focusSwitcher": { "allInputsConfigured": "すべての利用可能な入力ソースが設定されました", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index a31c77c..2352fec 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -333,7 +333,9 @@ "noOpenFile": "未打开文件", "selectFileToEdit": "请从文件管理器中选择文件以开始编辑。", "searchPlaceholder": "搜索文件...", - "dropFilesHere": "将文件拖拽到此处上传" + "dropFilesHere": "将文件拖拽到此处上传", + "changeEncodingTooltip": "更改文件编码", + "loadingEncoding": "加载中..." }, "statusMonitor": { "title": "服务器状态", diff --git a/packages/frontend/src/stores/fileEditor.store.ts b/packages/frontend/src/stores/fileEditor.store.ts index 5a5262d..c339fa6 100644 --- a/packages/frontend/src/stores/fileEditor.store.ts +++ b/packages/frontend/src/stores/fileEditor.store.ts @@ -2,7 +2,9 @@ import { ref, computed, readonly, watch, nextTick } from 'vue'; // Import nextTi import { defineStore } from 'pinia'; import { useI18n } from 'vue-i18n'; import { useSessionStore } from './session.store'; // 导入会话 Store -import type { EditorFileContent, SaveStatus } from '../types/sftp.types'; // 保持导入 SaveStatus +import type { SaveStatus, SftpReadFileSuccessPayload } from '../types/sftp.types'; // 移除 SftpReadFileRequestPayload, 因为 readFile 不再需要它 +import * as iconv from '@vscode/iconv-lite-umd'; // +++ 导入 iconv-lite +++ +import { Buffer } from 'buffer/'; // +++ 导入 Buffer (需要安装 buffer 依赖) +++ // --- 类型定义 --- // 文件信息,用于打开文件操作 @@ -12,24 +14,23 @@ export interface FileInfo { } // 编辑器标签页状态 +// 编辑器标签页状态 (简化) export interface FileTab { - id: string; // 唯一标识符,例如 `${sessionId}:${filePath}` + id: string; sessionId: string; filePath: string; - filename: string; // 文件名,用于标签显示 - content: string; // 当前编辑器内容 - originalContent: string; // 加载或上次保存时的内容 + filename: string; + content: string; // 当前解码后的内容 (前端解码) + originalContent: string; // 初始加载或上次保存时解码后的内容 (前端解码) + rawContentBase64: string | null; // +++ 新增:存储原始 Base64 数据 +++ language: string; - encoding: 'utf8' | 'base64'; // 原始编码 + selectedEncoding: string; // 当前选择或自动检测到的编码 isLoading: boolean; loadingError: string | null; isSaving: boolean; saveStatus: SaveStatus; saveError: string | null; - isModified: boolean; // 内容是否已修改 - // 添加 sessionId 以便在共享模式下区分来源 (虽然此 store 主要用于共享模式) - // 或者在独立模式下,此 store 可能不被使用或以不同方式使用 - // sessionId: string; // 暂时不加,因为 session.store 已处理独立模式 + isModified: boolean; } // --- 辅助函数 (移到外部并导出) --- @@ -68,6 +69,33 @@ export const getLanguageFromFilename = (filename: string): string => { export const getFilenameFromPath = (filePath: string): string => { return filePath.split('/').pop() || filePath; }; + +// +++ 新增:前端解码辅助函数 +++ +const decodeRawContent = (rawContentBase64: string, encoding: string): string => { + try { + const buffer = Buffer.from(rawContentBase64, 'base64'); + const normalizedEncoding = encoding.toLowerCase().replace(/[^a-z0-9]/g, ''); // Normalize encoding name + + // 优先使用 TextDecoder 处理标准编码 + if (['utf8', 'utf16le', 'utf16be'].includes(normalizedEncoding)) { + const decoder = new TextDecoder(encoding); // Use original encoding name for TextDecoder + return decoder.decode(buffer); + } + // 使用 iconv-lite 处理其他编码 + else if (iconv.encodingExists(normalizedEncoding)) { + return iconv.decode(buffer, normalizedEncoding); + } + // 如果 iconv-lite 也不支持,回退到 UTF-8 并警告 + else { + console.warn(`[decodeRawContent] Unsupported encoding "${encoding}" requested. Falling back to UTF-8.`); + const decoder = new TextDecoder('utf-8'); + return decoder.decode(buffer); + } + } catch (error: any) { + console.error(`[decodeRawContent] Error decoding content with encoding "${encoding}":`, error); + return `// Error decoding content: ${error.message}`; // 返回错误信息 + } +}; // --- End Helper Functions --- export const useFileEditorStore = defineStore('fileEditor', () => { @@ -98,6 +126,9 @@ export const useFileEditorStore = defineStore('fileEditor', () => { }, }); + // --- 移除 decodeBase64Content 辅助方法 --- + + // --- 核心方法 --- // 修改:triggerPopup 接收文件信息并存储 @@ -135,23 +166,23 @@ export const useFileEditorStore = defineStore('fileEditor', () => { return; } - // 创建新标签页 + // 创建新标签页 (使用简化后的 FileTab) const newTab: FileTab = { id: tabId, sessionId: sessionId, filePath: targetFilePath, filename: getFilenameFromPath(targetFilePath), - content: '', // 初始为空 - originalContent: '', // 初始为空 + content: '', // 将在加载后由前端解码填充 + originalContent: '', // 将在加载后由前端解码填充 + rawContentBase64: null, // +++ 初始化为 null +++ language: getLanguageFromFilename(targetFilePath), - encoding: 'utf8', // 默认为 utf8 - isLoading: true, // 开始加载 + selectedEncoding: 'utf-8', // 初始默认,将由后端更新 + isLoading: true, loadingError: null, isSaving: false, saveStatus: 'idle', saveError: null, isModified: false, - // sessionId: sessionId, // 记录来源会话 }; tabs.value.set(tabId, newTab); // setActiveTab(tabId); // 移除同步激活 @@ -179,51 +210,33 @@ export const useFileEditorStore = defineStore('fileEditor', () => { // 读取文件内容 try { - const fileData = await sftpManager.readFile(targetFilePath); - console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 读取成功。编码: ${fileData.encoding}`); + // 调用 sftpManager.readFile 获取原始数据和编码 + const fileData: SftpReadFileSuccessPayload = await sftpManager.readFile(targetFilePath); + console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 原始数据读取成功。后端使用编码: ${fileData.encodingUsed}`); - let decodedContent = ''; - let finalEncoding: 'utf8' | 'base64' = 'utf8'; - - if (fileData.encoding === 'base64') { - finalEncoding = 'base64'; - try { - const binaryString = atob(fileData.content); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - const decoder = new TextDecoder('utf-8'); // 显式使用 UTF-8 - decodedContent = decoder.decode(bytes); - console.log(`[文件编辑器 Store] Base64 文件 ${targetFilePath} 已解码为 UTF-8。`); - } catch (decodeError) { - console.error(`[文件编辑器 Store] Base64 或 UTF-8 解码错误 for ${targetFilePath}:`, decodeError); - const errorMsg = t('fileManager.errors.fileDecodeError'); - decodedContent = `// ${errorMsg}\n// Original Base64 content:\n${fileData.content}`; - // 更新标签页状态以反映错误 - const tabToUpdate = tabs.value.get(tabId); - if (tabToUpdate) { - tabToUpdate.loadingError = errorMsg; - } - } - } else { - finalEncoding = 'utf8'; - decodedContent = fileData.content; - console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 已按 ${finalEncoding} 处理。`); - if (decodedContent.includes('\uFFFD')) { - console.warn(`[文件编辑器 Store] 文件 ${targetFilePath} 内容可能包含无效字符,原始编码可能不是 UTF-8。`); - } + const tabToUpdate = tabs.value.get(tabId); + if (!tabToUpdate) { + console.error(`[文件编辑器 Store] 无法更新标签页 ${tabId},因为它在加载完成前被关闭了。`); + return; } + // +++ 前端解码 +++ + const initialContent = decodeRawContent(fileData.rawContentBase64, fileData.encodingUsed); + // 更新标签页状态 - const tabToUpdate = tabs.value.get(tabId); - if (tabToUpdate) { - tabToUpdate.content = decodedContent; - tabToUpdate.originalContent = decodedContent; // 设置初始内容 - tabToUpdate.encoding = finalEncoding; - tabToUpdate.isLoading = false; - tabToUpdate.isModified = false; // 初始未修改 - } + const updatedTab: FileTab = { + ...tabToUpdate, + rawContentBase64: fileData.rawContentBase64, // 存储原始数据 + content: initialContent, + originalContent: initialContent, // 初始原始内容 + selectedEncoding: fileData.encodingUsed, // 存储后端实际使用的编码 + isLoading: false, + isModified: false, + loadingError: null, + }; + tabs.value.set(tabId, updatedTab); // 替换以确保响应性 + + console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 内容已解码 (${fileData.encodingUsed}) 并设置到标签页 ${tabId}。`); } catch (err: any) { console.error(`[文件编辑器 Store] 读取文件 ${targetFilePath} 失败:`, err); @@ -323,10 +336,12 @@ export const useFileEditorStore = defineStore('fileEditor', () => { tab.saveError = null; const contentToSave = tab.content; + const encodingToUse = tab.selectedEncoding; // 获取选定的编码 try { - await sftpManager.writeFile(tab.filePath, contentToSave); - console.log(`[文件编辑器 Store] 文件 ${tab.filePath} 保存成功。`); + // --- 修改:传递 selectedEncoding 给 writeFile --- + await sftpManager.writeFile(tab.filePath, contentToSave, encodingToUse); + console.log(`[文件编辑器 Store] 文件 ${tab.filePath} 使用编码 ${encodingToUse} 保存成功。`); tab.isSaving = false; tab.saveStatus = 'success'; tab.saveError = null; @@ -427,6 +442,63 @@ export const useFileEditorStore = defineStore('fileEditor', () => { } }; + // +++ 修改:更改文件编码(通过请求后端重新读取) +++ + // +++ 修改:changeEncoding 现在在前端解码 +++ + const changeEncoding = (tabId: string, newEncoding: string) => { + const tab = tabs.value.get(tabId); + if (!tab) { + console.warn(`[文件编辑器 Store] 尝试更改不存在的标签页 ${tabId} 的编码。`); + return; + } + if (!tab.rawContentBase64) { + console.error(`[文件编辑器 Store] 无法更改编码:标签页 ${tabId} 没有原始文件数据。`); + // 可以设置错误状态 + tab.loadingError = '缺少原始文件数据,无法更改编码'; + return; + } + if (tab.selectedEncoding === newEncoding) { + console.log(`[文件编辑器 Store] 编码已经是 ${newEncoding},无需更改。`); + return; + } + + console.log(`[文件编辑器 Store] 使用新编码 "${newEncoding}" 在前端重新解码文件: ${tab.filePath} (Tab ID: ${tabId})`); + + // 设置加载状态(可选,解码通常很快,但可以防止 UI 闪烁) + // tab.isLoading = true; + // tab.loadingError = null; + + try { + // 使用新编码解码存储的原始数据 + const newContent = decodeRawContent(tab.rawContentBase64, newEncoding); + + // 更新标签页状态 + const updatedTab: FileTab = { + ...tab, + content: newContent, + selectedEncoding: newEncoding, // 更新选择的编码 + isLoading: false, // 解码完成 + loadingError: null, + // isModified 状态保持不变 + }; + tabs.value.set(tabId, updatedTab); + console.log(`[文件编辑器 Store] 文件 ${tab.filePath} 使用新编码 "${newEncoding}" 解码完成。`); + + } catch (err: any) { // catch 应该在 decodeRawContent 内部处理了,但以防万一 + console.error(`[文件编辑器 Store] 使用编码 "${newEncoding}" 在前端解码文件 ${tab.filePath} 失败:`, err); + const errorMsg = `前端解码失败 (编码: ${newEncoding}): ${err.message || err}`; + // 更新错误状态 + const errorTab: FileTab = { + ...tab, + isLoading: false, + loadingError: errorMsg, + }; + tabs.value.set(tabId, errorTab); + } + // finally { + // if (tab) tab.isLoading = false; // 确保加载状态被重置 + // } + }; + // 移除旧的 updateContent,因为它只更新活动标签页 // const updateContent = (newContent: string) => { ... }; @@ -487,6 +559,7 @@ export const useFileEditorStore = defineStore('fileEditor', () => { closeAllTabs, setActiveTab, updateFileContent, // 暴露新的更新方法 + changeEncoding, // +++ 暴露更改编码的方法 +++ triggerPopup, // 暴露新的触发方法 // setEditorVisibility, // 移除 }; diff --git a/packages/frontend/src/stores/session.store.ts b/packages/frontend/src/stores/session.store.ts index e08dc3f..4f9e4e0 100644 --- a/packages/frontend/src/stores/session.store.ts +++ b/packages/frontend/src/stores/session.store.ts @@ -4,7 +4,11 @@ import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; // +++ 导入 useRouter +++ import { useConnectionsStore, type ConnectionInfo } from './connections.store'; // 导入文件编辑器相关的类型 -import type { FileTab, FileInfo } from './fileEditor.store'; // 导入 FileTab 和 FileInfo +import type { FileTab, FileInfo } from './fileEditor.store'; // 导入 FileTab 和 FileInfo (确保 FileTab 已在 fileEditor.store.ts 中简化) +import type { SftpReadFileSuccessPayload } from '../types/sftp.types'; // 导入更新后的类型 +// --- 修复:添加 iconv-lite 和 buffer 导入 --- +import * as iconv from '@vscode/iconv-lite-umd'; +import { Buffer } from 'buffer/'; // 导入管理器工厂函数 (用于创建实例) // 导入 WsConnectionStatus 类型 @@ -51,6 +55,33 @@ const getLanguageFromFilename = (filename: string): string => { } }; +// --- 修复:添加 decodeRawContent 辅助函数 --- +const decodeRawContent = (rawContentBase64: string, encoding: string): string => { + try { + const buffer = Buffer.from(rawContentBase64, 'base64'); + const normalizedEncoding = encoding.toLowerCase().replace(/[^a-z0-9]/g, ''); // Normalize encoding name + + // 优先使用 TextDecoder 处理标准编码 + if (['utf8', 'utf16le', 'utf16be'].includes(normalizedEncoding)) { + const decoder = new TextDecoder(encoding); // Use original encoding name for TextDecoder + return decoder.decode(buffer); + } + // 使用 iconv-lite 处理其他编码 + else if (iconv.encodingExists(normalizedEncoding)) { + return iconv.decode(buffer, normalizedEncoding); + } + // 如果 iconv-lite 也不支持,回退到 UTF-8 并警告 + else { + console.warn(`[SessionStore decodeRawContent] Unsupported encoding "${encoding}" requested. Falling back to UTF-8.`); + const decoder = new TextDecoder('utf-8'); + return decoder.decode(buffer); + } + } catch (error: any) { + console.error(`[SessionStore decodeRawContent] Error decoding content with encoding "${encoding}":`, error); + return `// Error decoding content: ${error.message}`; // 返回错误信息 + } +}; +// --- 结束修复 --- // --- 类型定义 (导出以便其他模块使用) --- export type WsManagerInstance = ReturnType; @@ -91,6 +122,9 @@ export const useSessionStore = defineStore('session', () => { const connectionsStore = useConnectionsStore(); const router = useRouter(); // +++ 获取 router 实例 +++ + // --- 移除 decodeBase64Content 辅助方法 --- + + // --- State --- // 使用 shallowRef 避免深度响应性问题,保留管理器实例内部的响应性 const sessions = shallowRef>(new Map()); @@ -299,10 +333,12 @@ export const useSessionStore = defineStore('session', () => { tab.saveError = null; const contentToSave = tab.content; + const encodingToUse = tab.selectedEncoding; // 获取选定的编码 try { - await sftpManager.writeFile(tab.filePath, contentToSave); - console.log(`[SessionStore] 文件 ${tab.filePath} (会话 ${sessionId}) 保存成功。`); + // --- 修改:传递 selectedEncoding 给 writeFile --- + await sftpManager.writeFile(tab.filePath, contentToSave, encodingToUse); + console.log(`[SessionStore] 文件 ${tab.filePath} (会话 ${sessionId}) 使用编码 ${encodingToUse} 保存成功。`); tab.isSaving = false; tab.saveStatus = 'success'; tab.saveError = null; @@ -456,24 +492,24 @@ export const useSessionStore = defineStore('session', () => { console.log(`[SessionStore] 会话 ${sessionId} 中已存在文件 ${fileInfo.fullPath} 的标签页,已激活: ${existingTab.id}`); } else { // 创建新标签页 + // 创建新标签页 (使用简化后的 FileTab 接口) + // --- 修复:初始化 rawContentBase64 --- const newTab: FileTab = { - id: generateSessionId(), // 复用会话 ID 生成逻辑创建唯一标签页 ID - filename: fileInfo.name, // 使用 filename 匹配 FileTab 接口 - filePath: fileInfo.fullPath, // 使用 filePath 匹配 FileTab 接口 - // content, originalContent, language, encoding 将在 FileEditorContainer 或 fileEditor.store 中处理 - content: '', // 初始内容为空 - originalContent: '', // 初始原始内容为空 - language: 'plaintext', // 初始语言,稍后会根据文件名更新 - encoding: 'utf8', // 默认编码 - isModified: false, // 使用 isModified 匹配 FileTab 接口 - isLoading: false, // 初始化为 boolean - loadingError: null, // 使用 loadingError 匹配 FileTab 接口 - // --- 编辑器状态相关 --- + id: generateSessionId(), // 使用独立 ID + sessionId: sessionId, + filePath: fileInfo.fullPath, + filename: fileInfo.name, + content: '', // 将由后端填充 + originalContent: '', // 将由后端填充 + rawContentBase64: null, // 初始化为 null + language: getLanguageFromFilename(fileInfo.name), + selectedEncoding: 'utf-8', // 初始默认,将由后端更新 + isLoading: true, + loadingError: null, isSaving: false, saveStatus: 'idle', saveError: null, - // --- 关联会话 ID --- - sessionId: sessionId, // 记录此标签页属于哪个会话 + isModified: false, }; // session.editorTabs.value.push(newTab); // 移除重复的 push session.editorTabs.value.push(newTab); @@ -481,64 +517,67 @@ export const useSessionStore = defineStore('session', () => { console.log(`[SessionStore] 已在会话 ${sessionId} 中为文件 ${fileInfo.fullPath} 创建新标签页: ${newTab.id}`); // --- 新增:异步加载文件内容 --- + // --- 修改:异步加载文件内容 (处理后端解码后的内容) --- const loadContent = async () => { - const tabToLoad = session.editorTabs.value.find(t => t.id === newTab.id); - if (!tabToLoad) return; // Tab might have been closed quickly + const tabIndex = session.editorTabs.value.findIndex(t => t.id === newTab.id); + if (tabIndex === -1) return; // Tab might have been closed quickly - tabToLoad.isLoading = true; - tabToLoad.loadingError = null; + // 更新加载状态 (使用 splice 保证响应性) + const loadingTab = { ...session.editorTabs.value[tabIndex], isLoading: true, loadingError: null }; + session.editorTabs.value.splice(tabIndex, 1, loadingTab); - try { - // 获取默认的 sftpManager 实例来执行读取操作 - const sftpManager = getOrCreateSftpManager(sessionId, 'primary'); - if (!sftpManager) { - throw new Error(t('fileManager.errors.sftpManagerNotFound')); - } - console.log(`[SessionStore ${sessionId}] 使用 primary sftpManager 读取文件 ${fileInfo.fullPath}`); - const fileData = await sftpManager.readFile(fileInfo.fullPath); - console.log(`[SessionStore ${sessionId}] 文件 ${fileInfo.fullPath} 读取成功。编码: ${fileData.encoding}`); + try { + // 获取默认的 sftpManager 实例来执行读取操作 + const sftpManager = getOrCreateSftpManager(sessionId, 'primary'); + if (!sftpManager) { + throw new Error(t('fileManager.errors.sftpManagerNotFound')); + } + console.log(`[SessionStore ${sessionId}] 使用 primary sftpManager 读取文件 ${fileInfo.fullPath}`); - let decodedContent = ''; - let finalEncoding: 'utf8' | 'base64' = 'utf8'; + // 调用 sftpManager.readFile (不带编码参数,让后端自动检测) + // 后端现在返回 { content: string, encodingUsed: string } + const fileData: SftpReadFileSuccessPayload = await sftpManager.readFile(fileInfo.fullPath); + console.log(`[SessionStore ${sessionId}] 文件 ${fileInfo.fullPath} 读取成功。后端使用编码: ${fileData.encodingUsed}`); - if (fileData.encoding === 'base64') { - finalEncoding = 'base64'; - try { - const binaryString = atob(fileData.content); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - const decoder = new TextDecoder('utf-8'); - decodedContent = decoder.decode(bytes); - } catch (decodeError) { - console.error(`[SessionStore ${sessionId}] Base64 解码错误 for ${fileInfo.fullPath}:`, decodeError); - tabToLoad.loadingError = t('fileManager.errors.fileDecodeError'); - decodedContent = `// ${tabToLoad.loadingError}\n// Original Base64 content:\n${fileData.content}`; - } - } else { - finalEncoding = 'utf8'; - decodedContent = fileData.content; - if (decodedContent.includes('\uFFFD')) { - console.warn(`[SessionStore ${sessionId}] 文件 ${fileInfo.fullPath} 内容可能包含无效字符。`); + // 更新标签页状态 (使用 splice 保证响应性) + const currentTabState = session.editorTabs.value.find(t => t.id === newTab.id); // 获取最新状态 + if (!currentTabState) return; // 可能在请求期间关闭了 + + // --- 修复:使用 decodeRawContent 解码并存储原始数据 --- + const initialContent = decodeRawContent(fileData.rawContentBase64, fileData.encodingUsed); + const updatedTab: FileTab = { + ...currentTabState, + content: initialContent, + originalContent: initialContent, // 初始原始内容 + rawContentBase64: fileData.rawContentBase64, // 存储原始 Base64 数据 + selectedEncoding: fileData.encodingUsed, // 存储后端实际使用的编码 + isLoading: false, + isModified: false, + loadingError: null, + }; + const finalTabIndex = session.editorTabs.value.findIndex(t => t.id === newTab.id); // 重新获取索引 + if (finalTabIndex !== -1) { + session.editorTabs.value.splice(finalTabIndex, 1, updatedTab); + console.log(`[SessionStore ${sessionId}] 文件 ${fileInfo.fullPath} 内容已加载并设置到标签页 ${newTab.id}。`); + } else { + console.warn(`[SessionStore ${sessionId}] 尝试更新标签页 ${newTab.id} 时未找到索引。`); + } + + } catch (err: any) { + console.error(`[SessionStore ${sessionId}] 读取文件 ${fileInfo.fullPath} 失败:`, err); + // 更新错误状态 (使用 splice 保证响应性) + const errorTabIndex = session.editorTabs.value.findIndex(t => t.id === newTab.id); + if (errorTabIndex !== -1) { + const errorTab = { + ...session.editorTabs.value[errorTabIndex], + isLoading: false, + loadingError: `${t('fileManager.errors.readFileFailed')}: ${err.message || err}`, + content: `// 加载错误: ${err.message || err}` + }; + session.editorTabs.value.splice(errorTabIndex, 1, errorTab); } } - - // 更新标签页状态 - tabToLoad.content = decodedContent; - tabToLoad.originalContent = decodedContent; - tabToLoad.encoding = finalEncoding; - tabToLoad.language = getLanguageFromFilename(fileInfo.name); // 根据文件名设置语言 - tabToLoad.isModified = false; - - } catch (err: any) { - console.error(`[SessionStore ${sessionId}] 读取文件 ${fileInfo.fullPath} 失败:`, err); - tabToLoad.loadingError = `${t('fileManager.errors.readFileFailed')}: ${err.message || err}`; - tabToLoad.content = `// ${tabToLoad.loadingError}`; - } finally { - tabToLoad.isLoading = false; - } - }; + }; loadContent(); // 启动内容加载 } @@ -597,6 +636,84 @@ export const useSessionStore = defineStore('session', () => { }; + /** + * 在指定会话中更改文件编码并重新解码 + */ + // --- 修改:更改文件编码(通过请求后端重新读取) --- + const changeEncodingInSession = async (sessionId: string, tabId: string, newEncoding: string) => { + const session = sessions.value.get(sessionId); + if (!session) { + console.warn(`[SessionStore] 尝试更改不存在的会话 ${sessionId} 中标签页 ${tabId} 的编码。`); + return; + } + const tabIndex = session.editorTabs.value.findIndex(t => t.id === tabId); + if (tabIndex === -1) { + console.warn(`[SessionStore] 尝试更改会话 ${sessionId} 中不存在的标签页 ${tabId} 的编码。`); + return; + } + + // 获取默认的 sftpManager 实例 + const sftpManager = getOrCreateSftpManager(sessionId, 'primary'); + if (!sftpManager) { + console.error(`[SessionStore] 无法获取会话 ${sessionId} 的 primary sftpManager 来更改编码。`); + // 更新错误状态 + const errorTab = { ...session.editorTabs.value[tabIndex], isLoading: false, loadingError: '无法获取 SFTP 实例' }; + session.editorTabs.value.splice(tabIndex, 1, errorTab); + return; + } + + const tab = session.editorTabs.value[tabIndex]; + console.log(`[SessionStore] 请求使用新编码 "${newEncoding}" 重新读取文件: ${tab.filePath} (会话 ${sessionId}, Tab ID: ${tabId})`); + + // 设置加载状态 (使用 splice) + const loadingTab = { ...tab, isLoading: true, loadingError: null }; + session.editorTabs.value.splice(tabIndex, 1, loadingTab); + + try { + // 向后端发送 readFile 请求,并指定编码 + const fileData: SftpReadFileSuccessPayload = await sftpManager.readFile(tab.filePath, newEncoding); + console.log(`[SessionStore ${sessionId}] 文件 ${tab.filePath} 使用编码 "${newEncoding}" 重新读取成功。后端实际使用编码: ${fileData.encodingUsed}`); + + // 更新标签页状态 (使用 splice) + const currentTabState = session.editorTabs.value.find(t => t.id === tabId); // 获取最新状态 + if (!currentTabState) return; // 可能在请求期间关闭了 + + // --- 修复:使用 decodeRawContent 解码并更新原始数据 --- + const newDecodedContent = decodeRawContent(fileData.rawContentBase64, fileData.encodingUsed); + const updatedTab: FileTab = { + ...currentTabState, + content: newDecodedContent, + rawContentBase64: fileData.rawContentBase64, // 更新原始 Base64 数据 + // originalContent 保持不变 + selectedEncoding: fileData.encodingUsed, // 使用后端确认的编码 + isLoading: false, + loadingError: null, + // isModified 状态保持不变 + }; + const finalTabIndex = session.editorTabs.value.findIndex(t => t.id === tabId); // 重新获取索引 + if (finalTabIndex !== -1) { + session.editorTabs.value.splice(finalTabIndex, 1, updatedTab); + } else { + console.warn(`[SessionStore ${sessionId}] 尝试更新标签页 ${tabId} 时未找到索引 (changeEncoding)。`); + } + + } catch (err: any) { + console.error(`[SessionStore ${sessionId}] 使用编码 "${newEncoding}" 重新读取文件 ${tab.filePath} 失败:`, err); + const errorMsg = `${t('fileManager.errors.readFileFailed')} (编码: ${newEncoding}): ${err.message || err}`; + // 更新错误状态 (使用 splice) + const errorTabIndex = session.editorTabs.value.findIndex(t => t.id === tabId); + if (errorTabIndex !== -1) { + const errorTab = { + ...session.editorTabs.value[errorTabIndex], + isLoading: false, + loadingError: errorMsg, + }; + session.editorTabs.value.splice(errorTabIndex, 1, errorTab); + } + } + }; + + /** * 获取或创建指定会话和实例 ID 的 SFTP 管理器。 * @param sessionId 会话 ID @@ -694,6 +811,7 @@ export const useSessionStore = defineStore('session', () => { setActiveEditorTabInSession, updateFileContentInSession, // 导出更新内容 Action saveFileInSession, // 导出保存文件 Action + changeEncodingInSession, // 导出更改编码 Action // --- RDP Modal Actions --- openRdpModal, // 导出打开 RDP 模态框 Action closeRdpModal, // 导出关闭 RDP 模态框 Action diff --git a/packages/frontend/src/types/sftp.types.ts b/packages/frontend/src/types/sftp.types.ts index 8773773..9bc2d29 100644 --- a/packages/frontend/src/types/sftp.types.ts +++ b/packages/frontend/src/types/sftp.types.ts @@ -26,3 +26,15 @@ export interface EditorFileContent { // 类型定义:编辑器保存状态 (从 useFileEditor 迁移) export type SaveStatus = 'idle' | 'saving' | 'success' | 'error'; + +// 类型定义:后端 readFile 请求的 payload 结构 +export interface SftpReadFileRequestPayload { + path: string; + encoding?: string; // 可选:请求使用的特定编码 +} + +// 类型定义:后端 readFile 成功时返回的 payload 结构 (更新) +export interface SftpReadFileSuccessPayload { + rawContentBase64: string; // Base64 编码的原始文件内容 + encodingUsed: string; // 后端自动检测或用户请求时实际使用的编码 +} diff --git a/packages/frontend/src/views/WorkspaceView.vue b/packages/frontend/src/views/WorkspaceView.vue index 63cf4ee..564bbb1 100644 --- a/packages/frontend/src/views/WorkspaceView.vue +++ b/packages/frontend/src/views/WorkspaceView.vue @@ -372,6 +372,23 @@ const handleCloseEditorTab = (tabId: string) => { } }; + // +++ 新增:处理编辑器编码更改事件 +++ + const handleChangeEncoding = (payload: { tabId: string; encoding: string }) => { + const isShared = shareFileEditorTabsBoolean.value; + console.log(`[WorkspaceView] handleChangeEncoding for tab ${payload.tabId} to ${payload.encoding}, Shared mode: ${isShared}`); + if (isShared) { + fileEditorStore.changeEncoding(payload.tabId, payload.encoding); + } else { + const currentActiveSessionId = activeSessionId.value; + if (currentActiveSessionId) { + // 假设 sessionStore 有一个 changeEncodingInSession 方法 + sessionStore.changeEncodingInSession(currentActiveSessionId, payload.tabId, payload.encoding); + } else { + console.warn('[WorkspaceView] Cannot change editor encoding: No active session in independent mode.'); + } + } + }; + // --- 连接列表操作处理 (用于 WorkspaceConnectionList) --- const handleConnectRequest = (id: number) => { console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`); @@ -433,6 +450,7 @@ const handleCloseEditorTab = (tabId: string) => { @find-previous="handleFindPrevious" @close-search="handleCloseSearch" @clear-terminal="handleClearTerminal" + @change-encoding="handleChangeEncoding" >
{{ t('layout.loading', '加载布局中...') }}