diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index 9bf7046b34..0b2d64a281 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -191,46 +191,33 @@ export function handleShellConnection( isPlainShell && initialCommand ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}` : ''; - ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`; + const sessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`; + ptySessionKey = sessionKey; if (isLoginCommand) { - const oldSession = ptySessionsMap.get(ptySessionKey); + const oldSession = ptySessionsMap.get(sessionKey); if (oldSession) { if (oldSession.timeoutId) { clearTimeout(oldSession.timeoutId); } oldSession.pty.kill(); - ptySessionsMap.delete(ptySessionKey); + ptySessionsMap.delete(sessionKey); } } - const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey); + const existingSession = isLoginCommand ? null : ptySessionsMap.get(sessionKey); 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(sessionKey); } const resolvedProjectPath = path.resolve(projectPath); @@ -257,7 +244,7 @@ export function handleShellConnection( const termCols = readNumber(data.cols, 80); const termRows = readNumber(data.rows, 24); - shellProcess = pty.spawn(shell, shellArgs, { + const spawnedPty = pty.spawn(shell, shellArgs, { name: 'xterm-256color', cols: termCols, rows: termRows, @@ -270,8 +257,10 @@ export function handleShellConnection( }, }); - ptySessionsMap.set(ptySessionKey, { - pty: shellProcess, + shellProcess = spawnedPty; + + ptySessionsMap.set(sessionKey, { + pty: spawnedPty, ws, buffer: [], timeoutId: null, @@ -279,13 +268,11 @@ export function handleShellConnection( sessionId, }); - shellProcess.onData((chunk) => { - if (!ptySessionKey) { - return; - } - - const session = ptySessionsMap.get(ptySessionKey); - if (!session) { + // Capture immutable locals so callback closures never reference a + // replacement PTY or session key from a later init on the same socket. + spawnedPty.onData((chunk) => { + const session = ptySessionsMap.get(sessionKey); + if (!session || session.pty !== spawnedPty) { return; } @@ -355,13 +342,15 @@ export function handleShellConnection( } }); - shellProcess.onExit((exitCode) => { - if (!ptySessionKey) { + spawnedPty.onExit((exitCode) => { + const session = ptySessionsMap.get(sessionKey); + // 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 !== spawnedPty) { return; } - const session = ptySessionsMap.get(ptySessionKey); - if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { + if (session.ws && session.ws.readyState === WebSocket.OPEN) { session.ws.send( JSON.stringify({ type: 'output', @@ -372,12 +361,14 @@ export function handleShellConnection( ); } - if (session?.timeoutId) { + if (session.timeoutId) { clearTimeout(session.timeoutId); } - ptySessionsMap.delete(ptySessionKey); - shellProcess = null; + ptySessionsMap.delete(sessionKey); + if (shellProcess === spawnedPty) { + shellProcess = null; + } }); let welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`; 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..6aee15b62a 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, @@ -152,6 +155,12 @@ export default function CodeEditor({ dependency: content, }); + // Ensure projectId is populated for preview API requests. + const previewFile = useMemo( + () => ({ ...file, projectId: file.projectId ?? projectPath ?? '' }), + [file, projectPath], + ); + if (loading) { 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..ae2358bfab --- /dev/null +++ b/src/components/code-editor/view/subcomponents/CodeEditorImagePreview.tsx @@ -0,0 +1,157 @@ +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..6b22dca3f2 --- /dev/null +++ b/src/components/code-editor/view/subcomponents/CodeEditorPdfPreview.tsx @@ -0,0 +1,161 @@ +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 && ( +