diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c2e96d9..9810423 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -49,9 +49,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arc-swap" diff --git a/src-tauri/src/git_attribution.rs b/src-tauri/src/git_attribution.rs index 397e9d3..33b2cff 100644 --- a/src-tauri/src/git_attribution.rs +++ b/src-tauri/src/git_attribution.rs @@ -62,7 +62,11 @@ struct RemoteTemplate { } pub(crate) async fn attribution_for_file(workspace_root: &Path, relative: &str) -> GitAttribution { - let file_path = match resolve_existing_workspace_file_path(workspace_root, relative) { + // Never follow a symlink that escapes the workspace here — attribution must + // not become a way to read external files without the trust gate. In-workspace + // symlinks still resolve (allow_external = false still follows within root); + // a symlink pointing outside the repo has no meaningful git blame anyway. + let file_path = match resolve_existing_workspace_file_path(workspace_root, relative, false) { Ok(path) => path, Err(error) => return unsupported(relative, workspace_error_reason(error)), }; @@ -524,7 +528,9 @@ fn workspace_error_reason(error: WorkspaceError) -> String { WorkspaceError::NotAFile => "Path is not a file".to_string(), WorkspaceError::OutsideWorkspace => "File is outside the workspace".to_string(), WorkspaceError::InvalidPath => "File path is not supported".to_string(), - WorkspaceError::SymlinkUnsupported => "Symbolic links are not supported".to_string(), + WorkspaceError::SymlinkOutsideWorkspace => { + "Symbolic link points outside the workspace".to_string() + } WorkspaceError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { "File does not exist".to_string() } diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs index bfa7259..de974ee 100644 --- a/src-tauri/src/http_server.rs +++ b/src-tauri/src/http_server.rs @@ -627,6 +627,7 @@ fn search_indexed_files_with_expansion( visibility.show_dotfiles, visibility.show_generated_internal, visibility.show_gitignored_files, + false, ) { Ok(entries) => entries, Err(error) if !directory.is_empty() && stale_indexed_directory_error(&error) => { @@ -688,6 +689,7 @@ async fn directory( query.show_dotfiles.unwrap_or(false), query.show_generated_internal.unwrap_or(false), query.show_gitignored_files.unwrap_or(false), + false, )?; state .workspace_index @@ -715,6 +717,7 @@ async fn read_file( &workspace_root, &query.path, max_open_bytes, + false, )?) } @@ -747,6 +750,7 @@ async fn write_file( &request.path, &request.contents, request.expected_modified_ms, + false, )?; refresh_indexed_entry(&state.workspace_index, &workspace_root, &request.path)?; Ok(StatusCode::NO_CONTENT) @@ -760,7 +764,7 @@ async fn create_file( ) -> Result { require_bearer_auth(&headers, &state.mcp_token)?; let workspace_root = resolved.workspace_root.read().await.clone(); - create_workspace_file(&workspace_root, &request.path)?; + create_workspace_file(&workspace_root, &request.path, false)?; refresh_indexed_entry(&state.workspace_index, &workspace_root, &request.path)?; Ok(StatusCode::CREATED) } @@ -773,7 +777,7 @@ async fn create_folder( ) -> Result { require_bearer_auth(&headers, &state.mcp_token)?; let workspace_root = resolved.workspace_root.read().await.clone(); - create_workspace_folder(&workspace_root, &request.path)?; + create_workspace_folder(&workspace_root, &request.path, false)?; refresh_indexed_entry(&state.workspace_index, &workspace_root, &request.path)?; Ok(StatusCode::CREATED) } @@ -842,7 +846,7 @@ async fn rename_file( ) -> Result { require_bearer_auth(&headers, &state.mcp_token)?; let workspace_root = resolved.workspace_root.read().await.clone(); - rename_workspace_file(&workspace_root, &request.from_path, &request.to_path)?; + rename_workspace_file(&workspace_root, &request.from_path, &request.to_path, false)?; state .workspace_index .remove_path(&workspace_root, &request.from_path)?; @@ -1553,6 +1557,9 @@ mod tests { depth: path.matches('/').count(), size: 0, modified_ms: Some(1), + is_symlink: false, + is_external: false, + symlink_target: None, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 672144d..24e1daa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -418,6 +418,10 @@ struct PersistedWorkspaceUiState { selected_path: Option, #[serde(default)] sidebar_width: Option, + // "Trust for workspace" decision for following symlinks whose target escapes + // the workspace root; persisted so it survives restart. + #[serde(default)] + trust_external_symlinks: bool, updated_at: u128, } @@ -429,6 +433,8 @@ struct WorkspaceUiStatePayload { active_file: Option, selected_path: Option, sidebar_width: Option, + #[serde(default)] + trust_external_symlinks: bool, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -681,6 +687,7 @@ async fn list_directory( show_dotfiles: bool, show_generated_internal: bool, show_gitignored_files: bool, + allow_external_symlinks: Option, ) -> Result, CommandError> { let workspace_root = workspace_root_for_window(&state, &window).await; let entries = workspace_directory_entries( @@ -689,6 +696,7 @@ async fn list_directory( show_dotfiles, show_generated_internal, show_gitignored_files, + allow_external_symlinks.unwrap_or(false), ) .map_err(CommandError::from)?; state @@ -766,6 +774,8 @@ fn search_indexed_files_with_expansion( visibility.show_dotfiles, visibility.show_generated_internal, visibility.show_gitignored_files, + // Background quick-open indexing never follows external symlinks. + false, ) { Ok(entries) => entries, Err(error) if !directory.is_empty() && stale_indexed_directory_error(&error) => { @@ -831,6 +841,7 @@ async fn read_file( state: State<'_, AppState>, path: String, max_open_bytes: Option, + allow_external_symlinks: Option, ) -> Result { let workspace_root = workspace_root_for_window(&state, &window).await; let max_open_bytes = max_open_bytes @@ -845,7 +856,13 @@ async fn read_file( MIN_MAX_OPEN_FILE_KB.saturating_mul(1024), MAX_MAX_OPEN_FILE_KB.saturating_mul(1024), ); - read_workspace_file(&workspace_root, &path, max_open_bytes).map_err(CommandError::from) + read_workspace_file( + &workspace_root, + &path, + max_open_bytes, + allow_external_symlinks.unwrap_or(false), + ) + .map_err(CommandError::from) } #[tauri::command] @@ -875,10 +892,17 @@ async fn write_file( path: String, contents: String, expected_modified_ms: Option, + allow_external_symlinks: Option, ) -> Result<(), CommandError> { let workspace_root = workspace_root_for_window(&state, &window).await; - write_workspace_file(&workspace_root, &path, &contents, expected_modified_ms) - .map_err(CommandError::from)?; + write_workspace_file( + &workspace_root, + &path, + &contents, + expected_modified_ms, + allow_external_symlinks.unwrap_or(false), + ) + .map_err(CommandError::from)?; refresh_indexed_entry(&state.workspace_index, &workspace_root, &path)?; Ok(()) } @@ -888,9 +912,15 @@ async fn create_file( window: tauri::Window, state: State<'_, AppState>, path: String, + allow_external_symlinks: Option, ) -> Result<(), CommandError> { let workspace_root = workspace_root_for_window(&state, &window).await; - create_workspace_file(&workspace_root, &path).map_err(CommandError::from)?; + create_workspace_file( + &workspace_root, + &path, + allow_external_symlinks.unwrap_or(false), + ) + .map_err(CommandError::from)?; refresh_indexed_entry(&state.workspace_index, &workspace_root, &path)?; Ok(()) } @@ -900,9 +930,15 @@ async fn create_folder( window: tauri::Window, state: State<'_, AppState>, path: String, + allow_external_symlinks: Option, ) -> Result<(), CommandError> { let workspace_root = workspace_root_for_window(&state, &window).await; - create_workspace_folder(&workspace_root, &path).map_err(CommandError::from)?; + create_workspace_folder( + &workspace_root, + &path, + allow_external_symlinks.unwrap_or(false), + ) + .map_err(CommandError::from)?; refresh_indexed_entry(&state.workspace_index, &workspace_root, &path)?; Ok(()) } @@ -913,9 +949,16 @@ async fn rename_file( state: State<'_, AppState>, from_path: String, to_path: String, + allow_external_symlinks: Option, ) -> Result<(), CommandError> { let workspace_root = workspace_root_for_window(&state, &window).await; - rename_workspace_file(&workspace_root, &from_path, &to_path).map_err(CommandError::from)?; + rename_workspace_file( + &workspace_root, + &from_path, + &to_path, + allow_external_symlinks.unwrap_or(false), + ) + .map_err(CommandError::from)?; state .workspace_index .remove_path(&workspace_root, &from_path)?; @@ -1169,6 +1212,7 @@ async fn update_ui_state( active_file: workspace.active_file.take(), selected_path: workspace.selected_path.take(), sidebar_width: workspace.sidebar_width, + trust_external_symlinks: workspace.trust_external_symlinks, updated_at: now_ms(), }; ui_state @@ -2152,6 +2196,7 @@ fn rebuild_app_menu(app: &tauri::AppHandle, state: &AppState) -> Result<(), Comm .cut() .copy() .paste() + .select_all() .build() .map_err(|error| CommandError::Recent(error.to_string()))?; let go_to_definition = MenuItemBuilder::with_id("go_to_definition", "Go to Definition") @@ -2303,6 +2348,7 @@ fn workspace_ui_snapshot_for_root( active_file: workspace.active_file.clone(), selected_path: workspace.selected_path.clone(), sidebar_width: workspace.sidebar_width, + trust_external_symlinks: workspace.trust_external_symlinks, }) .unwrap_or_default(); Ok(PersistedUiSnapshot { view, workspace }) @@ -2341,6 +2387,7 @@ fn sanitize_workspace_ui_state(state: WorkspaceUiStatePayload) -> WorkspaceUiSta active_file, selected_path, sidebar_width, + trust_external_symlinks: state.trust_external_symlinks, } } @@ -3144,6 +3191,7 @@ mod tests { active_file: None, selected_path: None, sidebar_width: None, + trust_external_symlinks: false, updated_at: 1, }, PersistedWorkspaceUiState { @@ -3153,6 +3201,7 @@ mod tests { active_file: None, selected_path: None, sidebar_width: None, + trust_external_symlinks: false, updated_at: 2, }, ], @@ -3241,6 +3290,7 @@ mod tests { active_file: Some("src/App.tsx".to_string()), selected_path: Some("/tmp".to_string()), sidebar_width: Some(9_999), + trust_external_symlinks: false, }); assert_eq!(workspace.expanded_folders, vec!["src".to_string()]); @@ -3282,6 +3332,7 @@ mod tests { active_file: workspace.active_file, selected_path: workspace.selected_path, sidebar_width: workspace.sidebar_width, + trust_external_symlinks: workspace.trust_external_symlinks, updated_at: 123, }], }; @@ -3585,6 +3636,9 @@ mod tests { depth: path.matches('/').count(), size: 0, modified_ms: Some(1), + is_symlink: false, + is_external: false, + symlink_target: None, } } diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 093868a..1264bc1 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -17,6 +17,11 @@ pub struct FileEntry { pub depth: usize, pub size: u64, pub modified_ms: Option, + // Symlink metadata so the tree can mark these entries and decide whether an + // external target needs the trust prompt before it's followed. + pub is_symlink: bool, + pub is_external: bool, + pub symlink_target: Option, } #[derive(Debug, Clone, Serialize)] @@ -63,8 +68,8 @@ pub enum WorkspaceError { NotADirectory, #[error("path is not a file or directory")] NotAnEntry, - #[error("symbolic links are not supported for editor file operations")] - SymlinkUnsupported, + #[error("symbolic link points outside the workspace")] + SymlinkOutsideWorkspace, #[error("file changed on disk since it was opened")] FileModifiedExternally, #[error("file is not valid UTF-8 text")] @@ -104,6 +109,7 @@ pub fn scan_workspace_with_metadata( show_generated_internal: bool, show_gitignored_files: bool, ) -> Result { + let canonical_root = root.canonicalize()?; let mut entries = Vec::new(); let mut seen_paths = HashSet::new(); let mut max_depth = 1; @@ -142,7 +148,7 @@ pub fn scan_workspace_with_metadata( }); } - push_scan_entry(root, &entry, &mut entries, &mut seen_paths)?; + push_scan_entry(root, &canonical_root, &entry, &mut entries, &mut seen_paths)?; } if seen_paths.len() == previous_seen_count { @@ -170,20 +176,17 @@ fn sort_scan_entries(entries: &mut [FileEntry]) { } pub fn workspace_file_entry(root: &Path, relative: &str) -> Result { - let path = resolve_existing_workspace_file_path(root, relative)?; - let metadata = fs::symlink_metadata(&path)?; - let relative_path = Path::new(relative); - file_entry_from_relative(relative_path, metadata) + workspace_entry(root, relative) } +// Display-only metadata for a single entry. Resolves against the link itself +// (no trust gate — this is a stat, not a content read), so callers can show a +// symlink's marker and external state without prompting. pub fn workspace_entry(root: &Path, relative: &str) -> Result { - let path = resolve_existing_workspace_entry_path(root, relative)?; - let metadata = fs::symlink_metadata(&path)?; - let root = root.canonicalize()?; - let relative_path = path - .strip_prefix(root) - .map_err(|_| WorkspaceError::OutsideWorkspace)?; - file_entry_from_relative(relative_path, metadata) + let canonical_root = root.canonicalize()?; + let abs = resolve_workspace_path(root, relative)?; + let link_metadata = fs::symlink_metadata(&abs)?; + file_entry_from_relative(Path::new(relative), &abs, &link_metadata, &canonical_root) } pub fn workspace_directory_entries( @@ -192,18 +195,19 @@ pub fn workspace_directory_entries( show_dotfiles: bool, show_generated_internal: bool, show_gitignored_files: bool, + allow_external_symlinks: bool, ) -> Result, WorkspaceError> { + let canonical_root = root.canonicalize()?; let path = if relative.is_empty() { - root.canonicalize()? + canonical_root.clone() } else { - resolve_existing_workspace_entry_path(root, relative)? + resolve_existing_workspace_dir_path_following(root, relative, allow_external_symlinks)? }; - let metadata = fs::symlink_metadata(&path)?; - if !metadata.is_dir() { - return Err(WorkspaceError::NotADirectory); - } - let canonical_root = root.canonicalize()?; + // Children are keyed by their logical path under `relative` (the path the user + // navigated), not the canonical target — so a symlinked directory's children + // nest under the symlink in the tree instead of jumping to the real location. + let base = relative.trim_end_matches('/'); let mut entries = Vec::new(); let mut walker = workspace_walker( &path, @@ -219,11 +223,23 @@ pub fn workspace_directory_entries( continue; } - let metadata = fs::symlink_metadata(child_path)?; - let relative = child_path - .strip_prefix(&canonical_root) - .map_err(|_| WorkspaceError::OutsideWorkspace)?; - entries.push(file_entry_from_relative(relative, metadata)?); + let link_metadata = fs::symlink_metadata(child_path)?; + let name = child_path + .file_name() + .ok_or(WorkspaceError::InvalidPath)? + .to_string_lossy() + .to_string(); + let child_relative = if base.is_empty() { + PathBuf::from(&name) + } else { + PathBuf::from(format!("{base}/{name}")) + }; + entries.push(file_entry_from_relative( + &child_relative, + child_path, + &link_metadata, + &canonical_root, + )?); } sort_scan_entries(&mut entries); @@ -307,6 +323,7 @@ fn entry_passes_name_filters( fn push_scan_entry( root: &Path, + canonical_root: &Path, entry: &DirEntry, entries: &mut Vec, seen_paths: &mut HashSet, @@ -326,13 +343,93 @@ fn push_scan_entry( return Ok(()); } - entries.push(file_entry_from_relative(relative, metadata)?); + entries.push(file_entry_from_relative( + relative, + path, + &metadata, + canonical_root, + )?); Ok(()) } +struct SymlinkFacts { + is_dir: bool, + size: u64, + is_symlink: bool, + is_external: bool, + symlink_target: Option, + // Modified time of the thing the editor actually reads/writes — the link's + // target for a symlink, the entry itself otherwise. The save conflict check + // compares against the target's mtime, so an open + save round-trip has to + // start from the same source or it falsely reports "changed on disk". + modified_ms: Option, +} + +fn metadata_modified_ms(metadata: &fs::Metadata) -> Option { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) +} + +// Resolves the displayable facts for an entry. For a symlink it follows the link +// to report the target's type/size (so a linked dir shows as a folder) and flags +// whether the target escapes the workspace; a broken link is treated as external +// and non-directory. Following here is a stat only — content reads still require +// trust via the resolve_* gate. +fn symlink_facts( + abs_path: &Path, + link_metadata: &fs::Metadata, + canonical_root: &Path, +) -> SymlinkFacts { + if !link_metadata.file_type().is_symlink() { + return SymlinkFacts { + is_dir: link_metadata.is_dir(), + size: link_metadata.len(), + is_symlink: false, + is_external: false, + symlink_target: None, + modified_ms: metadata_modified_ms(link_metadata), + }; + } + + let symlink_target = fs::read_link(abs_path) + .ok() + .map(|target| target.to_string_lossy().to_string()); + + match abs_path.canonicalize() { + Ok(canonical) => { + let target_metadata = fs::metadata(&canonical).ok(); + SymlinkFacts { + is_dir: target_metadata + .as_ref() + .map(|m| m.is_dir()) + .unwrap_or(false), + size: target_metadata.as_ref().map(|m| m.len()).unwrap_or(0), + is_symlink: true, + is_external: !canonical.starts_with(canonical_root), + symlink_target, + modified_ms: target_metadata.as_ref().and_then(metadata_modified_ms), + } + } + // Broken link (target missing or loop): show it, but never as a folder. + Err(_) => SymlinkFacts { + is_dir: false, + size: 0, + is_symlink: true, + is_external: true, + symlink_target, + modified_ms: metadata_modified_ms(link_metadata), + }, + } +} + fn file_entry_from_relative( relative: &Path, - metadata: fs::Metadata, + abs_path: &Path, + link_metadata: &fs::Metadata, + canonical_root: &Path, ) -> Result { let relative_path = normalize_path(relative); let parent = relative @@ -340,11 +437,6 @@ fn file_entry_from_relative( .filter(|value| !value.as_os_str().is_empty()) .map(normalize_path); let depth = relative.components().count().saturating_sub(1); - let modified_ms = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()); let name = relative .file_name() @@ -352,14 +444,20 @@ fn file_entry_from_relative( .to_string_lossy() .to_string(); + let facts = symlink_facts(abs_path, link_metadata, canonical_root); + let modified_ms = facts.modified_ms; + Ok(FileEntry { path: relative_path, name, parent, - is_dir: metadata.is_dir(), + is_dir: facts.is_dir, depth, - size: metadata.len(), + size: facts.size, modified_ms, + is_symlink: facts.is_symlink, + is_external: facts.is_external, + symlink_target: facts.symlink_target, }) } @@ -490,8 +588,9 @@ pub fn read_workspace_file( root: &Path, relative: &str, max_open_bytes: u64, + allow_external_symlinks: bool, ) -> Result { - let path = resolve_existing_workspace_file_path(root, relative)?; + let path = resolve_existing_workspace_file_path(root, relative, allow_external_symlinks)?; let metadata = fs::metadata(&path)?; if metadata.len() > max_open_bytes { return Err(WorkspaceError::FileTooLarge); @@ -508,8 +607,9 @@ pub fn write_workspace_file( relative: &str, contents: &str, expected_modified_ms: Option, + allow_external_symlinks: bool, ) -> Result<(), WorkspaceError> { - let path = resolve_existing_workspace_file_path(root, relative)?; + let path = resolve_existing_workspace_file_path(root, relative, allow_external_symlinks)?; if let Some(expected_modified_ms) = expected_modified_ms { let current_modified_ms = fs::metadata(&path)? .modified() @@ -525,8 +625,12 @@ pub fn write_workspace_file( fs::write(path, contents).map_err(WorkspaceError::from) } -pub fn create_workspace_file(root: &Path, relative: &str) -> Result<(), WorkspaceError> { - let path = resolve_new_workspace_file_path(root, relative)?; +pub fn create_workspace_file( + root: &Path, + relative: &str, + allow_external_symlinks: bool, +) -> Result<(), WorkspaceError> { + let path = resolve_new_workspace_file_path(root, relative, allow_external_symlinks)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -544,8 +648,12 @@ pub fn create_workspace_file(root: &Path, relative: &str) -> Result<(), Workspac } } -pub fn create_workspace_folder(root: &Path, relative: &str) -> Result<(), WorkspaceError> { - let path = resolve_new_workspace_entry_path(root, relative)?; +pub fn create_workspace_folder( + root: &Path, + relative: &str, + allow_external_symlinks: bool, +) -> Result<(), WorkspaceError> { + let path = resolve_new_workspace_entry_path(root, relative, allow_external_symlinks)?; match fs::create_dir_all(path) { Ok(_) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { @@ -555,9 +663,14 @@ pub fn create_workspace_folder(root: &Path, relative: &str) -> Result<(), Worksp } } -pub fn rename_workspace_file(root: &Path, from: &str, to: &str) -> Result<(), WorkspaceError> { - let from_path = resolve_existing_workspace_entry_path(root, from)?; - let to_path = resolve_new_workspace_entry_path(root, to)?; +pub fn rename_workspace_file( + root: &Path, + from: &str, + to: &str, + allow_external_symlinks: bool, +) -> Result<(), WorkspaceError> { + let from_path = resolve_existing_workspace_entry_path(root, from, allow_external_symlinks)?; + let to_path = resolve_new_workspace_entry_path(root, to, allow_external_symlinks)?; if let Some(parent) = to_path.parent() { fs::create_dir_all(parent)?; } @@ -566,9 +679,18 @@ pub fn rename_workspace_file(root: &Path, from: &str, to: &str) -> Result<(), Wo } pub fn delete_workspace_file(root: &Path, relative: &str) -> Result<(), WorkspaceError> { - let path = resolve_existing_workspace_entry_path(root, relative)?; + // Deleting only ever removes a link or an in-workspace entry, so external + // traversal is never needed here. + let path = resolve_existing_workspace_entry_path(root, relative, false)?; + + // A symlink resolves to the link itself; remove just the link so the target + // (a real file or directory, possibly outside the workspace) is left intact. + let link_metadata = fs::symlink_metadata(&path)?; + if link_metadata.file_type().is_symlink() { + return fs::remove_file(path).map_err(WorkspaceError::from); + } - if path.is_dir() { + if link_metadata.is_dir() { fs::remove_dir_all(path).map_err(WorkspaceError::from) } else { fs::remove_file(path).map_err(WorkspaceError::from) @@ -709,6 +831,18 @@ fn case_insensitive_match_end_byte( } fn resolve_workspace_path(root: &Path, relative: &str) -> Result { + resolve_workspace_path_inner(root, relative, false) +} + +// Resolves a workspace-relative path to an absolute candidate (without following +// the final component). The candidate's parent must canonicalize within the +// workspace root unless `allow_external` is set — that flag is the user's granted +// trust, letting a path traverse a symlink whose target escapes the workspace. +fn resolve_workspace_path_inner( + root: &Path, + relative: &str, + allow_external: bool, +) -> Result { let relative_path = Path::new(relative); if relative_path.is_absolute() { return Err(WorkspaceError::OutsideWorkspace); @@ -730,20 +864,25 @@ fn resolve_workspace_path(root: &Path, relative: &str) -> Result Result { - resolve_new_workspace_entry_path(root, relative) +fn resolve_new_workspace_file_path( + root: &Path, + relative: &str, + allow_external: bool, +) -> Result { + resolve_new_workspace_entry_path(root, relative, allow_external) } fn resolve_new_workspace_entry_path( root: &Path, relative: &str, + allow_external: bool, ) -> Result { let relative_path = Path::new(relative); if relative_path.is_absolute() { @@ -767,16 +906,14 @@ fn resolve_new_workspace_entry_path( let candidate = root.join(relative_path); let parent = candidate.parent().ok_or(WorkspaceError::InvalidPath)?; let existing_ancestor = nearest_existing_ancestor(parent)?; - let ancestor_metadata = fs::symlink_metadata(&existing_ancestor)?; - if ancestor_metadata.file_type().is_symlink() { - return Err(WorkspaceError::SymlinkUnsupported); - } - if !ancestor_metadata.is_dir() { + // Follow the nearest existing ancestor (it may be a symlinked dir) and require + // the resolved target to stay within the workspace unless trust was granted. + let canonical_ancestor = existing_ancestor.canonicalize()?; + if !fs::metadata(&canonical_ancestor)?.is_dir() { return Err(WorkspaceError::NotAnEntry); } - let canonical_ancestor = existing_ancestor.canonicalize()?; - if !canonical_ancestor.starts_with(&root) { - return Err(WorkspaceError::OutsideWorkspace); + if !canonical_ancestor.starts_with(&root) && !allow_external { + return Err(WorkspaceError::SymlinkOutsideWorkspace); } if candidate.exists() { @@ -799,33 +936,57 @@ fn nearest_existing_ancestor(path: &Path) -> Result { pub(crate) fn resolve_existing_workspace_file_path( root: &Path, relative: &str, + allow_external: bool, ) -> Result { - let path = resolve_workspace_path(root, relative)?; - let metadata = fs::symlink_metadata(&path)?; - if metadata.file_type().is_symlink() { - return Err(WorkspaceError::SymlinkUnsupported); + let path = resolve_workspace_path_inner(root, relative, allow_external)?; + let root = root.canonicalize()?; + // Follow the link to its target; the target must stay within the workspace + // unless trust was granted. Read/write then operate on the real file. + let canonical = path.canonicalize()?; + if !canonical.starts_with(&root) && !allow_external { + return Err(WorkspaceError::SymlinkOutsideWorkspace); } - if !metadata.is_file() { + if !fs::metadata(&canonical)?.is_file() { return Err(WorkspaceError::NotAFile); } + Ok(canonical) +} + +// Follows a (possibly symlinked) directory to its target for listing. The target +// must stay within the workspace unless trust was granted. +fn resolve_existing_workspace_dir_path_following( + root: &Path, + relative: &str, + allow_external: bool, +) -> Result { + let path = resolve_workspace_path_inner(root, relative, allow_external)?; let root = root.canonicalize()?; let canonical = path.canonicalize()?; - if !canonical.starts_with(&root) { - return Err(WorkspaceError::OutsideWorkspace); + if !canonical.starts_with(&root) && !allow_external { + return Err(WorkspaceError::SymlinkOutsideWorkspace); + } + if !fs::metadata(&canonical)?.is_dir() { + return Err(WorkspaceError::NotADirectory); } Ok(canonical) } +// Resolves the source of a rename/delete. A symlink resolves to the link itself +// (so the operation moves/removes the link, never its target); its location is +// already verified within the workspace by resolve_workspace_path. `allow_external` +// permits an entry that lives inside a trusted external symlinked directory, so +// rename can follow the same trust grant that create/write already honour. fn resolve_existing_workspace_entry_path( root: &Path, relative: &str, + allow_external: bool, ) -> Result { - let path = resolve_workspace_path(root, relative)?; + let path = resolve_workspace_path_inner(root, relative, allow_external)?; let metadata = fs::symlink_metadata(&path)?; if metadata.file_type().is_symlink() { - return Err(WorkspaceError::SymlinkUnsupported); + return Ok(path); } if !metadata.is_file() && !metadata.is_dir() { return Err(WorkspaceError::NotAnEntry); @@ -833,7 +994,7 @@ fn resolve_existing_workspace_entry_path( let root = root.canonicalize()?; let canonical = path.canonicalize()?; - if !canonical.starts_with(&root) { + if !canonical.starts_with(&root) && !allow_external { return Err(WorkspaceError::OutsideWorkspace); } @@ -858,7 +1019,7 @@ mod tests { #[test] fn read_workspace_file_rejects_parent_traversal() { let dir = tempdir().unwrap(); - let result = read_workspace_file(dir.path(), "../secret.txt", 1024); + let result = read_workspace_file(dir.path(), "../secret.txt", 1024, false); assert!(matches!(result, Err(WorkspaceError::InvalidPath))); } @@ -866,7 +1027,7 @@ mod tests { #[test] fn read_workspace_file_rejects_absolute_paths() { let dir = tempdir().unwrap(); - let result = read_workspace_file(dir.path(), "/etc/hosts", 1024); + let result = read_workspace_file(dir.path(), "/etc/hosts", 1024, false); assert!(matches!(result, Err(WorkspaceError::OutsideWorkspace))); } @@ -876,9 +1037,9 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("note.txt"), "before").unwrap(); - let before = read_workspace_file(dir.path(), "note.txt", 1024).unwrap(); - write_workspace_file(dir.path(), "note.txt", "after", None).unwrap(); - let after = read_workspace_file(dir.path(), "note.txt", 1024).unwrap(); + let before = read_workspace_file(dir.path(), "note.txt", 1024, false).unwrap(); + write_workspace_file(dir.path(), "note.txt", "after", None, false).unwrap(); + let after = read_workspace_file(dir.path(), "note.txt", 1024, false).unwrap(); assert_eq!(before, "before"); assert_eq!(after, "after"); @@ -889,7 +1050,7 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("invalid.txt"), b"valid prefix \xFF").unwrap(); - let result = read_workspace_file(dir.path(), "invalid.txt", 1024); + let result = read_workspace_file(dir.path(), "invalid.txt", 1024, false); assert!(matches!(result, Err(WorkspaceError::UnsupportedEncoding))); } @@ -899,18 +1060,18 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("large.txt"), "123456").unwrap(); - let result = read_workspace_file(dir.path(), "large.txt", 5); + let result = read_workspace_file(dir.path(), "large.txt", 5, false); assert!(matches!(result, Err(WorkspaceError::FileTooLarge))); assert_eq!( - read_workspace_file(dir.path(), "large.txt", 6).unwrap(), + read_workspace_file(dir.path(), "large.txt", 6, false).unwrap(), "123456" ); } #[cfg(unix)] #[test] - fn read_workspace_file_rejects_symlink_sources() { + fn read_workspace_file_rejects_external_symlink_without_trust() { let dir = tempdir().unwrap(); let outside = tempdir().unwrap(); fs::write(outside.path().join("secret.txt"), "secret").unwrap(); @@ -920,24 +1081,71 @@ mod tests { ) .unwrap(); - let result = read_workspace_file(dir.path(), "linked.txt", 1024); + // Untrusted external symlink is refused... + let result = read_workspace_file(dir.path(), "linked.txt", 1024, false); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); + + // ...but reads through once the user grants trust. + assert_eq!( + read_workspace_file(dir.path(), "linked.txt", 1024, true).unwrap(), + "secret" + ); + } + + #[cfg(unix)] + #[test] + fn read_workspace_file_follows_in_workspace_symlink() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("real")).unwrap(); + fs::write(dir.path().join("real/note.txt"), "inside").unwrap(); + symlink( + dir.path().join("real/note.txt"), + dir.path().join("link.txt"), + ) + .unwrap(); - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); + // In-workspace target needs no trust. + assert_eq!( + read_workspace_file(dir.path(), "link.txt", 1024, false).unwrap(), + "inside" + ); } #[cfg(unix)] #[test] - fn write_workspace_file_rejects_symlink_sources() { + fn write_workspace_file_rejects_external_symlink_without_trust() { let dir = tempdir().unwrap(); let outside = tempdir().unwrap(); let secret_path = outside.path().join("secret.txt"); fs::write(&secret_path, "secret").unwrap(); symlink(&secret_path, dir.path().join("linked.txt")).unwrap(); - let result = write_workspace_file(dir.path(), "linked.txt", "changed", None); + let result = write_workspace_file(dir.path(), "linked.txt", "changed", None, false); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); + assert_eq!(fs::read_to_string(&secret_path).unwrap(), "secret"); + + // With trust, the write edits through the link to the real target. + write_workspace_file(dir.path(), "linked.txt", "changed", None, true).unwrap(); + assert_eq!(fs::read_to_string(&secret_path).unwrap(), "changed"); + } - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); - assert_eq!(fs::read_to_string(secret_path).unwrap(), "secret"); + #[cfg(unix)] + #[test] + fn write_workspace_file_follows_in_workspace_symlink() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("real")).unwrap(); + let target = dir.path().join("real/note.txt"); + fs::write(&target, "before").unwrap(); + symlink(&target, dir.path().join("link.txt")).unwrap(); + + write_workspace_file(dir.path(), "link.txt", "after", None, false).unwrap(); + assert_eq!(fs::read_to_string(&target).unwrap(), "after"); } #[test] @@ -955,7 +1163,13 @@ mod tests { fs::write(&path, "outside change").unwrap(); let stale_modified_ms = modified_ms.saturating_sub(1); - let result = write_workspace_file(dir.path(), "note.txt", "after", Some(stale_modified_ms)); + let result = write_workspace_file( + dir.path(), + "note.txt", + "after", + Some(stale_modified_ms), + false, + ); assert!(matches!( result, @@ -969,7 +1183,7 @@ mod tests { let dir = tempdir().unwrap(); fs::create_dir(dir.path().join("src")).unwrap(); - create_workspace_file(dir.path(), "src/new.rs").unwrap(); + create_workspace_file(dir.path(), "src/new.rs", false).unwrap(); assert_eq!( fs::read_to_string(dir.path().join("src/new.rs")).unwrap(), @@ -981,7 +1195,7 @@ mod tests { fn create_workspace_file_creates_missing_parent_directories() { let dir = tempdir().unwrap(); - create_workspace_file(dir.path(), "src/features/new.tsx").unwrap(); + create_workspace_file(dir.path(), "src/features/new.tsx", false).unwrap(); assert_eq!( fs::read_to_string(dir.path().join("src/features/new.tsx")).unwrap(), @@ -994,7 +1208,7 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("note.txt"), "before").unwrap(); - let result = create_workspace_file(dir.path(), "note.txt"); + let result = create_workspace_file(dir.path(), "note.txt", false); assert!(matches!(result, Err(WorkspaceError::FileAlreadyExists))); assert_eq!( @@ -1007,7 +1221,7 @@ mod tests { fn create_workspace_file_rejects_parent_traversal() { let dir = tempdir().unwrap(); - let result = create_workspace_file(dir.path(), "../secret.txt"); + let result = create_workspace_file(dir.path(), "../secret.txt", false); assert!(matches!(result, Err(WorkspaceError::InvalidPath))); } @@ -1019,9 +1233,12 @@ mod tests { let outside = tempdir().unwrap(); symlink(outside.path(), dir.path().join("linked")).unwrap(); - let result = create_workspace_file(dir.path(), "linked/new.txt"); + let result = create_workspace_file(dir.path(), "linked/new.txt", false); - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); assert!(!outside.path().join("new.txt").exists()); } @@ -1029,7 +1246,7 @@ mod tests { fn create_workspace_folder_creates_directory_inside_root() { let dir = tempdir().unwrap(); - create_workspace_folder(dir.path(), "src").unwrap(); + create_workspace_folder(dir.path(), "src", false).unwrap(); assert!(dir.path().join("src").is_dir()); } @@ -1038,7 +1255,7 @@ mod tests { fn create_workspace_folder_creates_missing_parent_directories() { let dir = tempdir().unwrap(); - create_workspace_folder(dir.path(), "src/features/editor").unwrap(); + create_workspace_folder(dir.path(), "src/features/editor", false).unwrap(); assert!(dir.path().join("src/features/editor").is_dir()); } @@ -1048,7 +1265,7 @@ mod tests { let dir = tempdir().unwrap(); fs::create_dir(dir.path().join("src")).unwrap(); - let result = create_workspace_folder(dir.path(), "src"); + let result = create_workspace_folder(dir.path(), "src", false); assert!(matches!(result, Err(WorkspaceError::FileAlreadyExists))); assert!(dir.path().join("src").is_dir()); @@ -1058,7 +1275,7 @@ mod tests { fn create_workspace_folder_rejects_parent_traversal() { let dir = tempdir().unwrap(); - let result = create_workspace_folder(dir.path(), "../outside"); + let result = create_workspace_folder(dir.path(), "../outside", false); assert!(matches!(result, Err(WorkspaceError::InvalidPath))); } @@ -1070,9 +1287,12 @@ mod tests { let outside = tempdir().unwrap(); symlink(outside.path(), dir.path().join("linked")).unwrap(); - let result = create_workspace_folder(dir.path(), "linked/new-folder"); + let result = create_workspace_folder(dir.path(), "linked/new-folder", false); - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); assert!(!outside.path().join("new-folder").exists()); } @@ -1082,7 +1302,7 @@ mod tests { fs::create_dir(dir.path().join("src")).unwrap(); fs::write(dir.path().join("note.txt"), "contents").unwrap(); - rename_workspace_file(dir.path(), "note.txt", "src/renamed.txt").unwrap(); + rename_workspace_file(dir.path(), "note.txt", "src/renamed.txt", false).unwrap(); assert!(!dir.path().join("note.txt").exists()); assert_eq!( @@ -1096,7 +1316,7 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("note.txt"), "contents").unwrap(); - rename_workspace_file(dir.path(), "note.txt", "src/features/renamed.txt").unwrap(); + rename_workspace_file(dir.path(), "note.txt", "src/features/renamed.txt", false).unwrap(); assert!(!dir.path().join("note.txt").exists()); assert_eq!( @@ -1111,7 +1331,7 @@ mod tests { fs::write(dir.path().join("note.txt"), "contents").unwrap(); fs::write(dir.path().join("existing.txt"), "other").unwrap(); - let result = rename_workspace_file(dir.path(), "note.txt", "existing.txt"); + let result = rename_workspace_file(dir.path(), "note.txt", "existing.txt", false); assert!(matches!(result, Err(WorkspaceError::FileAlreadyExists))); assert_eq!( @@ -1130,7 +1350,7 @@ mod tests { fs::create_dir_all(dir.path().join("src/nested")).unwrap(); fs::write(dir.path().join("src/nested/file.txt"), "contents").unwrap(); - rename_workspace_file(dir.path(), "src", "renamed").unwrap(); + rename_workspace_file(dir.path(), "src", "renamed", false).unwrap(); assert!(!dir.path().join("src").exists()); assert_eq!( @@ -1144,7 +1364,7 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("note.txt"), "contents").unwrap(); - let result = rename_workspace_file(dir.path(), "note.txt", "../secret.txt"); + let result = rename_workspace_file(dir.path(), "note.txt", "../secret.txt", false); assert!(matches!(result, Err(WorkspaceError::InvalidPath))); assert!(dir.path().join("note.txt").exists()); @@ -1152,7 +1372,7 @@ mod tests { #[cfg(unix)] #[test] - fn rename_workspace_file_rejects_symlink_sources() { + fn rename_workspace_file_renames_the_symlink_not_its_target() { let dir = tempdir().unwrap(); let outside = tempdir().unwrap(); let secret_path = outside.path().join("secret.txt"); @@ -1160,12 +1380,16 @@ mod tests { fs::write(&secret_path, "secret").unwrap(); symlink(&secret_path, &linked_path).unwrap(); - let result = rename_workspace_file(dir.path(), "linked.txt", "renamed.txt"); + rename_workspace_file(dir.path(), "linked.txt", "renamed.txt", false).unwrap(); - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); - assert!(fs::symlink_metadata(linked_path).is_ok()); + // The link moved; the external target is untouched and not relocated. + assert!(fs::symlink_metadata(&linked_path).is_err()); + let renamed = dir.path().join("renamed.txt"); + assert!(fs::symlink_metadata(&renamed) + .unwrap() + .file_type() + .is_symlink()); assert_eq!(fs::read_to_string(secret_path).unwrap(), "secret"); - assert!(!dir.path().join("renamed.txt").exists()); } #[cfg(unix)] @@ -1176,9 +1400,12 @@ mod tests { fs::write(dir.path().join("note.txt"), "contents").unwrap(); symlink(outside.path(), dir.path().join("linked")).unwrap(); - let result = rename_workspace_file(dir.path(), "note.txt", "linked/renamed.txt"); + let result = rename_workspace_file(dir.path(), "note.txt", "linked/renamed.txt", false); - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); assert_eq!( fs::read_to_string(dir.path().join("note.txt")).unwrap(), "contents" @@ -1220,7 +1447,7 @@ mod tests { #[cfg(unix)] #[test] - fn delete_workspace_file_rejects_symlink_sources() { + fn delete_workspace_file_removes_the_symlink_not_its_target() { let dir = tempdir().unwrap(); let outside = tempdir().unwrap(); let secret_path = outside.path().join("secret.txt"); @@ -1228,13 +1455,31 @@ mod tests { fs::write(&secret_path, "secret").unwrap(); symlink(&secret_path, &linked_path).unwrap(); - let result = delete_workspace_file(dir.path(), "linked.txt"); + delete_workspace_file(dir.path(), "linked.txt").unwrap(); - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); - assert!(fs::symlink_metadata(linked_path).is_ok()); + // Only the link is removed; the external target survives. + assert!(fs::symlink_metadata(linked_path).is_err()); assert_eq!(fs::read_to_string(secret_path).unwrap(), "secret"); } + #[cfg(unix)] + #[test] + fn delete_workspace_dir_symlink_removes_only_the_link() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::write(outside.path().join("keep.txt"), "keep").unwrap(); + symlink(outside.path(), dir.path().join("linked")).unwrap(); + + delete_workspace_file(dir.path(), "linked").unwrap(); + + assert!(fs::symlink_metadata(dir.path().join("linked")).is_err()); + // The real directory and its contents are left intact. + assert_eq!( + fs::read_to_string(outside.path().join("keep.txt")).unwrap(), + "keep" + ); + } + #[test] fn scan_workspace_skips_generated_and_git_directories() { let dir = tempdir().unwrap(); @@ -1440,7 +1685,8 @@ mod tests { fs::write(dir.path().join("src/.env"), "").unwrap(); fs::write(dir.path().join("src/node_modules/pkg/index.js"), "").unwrap(); - let entries = workspace_directory_entries(dir.path(), "src", false, false, true).unwrap(); + let entries = + workspace_directory_entries(dir.path(), "src", false, false, true, false).unwrap(); let paths = entries .iter() .map(|entry| entry.path.as_str()) @@ -1452,7 +1698,8 @@ mod tests { assert!(!paths.contains(&"src/.env")); assert!(!paths.contains(&"src/node_modules")); - let entries = workspace_directory_entries(dir.path(), "src", true, true, true).unwrap(); + let entries = + workspace_directory_entries(dir.path(), "src", true, true, true, false).unwrap(); let paths = entries .iter() .map(|entry| entry.path.as_str()) @@ -1470,7 +1717,8 @@ mod tests { fs::create_dir_all(dir.path().join("src")).unwrap(); fs::write(dir.path().join("README.md"), "").unwrap(); - let entries = workspace_directory_entries(dir.path(), "", false, false, true).unwrap(); + let entries = + workspace_directory_entries(dir.path(), "", false, false, true, false).unwrap(); let paths = entries .iter() .map(|entry| entry.path.as_str()) @@ -1480,6 +1728,64 @@ mod tests { assert!(paths.contains(&"README.md")); } + #[cfg(unix)] + #[test] + fn workspace_directory_entries_lists_symlinked_dir_children_under_logical_path() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("real")).unwrap(); + fs::write(dir.path().join("real/inner.txt"), "x").unwrap(); + symlink(dir.path().join("real"), dir.path().join("link")).unwrap(); + + let entries = + workspace_directory_entries(dir.path(), "link", false, false, true, false).unwrap(); + let paths = entries + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(); + + // Children nest under the symlink the user navigated, not the real target. + assert!(paths.contains(&"link/inner.txt")); + assert!(!paths.contains(&"real/inner.txt")); + } + + #[cfg(unix)] + #[test] + fn file_entry_reports_symlink_facts() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::create_dir(dir.path().join("real")).unwrap(); + symlink(dir.path().join("real"), dir.path().join("inside_dir_link")).unwrap(); + fs::write(dir.path().join("file.txt"), "x").unwrap(); + symlink( + dir.path().join("file.txt"), + dir.path().join("inside_file_link"), + ) + .unwrap(); + symlink(outside.path(), dir.path().join("external_link")).unwrap(); + + let entries = + workspace_directory_entries(dir.path(), "", false, false, true, false).unwrap(); + let by_name = |name: &str| { + entries + .iter() + .find(|entry| entry.name == name) + .unwrap_or_else(|| panic!("missing entry {name}")) + .clone() + }; + + let dir_link = by_name("inside_dir_link"); + assert!(dir_link.is_symlink && dir_link.is_dir && !dir_link.is_external); + + let file_link = by_name("inside_file_link"); + assert!(file_link.is_symlink && !file_link.is_dir && !file_link.is_external); + + let external = by_name("external_link"); + assert!(external.is_symlink && external.is_external); + + let plain = by_name("file.txt"); + assert!(!plain.is_symlink && !plain.is_external); + } + #[test] fn search_workspace_finds_case_insensitive_line_matches() { let dir = tempdir().unwrap(); diff --git a/src-tauri/src/workspace_index.rs b/src-tauri/src/workspace_index.rs index adb5216..d6d5ad0 100644 --- a/src-tauri/src/workspace_index.rs +++ b/src-tauri/src/workspace_index.rs @@ -385,6 +385,8 @@ pub fn advance_workspace_index( show_dotfiles, show_generated_internal, show_gitignored_files, + // Background indexing never follows external symlinks. + false, ) { Ok(entries) => entries, Err(error) if !directory.is_empty() && stale_indexed_directory_error(&error) => { @@ -547,6 +549,12 @@ fn file_entry_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { depth: usize::try_from(depth).unwrap_or(usize::MAX), size: u64::try_from(size).unwrap_or(u64::MAX), modified_ms: modified_ms.and_then(|value| u128::try_from(value).ok()), + // The index (quick-open) does not persist symlink metadata; the live tree + // scan / directory listing carry it. Opening still resolves through the + // symlink-aware read path. + is_symlink: false, + is_external: false, + symlink_target: None, }) } @@ -569,6 +577,9 @@ mod tests { depth: path.matches('/').count(), size: 0, modified_ms: Some(1), + is_symlink: false, + is_external: false, + symlink_target: None, } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 8742ab3..3601911 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -680,7 +680,7 @@ describe("App shell interactions", () => { fireEvent.click(await treeButton("src")); - expect(tauriMocks.listDirectory).toHaveBeenCalledWith("src", false, false, false); + expect(tauriMocks.listDirectory).toHaveBeenCalledWith("src", false, false, false, false); expect(await treeButton("App.tsx")).toBeInTheDocument(); }); @@ -1417,7 +1417,7 @@ describe("App shell interactions", () => { fireEvent.click(await treeButton("README.md")); await waitFor(() => - expect(tauriMocks.readFile).toHaveBeenCalledWith("README.md", 2048 * 1024), + expect(tauriMocks.readFile).toHaveBeenCalledWith("README.md", 2048 * 1024, false), ); await waitFor(() => expect(tauriMocks.updateUiState).toHaveBeenLastCalledWith( @@ -1736,6 +1736,96 @@ describe("App shell interactions", () => { ); }); + it("drives find and replace in the active file from the keyboard", async () => { + tauriMocks.readFile.mockImplementation(async (path: string) => { + if (path === "README.md") return "needle one\nsecond needle\nthird needle"; + if (path === "src/App.tsx") return "export function App() {}"; + return ""; + }); + render(); + + fireEvent.click(await treeButton("README.md")); + await findTab("README.md"); + + // Cmd/Ctrl+F opens the app's Find in File even while the editor has focus, + // rather than CodeMirror's own search panel. + const editor = await screen.findByLabelText("Editor README.md"); + fireEvent.keyDown(editor, { key: "f", ctrlKey: true }); + const findInput = await screen.findByPlaceholderText("Find in file"); + fireEvent.change(findInput, { target: { value: "needle" } }); + + // Arrow keys step through matches and reveal each one in the editor. + fireEvent.keyDown(findInput, { key: "ArrowDown" }); + expect(await screen.findByText("Reveal line 1")).toBeInTheDocument(); + fireEvent.keyDown(findInput, { key: "ArrowDown" }); + expect(await screen.findByText("Reveal line 2")).toBeInTheDocument(); + fireEvent.keyDown(findInput, { key: "ArrowUp" }); + expect(await screen.findByText("Reveal line 1")).toBeInTheDocument(); + + // Cmd/Ctrl+R reveals the replace field; Replace All emits the editor command. + fireEvent.keyDown(findInput, { key: "r", ctrlKey: true }); + const replaceInput = await screen.findByPlaceholderText("Replace with"); + fireEvent.change(replaceInput, { target: { value: "pin" } }); + fireEvent.click(screen.getByRole("button", { name: "All" })); + expect(await screen.findByTestId("editor-command")).toHaveTextContent( + /replaceAll:/, + ); + }); + + it("marks an external symlink and prompts for trust before following it", async () => { + tauriMocks.listFiles.mockResolvedValue([ + ...files, + { + path: "ext-link", + name: "ext-link", + isDir: false, + depth: 0, + size: 0, + isSymlink: true, + isExternal: true, + symlinkTarget: "/outside/secret.txt", + }, + ]); + tauriMocks.readFile.mockImplementation( + async (path: string, _maxBytes?: number, allowExternal?: boolean) => { + if (path === "ext-link") { + if (!allowExternal) { + throw new Error("symbolic link points outside the workspace"); + } + return "external contents"; + } + if (path === "README.md") return "readme"; + return ""; + }, + ); + render(); + + const link = await treeButton("ext-link"); + // The external symlink is visually marked. + expect(within(link).getByTestId("tree-symlink-external")).toBeInTheDocument(); + + // Opening it surfaces the trust prompt naming the target, not the file. + fireEvent.click(link); + const dialog = await screen.findByRole("alertdialog", { + name: "Follow link outside the workspace?", + }); + expect(within(dialog).getByText("/outside/secret.txt")).toBeInTheDocument(); + expect(screen.queryByLabelText("Editor ext-link")).not.toBeInTheDocument(); + + // Trust once follows the link for the session. + fireEvent.click(within(dialog).getByRole("button", { name: "Trust once" })); + expect(await screen.findByLabelText("Editor ext-link")).toHaveValue( + "external contents", + ); + await waitFor(() => + expect(tauriMocks.readFile).toHaveBeenCalledWith( + "ext-link", + expect.any(Number), + true, + ), + ); + }); + it("keeps file filtering and content search as separate sidebar modes", async () => { tauriMocks.searchFiles.mockResolvedValueOnce([ { @@ -2427,6 +2517,7 @@ describe("App shell interactions", () => { "README.md", "changed readme", 101, + false, ), ); await waitFor(() => expect(tabButton("README.md")).toBeUndefined()); @@ -2537,6 +2628,7 @@ describe("App shell interactions", () => { "README.md", "changed readme", 101, + false, ), ); @@ -2552,6 +2644,7 @@ describe("App shell interactions", () => { "README.md", "changed again", 101, + false, ), ); }); @@ -2682,6 +2775,7 @@ describe("App shell interactions", () => { "README.md", "changed readme", 101, + false, ), ); await waitFor(() => expect(tabButton("README.md")).toBeUndefined()); @@ -2762,11 +2856,13 @@ describe("App shell interactions", () => { "README.md", "changed readme", 101, + false, ); expect(tauriMocks.writeFile).toHaveBeenCalledWith( "src/App.tsx", "changed app", 202, + false, ); await waitFor(() => expect(document.querySelectorAll(".dirty-dot")).toHaveLength(0)); expect(screen.getByText("Saved 2 unsaved files")).toBeInTheDocument(); @@ -2794,6 +2890,7 @@ describe("App shell interactions", () => { "README.md", "changed readme", 101, + false, ); expect(document.querySelectorAll(".dirty-dot")).toHaveLength(1); expect(screen.getByText("Save failed")).toBeInTheDocument(); @@ -2831,6 +2928,7 @@ describe("App shell interactions", () => { "README.md", "changed readme", 303, + false, ), ); }); @@ -2922,6 +3020,7 @@ describe("App shell interactions", () => { "README.md", "changed readme", 303, + false, ), ); }); @@ -3526,7 +3625,7 @@ describe("App shell interactions", () => { ]); fireEvent.click(screen.getByText("Create")); - await waitFor(() => expect(tauriMocks.createFile).toHaveBeenCalledWith("src/NewFile.tsx")); + await waitFor(() => expect(tauriMocks.createFile).toHaveBeenCalledWith("src/NewFile.tsx", false)); const tab = await findTab("src/NewFile.tsx"); expect(tab).not.toHaveClass("tab--temp"); expect(screen.getByLabelText("Editor src/NewFile.tsx")).toHaveValue(""); @@ -3542,6 +3641,7 @@ describe("App shell interactions", () => { "src/NewFile.tsx", "export function NewFile() {}", 404, + false, ), ); }); @@ -3574,7 +3674,7 @@ describe("App shell interactions", () => { fireEvent.click(screen.getByText("Create")); await waitFor(() => - expect(tauriMocks.createFolder).toHaveBeenCalledWith("src/features/editor"), + expect(tauriMocks.createFolder).toHaveBeenCalledWith("src/features/editor", false), ); expect(screen.queryByRole("dialog", { name: "New folder" })).not.toBeInTheDocument(); expect(screen.getByText("Created folder src/features/editor")).toBeInTheDocument(); @@ -3623,6 +3723,7 @@ describe("App shell interactions", () => { expect(tauriMocks.renameFile).toHaveBeenCalledWith( "README.md", "README-renamed.md", + false, ), ); const tab = await findTab("README-renamed.md"); @@ -3640,6 +3741,7 @@ describe("App shell interactions", () => { "README-renamed.md", "renamed readme", 505, + false, ), ); }); @@ -3678,7 +3780,7 @@ describe("App shell interactions", () => { ]); fireEvent.click(screen.getByText("Rename")); - await waitFor(() => expect(tauriMocks.renameFile).toHaveBeenCalledWith("src", "app")); + await waitFor(() => expect(tauriMocks.renameFile).toHaveBeenCalledWith("src", "app", false)); expect(await findTab("app/App.tsx")).toHaveClass("tab--active"); expect(tabButton("src/App.tsx")).toBeUndefined(); expect(screen.getByLabelText("Editor app/App.tsx")).toHaveValue( diff --git a/src/App.tsx b/src/App.tsx index 7d3ca21..24c7cac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { Copy, ExternalLink, FileInput, + Link2, FilePlus, FileCog, FolderOpen, @@ -28,6 +29,7 @@ import { PanelLeftOpen, Pencil, RefreshCw, + Replace, RotateCcw, Save, SaveAll, @@ -56,6 +58,7 @@ import { } from "./dateTimeFormat"; import { currentFileMatches, + currentFileResultWindow, nextCurrentFileMatchIndex, } from "./currentFileSearch"; import { iconForFile, isKnownBinaryFile } from "./fileTypes"; @@ -160,6 +163,7 @@ import { editorCommandLabel, type EditorCommandName, type EditorCommandRequest, + type EditorReplacePayload, } from "./editorCommands"; import { cursorStatus, type EditorCursor } from "./editorCursor"; @@ -187,6 +191,9 @@ interface RevealTarget { path: string; lineNumber: number; preserveFocus?: boolean; + // Column offsets (0-based, within lineNumber) of a find match to select on reveal. + matchStart?: number; + matchEnd?: number; } interface PendingReloadRequest { @@ -285,6 +292,9 @@ const defaultCurrentFileSearchResultLimit = 200; const minCurrentFileResultPreviewLimit = 3; const maxCurrentFileResultPreviewLimit = 100; const defaultCurrentFileResultPreviewLimit = 12; +// Upper bound on matches a single Replace All rewrites — high enough to cover any +// realistic file, low enough to keep the array and the CodeMirror transaction sane. +const replaceAllMatchLimit = 100000; const minQuickOpenResultLimit = 5; const maxQuickOpenResultLimit = 100; const defaultQuickOpenResultLimit = 12; @@ -420,6 +430,8 @@ export default function App() { const [filter, setFilter] = useState(""); const [contentQuery, setContentQuery] = useState(""); const [currentFileQuery, setCurrentFileQuery] = useState(""); + const [replaceQuery, setReplaceQuery] = useState(""); + const [replaceVisible, setReplaceVisible] = useState(false); const [activeSidebarSearch, setActiveSidebarSearch] = useState(); const [currentFindOpen, setCurrentFindOpen] = useState(false); @@ -511,6 +523,12 @@ export default function App() { const [uiStateLoaded, setUiStateLoaded] = useState(false); const [workspaceUiRestored, setWorkspaceUiRestored] = useState(false); const [expandedFolders, setExpandedFolders] = useState>(() => new Set()); + // Trust to follow symlinks whose target escapes the workspace. Session = this + // run only ("Trust once"); workspace = persisted ("Trust for workspace"). + const [trustExternalSession, setTrustExternalSession] = useState(false); + const [trustExternalWorkspace, setTrustExternalWorkspace] = useState(false); + const [pendingSymlinkTrust, setPendingSymlinkTrust] = + useState<{ entry: FileEntry; action: "open" | "expand" }>(); const [openFailure, setOpenFailure] = useState(); const [error, setError] = useState(); const [status, setStatus] = useState("Ready"); @@ -530,9 +548,17 @@ export default function App() { const sidebarFilterInputRef = useRef(null); const sidebarContentSearchInputRef = useRef(null); const currentFindInputRef = useRef(null); + const replaceInputRef = useRef(null); const gitCommitPopoverCloseRef = useRef(null); const initialFileOpenedRef = useRef(false); const openedLaunchTargetsDrainedRef = useRef(false); + // Effective external-symlink trust, mirrored to a ref so the many file I/O + // callbacks can read the current value without each depending on it. + const allowExternalSymlinks = trustExternalSession || trustExternalWorkspace; + const allowExternalSymlinksRef = useRef(allowExternalSymlinks); + useEffect(() => { + allowExternalSymlinksRef.current = allowExternalSymlinks; + }, [allowExternalSymlinks]); const persistedWorkspaceRef = useRef({ expandedFolders: [], openFiles: [], @@ -612,6 +638,15 @@ export default function App() { : [], [activeFile, currentFileQuery, currentFileSearchResultLimit], ); + const currentFindWindow = useMemo( + () => + currentFileResultWindow( + currentFindResults, + currentFindIndex, + currentFileResultPreviewLimit, + ), + [currentFindResults, currentFindIndex, currentFileResultPreviewLimit], + ); const diagnostics = useMemo( () => sortDiagnostics(Object.values(diagnosticsByPath).flat()), [diagnosticsByPath], @@ -668,6 +703,7 @@ export default function App() { renameDialogOpen || goToLineDialogOpen || pendingDeletePath !== undefined || + pendingSymlinkTrust !== undefined || pendingReloadRequest !== undefined || pendingCloseAll || pendingAppClose || @@ -914,6 +950,7 @@ export default function App() { ); setFeatureFlags(sanitizeFeatureFlagOverrides(snapshot.view.featureFlags)); setExpandedFolders(new Set(snapshot.workspace.expandedFolders)); + setTrustExternalWorkspace(Boolean(snapshot.workspace.trustExternalSymlinks)); setSelectedPath(snapshot.workspace.selectedPath); setSidebarWidth( sanitizeNumberLimit( @@ -952,6 +989,7 @@ export default function App() { showDotfiles, showGeneratedInternal, showGitignoredFiles, + allowExternalSymlinksRef.current, ); setFiles((current) => mergeFileEntries(current, entries)); setLoadedFolders((current) => new Set(current).add(path)); @@ -972,6 +1010,13 @@ export default function App() { ); const toggleFolder = useCallback((path: string) => { + // Expanding an external symlinked directory follows it outside the workspace; + // require trust first. + const entry = files.find((file) => file.path === path); + if (entry?.isExternal && !allowExternalSymlinksRef.current) { + setPendingSymlinkTrust({ entry, action: "expand" }); + return; + } const shouldLoad = !expandedFolders.has(path); setExpandedFolders((current) => { const next = new Set(current); @@ -985,7 +1030,7 @@ export default function App() { if (shouldLoad) { void loadFolderChildren(path); } - }, [expandedFolders, loadFolderChildren]); + }, [expandedFolders, files, loadFolderChildren]); const refreshIntegrationStatus = useCallback(async () => { try { @@ -1099,7 +1144,11 @@ export default function App() { const readOpenFileFromDisk = useCallback(async (path: string) => { const entry = await statFile(path); - const contents = await readFile(path, maxOpenFileKb * 1024); + const contents = await readFile( + path, + maxOpenFileKb * 1024, + allowExternalSymlinksRef.current, + ); return { contents, modifiedMs: entry.modifiedMs }; }, [maxOpenFileKb]); @@ -1108,7 +1157,11 @@ export default function App() { const openFileBeforeRead = openFilesRef.current.find((file) => file.path === path); if (!openFileBeforeRead || openFileBeforeRead.dirty) return; - const contents = await readFile(path, maxOpenFileKb * 1024); + const contents = await readFile( + path, + maxOpenFileKb * 1024, + allowExternalSymlinksRef.current, + ); const openFileAfterRead = openFilesRef.current.find((file) => file.path === path); if (!openFileAfterRead || openFileAfterRead.dirty) return; @@ -1256,7 +1309,11 @@ export default function App() { let cancelled = false; const timeout = window.setTimeout(() => { const searchPromise = singleFileMode && singleFilePath - ? readFile(singleFilePath, maxOpenFileKb * 1024).then((contents) => { + ? readFile( + singleFilePath, + maxOpenFileKb * 1024, + allowExternalSymlinksRef.current, + ).then((contents) => { const limit = currentFileSearchResultLimit; const matches = currentFileMatches( singleFilePath, @@ -1393,6 +1450,13 @@ export default function App() { return; } + // Opening an external symlinked file reads outside the workspace; require trust. + if (entry.isExternal && !allowExternalSymlinksRef.current) { + setPendingSymlinkTrust({ entry, action: "open" }); + setStatus("Ready"); + return; + } + try { const diskFile = await readOpenFileFromDisk(entry.path); setOpenFiles((current) => @@ -1426,6 +1490,32 @@ export default function App() { ], ); + const confirmSymlinkTrust = useCallback( + (scope: "once" | "workspace") => { + const pending = pendingSymlinkTrust; + if (!pending) return; + // Apply to the ref immediately so the retried action sees the grant before + // the state update has flushed. + allowExternalSymlinksRef.current = true; + if (scope === "workspace") { + setTrustExternalWorkspace(true); + } else { + setTrustExternalSession(true); + } + setPendingSymlinkTrust(undefined); + if (pending.action === "open") { + void openPath(pending.entry); + } else { + toggleFolder(pending.entry.path); + } + }, + [openPath, pendingSymlinkTrust, toggleFolder], + ); + + const cancelSymlinkTrust = useCallback(() => { + setPendingSymlinkTrust(undefined); + }, []); + useEffect(() => { if (!trackActiveFile || !activePath || singleFileMode) return; @@ -1612,6 +1702,7 @@ export default function App() { activeFile: activePath, selectedPath, sidebarWidth, + trustExternalSymlinks: trustExternalWorkspace, }, ).catch((reason) => { setError(`Unable to save UI state: ${String(reason)}`); @@ -1622,6 +1713,7 @@ export default function App() { }, [ activePath, expandedFolders, + trustExternalWorkspace, openFilePathSignature, selectedPath, sidebarWidth, @@ -1904,7 +1996,12 @@ export default function App() { const revealCurrentFileMatch = useCallback((match: SearchMatch, index?: number) => { if (index !== undefined) setCurrentFindIndex(index); - setRevealTarget({ path: match.path, lineNumber: match.lineNumber }); + setRevealTarget({ + path: match.path, + lineNumber: match.lineNumber, + matchStart: match.matchStart, + matchEnd: match.matchEnd, + }); setStatus(`Found ${match.path}:${match.lineNumber}`); }, []); @@ -1930,6 +2027,8 @@ export default function App() { path: match.path, lineNumber: match.lineNumber, preserveFocus: true, + matchStart: match.matchStart, + matchEnd: match.matchEnd, }); setStatus( `Match ${nextIndex + 1} of ${currentFindResults.length} at ${match.path}:${match.lineNumber}`, @@ -1980,7 +2079,12 @@ export default function App() { setStatus(`Saving ${fileToSave.path}`); savingPathsRef.current.add(fileToSave.path); try { - await writeFile(fileToSave.path, fileToSave.contents, fileToSave.modifiedMs); + await writeFile( + fileToSave.path, + fileToSave.contents, + fileToSave.modifiedMs, + allowExternalSymlinksRef.current, + ); const savedEntry = await statFile(fileToSave.path); setOpenFiles((current) => current.map((file) => @@ -2096,7 +2200,7 @@ export default function App() { }, [activeFile, reloadFileFromDisk]); const requestEditorCommand = useCallback( - (name: EditorCommandName) => { + (name: EditorCommandName, replace?: EditorReplacePayload) => { if (!activeFile) { setStatus(`${editorCommandLabel(name)} requires an open file`); return; @@ -2107,11 +2211,90 @@ export default function App() { filePath: activeFile.path, name, nonce: editorCommandNonceRef.current, + replace, }); }, [activeFile], ); + const replaceTargetsFrom = useCallback( + (matches: SearchMatch[]): EditorReplacePayload => ({ + replacement: replaceQuery, + targets: matches.map((match) => ({ + line: match.lineNumber, + matchStart: match.matchStart, + matchEnd: match.matchEnd, + })), + }), + [replaceQuery], + ); + + const replaceCurrentMatch = useCallback(() => { + if (!activeFile) { + setStatus("Find in file requires an open file"); + return; + } + if (currentFindResults.length === 0) { + setStatus("No matches to replace"); + return; + } + + const targetIndex = + currentFindIndex >= 0 && currentFindIndex < currentFindResults.length + ? currentFindIndex + : 0; + requestEditorCommand( + "replaceMatch", + replaceTargetsFrom([currentFindResults[targetIndex]]), + ); + }, [ + activeFile, + currentFindIndex, + currentFindResults, + replaceTargetsFrom, + requestEditorCommand, + ]); + + const replaceAllMatches = useCallback(() => { + if (!activeFile) { + setStatus("Find in file requires an open file"); + return; + } + // The find preview caps results at currentFileSearchResultLimit; replacing + // only that capped set would silently skip later matches in large files. So + // recompute over the full file — but bound it so a pathological query (e.g. a + // single character in a huge file) can't allocate an unbounded array and freeze + // the editor on one giant transaction. + const allMatches = currentFileMatches( + activeFile.path, + activeFile.contents, + currentFileQuery, + replaceAllMatchLimit, + ); + if (allMatches.length === 0) { + setStatus("No matches to replace"); + return; + } + + requestEditorCommand("replaceAll", replaceTargetsFrom(allMatches)); + if (allMatches.length >= replaceAllMatchLimit) { + setStatus( + `Replaced the first ${replaceAllMatchLimit.toLocaleString()} matches — run Replace All again for the rest`, + ); + } + }, [activeFile, currentFileQuery, replaceTargetsFrom, requestEditorCommand]); + + const openReplaceInFile = useCallback(() => { + if (!activeFile) { + setStatus("Replace in file requires an open file"); + return; + } + setCurrentFindOpen(true); + setReplaceVisible(true); + // Defer focus until the replace input has mounted in the expanded overlay. + requestAnimationFrame(() => replaceInputRef.current?.focus()); + }, [activeFile]); + const openQuickOpen = useCallback(() => { setCommandPaletteVisible(false); setCommandPaletteQuery(""); @@ -2232,6 +2415,19 @@ export default function App() { return; } + // Arrow up/down step through matches and select each in the editor while + // focus stays in the find input, so you can bounce around the file. + if (event.key === "ArrowDown") { + event.preventDefault(); + revealCurrentFindMatch(1); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + revealCurrentFindMatch(-1); + return; + } + if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); @@ -2241,11 +2437,36 @@ export default function App() { return; } + setReplaceVisible(false); setCurrentFindOpen(false); }, [currentFileQuery, revealCurrentFindMatch], ); + const handleReplaceKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + // Cmd/Ctrl+Enter = Replace All (matches the button title); plain Enter = + // replace current. Alt is deliberately excluded — Alt+Enter inserts a + // newline on some layouts and shouldn't rewrite the whole file. + if (event.metaKey || event.ctrlKey) { + replaceAllMatches(); + } else { + replaceCurrentMatch(); + } + return; + } + + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + setReplaceVisible(false); + currentFindInputRef.current?.focus(); + }, + [replaceAllMatches, replaceCurrentMatch], + ); + const cancelReloadActiveFile = useCallback(() => { const request = pendingReloadRequestRef.current; if (request?.reason === "external") { @@ -2929,7 +3150,7 @@ export default function App() { setError(undefined); setStatus(`Creating ${path}`); try { - await createFile(path); + await createFile(path, allowExternalSymlinksRef.current); const refreshedEntries = await refreshFiles(); const modifiedMs = refreshedEntries.find((entry) => entry.path === path)?.modifiedMs; setOpenFiles((current) => @@ -2963,7 +3184,7 @@ export default function App() { setError(undefined); setStatus(`Creating ${path}`); try { - await createFolder(path); + await createFolder(path, allowExternalSymlinksRef.current); setSelectedPath(path); closeNewFolderDialog(); await refreshFiles(); @@ -2990,7 +3211,7 @@ export default function App() { setError(undefined); setStatus(`Renaming ${fromPath}`); try { - await renameFile(fromPath, toPath); + await renameFile(fromPath, toPath, allowExternalSymlinksRef.current); const refreshedEntries = await refreshFiles(); const modifiedMs = refreshedEntries.find((entry) => entry.path === toPath)?.modifiedMs; setOpenFiles((current) => @@ -3185,6 +3406,11 @@ export default function App() { cancelDeleteSelectedFile(); return; } + if (event.key === "Escape" && pendingSymlinkTrust) { + event.preventDefault(); + cancelSymlinkTrust(); + return; + } if (event.key === "Escape" && pendingReloadRequest) { event.preventDefault(); cancelReloadActiveFile(); @@ -3295,6 +3521,10 @@ export default function App() { } else if (isIntellijShortcut(event, "findInFile")) { event.preventDefault(); openCurrentFileFind(); + } else if (isIntellijShortcut(event, "findInFileReplace")) { + // preventDefault stops the webview from treating Cmd/Ctrl+R as a reload. + event.preventDefault(); + openReplaceInFile(); } else if (isIntellijShortcut(event, "goToDefinition")) { event.preventDefault(); requestEditorCommand("goToDefinition"); @@ -3315,6 +3545,8 @@ export default function App() { closeRenameDialog, activateAdjacentTab, cancelDeleteSelectedFile, + cancelSymlinkTrust, + pendingSymlinkTrust, cancelReloadActiveFile, newFileDialogOpen, newFolderDialogOpen, @@ -3331,6 +3563,7 @@ export default function App() { openNewFileDialog, openCommandPalette, openCurrentFileFind, + openReplaceInFile, openGoToLineDialog, openQuickOpen, openWorkspaceSearch, @@ -3686,24 +3919,84 @@ export default function App() {
{currentFindExpanded ? ( - +
+ + {replaceVisible ? ( + + ) : null} +
) : (
- {currentFindResults.slice(0, currentFileResultPreviewLimit).map((result, index) => ( - - ))} + {currentFindWindow.items.map((result, offset) => { + const index = currentFindWindow.startIndex + offset; + return ( + + ); + })} {currentFindResults.length === 0 ? (
No matches
) : null} @@ -3790,6 +4086,12 @@ export default function App() { revealLine={ revealTarget?.path === activeFile.path ? revealTarget.lineNumber : undefined } + revealMatchStart={ + revealTarget?.path === activeFile.path ? revealTarget.matchStart : undefined + } + revealMatchEnd={ + revealTarget?.path === activeFile.path ? revealTarget.matchEnd : undefined + } focusOnReveal={ revealTarget?.path === activeFile.path ? !revealTarget.preserveFocus @@ -4988,6 +5290,49 @@ export default function App() { ) : null} + {pendingSymlinkTrust ? ( +
+
+
+
External symbolic link
+ +

+ {pendingSymlinkTrust.entry.path} points to{" "} + {pendingSymlinkTrust.entry.symlinkTarget ?? "an external location"}, + which is outside this workspace. Following it lets the editor{" "} + {pendingSymlinkTrust.action === "open" ? "read and edit" : "browse"} files + outside the folder you opened. +

+
+
+ + + +
+
+
+ ) : null} + {pendingCloseFile ? (
)} - {node.name} + {node.name} + {node.isSymlink ? ( + node.isExternal ? ( + + ) : ( + + ) + ) : null} {node.isDir && expanded ? node.children.map((child) => ( @@ -5327,6 +5689,7 @@ type ShortcutAction = | "goToLine" | "findInFile" | "findInFiles" + | "findInFileReplace" | "goToDefinition" | "findReferences" | "saveAll" @@ -5372,6 +5735,10 @@ const intellijShortcuts: Record { if (!cancelled) { onError(`Unable to initialize editor for ${path}: ${String(error)}`); @@ -241,9 +248,15 @@ export default function EditorPane({ useEffect(() => { if (viewRef.current) { - revealLineInView(viewRef.current, revealLine, focusOnReveal); + revealLineInView( + viewRef.current, + revealLine, + focusOnReveal, + revealMatchStart, + revealMatchEnd, + ); } - }, [focusOnReveal, revealLine]); + }, [focusOnReveal, revealLine, revealMatchStart, revealMatchEnd]); useEffect(() => { const view = viewRef.current; @@ -252,6 +265,19 @@ export default function EditorPane({ const label = editorCommandLabel(editorCommand.name); try { + if ( + editorCommand.name === "replaceMatch" || + editorCommand.name === "replaceAll" + ) { + const replaced = applyReplace(view, editorCommand.replace); + onNotice?.( + replaced > 0 + ? `${label}: ${replaced} occurrence${replaced === 1 ? "" : "s"}` + : `${label}: nothing to replace`, + ); + return; + } + const handled = editorCommand.name === "goToDefinition" ? jumpToDefinition(view) @@ -298,18 +324,75 @@ function emitCursorAndSelection( ); } +// Rewrites the given match ranges in one dispatch so a single undo reverts the +// whole replace. Targets carry 1-based line + 0-based column offsets (SearchMatch +// shape); they are mapped to absolute doc offsets here. Returns the count applied. +function applyReplace( + view: EditorView, + payload: EditorReplacePayload | undefined, +): number { + if (!payload || payload.targets.length === 0) return 0; + + const doc = view.state.doc; + const changes: { from: number; to: number; insert: string }[] = []; + for (const target of payload.targets) { + const line = clampLineNumber(target.line, doc.lines); + if (!line) continue; + const docLine = doc.line(line); + // Clamp to the line on both ends so a malformed payload (negative or + // overshooting column offsets) can't rewrite outside the matched line. + const from = Math.min( + Math.max(docLine.from + target.matchStart, docLine.from), + docLine.to, + ); + const to = Math.min( + Math.max(docLine.from + target.matchEnd, docLine.from), + docLine.to, + ); + if (to < from) continue; + changes.push({ from, to, insert: payload.replacement }); + } + if (changes.length === 0) return 0; + + // CodeMirror requires change specs sorted by `from` and non-overlapping, or + // the transaction throws. Match order is normally already sorted, but sort and + // drop overlaps defensively so a bad payload can't crash the editor. + changes.sort((a, b) => a.from - b.from); + const safeChanges: typeof changes = []; + let lastTo = -1; + for (const change of changes) { + if (change.from >= lastTo) { + safeChanges.push(change); + lastTo = change.to; + } + } + if (safeChanges.length === 0) return 0; + + view.dispatch({ changes: safeChanges }); + return safeChanges.length; +} + function revealLineInView( view: EditorView, lineNumber: number | undefined, focus = true, + matchStart?: number, + matchEnd?: number, ) { const line = clampLineNumber(lineNumber, view.state.doc.lines); if (!line) return; - const position = view.state.doc.line(line).from; + const docLine = view.state.doc.line(line); + // Select the exact match when columns are supplied (find navigation); otherwise + // just place the cursor at the start of the line (go-to-line, reveal). + const hasMatch = matchStart !== undefined && matchEnd !== undefined; + const anchor = hasMatch + ? Math.min(docLine.from + matchStart, docLine.to) + : docLine.from; + const head = hasMatch ? Math.min(docLine.from + matchEnd, docLine.to) : anchor; view.dispatch({ - selection: { anchor: position }, - effects: EditorView.scrollIntoView(position, { y: "center" }), + selection: { anchor, head }, + effects: EditorView.scrollIntoView(anchor, { y: "center" }), }); if (focus) view.focus(); } diff --git a/src/currentFileSearch.test.ts b/src/currentFileSearch.test.ts index ed80fe2..11ec25d 100644 --- a/src/currentFileSearch.test.ts +++ b/src/currentFileSearch.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { currentFileMatches, + currentFileResultWindow, nextCurrentFileMatchIndex, } from "./currentFileSearch"; @@ -98,4 +99,48 @@ describe("current file search", () => { expect(nextCurrentFileMatchIndex(99, 1, 3)).toBe(0); expect(nextCurrentFileMatchIndex(0, 1, 0)).toBe(-1); }); + + it("scrolls the result preview window to follow the active match", () => { + const results = Array.from({ length: 14 }, (_, index) => index); + + // No active match yet: show the first window. + expect(currentFileResultWindow(results, -1, 4)).toEqual({ + startIndex: 0, + items: [0, 1, 2, 3], + }); + + // Active near the start stays pinned to the top. + expect(currentFileResultWindow(results, 1, 4)).toEqual({ + startIndex: 0, + items: [0, 1, 2, 3], + }); + + // Mid-list: the active row sits one slot from the bottom (offset limit-2), + // keeping one item of lookahead visible. + expect(currentFileResultWindow(results, 5, 4)).toEqual({ + startIndex: 3, + items: [3, 4, 5, 6], + }); + + // Near the end the window clamps so the last items show and active is still in view. + expect(currentFileResultWindow(results, 13, 4)).toEqual({ + startIndex: 10, + items: [10, 11, 12, 13], + }); + }); + + it("handles result windows shorter than the limit and a single-row limit", () => { + expect(currentFileResultWindow([0, 1], 1, 4)).toEqual({ + startIndex: 0, + items: [0, 1], + }); + expect(currentFileResultWindow([0, 1, 2, 3], 2, 1)).toEqual({ + startIndex: 2, + items: [2], + }); + expect(currentFileResultWindow([], 0, 4)).toEqual({ + startIndex: 0, + items: [], + }); + }); }); diff --git a/src/currentFileSearch.ts b/src/currentFileSearch.ts index 7381958..4029397 100644 --- a/src/currentFileSearch.ts +++ b/src/currentFileSearch.ts @@ -39,6 +39,29 @@ function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +// Picks the slice of results to preview so the active match stays visible with +// one item of lookahead: the active row sits one slot from the bottom (offset +// limit-2) until it is the genuine last match, then it occupies the last slot. +// `startIndex` lets callers map back to absolute match indices. +export function currentFileResultWindow( + results: T[], + activeIndex: number, + limit: number, +): { startIndex: number; items: T[] } { + if (limit <= 0 || results.length === 0) { + return { startIndex: 0, items: [] }; + } + + const maxStart = Math.max(0, results.length - limit); + let startIndex = 0; + if (activeIndex >= 0) { + const desiredOffset = Math.max(0, limit - 2); + startIndex = Math.min(Math.max(activeIndex - desiredOffset, 0), maxStart); + } + + return { startIndex, items: results.slice(startIndex, startIndex + limit) }; +} + export function nextCurrentFileMatchIndex( currentIndex: number, direction: 1 | -1, diff --git a/src/editorCommands.ts b/src/editorCommands.ts index 9ce5ed7..3ec3a78 100644 --- a/src/editorCommands.ts +++ b/src/editorCommands.ts @@ -1,14 +1,34 @@ -export type EditorCommandName = "goToDefinition" | "findReferences"; +export type EditorCommandName = + | "goToDefinition" + | "findReferences" + | "replaceMatch" + | "replaceAll"; + +// Column offsets are 0-based within the line, matching SearchMatch (tauri.ts). +export interface EditorReplaceTarget { + line: number; + matchStart: number; + matchEnd: number; +} + +export interface EditorReplacePayload { + targets: EditorReplaceTarget[]; + replacement: string; +} export interface EditorCommandRequest { filePath: string; name: EditorCommandName; nonce: number; + // Present for replaceMatch / replaceAll; carries the range(s) to rewrite. + replace?: EditorReplacePayload; } const labels: Record = { goToDefinition: "Go to definition", findReferences: "Find references", + replaceMatch: "Replace", + replaceAll: "Replace all", }; export function editorCommandLabel(command: EditorCommandName) { diff --git a/src/styles.css b/src/styles.css index 5751b8d..4f2b9cb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -596,7 +596,7 @@ button { .tree-row { display: grid; - grid-template-columns: 14px 18px minmax(0, 1fr); + grid-template-columns: 14px 18px minmax(0, 1fr) auto; width: 100%; min-height: 28px; align-items: center; @@ -608,6 +608,23 @@ button { text-align: left; } +.tree-row__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-row__symlink { + color: var(--muted); + opacity: 0.85; + flex-shrink: 0; +} + +.tree-row__symlink--external { + color: var(--accent); + opacity: 1; +} + .tree-row:hover, .tree-row:focus-visible, .tree-row--active { @@ -732,9 +749,15 @@ button { gap: 6px; } +.topbar-find-group { + position: relative; + display: flex; + align-items: center; +} + .topbar-find { display: grid; - grid-template-columns: 16px minmax(80px, 180px) 28px; + grid-template-columns: 16px minmax(80px, 180px) 28px 24px; height: 32px; align-items: center; gap: 6px; @@ -745,6 +768,62 @@ button { color: var(--muted); } +/* Replace row drops below the find box so it doesn't grow the fixed-height topbar. */ +.topbar-find--replace { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + grid-template-columns: 16px minmax(80px, 180px) auto auto; + box-shadow: var(--shadow); +} + +.topbar-find__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.topbar-find__toggle:hover, +.topbar-find__toggle:focus-visible { + color: var(--text); +} + +.topbar-find__toggle[aria-pressed="true"] { + background: var(--accent-weak); + color: var(--text); +} + +.topbar-find__action { + height: 24px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--panel); + color: var(--text); + font-size: 11px; + white-space: nowrap; + cursor: pointer; +} + +.topbar-find__action:hover:not(:disabled), +.topbar-find__action:focus-visible { + border-color: var(--accent); +} + +.topbar-find__action:disabled { + color: var(--muted); + cursor: not-allowed; + opacity: 0.6; +} + .topbar-find input { min-width: 0; border: 0; diff --git a/src/tauri.test.ts b/src/tauri.test.ts index 5bd1e6e..5c5b1a0 100644 --- a/src/tauri.test.ts +++ b/src/tauri.test.ts @@ -798,6 +798,7 @@ describe("hosted Tauri API transport", () => { expect(invoke).toHaveBeenCalledWith("read_file", { path: "README.md", maxOpenBytes: 5 * 1024, + allowExternalSymlinks: false, }); }); @@ -831,6 +832,7 @@ describe("hosted Tauri API transport", () => { showDotfiles: true, showGeneratedInternal: true, showGitignoredFiles: false, + allowExternalSymlinks: false, }); }); diff --git a/src/tauri.ts b/src/tauri.ts index 44c1847..5c2f84d 100644 --- a/src/tauri.ts +++ b/src/tauri.ts @@ -14,6 +14,9 @@ export interface FileEntry { depth: number; size: number; modifiedMs?: number; + isSymlink?: boolean; + isExternal?: boolean; + symlinkTarget?: string; } export interface FileListResult { @@ -173,6 +176,7 @@ export interface WorkspaceUiState { activeFile?: string; selectedPath?: string; sidebarWidth?: number; + trustExternalSymlinks?: boolean; } export interface PersistedUiSnapshot { @@ -456,13 +460,20 @@ export function listDirectory( showDotfiles = false, showGeneratedInternal = false, showGitignoredFiles = false, + allowExternalSymlinks = false, ) { const params = new URLSearchParams({ path }); if (showDotfiles) params.set("showDotfiles", "true"); if (showGeneratedInternal) params.set("showGeneratedInternal", "true"); if (showGitignoredFiles) params.set("showGitignoredFiles", "true"); return callApi("list_directory", `/api/directory?${params.toString()}`, { - invokeArgs: { path, showDotfiles, showGeneratedInternal, showGitignoredFiles }, + invokeArgs: { + path, + showDotfiles, + showGeneratedInternal, + showGitignoredFiles, + allowExternalSymlinks, + }, }).then((entries) => { if (!Array.isArray(entries)) { throw new Error("Workspace directory response was not valid JSON"); @@ -471,7 +482,11 @@ export function listDirectory( }); } -export function readFile(path: string, maxOpenBytes?: number) { +export function readFile( + path: string, + maxOpenBytes?: number, + allowExternalSymlinks = false, +) { const params = new URLSearchParams({ path }); if (maxOpenBytes !== undefined) { params.set("maxOpenBytes", String(maxOpenBytes)); @@ -481,6 +496,7 @@ export function readFile(path: string, maxOpenBytes?: number) { invokeArgs: { path, ...(maxOpenBytes === undefined ? {} : { maxOpenBytes }), + allowExternalSymlinks, }, }); } @@ -513,11 +529,16 @@ export function getGitAttribution(path: string) { }); } -export function writeFile(path: string, contents: string, expectedModifiedMs?: number) { +export function writeFile( + path: string, + contents: string, + expectedModifiedMs?: number, + allowExternalSymlinks = false, +) { return callApi("write_file", "/api/file", { method: "PUT", body: { path, contents, expectedModifiedMs }, - invokeArgs: { path, contents, expectedModifiedMs }, + invokeArgs: { path, contents, expectedModifiedMs, allowExternalSymlinks }, }); } @@ -643,27 +664,31 @@ function normalizeGitCommitAction(value: unknown): GitCommitAction | undefined { }; } -export function createFile(path: string) { +export function createFile(path: string, allowExternalSymlinks = false) { return callApi("create_file", "/api/file", { method: "POST", body: { path, contents: "" }, - invokeArgs: { path }, + invokeArgs: { path, allowExternalSymlinks }, }); } -export function createFolder(path: string) { +export function createFolder(path: string, allowExternalSymlinks = false) { return callApi("create_folder", "/api/folder", { method: "POST", body: { path }, - invokeArgs: { path }, + invokeArgs: { path, allowExternalSymlinks }, }); } -export function renameFile(fromPath: string, toPath: string) { +export function renameFile( + fromPath: string, + toPath: string, + allowExternalSymlinks = false, +) { return callApi("rename_file", "/api/file", { method: "PATCH", body: { fromPath, toPath }, - invokeArgs: { fromPath, toPath }, + invokeArgs: { fromPath, toPath, allowExternalSymlinks }, }); }