diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index a93b6dc868..2c56fdd9e4 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -5,8 +5,8 @@ use crate::api::dto::WorkspaceInfoDto; use crate::api::path_target::{ create_directory as create_desktop_directory, create_empty_file, delete_directory as delete_desktop_directory, delete_file as delete_desktop_file, - get_path_metadata, path_exists, read_text_file, rename_path, resolve_desktop_path_target, - write_text_file, DesktopPathTarget, + get_path_metadata, path_exists, read_text_file, read_text_file_prefix, rename_path, + resolve_desktop_path_target, write_text_file, DesktopPathTarget, }; use crate::api::search_api::{ build_content_search_request, group_search_results, prepare_content_search_runner, @@ -465,6 +465,16 @@ pub struct ReadFileContentRequest { pub remote_connection_id: Option, } +#[derive(Debug, Deserialize)] +pub struct ReadFileContentPrefixRequest { + #[serde(rename = "filePath")] + pub file_path: String, + #[serde(rename = "maxBytes")] + pub max_bytes: usize, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ImportAgentCompanionPetPackageRequest { @@ -2613,6 +2623,20 @@ pub async fn explorer_get_children_paginated( get_directory_children_paginated_response(&state, &request).await } +#[tauri::command] +pub async fn read_file_content_prefix( + state: State<'_, AppState>, + request: ReadFileContentPrefixRequest, +) -> Result { + read_text_file_prefix( + &state, + &request.file_path, + request.max_bytes, + request.remote_connection_id.as_deref(), + ) + .await +} + #[tauri::command] pub async fn read_file_content( state: State<'_, AppState>, @@ -3198,6 +3222,8 @@ pub async fn create_directory( pub struct CompressPathRequest { pub path: String, #[serde(default)] + pub is_directory: bool, + #[serde(default)] pub remote_connection_id: Option, } @@ -3218,12 +3244,14 @@ pub async fn compress_path( request: CompressPathRequest, ) -> Result { let src = request.path; + let is_directory = request.is_directory; let remote_cid = request.remote_connection_id; // Remote: execute compress command via SSH. if let Some(cid) = &remote_cid { let manager = state.get_ssh_manager_async().await?; let (parent, base_name) = split_remote_archive_path(&src)?; + let archive_base_name = archive_output_base_name(&base_name, is_directory); // Run from the source's parent directory so an absolute remote path is // never serialized as archive member directories. Directory archives @@ -3231,7 +3259,7 @@ pub async fn compress_path( // therefore does not create `//...`. let mut zip_suffix = 0; let zip_archive_name = loop { - let candidate = archive_name_with_suffix(&base_name, ".zip", zip_suffix); + let candidate = archive_name_with_suffix(&archive_base_name, ".zip", zip_suffix); let check_command = build_remote_path_exists_command(&parent, &candidate); let (_, _, code) = manager .execute_command(cid, &check_command) @@ -3262,7 +3290,7 @@ pub async fn compress_path( // zip not available or failed — try tar. let mut tar_suffix = 0; let tar_archive_name = loop { - let candidate = archive_name_with_suffix(&base_name, ".tar.gz", tar_suffix); + let candidate = archive_name_with_suffix(&archive_base_name, ".tar.gz", tar_suffix); let check_command = build_remote_path_exists_command(&parent, &candidate); let (_, _, code) = manager .execute_command(cid, &check_command) @@ -3321,7 +3349,8 @@ pub async fn compress_path( .and_then(|n| n.to_str()) .ok_or_else(|| format!("Cannot determine file name of '{}'", src))? .to_string(); - let zip_path = next_available_local_archive_path(parent, &file_name, ".zip"); + let archive_base_name = archive_output_base_name(&file_name, src_path.is_dir()); + let zip_path = next_available_local_archive_path(parent, &archive_base_name, ".zip"); let zip_path_clone = zip_path.clone(); let src_path_clone = src_path.clone(); @@ -3335,6 +3364,17 @@ pub async fn compress_path( Ok(zip_path.to_string_lossy().to_string()) } +fn archive_output_base_name(source_name: &str, is_directory: bool) -> String { + if is_directory { + return source_name.to_string(); + } + + match source_name.rfind('.') { + Some(index) if index > 0 => source_name[..index].to_string(), + _ => source_name.to_string(), + } +} + fn archive_name_with_suffix(base_name: &str, extension: &str, suffix: usize) -> String { if suffix == 0 { format!("{}{}", base_name, extension) @@ -3352,6 +3392,22 @@ fn build_remote_path_exists_command(parent: &str, candidate: &str) -> String { ) } +fn build_remote_extraction_destination_available_command( + parent: &str, + candidate: &str, + allow_existing_directory: bool, +) -> String { + let parent = shell_quote_posix(parent); + let candidate = shell_quote_posix(&format!("./{}", candidate)); + if allow_existing_directory { + format!( + "cd -- {parent} || exit 2; if [ -L {candidate} ]; then exit 1; fi; [ -d {candidate} ] || [ ! -e {candidate} ]" + ) + } else { + format!("cd -- {parent} || exit 2; test ! -e {candidate} && test ! -L {candidate}") + } +} + fn next_available_local_archive_path(parent: &Path, base_name: &str, extension: &str) -> PathBuf { let initial = parent.join(archive_name_with_suffix(base_name, extension, 0)); if !initial.exists() { @@ -3399,7 +3455,12 @@ fn split_remote_archive_path(path: &str) -> Result<(String, String), String> { // // Checked against the input rather than the resolved parent, which is // synthesized for relative paths. - if trimmed.split('/').rev().skip(1).any(|component| component == "..") { + if trimmed + .split('/') + .rev() + .skip(1) + .any(|component| component == "..") + { return Err(format!( "Remote path '{}' must not contain '..' components", path @@ -3578,7 +3639,32 @@ pub async fn decompress_path( if let Some(cid) = &remote_cid { let manager = state.get_ssh_manager_async().await?; let (remote_parent, remote_file_name) = split_remote_archive_path(&src)?; - let remote_dest_dir_name = archive_destination_name(&remote_file_name)?; + let remote_dest_base_name = archive_destination_name(&remote_file_name)?; + let mut remote_dest_suffix = 0; + let remote_dest_dir_name = loop { + let candidate = + extraction_destination_name_with_suffix(&remote_dest_base_name, remote_dest_suffix); + let check_command = build_remote_extraction_destination_available_command( + &remote_parent, + &candidate, + remote_dest_suffix == 0, + ); + let (stdout, stderr, code) = manager + .execute_command(cid, &check_command) + .await + .map_err(|e| e.to_string())?; + match code { + 0 => break candidate, + 1 => remote_dest_suffix += 1, + _ => { + let error = if stderr.is_empty() { stdout } else { stderr }; + return Err(format!( + "Failed to inspect remote extraction destination: {}", + error.trim() + )); + } + } + }; let lower = remote_file_name.to_lowercase(); let (extract_command, required_tool, label, legacy_wrapper_relative) = @@ -3689,7 +3775,8 @@ pub async fn decompress_path( .and_then(|n| n.to_str()) .ok_or_else(|| format!("Cannot determine file name of '{}'", src))? .to_string(); - let dest_dir = parent.join(archive_destination_name(&file_name)?); + let destination_base_name = archive_destination_name(&file_name)?; + let dest_dir = next_available_local_extraction_path(parent, &destination_base_name)?; let dest_dir_clone = dest_dir.clone(); let src_clone = src_path; @@ -3865,6 +3952,8 @@ fn extract_local_archive( "tar.xz", )?; } + #[cfg(target_env = "ohos")] + return Err("Unsupported archive format on this platform: tar.xz".to_string()); } else if lower.ends_with(".tar.zst") || lower.ends_with(".tzst") { let file = std::fs::File::open(source_path) .map_err(|e| format!("Failed to open '{}': {}", source_path.display(), e))?; @@ -4092,6 +4181,40 @@ fn archive_destination_name(file_name: &str) -> Result { } } +fn extraction_destination_name_with_suffix(base_name: &str, suffix: usize) -> String { + if suffix == 0 { + base_name.to_string() + } else { + format!("{} ({})", base_name, suffix) + } +} + +fn next_available_local_extraction_path(parent: &Path, base_name: &str) -> Result { + for suffix in 0.. { + let candidate = parent.join(extraction_destination_name_with_suffix(base_name, suffix)); + match std::fs::symlink_metadata(&candidate) { + Ok(metadata) + if suffix == 0 + && metadata.file_type().is_dir() + && !metadata.file_type().is_symlink() => + { + return Ok(candidate); + } + Ok(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate), + Err(error) => { + return Err(format!( + "Failed to inspect extraction destination '{}': {}", + candidate.display(), + error + )); + } + } + } + + unreachable!("extraction destination suffix search is unbounded") +} + #[cfg(test)] mod archive_tests { use super::*; @@ -4110,6 +4233,20 @@ mod archive_tests { writer.finish().expect("finish test zip"); } + #[test] + fn archive_output_names_drop_file_extensions_only() { + assert_eq!(archive_output_base_name("notes.txt", false), "notes"); + assert_eq!( + archive_output_base_name("bundle.tar.gz", false), + "bundle.tar" + ); + assert_eq!(archive_output_base_name(".env", false), ".env"); + assert_eq!( + archive_output_base_name("folder.with.dot", true), + "folder.with.dot" + ); + } + #[test] fn local_archive_path_uses_incrementing_suffixes() { let temp = tempfile::tempdir().expect("create temp dir"); @@ -4123,6 +4260,26 @@ mod archive_tests { ); } + #[test] + fn local_extraction_path_avoids_existing_files_but_reuses_base_directory() { + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::write(temp.path().join("aa"), "source").expect("write source file"); + std::fs::create_dir(temp.path().join("aa (1)")).expect("create existing directory"); + + assert_eq!( + next_available_local_extraction_path(temp.path(), "aa") + .expect("choose extraction destination"), + temp.path().join("aa (2)") + ); + + std::fs::create_dir(temp.path().join("project")).expect("create merge destination"); + assert_eq!( + next_available_local_extraction_path(temp.path(), "project") + .expect("reuse existing destination directory"), + temp.path().join("project") + ); + } + #[test] fn archive_names_with_special_characters_are_shell_quoted() { assert_eq!( @@ -4132,6 +4289,21 @@ mod archive_tests { let command = build_remote_path_exists_command("/home/work tree", "中 文@!# (2).zip"); assert!(command.contains("cd -- '/home/work tree'")); assert!(command.contains("test -e './中 文@!# (2).zip'")); + + let command = build_remote_extraction_destination_available_command( + "/home/work tree", + "中 文@!# (2)", + false, + ); + assert!(command.contains("test ! -e './中 文@!# (2)'")); + assert!(command.contains("test ! -L './中 文@!# (2)'")); + + let command = build_remote_extraction_destination_available_command( + "/home/work tree", + "中 文@!#", + true, + ); + assert!(command.contains("[ -d './中 文@!#' ] || [ ! -e './中 文@!#' ]")); } #[test] @@ -4187,6 +4359,30 @@ mod archive_tests { assert!(!source.join("project").exists()); } + #[test] + fn local_single_file_round_trip_uses_a_suffixed_destination() { + let temp = tempfile::tempdir().expect("create temp dir"); + let source = temp.path().join("aa"); + std::fs::write(&source, "content").expect("write source file"); + let archive_path = temp.path().join("aa.zip"); + create_local_zip_archive(&source, &archive_path, "aa").expect("create local archive"); + + let destination = next_available_local_extraction_path(temp.path(), "aa") + .expect("choose extraction destination"); + extract_local_archive(&archive_path, &destination, "aa.zip") + .expect("extract local archive beside existing source file"); + + assert_eq!(destination, temp.path().join("aa (1)")); + assert_eq!( + std::fs::read_to_string(destination.join("aa")).expect("read extracted file"), + "content" + ); + assert_eq!( + std::fs::read_to_string(source).expect("read original source file"), + "content" + ); + } + #[test] fn local_zip_round_trip_preserves_a_real_same_name_child_directory() { let temp = tempfile::tempdir().expect("create temp dir"); @@ -4585,7 +4781,6 @@ pub(crate) fn reveal_local_path_in_explorer( return Ok(()); } - #[cfg(target_os = "windows")] { if is_directory { diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 23ea8b1a46..008a929927 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -13,6 +13,7 @@ use bitfun_core::service::remote_ssh::{ }; use bitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind}; use serde::Serialize; +use std::io::Read; use std::path::{Path, PathBuf}; use std::time::SystemTime; @@ -239,6 +240,37 @@ pub fn stat_local_path_metadata( }) } +pub async fn read_text_file_prefix( + app_state: &AppState, + raw_path: &str, + max_bytes: usize, + preferred_remote_connection_id: Option<&str>, +) -> Result { + let target = + resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await?; + let bytes = match &target { + DesktopPathTarget::Local { resolved_path, .. } => { + let path = resolved_path.clone(); + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&path) + .map_err(|e| format!("Failed to open '{}': {}", path.display(), e))?; + let mut bytes = Vec::with_capacity(max_bytes.min(1024 * 1024)); + file.take(max_bytes as u64) + .read_to_end(&mut bytes) + .map_err(|e| format!("Failed to read '{}': {}", path.display(), e))?; + Ok::<_, String>(bytes) + }) + .await + .map_err(|e| e.to_string())?? + } + DesktopPathTarget::Remote { .. } => { + return Err("Large-file preview is not supported for remote workspaces".to_string()); + } + }; + + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + pub async fn read_text_file( app_state: &AppState, raw_path: &str, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 94d0b21777..cdc2ac9cbf 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1152,6 +1152,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("read_file_content", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "read_file_content_prefix", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("read_mcp_resource", RemoteWorkspacePolicy::LegacyUnaudited), ("record_file_change", RemoteWorkspacePolicy::LegacyUnaudited), ( diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index cc67dfc2dd..b325c59c66 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1074,6 +1074,7 @@ pub async fn _run() { import_agent_companion_pet_package, delete_agent_companion_pet_package, read_file_content, + read_file_content_prefix, write_file_content, reset_workspace_persona_files, check_path_exists, diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index 22601aa42f..5c61d97114 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -510,7 +510,7 @@ const FilesPanel: React.FC = ({ const ws = workspaceManager.getState().currentWorkspace; const remoteCid = ws?.connectionId; try { - await workspaceAPI.compressPath(data.path, remoteCid); + await workspaceAPI.compressPath(data.path, data.isDirectory === true, remoteCid); notification.success( t('archive.compressSuccess', { name: data.path.split(/[/\\]/).pop() || '' }), ); @@ -675,13 +675,6 @@ const FilesPanel: React.FC = ({ targetDirectory = normalizeWorkspaceTargetDirectory(targetDirectory, currentWorkspace); - notification.info( - t('notifications.pastingFiles', { - count: 1, - target: targetDirectory.split(/[/\\]/).pop(), - }) - ); - const result = await pasteClipboardFilesToWorkspaceDirectory( targetDirectory, currentWorkspace, @@ -695,16 +688,6 @@ const FilesPanel: React.FC = ({ } if (result.successCount > 0) { - const dirCount = result.directoryCount ?? 0; - let key: string; - if (dirCount === 0) { - key = 'notifications.pasteSuccessFiles'; - } else if (dirCount === result.successCount) { - key = 'notifications.pasteSuccessFolders'; - } else { - key = 'notifications.pasteSuccessItems'; - } - notification.success(t(key, { count: result.successCount })); await loadFileTree(undefined, true); if (!pathsEquivalentFs(targetDirectory, workspacePath)) { @@ -721,6 +704,14 @@ const FilesPanel: React.FC = ({ t('notifications.pasteFailed', { count: result.failedFiles.length }) + `:\n${failedNames}`, { duration: 5000 } ); + } else { + const dirCount = result.directoryCount ?? 0; + const key = dirCount === 0 + ? 'notifications.pasteSuccessFiles' + : dirCount === result.successCount + ? 'notifications.pasteSuccessFolders' + : 'notifications.pasteSuccessItems'; + notification.success(t(key, { count: result.successCount })); } } catch (error) { log.error('Failed to paste files', error); diff --git a/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx b/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx index 096dada150..ea5d7500f3 100644 --- a/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx +++ b/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx @@ -12,10 +12,21 @@ import { useTranslation } from 'react-i18next'; import { useCurrentWorkspace } from '../../../infrastructure/contexts/WorkspaceContext'; import { useI18n } from '@/infrastructure/i18n'; import { IconButton } from '@/component-library'; +import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; +import { FILETREE_SHORTCUTS } from '@/shared/constants/shortcuts'; import type { FileExplorerToolbarHandlers } from '@/tools/file-system'; import FilesPanel from '../../components/panels/FilesPanel'; import './FileViewerNav.scss'; +function shortcutTooltip(label: string, id: string): string { + const defaultConfig = FILETREE_SHORTCUTS.find((shortcut) => shortcut.id === id)?.config; + if (!defaultConfig) { + return label; + } + const config = shortcutManager.getEffectiveConfig(id, defaultConfig); + return `${label} (${shortcutManager.formatShortcut(config)})`; +} + const FileViewerNav: React.FC = () => { const { workspace: currentWorkspace } = useCurrentWorkspace(); const { t } = useI18n('common'); @@ -45,7 +56,7 @@ const FileViewerNav: React.FC = () => { size="xs" variant="ghost" onClick={explorerToolbar.onNewFile} - tooltip={tTools('fileTree.newFile')} + tooltip={shortcutTooltip(tTools('fileTree.newFile'), 'filetree.newFile')} tooltipPlacement="bottom" > @@ -54,7 +65,7 @@ const FileViewerNav: React.FC = () => { size="xs" variant="ghost" onClick={explorerToolbar.onNewFolder} - tooltip={tTools('fileTree.newFolder')} + tooltip={shortcutTooltip(tTools('fileTree.newFolder'), 'filetree.newFolder')} tooltipPlacement="bottom" > @@ -63,7 +74,7 @@ const FileViewerNav: React.FC = () => { size="xs" variant="ghost" onClick={explorerToolbar.onRefresh} - tooltip={tTools('fileTree.refresh')} + tooltip={shortcutTooltip(tTools('fileTree.refresh'), 'filetree.refresh')} tooltipPlacement="bottom" > diff --git a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts index 3ce587d191..031538db74 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts @@ -340,10 +340,10 @@ export class WorkspaceAPI { * Local workspaces produce `.zip`; remote workspaces try `zip` then `tar.gz`. * Returns the path of the created archive. */ - async compressPath(path: string, remoteConnectionId?: string): Promise { + async compressPath(path: string, isDirectory: boolean, remoteConnectionId?: string): Promise { try { return await api.invoke('compress_path', { - request: { path, remoteConnectionId } + request: { path, isDirectory, remoteConnectionId } }); } catch (error) { throw createTauriCommandError('compress_path', error, { path }); @@ -353,7 +353,8 @@ export class WorkspaceAPI { /** * Decompress an archive into a new folder named after the archive (without * extension) in the same parent directory. - * Supports `.zip`, `.tar.gz`, `.tgz`, `.tar`. + * Supports `.zip`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tbz2`, `.tar.xz`, + * `.txz`, `.tar.zst`, `.tzst`, and `.tar`. * Returns the path of the created folder. */ async decompressPath(path: string, remoteConnectionId?: string): Promise { @@ -414,6 +415,24 @@ export class WorkspaceAPI { } + async readFileContentPrefix( + filePath: string, + maxBytes: number, + remoteConnectionId?: string, + ): Promise { + try { + return await api.invoke('read_file_content_prefix', { + request: { filePath, maxBytes, remoteConnectionId } + }); + } catch (error) { + throw createTauriCommandError('read_file_content_prefix', error, { + filePath, + maxBytes, + remoteConnectionId, + }); + } + } + async readFileContent( filePath: string, encoding?: string, diff --git a/src/web-ui/src/locales/en-US/tools.json b/src/web-ui/src/locales/en-US/tools.json index d946ecb075..9400295ba7 100644 --- a/src/web-ui/src/locales/en-US/tools.json +++ b/src/web-ui/src/locales/en-US/tools.json @@ -32,6 +32,7 @@ "retry": "Retry", "loading": "Loading...", "loadFailed": "Load failed", + "largeFilePreview": "The file is too large; showing the first 8 MB as a read-only preview", "fileNotFound": "File not found", "permissionDenied": "Permission denied", "networkError": "Network error", diff --git a/src/web-ui/src/locales/zh-CN/tools.json b/src/web-ui/src/locales/zh-CN/tools.json index b24762c9f6..9d6aa5dbfe 100644 --- a/src/web-ui/src/locales/zh-CN/tools.json +++ b/src/web-ui/src/locales/zh-CN/tools.json @@ -32,6 +32,7 @@ "retry": "重试", "loading": "加载中...", "loadFailed": "加载失败", + "largeFilePreview": "文件过大,已打开前 8 MB 只读预览", "fileNotFound": "文件不存在", "permissionDenied": "权限不足", "networkError": "网络错误", diff --git a/src/web-ui/src/locales/zh-TW/tools.json b/src/web-ui/src/locales/zh-TW/tools.json index d2961c7a17..948e0c69f3 100644 --- a/src/web-ui/src/locales/zh-TW/tools.json +++ b/src/web-ui/src/locales/zh-TW/tools.json @@ -32,6 +32,7 @@ "retry": "重試", "loading": "載入中...", "loadFailed": "載入失敗", + "largeFilePreview": "檔案過大,已開啟前 8 MB 唯讀預覽", "fileNotFound": "檔案不存在", "permissionDenied": "權限不足", "networkError": "網絡錯誤", diff --git a/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts b/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts index 9aaa69da49..d5f00a297f 100644 --- a/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts +++ b/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts @@ -11,8 +11,21 @@ import { isRemoteWorkspace } from '../../../shared/types'; import { addFileMentionToChat } from '@/shared/utils/chatContext'; import { dirnameAbsolutePath } from '@/shared/utils/pathUtils'; import { isHtmlFilePath } from '@/shared/utils/htmlFilePreview'; +import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; +import { FILETREE_SHORTCUTS } from '@/shared/constants/shortcuts'; -const PASTE_SHORTCUT = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Cmd+V' : 'Ctrl+V'; +function fileTreeShortcut(id: string): string { + const defaultConfig = FILETREE_SHORTCUTS.find((shortcut) => shortcut.id === id)?.config; + if (!defaultConfig) { + return ''; + } + return shortcutManager.formatShortcut(shortcutManager.getEffectiveConfig(id, defaultConfig)); +} + +const PASTE_SHORTCUT = fileTreeShortcut('filetree.paste'); +const NEW_FILE_SHORTCUT = fileTreeShortcut('filetree.newFile'); +const NEW_FOLDER_SHORTCUT = fileTreeShortcut('filetree.newFolder'); +const DELETE_SHORTCUT = fileTreeShortcut('filetree.delete'); const ARCHIVE_EXTENSIONS = [ '.zip', '.tar.gz', '.tgz', '.tar', @@ -62,6 +75,7 @@ export class FileExplorerMenuProvider implements IMenuProvider { id: 'file-new-file', label: i18nService.t('common:file.newFile'), icon: 'FilePlus', + shortcut: NEW_FILE_SHORTCUT, onClick: () => { globalEventBus.emit('file:new-file', { parentPath }); } @@ -71,6 +85,7 @@ export class FileExplorerMenuProvider implements IMenuProvider { id: 'file-new-folder', label: i18nService.t('common:file.newFolder'), icon: 'FolderPlus', + shortcut: NEW_FOLDER_SHORTCUT, onClick: () => { globalEventBus.emit('file:new-folder', { parentPath }); } @@ -175,6 +190,7 @@ export class FileExplorerMenuProvider implements IMenuProvider { id: 'file-new-file', label: i18nService.t('common:file.file'), icon: 'FilePlus', + shortcut: NEW_FILE_SHORTCUT, command: 'file.new-file', onClick: async (ctx) => { await commandExecutor.execute('file.new-file', ctx); @@ -184,6 +200,7 @@ export class FileExplorerMenuProvider implements IMenuProvider { id: 'file-new-folder', label: i18nService.t('common:file.folder'), icon: 'FolderPlus', + shortcut: NEW_FOLDER_SHORTCUT, command: 'file.new-folder', onClick: async (ctx) => { await commandExecutor.execute('file.new-folder', ctx); @@ -210,6 +227,7 @@ export class FileExplorerMenuProvider implements IMenuProvider { id: 'file-delete', label: i18nService.t('common:file.delete'), icon: 'Trash2', + shortcut: DELETE_SHORTCUT, command: 'file.delete', onClick: async (ctx) => { await commandExecutor.execute('file.delete', ctx); diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index f3a0e47dd5..1cecfaa49f 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -106,6 +106,8 @@ export interface CodeEditorProps { } const LARGE_FILE_SIZE_THRESHOLD_BYTES = 1 * 1024 * 1024; // 1MB +const LARGE_FILE_FULL_LOAD_LIMIT_BYTES = 100 * 1024 * 1024; +const LARGE_FILE_PREVIEW_BYTES = 8 * 1024 * 1024; const LARGE_FILE_MAX_LINE_LENGTH = 20000; const LARGE_FILE_RENDER_LINE_LIMIT = 10000; const LARGE_FILE_MAX_TOKENIZATION_LINE_LENGTH = 2000; @@ -256,6 +258,7 @@ const CodeEditor: React.FC = ({ const [statusBarAnchorRect, setStatusBarAnchorRect] = useState(null); const [encoding, setEncoding] = useState('UTF-8'); const [largeFileMode, setLargeFileMode] = useState(false); + const [largeFilePreview, setLargeFilePreview] = useState(false); const containerRef = useRef(null); const editorRef = useRef(null); const modelRef = useRef(null); @@ -311,7 +314,7 @@ const CodeEditor: React.FC = ({ const editorConfigRuntimeRef = useRef(editorConfig); workspacePathRuntimeRef.current = workspacePath; - readOnlyRuntimeRef.current = readOnly; + readOnlyRuntimeRef.current = readOnly || largeFilePreview; showLineNumbersRuntimeRef.current = showLineNumbers; showMinimapRuntimeRef.current = showMinimap; onContentChangeRef.current = onContentChange; @@ -550,6 +553,7 @@ const CodeEditor: React.FC = ({ insertSpaces, wordWrap: (config.word_wrap as any) || 'off', lineNumbers: config.line_numbers as any || 'on', + readOnly: readOnly || largeFilePreview, minimap: { enabled: showMinimap && !largeFileMode, side: (config.minimap?.side as any) || 'right', @@ -604,7 +608,7 @@ const CodeEditor: React.FC = ({ return () => { globalEventBus.off('editor:config:changed', handleConfigChange); }; - }, [showMinimap, largeFileMode]); + }, [showMinimap, largeFileMode, largeFilePreview, readOnly]); useEffect(() => { const container = containerRef.current; @@ -1577,9 +1581,20 @@ const CodeEditor: React.FC = ({ try { const { workspaceAPI } = await import('@/infrastructure/api'); - const fileContent = await workspaceAPI.readFileContent(filePath); + const fileInfoBefore = await fetchFileMetadata(); + if (isFileMissingFromMetadata(fileInfoBefore)) { + throw new Error('File does not exist'); + } + let fileSizeBytes = typeof fileInfoBefore?.size === 'number' + ? fileInfoBefore.size + : undefined; + const shouldPreview = 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); + setLargeFilePreview(shouldPreview); reportFileMissingFromDisk(false); - let fileSizeBytes: number | undefined; try { const fileInfoAfter = await fetchFileMetadata(); if (isFileMissingFromMetadata(fileInfoAfter)) { @@ -2345,7 +2360,7 @@ const CodeEditor: React.FC = ({ data-monaco-editor="true" data-editor-id={`editor-${filePath.replace(/[^a-zA-Z0-9]/g, '-')}`} data-file-path={filePath} - data-readonly={readOnly ? 'true' : 'false'} + data-readonly={readOnly || largeFilePreview ? 'true' : 'false'} onKeyDownCapture={handleContainerKeyDown} > {showBreadcrumb && ( @@ -2388,6 +2403,12 @@ const CodeEditor: React.FC = ({ )} + {largeFilePreview && !loading && !error && ( +
+ {t('editor.common.largeFilePreview')} +
+ )} + {saving && (
{t('editor.codeEditor.saving')} @@ -2401,7 +2422,7 @@ const CodeEditor: React.FC = ({ selectedLines={selection.lines} language={detectedLanguage} encoding={encoding} - isReadOnly={readOnly} + isReadOnly={readOnly || largeFilePreview} lspStatus={ enableLsp && lspExtensionRegistry.isFileSupported(filePath) ? (lspReady ? 'connected' : 'connecting') diff --git a/src/web-ui/src/tools/file-system/components/FileExplorer.tsx b/src/web-ui/src/tools/file-system/components/FileExplorer.tsx index de765ea74a..86e909f244 100644 --- a/src/web-ui/src/tools/file-system/components/FileExplorer.tsx +++ b/src/web-ui/src/tools/file-system/components/FileExplorer.tsx @@ -1,5 +1,7 @@ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; +import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; +import { FILETREE_SHORTCUTS } from '@/shared/constants/shortcuts'; import { Folder, ChevronRight, FilePlus, FolderPlus, RefreshCw } from 'lucide-react'; import { FileTree } from './FileTree'; import { VirtualFileTree } from './VirtualFileTree'; @@ -46,6 +48,20 @@ function buildFileNodeContext(node: FileSystemNode, workspacePath?: string): Fil } const VIRTUAL_SCROLL_THRESHOLD = 100; +const getShortcutConfig = (id: string) => { + const defaultConfig = FILETREE_SHORTCUTS.find((shortcut) => shortcut.id === id)?.config; + if (!defaultConfig) { + throw new Error(`Missing file-tree shortcut definition: ${id}`); + } + return shortcutManager.getEffectiveConfig(id, defaultConfig); +}; +const NEW_FILE_SHORTCUT = getShortcutConfig('filetree.newFile'); +const NEW_FOLDER_SHORTCUT = getShortcutConfig('filetree.newFolder'); +const REFRESH_SHORTCUT = getShortcutConfig('filetree.refresh'); + +function withShortcut(label: string, config: typeof NEW_FILE_SHORTCUT): string { + return `${label} (${shortcutManager.formatShortcut(config)})`; +} interface ScrollBreadcrumbProps { containerRef: React.RefObject; @@ -367,19 +383,19 @@ export const FileExplorer: React.FC = ({ useShortcut( 'filetree.refresh', - { key: 'F5', scope: 'filetree' }, + REFRESH_SHORTCUT, handleRefresh, { enabled: Boolean(onRefresh), description: 'keyboard.shortcuts.filetree.refresh' } ); useShortcut( 'filetree.newFile', - { key: 'N', ctrl: true, scope: 'filetree' }, + NEW_FILE_SHORTCUT, handleNewFile, { enabled: Boolean(onNewFile), description: 'keyboard.shortcuts.filetree.newFile' } ); useShortcut( 'filetree.newFolder', - { key: 'N', ctrl: true, shift: true, scope: 'filetree' }, + NEW_FOLDER_SHORTCUT, handleNewFolder, { enabled: Boolean(onNewFolder), description: 'keyboard.shortcuts.filetree.newFolder' } ); @@ -449,7 +465,7 @@ export const FileExplorer: React.FC = ({ size="xs" variant="ghost" onClick={handleNewFile} - tooltip={t('fileTree.newFile')} + tooltip={withShortcut(t('fileTree.newFile'), NEW_FILE_SHORTCUT)} tooltipPlacement="bottom" > @@ -460,7 +476,7 @@ export const FileExplorer: React.FC = ({ size="xs" variant="ghost" onClick={handleNewFolder} - tooltip={t('fileTree.newFolder')} + tooltip={withShortcut(t('fileTree.newFolder'), NEW_FOLDER_SHORTCUT)} tooltipPlacement="bottom" > @@ -471,7 +487,7 @@ export const FileExplorer: React.FC = ({ size="xs" variant="ghost" onClick={handleRefresh} - tooltip={t('fileTree.refresh')} + tooltip={withShortcut(t('fileTree.refresh'), REFRESH_SHORTCUT)} tooltipPlacement="bottom" >