From 2e648a0f3759eea4086db9e7e2eab2c9a7e37c2f Mon Sep 17 00:00:00 2001 From: gunk230_cloudcli <2512690268@qq.com> Date: Tue, 5 May 2026 15:28:16 +0800 Subject: [PATCH 1/2] feat: add DeepSeek models, fix Shell tab switching, add image/PDF preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add deepseek-v4-flash and deepseek-v4-pro to model selector - Fix Shell→Chat→Shell switching: keep Shell mounted with absolute positioning instead of unmounting, preventing PTY teardown - Fix PTY onExit race condition when old PTY is killed and new one is spawned - Add image and PDF preview in code editor sidebar/fullscreen - Fix xterm.js layout corruption when Shell tab becomes visible after being hidden Co-Authored-By: Claude Opus 4.7 --- .../services/shell-websocket.service.ts | 40 ++--- shared/modelConstants.js | 2 + .../hooks/useCodeEditorDocument.ts | 17 +- .../code-editor/utils/binaryFile.ts | 38 ++++- .../code-editor/view/CodeEditor.tsx | 29 ++++ .../subcomponents/CodeEditorImagePreview.tsx | 154 +++++++++++++++++ .../subcomponents/CodeEditorPdfPreview.tsx | 158 ++++++++++++++++++ .../main-content/view/MainContent.tsx | 22 ++- src/components/shell/hooks/useShellRuntime.ts | 9 + src/components/shell/types/types.ts | 1 + src/components/shell/view/Shell.tsx | 16 +- 11 files changed, 427 insertions(+), 59 deletions(-) create mode 100644 src/components/code-editor/view/subcomponents/CodeEditorImagePreview.tsx create mode 100644 src/components/code-editor/view/subcomponents/CodeEditorPdfPreview.tsx diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index 9bf7046b34..7ec5ca8abe 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -206,31 +206,17 @@ export function handleShellConnection( const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey); if (existingSession) { - shellProcess = existingSession.pty; + // Kill the old PTY and start fresh so the shell shows current state. + // claude --resume restores conversation from the JSONL file. if (existingSession.timeoutId) { clearTimeout(existingSession.timeoutId); } - - ws.send( - JSON.stringify({ - type: 'output', - data: '\x1b[36m[Reconnected to existing session]\x1b[0m\r\n', - }) - ); - - if (existingSession.buffer.length > 0) { - existingSession.buffer.forEach((bufferedData) => { - ws.send( - JSON.stringify({ - type: 'output', - data: bufferedData, - }) - ); - }); + try { + existingSession.pty.kill(); + } catch { + // PTY may already be dead } - - existingSession.ws = ws; - return; + ptySessionsMap.delete(ptySessionKey); } const resolvedProjectPath = path.resolve(projectPath); @@ -285,7 +271,7 @@ export function handleShellConnection( } const session = ptySessionsMap.get(ptySessionKey); - if (!session) { + if (!session || session.pty !== shellProcess) { return; } @@ -361,7 +347,13 @@ export function handleShellConnection( } const session = ptySessionsMap.get(ptySessionKey); - if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { + // Only act if this PTY is still the active one — a replacement PTY + // may have been spawned while this one was being killed. + if (!session || session.pty !== shellProcess) { + return; + } + + if (session.ws && session.ws.readyState === WebSocket.OPEN) { session.ws.send( JSON.stringify({ type: 'output', @@ -372,7 +364,7 @@ export function handleShellConnection( ); } - if (session?.timeoutId) { + if (session.timeoutId) { clearTimeout(session.timeoutId); } diff --git a/shared/modelConstants.js b/shared/modelConstants.js index 90b973ed9c..3e63576cd6 100644 --- a/shared/modelConstants.js +++ b/shared/modelConstants.js @@ -20,6 +20,8 @@ export const CLAUDE_MODELS = { { value: "opusplan", label: "Opus Plan" }, { value: "sonnet[1m]", label: "Sonnet [1M]" }, { value: "opus[1m]", label: "Opus [1M]" }, + { value: "deepseek-v4-flash", label: "DeepSeek V4 Flash" }, + { value: "deepseek-v4-pro", label: "DeepSeek V4 Pro" }, ], DEFAULT: "opus", diff --git a/src/components/code-editor/hooks/useCodeEditorDocument.ts b/src/components/code-editor/hooks/useCodeEditorDocument.ts index b2b7acd2c0..6dd9937305 100644 --- a/src/components/code-editor/hooks/useCodeEditorDocument.ts +++ b/src/components/code-editor/hooks/useCodeEditorDocument.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '../../../utils/api'; import type { CodeEditorFile } from '../types/types'; -import { isBinaryFile } from '../utils/binaryFile'; +import { getFileCategory, type FileCategory } from '../utils/binaryFile'; type UseCodeEditorDocumentParams = { file: CodeEditorFile; @@ -22,7 +22,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume const [saving, setSaving] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); const [saveError, setSaveError] = useState(null); - const [isBinary, setIsBinary] = useState(false); + const [fileCategory, setFileCategory] = useState('text'); // `fileProjectId` is the DB primary key passed down from the editor sidebar; // the fallback to `projectPath` preserves older callers that didn't yet // propagate the identifier. @@ -36,11 +36,13 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume const loadFileContent = async () => { try { setLoading(true); - setIsBinary(false); + setFileCategory('text'); - // Check if file is binary by extension - if (isBinaryFile(file.name)) { - setIsBinary(true); + // Check file category by extension — previewable types (image/pdf) + // are handled by dedicated viewer components in CodeEditor. + const category = getFileCategory(file.name); + if (category !== 'text') { + setFileCategory(category); setLoading(false); return; } @@ -133,7 +135,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume saving, saveSuccess, saveError, - isBinary, + fileCategory, + isBinary: fileCategory === 'binary', handleSave, handleDownload, }; diff --git a/src/components/code-editor/utils/binaryFile.ts b/src/components/code-editor/utils/binaryFile.ts index f03205c00f..9096c410bc 100644 --- a/src/components/code-editor/utils/binaryFile.ts +++ b/src/components/code-editor/utils/binaryFile.ts @@ -1,22 +1,44 @@ -// Binary file extensions (images are handled by ImageViewer, not here) +const IMAGE_EXTENSIONS = [ + 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', +]; + +const PDF_EXTENSIONS = ['pdf']; + +// Truly unviewable binary formats (no in-browser preview possible) const BINARY_EXTENSIONS = [ // Archives 'zip', 'tar', 'gz', 'rar', '7z', 'bz2', 'xz', // Executables 'exe', 'dll', 'so', 'dylib', 'app', 'dmg', 'msi', - // Media + // Media (audio/video — could be previewable, but not in scope yet) 'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'm4a', 'ogg', - // Documents - 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', + // Documents (non-PDF) + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', // Fonts 'ttf', 'otf', 'woff', 'woff2', 'eot', // Database 'db', 'sqlite', 'sqlite3', // Other binary - 'bin', 'dat', 'iso', 'img', 'class', 'jar', 'war', 'pyc', 'pyo' + 'bin', 'dat', 'iso', 'img', 'class', 'jar', 'war', 'pyc', 'pyo', ]; -export const isBinaryFile = (filename: string): boolean => { - const ext = filename.split('.').pop()?.toLowerCase(); - return BINARY_EXTENSIONS.includes(ext ?? ''); +const getExt = (filename: string): string => + (filename.split('.').pop() ?? '').toLowerCase(); + +export const isImageFile = (filename: string): boolean => + IMAGE_EXTENSIONS.includes(getExt(filename)); + +export const isPdfFile = (filename: string): boolean => + PDF_EXTENSIONS.includes(getExt(filename)); + +export const isBinaryFile = (filename: string): boolean => + BINARY_EXTENSIONS.includes(getExt(filename)); + +export type FileCategory = 'text' | 'image' | 'pdf' | 'binary'; + +export const getFileCategory = (filename: string): FileCategory => { + if (isImageFile(filename)) return 'image'; + if (isPdfFile(filename)) return 'pdf'; + if (isBinaryFile(filename)) return 'binary'; + return 'text'; }; diff --git a/src/components/code-editor/view/CodeEditor.tsx b/src/components/code-editor/view/CodeEditor.tsx index 5861ce715c..efe384cc70 100644 --- a/src/components/code-editor/view/CodeEditor.tsx +++ b/src/components/code-editor/view/CodeEditor.tsx @@ -16,6 +16,8 @@ import CodeEditorHeader from './subcomponents/CodeEditorHeader'; import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState'; import CodeEditorSurface from './subcomponents/CodeEditorSurface'; import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile'; +import CodeEditorImagePreview from './subcomponents/CodeEditorImagePreview'; +import CodeEditorPdfPreview from './subcomponents/CodeEditorPdfPreview'; type CodeEditorProps = { file: CodeEditorFile; @@ -57,6 +59,7 @@ export default function CodeEditor({ saving, saveSuccess, saveError, + fileCategory, isBinary, handleSave, handleDownload, @@ -162,6 +165,32 @@ export default function CodeEditor({ ); } + // Image preview + if (fileCategory === 'image') { + return ( + setIsFullscreen((prev) => !prev)} + /> + ); + } + + // PDF preview + if (fileCategory === 'pdf') { + return ( + setIsFullscreen((prev) => !prev)} + /> + ); + } + // Binary file display if (isBinary) { return ( diff --git a/src/components/code-editor/view/subcomponents/CodeEditorImagePreview.tsx b/src/components/code-editor/view/subcomponents/CodeEditorImagePreview.tsx new file mode 100644 index 0000000000..59ca2274f3 --- /dev/null +++ b/src/components/code-editor/view/subcomponents/CodeEditorImagePreview.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../../../utils/api'; +import type { CodeEditorFile } from '../../types/types'; + +type CodeEditorImagePreviewProps = { + file: CodeEditorFile; + isSidebar: boolean; + isFullscreen: boolean; + onClose: () => void; + onToggleFullscreen: () => void; +}; + +export default function CodeEditorImagePreview({ + file, + isSidebar, + isFullscreen, + onClose, + onToggleFullscreen, +}: CodeEditorImagePreviewProps) { + const [imageUrl, setImageUrl] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let objectUrl: string | null = null; + let cancelled = false; + + const loadImage = async () => { + try { + setLoading(true); + setError(null); + + const response = await api.readFileBlob(file.projectId ?? '', file.path); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const blob = await response.blob(); + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setImageUrl(objectUrl); + } catch (err: unknown) { + if (cancelled) return; + console.error('Error loading image:', err); + setError('Unable to load image'); + } finally { + if (!cancelled) setLoading(false); + } + }; + + loadImage(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [file.path, file.projectId]); + + const previewContent = ( +
+ {loading && ( +
+
+

Loading image...

+
+ )} + {!loading && imageUrl && ( + {file.name} + )} + {!loading && (error || !imageUrl) && ( +
+

{error || 'Unable to load image'}

+

{file.path}

+
+ )} +
+ ); + + if (isSidebar) { + return ( +
+
+

{file.name}

+ +
+ {previewContent} +
+ ); + } + + const containerClass = isFullscreen + ? 'fixed inset-0 z-[9999] bg-[#1e1e1e] flex flex-col' + : 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'; + + const innerClass = isFullscreen + ? 'flex flex-col w-full h-full' + : 'bg-[#1e1e1e] shadow-2xl flex flex-col w-full h-full md:rounded-lg md:w-full md:max-w-4xl md:h-[85vh]'; + + return ( +
+
+
+

{file.name}

+
+ + +
+
+ {previewContent} +
+

{file.path}

+
+
+
+ ); +} diff --git a/src/components/code-editor/view/subcomponents/CodeEditorPdfPreview.tsx b/src/components/code-editor/view/subcomponents/CodeEditorPdfPreview.tsx new file mode 100644 index 0000000000..4add95f825 --- /dev/null +++ b/src/components/code-editor/view/subcomponents/CodeEditorPdfPreview.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../../../utils/api'; +import type { CodeEditorFile } from '../../types/types'; + +type CodeEditorPdfPreviewProps = { + file: CodeEditorFile; + isSidebar: boolean; + isFullscreen: boolean; + onClose: () => void; + onToggleFullscreen: () => void; +}; + +export default function CodeEditorPdfPreview({ + file, + isSidebar, + isFullscreen, + onClose, + onToggleFullscreen, +}: CodeEditorPdfPreviewProps) { + const [pdfUrl, setPdfUrl] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let objectUrl: string | null = null; + let cancelled = false; + + const loadPdf = async () => { + try { + setLoading(true); + setError(null); + + const response = await api.readFileBlob(file.projectId ?? '', file.path); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const blob = await response.blob(); + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setPdfUrl(objectUrl); + } catch (err: unknown) { + if (cancelled) return; + console.error('Error loading PDF:', err); + setError('Unable to load PDF'); + } finally { + if (!cancelled) setLoading(false); + } + }; + + loadPdf(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [file.path, file.projectId]); + + const previewContent = ( +
+ {loading && ( +
+
+
+

Loading PDF...

+
+
+ )} + {!loading && pdfUrl && ( +