diff --git a/src/apps/desktop/src/api/clipboard_file_api.rs b/src/apps/desktop/src/api/clipboard_file_api.rs index 6b99ec9af8..f5a79c1d0e 100644 --- a/src/apps/desktop/src/api/clipboard_file_api.rs +++ b/src/apps/desktop/src/api/clipboard_file_api.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use std::path::Path; #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct ClipboardFilesResponse { pub files: Vec, pub is_cut: bool, @@ -155,25 +156,26 @@ mod windows_clipboard { return Ok(Vec::new()); } - let file_count = DragQueryFileW(hdrop, 0xFFFFFFFF, std::ptr::null_mut(), 0); + let file_count = DragQueryFileW(hdrop, u32::MAX, std::ptr::null_mut(), 0); if file_count == 0 { return Ok(Vec::new()); } let mut files = Vec::with_capacity(file_count as usize); - - for i in 0..file_count { - let len = DragQueryFileW(hdrop, i, std::ptr::null_mut(), 0); + for index in 0..file_count { + let len = DragQueryFileW(hdrop, index, std::ptr::null_mut(), 0); if len == 0 { continue; } - let mut buffer: Vec = vec![0; (len + 1) as usize]; - let actual_len = DragQueryFileW(hdrop, i, buffer.as_mut_ptr(), len + 1); - + let mut buffer = vec![0_u16; len as usize + 1]; + let actual_len = DragQueryFileW(hdrop, index, buffer.as_mut_ptr(), len + 1); if actual_len > 0 { - let path = OsString::from_wide(&buffer[..actual_len as usize]); - files.push(path.to_string_lossy().into_owned()); + files.push( + OsString::from_wide(&buffer[..actual_len as usize]) + .to_string_lossy() + .into_owned(), + ); } } @@ -474,10 +476,22 @@ pub(crate) fn copy_directory_recursive(source: &Path, target: &Path) -> Result<( mod tests { use super::{ copy_directory_recursive, decode_file_uri, generate_unique_path, - parse_clipboard_path_segments, parse_uri_list, + parse_clipboard_path_segments, parse_uri_list, ClipboardFilesResponse, }; use std::path::Path; + #[test] + fn clipboard_files_response_uses_camel_case() { + let value = serde_json::to_value(ClipboardFilesResponse { + files: vec!["C:/example.txt".to_string()], + is_cut: true, + }) + .expect("serialize clipboard response"); + + assert_eq!(value["isCut"], true); + assert!(value.get("is_cut").is_none()); + } + #[test] fn decode_unix_file_uri() { assert_eq!( diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 008a929927..87298e3689 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -281,18 +281,27 @@ pub async fn read_text_file( resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await?; match &target { DesktopPathTarget::Local { resolved_path, .. } => { + if encoding.is_some_and(|value| { + value.eq_ignore_ascii_case("base64") || value.eq_ignore_ascii_case("text-preview") + }) { + let bytes = app_state + .filesystem_service + .read_file_bytes(&resolved_path.to_string_lossy()) + .await + .map_err(|e| format!("Failed to read file content: {}", e))?; + + if encoding.is_some_and(|value| value.eq_ignore_ascii_case("base64")) { + return Ok(BASE64.encode(bytes)); + } + return Ok(String::from_utf8_lossy(&bytes).into_owned()); + } + let result = app_state .filesystem_service .read_file(&resolved_path.to_string_lossy()) .await .map_err(|e| format!("Failed to read file content: {}", e))?; - if encoding.is_some_and(|value| value.eq_ignore_ascii_case("base64")) - && !result.encoding.eq_ignore_ascii_case("base64") - { - Ok(BASE64.encode(result.content.as_bytes())) - } else { - Ok(result.content) - } + Ok(result.content) } DesktopPathTarget::Remote { requested_path, @@ -316,6 +325,10 @@ fn encode_remote_file_bytes(bytes: Vec, encoding: Option<&str>) -> Result { try { const systemPasteboard = pasteboard.getSystemPasteboard(); @@ -123,21 +122,33 @@ export class CommonUtils { for (let index = 0; index < recordCount; index++) { const record = data.getRecord(index); const candidates: string[] = []; - - const uri = (record.uri || '').trim(); - if (uri.length > 0) { - candidates.push(uri); - } - - const plainText = (record.plainText || '').trim(); - if (plainText.length > 0) { - // A bare URI list may come through as plain text (one per line). - for (const line of plainText.split(/\r?\n/)) { + const addStringCandidates = (value: string): void => { + for (const line of value.split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed.length > 0) { candidates.push(trimmed); } } + }; + + addStringCandidates(record.uri || ''); + addStringCandidates(record.plainText || ''); + + // File Manager can place URI and plain-text values in additional MIME + // entries. Those values are only available through getData(). + const supportedTypes = record.getValidTypes([ + pasteboard.MIMETYPE_TEXT_URI, + pasteboard.MIMETYPE_TEXT_PLAIN + ]); + for (const type of supportedTypes) { + try { + const value = await record.getData(type); + if (typeof value === 'string') { + addStringCandidates(value); + } + } catch (e) { + hilog.warn(0x0000, 'vnext', 'read_clipboard_files: failed to read mime entry ' + type + ': ' + e); + } } for (const candidate of candidates) { diff --git a/src/crates/assembly/core/src/service/filesystem/service.rs b/src/crates/assembly/core/src/service/filesystem/service.rs index bdae1ec33b..79a43afaee 100644 --- a/src/crates/assembly/core/src/service/filesystem/service.rs +++ b/src/crates/assembly/core/src/service/filesystem/service.rs @@ -255,6 +255,14 @@ impl FileSystemService { .map_err(map_filesystem_error) } + /// Reads raw file bytes using the shared filesystem access and size checks. + pub async fn read_file_bytes(&self, file_path: &str) -> BitFunResult> { + self.inner + .read_file_bytes(file_path) + .await + .map_err(map_filesystem_error) + } + /// Reads a file. pub async fn read_file(&self, file_path: &str) -> BitFunResult { self.inner diff --git a/src/crates/services/services-core/src/filesystem/operations.rs b/src/crates/services/services-core/src/filesystem/operations.rs index 96f10b42f9..e019e0419e 100644 --- a/src/crates/services/services-core/src/filesystem/operations.rs +++ b/src/crates/services/services-core/src/filesystem/operations.rs @@ -103,37 +103,20 @@ impl FileOperationService { } } - pub async fn read_file(&self, file_path: &str) -> FileSystemResult { + pub async fn read_file_bytes(&self, file_path: &str) -> FileSystemResult> { let path = Path::new(file_path); - self.validate_file_access(path, false).await?; - - if !path.exists() { - return Err(FileSystemError::service(format!( - "File does not exist: {}", - file_path - ))); - } + self.validate_readable_file(path, file_path).await?; - if path.is_dir() { - return Err(FileSystemError::service(format!( - "Path is a directory: {}", - file_path - ))); - } + fs::read(path) + .await + .map_err(|e| FileSystemError::service(format!("Failed to read file: {}", e))) + } - let metadata = fs::metadata(path).await.map_err(|e| { - FileSystemError::service(format!("Failed to read file metadata: {}", e)) - })?; + pub async fn read_file(&self, file_path: &str) -> FileSystemResult { + let path = Path::new(file_path); - let file_size = metadata.len(); - if file_size > self.max_file_size_mb * 1024 * 1024 { - return Err(FileSystemError::service(format!( - "File too large: {}MB (max: {}MB)", - file_size / (1024 * 1024), - self.max_file_size_mb - ))); - } + let file_size = self.validate_readable_file(path, file_path).await?; match fs::read_to_string(path).await { Ok(content) => { @@ -520,6 +503,39 @@ impl FileOperationService { Path::new(path).is_file() } + async fn validate_readable_file(&self, path: &Path, file_path: &str) -> FileSystemResult { + self.validate_file_access(path, false).await?; + + if !path.exists() { + return Err(FileSystemError::service(format!( + "File does not exist: {}", + file_path + ))); + } + + if path.is_dir() { + return Err(FileSystemError::service(format!( + "Path is a directory: {}", + file_path + ))); + } + + let metadata = fs::metadata(path).await.map_err(|e| { + FileSystemError::service(format!("Failed to read file metadata: {}", e)) + })?; + + let file_size = metadata.len(); + if file_size > self.max_file_size_mb * 1024 * 1024 { + return Err(FileSystemError::service(format!( + "File too large: {}MB (max: {}MB)", + file_size / (1024 * 1024), + self.max_file_size_mb + ))); + } + + Ok(file_size) + } + async fn validate_file_access(&self, path: &Path, is_write: bool) -> FileSystemResult<()> { for restricted in &self.restricted_paths { if path.starts_with(restricted) { diff --git a/src/crates/services/services-core/src/filesystem/service.rs b/src/crates/services/services-core/src/filesystem/service.rs index 546f5bb2f4..d79588a62e 100644 --- a/src/crates/services/services-core/src/filesystem/service.rs +++ b/src/crates/services/services-core/src/filesystem/service.rs @@ -271,6 +271,11 @@ impl FileSystemService { Ok(outcome) } + /// Reads raw file bytes with the same access and size checks as [`Self::read_file`]. + pub async fn read_file_bytes(&self, file_path: &str) -> FileSystemResult> { + self.file_operation_service.read_file_bytes(file_path).await + } + /// Reads a file. pub async fn read_file(&self, file_path: &str) -> FileSystemResult { self.file_operation_service.read_file(file_path).await diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index 229da7ba44..673c29d298 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -3,6 +3,7 @@ import { Download, Copy, X, AlertCircle } from 'lucide-react'; import { IconButton } from '@/component-library'; import { Markdown as MarkdownRenderer } from '@/component-library/components/Markdown/Markdown'; import { useI18n } from '@/infrastructure/i18n'; +import { getFileIconType } from '@/infrastructure/language-detection'; import { createLogger } from '@/shared/utils/logger'; import { globalEventBus } from '@/infrastructure/event-bus'; @@ -423,6 +424,9 @@ const FlexiblePanel: React.FC = memo(({ const fileName = editorData.fileName || content.title; const editorLanguage = editorData.language; const editorWorkspacePath = editorData.workspacePath || workspacePath; + const isBinaryPreview = ['archive', 'binary'].includes( + getFileIconType(fileName) + ); const syncGenerativeWidgetToolResult = async (nextWidgetCode: string, persistToSession: boolean) => { const source = editorData._source; if ( @@ -492,7 +496,8 @@ const FlexiblePanel: React.FC = memo(({ workspacePath={editorWorkspacePath} fileName={fileName} language={editorLanguage} - readOnly={editorData.readOnly || false} + readOnly={isBinaryPreview || editorData.readOnly || false} + readEncoding={isBinaryPreview ? 'text-preview' : undefined} autoSave={editorData.autoSave === true} autoSaveDelayMs={typeof editorData.autoSaveDelayMs === 'number' ? editorData.autoSaveDelayMs : undefined} showLineNumbers={editorData.showLineNumbers !== false} diff --git a/src/web-ui/src/infrastructure/language-detection/core/LanguageRegistry.ts b/src/web-ui/src/infrastructure/language-detection/core/LanguageRegistry.ts index d039e9f14c..1b6d96bc75 100644 --- a/src/web-ui/src/infrastructure/language-detection/core/LanguageRegistry.ts +++ b/src/web-ui/src/infrastructure/language-detection/core/LanguageRegistry.ts @@ -296,7 +296,7 @@ const BUILTIN_LANGUAGES: Language[] = [ id: 'xml', name: 'XML', category: 'markup', - extensions: ['xml', 'xsl', 'xslt', 'xsd', 'svg', 'rss', 'atom'], + extensions: ['xml', 'xsl', 'xslt', 'xsd', 'rss', 'atom'], monacoId: 'xml', iconType: 'xml', color: BUILTIN_LANGUAGE_ACCENTS.xml, diff --git a/src/web-ui/src/infrastructure/language-detection/utils/helpers.test.ts b/src/web-ui/src/infrastructure/language-detection/utils/helpers.test.ts new file mode 100644 index 0000000000..abc051e98f --- /dev/null +++ b/src/web-ui/src/infrastructure/language-detection/utils/helpers.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getEditorType, getFileIconType } from './helpers'; + +describe('language detection editor routing', () => { + it('routes SVG files to the image viewer', () => { + expect(getFileIconType('icon.svg')).toBe('image'); + expect(getEditorType('icon.svg')).toBe('image-viewer'); + }); + + it('keeps executable and archive files in the text editor route', () => { + expect(getFileIconType('app.exe')).toBe('binary'); + expect(getFileIconType('bundle.zip')).toBe('archive'); + expect(getEditorType('app.exe')).toBe('code-editor'); + expect(getEditorType('bundle.zip')).toBe('code-editor'); + }); +}); diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 1cecfaa49f..fc94fb8e0d 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -73,6 +73,8 @@ export interface CodeEditorProps { language?: string; /** Read-only mode */ readOnly?: boolean; + /** Read encoding passed to the workspace file API. */ + readEncoding?: string; /** Show line numbers */ showLineNumbers?: boolean; /** Show minimap */ @@ -170,6 +172,7 @@ const CodeEditor: React.FC = ({ fileName, language = 'plaintext', readOnly = false, + readEncoding, showLineNumbers = true, showMinimap = true, className = '', @@ -1588,11 +1591,11 @@ const CodeEditor: React.FC = ({ let fileSizeBytes = typeof fileInfoBefore?.size === 'number' ? fileInfoBefore.size : undefined; - const shouldPreview = typeof fileSizeBytes === 'number' + const shouldPreview = !readEncoding && typeof fileSizeBytes === 'number' && fileSizeBytes > LARGE_FILE_FULL_LOAD_LIMIT_BYTES; const fileContent = shouldPreview ? await workspaceAPI.readFileContentPrefix(filePath, LARGE_FILE_PREVIEW_BYTES) - : await workspaceAPI.readFileContent(filePath); + : await workspaceAPI.readFileContent(filePath, readEncoding); setLargeFilePreview(shouldPreview); reportFileMissingFromDisk(false); try { @@ -1665,6 +1668,7 @@ const CodeEditor: React.FC = ({ filePath, initialContent, isMemoryContent, + readEncoding, reportFileMissingFromDisk, t, updateLargeFileMode, @@ -1672,7 +1676,7 @@ const CodeEditor: React.FC = ({ // Save file content const saveFileContent = useCallback(async () => { - if (!filePath) return; + if (!filePath || readOnly) return; if (isMemoryContent) return; // Read latest hasChanges state from ref to avoid closure issues @@ -1709,7 +1713,7 @@ const CodeEditor: React.FC = ({ confirmDanger: true, }); if (!overwrite) { - const diskContent = await workspaceAPI.readFileContent(filePath); + const diskContent = await workspaceAPI.readFileContent(filePath, readEncoding); const fileInfoAfter = await fetchFileMetadata(); const vAfter = diskVersionFromMetadata(fileInfoAfter); applyDiskSnapshotToEditor(diskContent, vAfter); @@ -1758,6 +1762,8 @@ const CodeEditor: React.FC = ({ filePath, isMemoryContent, onSave, + readEncoding, + readOnly, reportFileMissingFromDisk, t, workspacePath, @@ -1768,7 +1774,7 @@ const CodeEditor: React.FC = ({ }, [saveFileContent]); useEffect(() => { - if (!autoSave || !filePath || !hasChanges || loading || saving) { + if (!autoSave || readOnly || !filePath || !hasChanges || loading || saving) { return; } @@ -1779,7 +1785,7 @@ const CodeEditor: React.FC = ({ return () => { window.clearTimeout(timeout); }; - }, [autoSave, autoSaveDelayMs, filePath, hasChanges, loading, saving, content]); + }, [autoSave, autoSaveDelayMs, filePath, hasChanges, loading, readOnly, saving, content]); // Container-level keyboard event handler, solves global conflict issues with multiple editor instances const handleContainerKeyDown = useCallback((event: React.KeyboardEvent) => { @@ -1911,7 +1917,7 @@ const CodeEditor: React.FC = ({ return; } - const fileContent = await workspaceAPI.readFileContent(filePath); + const fileContent = await workspaceAPI.readFileContent(filePath, readEncoding); if (diskContentMatchesEditorForExternalSync(fileContent, editorBuffer)) { diskVersionRef.current = currentVersion; outcome = 'content-match'; @@ -1962,7 +1968,7 @@ const CodeEditor: React.FC = ({ } isCheckingFileRef.current = false; } - }, [applyDiskSnapshotToEditor, fetchFileMetadata, filePath, isActiveTab, reportFileMissingFromDisk, t]); + }, [applyDiskSnapshotToEditor, fetchFileMetadata, filePath, isActiveTab, readEncoding, reportFileMissingFromDisk, t]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); @@ -2229,7 +2235,7 @@ const CodeEditor: React.FC = ({ }); } - const diskContent = await workspaceAPI.readFileContent(filePath); + const diskContent = await workspaceAPI.readFileContent(filePath, readEncoding); const editorBuffer = modelRef.current?.getValue(); if ( bufferBeforeRead !== undefined && @@ -2297,7 +2303,7 @@ const CodeEditor: React.FC = ({ return () => { unsubscribers.forEach(unsub => unsub()); }; - }, [applyDiskSnapshotToEditor, fetchFileMetadata, monacoReady, filePath, t, workspacePath]); + }, [applyDiskSnapshotToEditor, fetchFileMetadata, monacoReady, filePath, readEncoding, t, workspacePath]); useEffect(() => { userLanguageOverrideRef.current = false; diff --git a/src/web-ui/src/tools/editor/components/ImageViewer.test.ts b/src/web-ui/src/tools/editor/components/ImageViewer.test.ts new file mode 100644 index 0000000000..0a1df3ea86 --- /dev/null +++ b/src/web-ui/src/tools/editor/components/ImageViewer.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { getSmallImageDisplayScale } from './ImageViewer'; + +function getDisplayedSize(width: number, height: number, zoom: number) { + const displayScale = getSmallImageDisplayScale(width, height); + return { + width: width * displayScale * zoom / 100, + height: height * displayScale * zoom / 100, + }; +} + +describe('ImageViewer small image scaling', () => { + it('makes a 1 by 1 image visible without changing its reported dimensions', () => { + expect(getSmallImageDisplayScale(1, 1)).toBe(32); + }); + + it('keeps a 1 by 1 image square at low zoom levels', () => { + expect(getDisplayedSize(1, 1, 25)).toEqual({ width: 8, height: 8 }); + expect(getDisplayedSize(1, 1, 100)).toEqual({ width: 32, height: 32 }); + }); + + it('preserves the natural scale for normal images', () => { + expect(getSmallImageDisplayScale(640, 480)).toBe(1); + }); + + it('does not scale invalid dimensions', () => { + expect(getSmallImageDisplayScale(0, 1)).toBe(1); + }); +}); diff --git a/src/web-ui/src/tools/editor/components/ImageViewer.tsx b/src/web-ui/src/tools/editor/components/ImageViewer.tsx index e92c5fcc27..e8f6717380 100644 --- a/src/web-ui/src/tools/editor/components/ImageViewer.tsx +++ b/src/web-ui/src/tools/editor/components/ImageViewer.tsx @@ -14,6 +14,16 @@ import './ImageViewer.scss'; const log = createLogger('ImageViewer'); +const MIN_SMALL_IMAGE_DISPLAY_SIZE = 32; + +export function getSmallImageDisplayScale(width: number, height: number): number { + if (width <= 0 || height <= 0 || width > MIN_SMALL_IMAGE_DISPLAY_SIZE || height > MIN_SMALL_IMAGE_DISPLAY_SIZE) { + return 1; + } + + return Math.max(1, MIN_SMALL_IMAGE_DISPLAY_SIZE / Math.max(width, height)); +} + export interface ImageViewerProps { /** Image file path */ filePath: string; @@ -35,6 +45,7 @@ export const ImageViewer: React.FC = ({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [zoom, setZoom] = useState(100); + const [displayScale, setDisplayScale] = useState(1); const [rotation, setRotation] = useState(0); const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); @@ -67,9 +78,11 @@ export const ImageViewer: React.FC = ({ try { setLoading(true); setError(null); + setImageDimensions(null); + setDisplayScale(1); const { workspaceAPI } = await import('@/infrastructure/api'); - const result = await workspaceAPI.readFileContent(filePath); + const result = await workspaceAPI.readFileContent(filePath, 'base64'); const mimeType = getMimeType(filePath); @@ -96,6 +109,7 @@ export const ImageViewer: React.FC = ({ width: img.naturalWidth, height: img.naturalHeight }); + setDisplayScale(getSmallImageDisplayScale(img.naturalWidth, img.naturalHeight)); }, []); const handleImageError = useCallback((e: React.SyntheticEvent) => { @@ -244,7 +258,11 @@ export const ImageViewer: React.FC = ({ alt={fileName || filePath} className="bitfun-image-viewer__image" style={{ - transform: `scale(${zoom / 100}) rotate(${rotation}deg)`, + width: imageDimensions ? `${imageDimensions.width * displayScale * zoom / 100}px` : undefined, + height: imageDimensions ? `${imageDimensions.height * displayScale * zoom / 100}px` : undefined, + transform: `rotate(${rotation}deg)`, + imageRendering: displayScale > 1 ? 'pixelated' : undefined, + borderRadius: 0, }} onLoad={handleImageLoad} onError={handleImageError}