From 57a2adc592f3a6637e0af2d1f133b6030e515b07 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 1 Jul 2026 01:49:16 +1000 Subject: [PATCH 1/6] Add find/replace and follow workspace symlinks Two editor changes. Find in File is now a real find-and-replace. Cmd+F opens the app's own overlay instead of CodeMirror's panel, the arrow keys step through matches and select each one in the editor while focus stays in the field, the results preview scrolls to keep the active match visible, and Cmd+R reveals a replace row with Replace and Replace All. Standard edit keybinds (Select All, word jumps, cut/copy/paste) now work in every textbox after wiring the macOS Edit menu's Select All item. Symlinks used to be rejected outright. The editor now follows them: a target inside the workspace opens and expands with no fuss, and a target that escapes the workspace asks first with a Cancel / Trust once / Trust for workspace prompt. "Trust once" lasts the session, "Trust for workspace" is persisted. Symlinked entries get a link icon in the tree, with a separate mark and the target path shown when they point outside the workspace. Renaming or deleting a symlink only touches the link, never its target. Co-authored-by: Claude Co-authored-by: GitButler --- src-tauri/src/git_attribution.rs | 8 +- src-tauri/src/http_server.rs | 13 +- src-tauri/src/lib.rs | 58 +++- src-tauri/src/workspace.rs | 462 ++++++++++++++++++++++++------- src-tauri/src/workspace_index.rs | 11 + src/App.test.tsx | 112 +++++++- src/App.tsx | 423 +++++++++++++++++++++++++--- src/EditorPane.tsx | 77 +++++- src/currentFileSearch.test.ts | 45 +++ src/currentFileSearch.ts | 23 ++ src/editorCommands.ts | 22 +- src/styles.css | 83 +++++- src/tauri.test.ts | 2 + src/tauri.ts | 45 ++- 14 files changed, 1196 insertions(+), 188 deletions(-) diff --git a/src-tauri/src/git_attribution.rs b/src-tauri/src/git_attribution.rs index 397e9d3..215f441 100644 --- a/src-tauri/src/git_attribution.rs +++ b/src-tauri/src/git_attribution.rs @@ -62,7 +62,9 @@ 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) { + // Attribution is only requested for a file already open in the editor, which + // means its symlink (if any) was already trusted at open time. + let file_path = match resolve_existing_workspace_file_path(workspace_root, relative, true) { Ok(path) => path, Err(error) => return unsupported(relative, workspace_error_reason(error)), }; @@ -524,7 +526,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..dcc06fd 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,11 @@ 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 +926,11 @@ 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 +941,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 +1204,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 +2188,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 +2340,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 +2379,7 @@ fn sanitize_workspace_ui_state(state: WorkspaceUiStatePayload) -> WorkspaceUiSta active_file, selected_path, sidebar_width, + trust_external_symlinks: state.trust_external_symlinks, } } @@ -3144,6 +3183,7 @@ mod tests { active_file: None, selected_path: None, sidebar_width: None, + trust_external_symlinks: false, updated_at: 1, }, PersistedWorkspaceUiState { @@ -3153,6 +3193,7 @@ mod tests { active_file: None, selected_path: None, sidebar_width: None, + trust_external_symlinks: false, updated_at: 2, }, ], @@ -3241,6 +3282,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 +3324,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 +3628,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..ed41e41 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,74 @@ 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, +} + +// 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, + }; + } + + 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, + } + } + // 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, + }, + } +} + 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,7 +418,7 @@ 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 + let modified_ms = link_metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) @@ -352,14 +430,19 @@ fn file_entry_from_relative( .to_string_lossy() .to_string(); + let facts = symlink_facts(abs_path, link_metadata, canonical_root); + 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 +573,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 +592,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 +610,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 +633,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 +648,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> { +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)?; - let to_path = resolve_new_workspace_entry_path(root, to)?; + 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)?; } @@ -568,7 +666,14 @@ 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)?; - if path.is_dir() { + // 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 link_metadata.is_dir() { fs::remove_dir_all(path).map_err(WorkspaceError::from) } else { fs::remove_file(path).map_err(WorkspaceError::from) @@ -709,6 +814,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 +847,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 +889,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,25 +919,46 @@ 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. fn resolve_existing_workspace_entry_path( root: &Path, relative: &str, @@ -825,7 +966,7 @@ fn resolve_existing_workspace_entry_path( let path = resolve_workspace_path(root, relative)?; 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); @@ -858,7 +999,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 +1007,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 +1017,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 +1030,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 +1040,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 +1061,61 @@ 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))); - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); + // ...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 write_workspace_file_rejects_symlink_sources() { + 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(); + + // 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_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"); - assert!(matches!(result, Err(WorkspaceError::SymlinkUnsupported))); - 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"); + } + + #[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 +1133,8 @@ 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 +1148,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 +1160,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 +1173,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 +1186,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 +1198,9 @@ 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 +1208,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 +1217,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 +1227,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 +1237,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 +1249,9 @@ 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 +1261,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 +1275,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 +1290,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 +1309,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 +1323,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 +1331,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 +1339,13 @@ 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 +1356,9 @@ 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 +1400,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 +1408,28 @@ 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 +1635,7 @@ 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 +1647,7 @@ 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 +1665,7 @@ 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 +1675,59 @@ 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..cf6f5e4 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 { @@ -420,6 +427,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 +520,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 +545,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 +635,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 +700,7 @@ export default function App() { renameDialogOpen || goToLineDialogOpen || pendingDeletePath !== undefined || + pendingSymlinkTrust !== undefined || pendingReloadRequest !== undefined || pendingCloseAll || pendingAppClose || @@ -914,6 +947,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 +986,7 @@ export default function App() { showDotfiles, showGeneratedInternal, showGitignoredFiles, + allowExternalSymlinksRef.current, ); setFiles((current) => mergeFileEntries(current, entries)); setLoadedFolders((current) => new Set(current).add(path)); @@ -972,6 +1007,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 +1027,7 @@ export default function App() { if (shouldLoad) { void loadFolderChildren(path); } - }, [expandedFolders, loadFolderChildren]); + }, [expandedFolders, files, loadFolderChildren]); const refreshIntegrationStatus = useCallback(async () => { try { @@ -1099,7 +1141,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 +1154,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 +1306,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 +1447,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 +1487,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 +1699,7 @@ export default function App() { activeFile: activePath, selectedPath, sidebarWidth, + trustExternalSymlinks: trustExternalWorkspace, }, ).catch((reason) => { setError(`Unable to save UI state: ${String(reason)}`); @@ -1622,6 +1710,7 @@ export default function App() { }, [ activePath, expandedFolders, + trustExternalWorkspace, openFilePathSignature, selectedPath, sidebarWidth, @@ -1904,7 +1993,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 +2024,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 +2076,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 +2197,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 +2208,74 @@ 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; + } + if (currentFindResults.length === 0) { + setStatus("No matches to replace"); + return; + } + + requestEditorCommand("replaceAll", replaceTargetsFrom(currentFindResults)); + }, [activeFile, currentFindResults, 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 +2396,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 +2418,33 @@ export default function App() { return; } + setReplaceVisible(false); setCurrentFindOpen(false); }, [currentFileQuery, revealCurrentFindMatch], ); + const handleReplaceKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + if (event.metaKey || event.ctrlKey || event.altKey) { + 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 +3128,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 +3162,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 +3189,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 +3384,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 +3499,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 +3523,8 @@ export default function App() { closeRenameDialog, activateAdjacentTab, cancelDeleteSelectedFile, + cancelSymlinkTrust, + pendingSymlinkTrust, cancelReloadActiveFile, newFileDialogOpen, newFolderDialogOpen, @@ -3331,6 +3541,7 @@ export default function App() { openNewFileDialog, openCommandPalette, openCurrentFileFind, + openReplaceInFile, openGoToLineDialog, openQuickOpen, openWorkspaceSearch, @@ -3686,24 +3897,74 @@ 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 +4054,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 +5258,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 +5657,7 @@ type ShortcutAction = | "goToLine" | "findInFile" | "findInFiles" + | "findInFileReplace" | "goToDefinition" | "findReferences" | "saveAll" @@ -5372,6 +5703,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,53 @@ 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 = []; + for (const target of payload.targets) { + const line = clampLineNumber(target.line, doc.lines); + if (!line) continue; + const docLine = doc.line(line); + const from = Math.min(docLine.from + target.matchStart, docLine.to); + const to = Math.min(docLine.from + target.matchEnd, docLine.to); + if (to < from) continue; + changes.push({ from, to, insert: payload.replacement }); + } + if (changes.length === 0) return 0; + + view.dispatch({ changes }); + return changes.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 }, }); } From 558a46c049727b2fe9b2b6fd2b0a9030dbfffac5 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 1 Jul 2026 02:04:07 +1000 Subject: [PATCH 2/6] Apply cargo fmt Co-authored-by: Claude Co-authored-by: GitButler --- src-tauri/src/lib.rs | 16 ++++++--- src-tauri/src/workspace.rs | 73 +++++++++++++++++++++++++++++--------- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dcc06fd..24e1daa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -915,8 +915,12 @@ async fn create_file( allow_external_symlinks: Option, ) -> Result<(), CommandError> { let workspace_root = workspace_root_for_window(&state, &window).await; - create_workspace_file(&workspace_root, &path, allow_external_symlinks.unwrap_or(false)) - .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(()) } @@ -929,8 +933,12 @@ async fn create_folder( allow_external_symlinks: Option, ) -> Result<(), CommandError> { let workspace_root = workspace_root_for_window(&state, &window).await; - create_workspace_folder(&workspace_root, &path, allow_external_symlinks.unwrap_or(false)) - .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(()) } diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index ed41e41..23b8d43 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -388,7 +388,10 @@ fn symlink_facts( Ok(canonical) => { let target_metadata = fs::metadata(&canonical).ok(); SymlinkFacts { - is_dir: target_metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false), + 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), @@ -1063,7 +1066,10 @@ mod tests { // Untrusted external symlink is refused... let result = read_workspace_file(dir.path(), "linked.txt", 1024, false); - assert!(matches!(result, Err(WorkspaceError::SymlinkOutsideWorkspace))); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); // ...but reads through once the user grants trust. assert_eq!( @@ -1078,7 +1084,11 @@ mod tests { 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(); + symlink( + dir.path().join("real/note.txt"), + dir.path().join("link.txt"), + ) + .unwrap(); // In-workspace target needs no trust. assert_eq!( @@ -1097,7 +1107,10 @@ mod tests { symlink(&secret_path, dir.path().join("linked.txt")).unwrap(); let result = write_workspace_file(dir.path(), "linked.txt", "changed", None, false); - assert!(matches!(result, Err(WorkspaceError::SymlinkOutsideWorkspace))); + 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. @@ -1133,8 +1146,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), false); + let result = write_workspace_file( + dir.path(), + "note.txt", + "after", + Some(stale_modified_ms), + false, + ); assert!(matches!( result, @@ -1200,7 +1218,10 @@ mod tests { let result = create_workspace_file(dir.path(), "linked/new.txt", false); - assert!(matches!(result, Err(WorkspaceError::SymlinkOutsideWorkspace))); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); assert!(!outside.path().join("new.txt").exists()); } @@ -1251,7 +1272,10 @@ mod tests { let result = create_workspace_folder(dir.path(), "linked/new-folder", false); - assert!(matches!(result, Err(WorkspaceError::SymlinkOutsideWorkspace))); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); assert!(!outside.path().join("new-folder").exists()); } @@ -1344,7 +1368,10 @@ mod tests { // 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!(fs::symlink_metadata(&renamed) + .unwrap() + .file_type() + .is_symlink()); assert_eq!(fs::read_to_string(secret_path).unwrap(), "secret"); } @@ -1358,7 +1385,10 @@ mod tests { let result = rename_workspace_file(dir.path(), "note.txt", "linked/renamed.txt", false); - assert!(matches!(result, Err(WorkspaceError::SymlinkOutsideWorkspace))); + assert!(matches!( + result, + Err(WorkspaceError::SymlinkOutsideWorkspace) + )); assert_eq!( fs::read_to_string(dir.path().join("note.txt")).unwrap(), "contents" @@ -1427,7 +1457,10 @@ mod tests { 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"); + assert_eq!( + fs::read_to_string(outside.path().join("keep.txt")).unwrap(), + "keep" + ); } #[test] @@ -1635,7 +1668,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, false).unwrap(); + let entries = + workspace_directory_entries(dir.path(), "src", false, false, true, false).unwrap(); let paths = entries .iter() .map(|entry| entry.path.as_str()) @@ -1647,7 +1681,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, false).unwrap(); + let entries = + workspace_directory_entries(dir.path(), "src", true, true, true, false).unwrap(); let paths = entries .iter() .map(|entry| entry.path.as_str()) @@ -1665,7 +1700,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, false).unwrap(); + let entries = + workspace_directory_entries(dir.path(), "", false, false, true, false).unwrap(); let paths = entries .iter() .map(|entry| entry.path.as_str()) @@ -1703,10 +1739,15 @@ mod tests { 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( + 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 entries = + workspace_directory_entries(dir.path(), "", false, false, true, false).unwrap(); let by_name = |name: &str| { entries .iter() From aae1a85de379c8aa6c25651f1ed3f242c4a66c34 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 1 Jul 2026 02:10:19 +1000 Subject: [PATCH 3/6] Address PR review feedback - Report a symlinked file's target mtime so saving through the link doesn't false-positive "file changed on disk" - Sort and de-overlap replace ranges before dispatch so CodeMirror can't throw on an out-of-order payload; give the change array an explicit type - Move the replace-toggle focus side effect out of the state updater - Drop Alt+Enter as a Replace All trigger; only Cmd/Ctrl+Enter - Replace All now recomputes over the full file, not the capped preview set Co-authored-by: Claude Co-authored-by: GitButler --- src-tauri/src/workspace.rs | 22 +++++++++++++++++----- src/App.tsx | 32 +++++++++++++++++++++----------- src/EditorPane.tsx | 20 +++++++++++++++++--- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 23b8d43..973b0ee 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -358,6 +358,19 @@ struct SymlinkFacts { 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 @@ -377,6 +390,7 @@ fn symlink_facts( is_symlink: false, is_external: false, symlink_target: None, + modified_ms: metadata_modified_ms(link_metadata), }; } @@ -396,6 +410,7 @@ fn symlink_facts( 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. @@ -405,6 +420,7 @@ fn symlink_facts( is_symlink: true, is_external: true, symlink_target, + modified_ms: metadata_modified_ms(link_metadata), }, } } @@ -421,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 = link_metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()); let name = relative .file_name() @@ -434,6 +445,7 @@ fn file_entry_from_relative( .to_string(); let facts = symlink_facts(abs_path, link_metadata, canonical_root); + let modified_ms = facts.modified_ms; Ok(FileEntry { path: relative_path, diff --git a/src/App.tsx b/src/App.tsx index cf6f5e4..ecd8382 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2257,13 +2257,22 @@ export default function App() { setStatus("Find in file requires an open file"); return; } - if (currentFindResults.length === 0) { + // The find preview caps results at currentFileSearchResultLimit; replacing + // only that capped set would silently skip later matches in large files. + // Recompute over the full file contents so "Replace All" really means all. + const allMatches = currentFileMatches( + activeFile.path, + activeFile.contents, + currentFileQuery, + Number.MAX_SAFE_INTEGER, + ); + if (allMatches.length === 0) { setStatus("No matches to replace"); return; } - requestEditorCommand("replaceAll", replaceTargetsFrom(currentFindResults)); - }, [activeFile, currentFindResults, replaceTargetsFrom, requestEditorCommand]); + requestEditorCommand("replaceAll", replaceTargetsFrom(allMatches)); + }, [activeFile, currentFileQuery, replaceTargetsFrom, requestEditorCommand]); const openReplaceInFile = useCallback(() => { if (!activeFile) { @@ -2428,7 +2437,10 @@ export default function App() { (event: ReactKeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); - if (event.metaKey || event.ctrlKey || event.altKey) { + // 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(); @@ -3922,13 +3934,11 @@ export default function App() { aria-label={replaceVisible ? "Hide replace" : "Replace"} aria-pressed={replaceVisible} onClick={() => { - setReplaceVisible((visible) => { - const next = !visible; - if (next) { - requestAnimationFrame(() => replaceInputRef.current?.focus()); - } - return next; - }); + const next = !replaceVisible; + setReplaceVisible(next); + if (next) { + requestAnimationFrame(() => replaceInputRef.current?.focus()); + } }} > diff --git a/src/EditorPane.tsx b/src/EditorPane.tsx index f5f3083..651f8ae 100644 --- a/src/EditorPane.tsx +++ b/src/EditorPane.tsx @@ -334,7 +334,7 @@ function applyReplace( if (!payload || payload.targets.length === 0) return 0; const doc = view.state.doc; - const changes = []; + const changes: { from: number; to: number; insert: string }[] = []; for (const target of payload.targets) { const line = clampLineNumber(target.line, doc.lines); if (!line) continue; @@ -346,8 +346,22 @@ function applyReplace( } if (changes.length === 0) return 0; - view.dispatch({ changes }); - return changes.length; + // 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( From b3793fa744018f38b8fd64d3d2bedfa3c51724fc Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 1 Jul 2026 02:25:50 +1000 Subject: [PATCH 4/6] Bump anyhow to 1.0.103 Clears RUSTSEC-2026-0190 (unsoundness in anyhow's Error::downcast_mut), which fails the cargo audit --deny warnings gate in CI. Co-authored-by: Claude Co-authored-by: GitButler --- src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" From 21fe6b2ee973a2d2d9f8733b9bea4b5a71c9e190 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 1 Jul 2026 02:30:51 +1000 Subject: [PATCH 5/6] Address second round of PR review feedback - Git attribution no longer follows symlinks outside the workspace, so it can't read external files past the trust gate (in-workspace links still work) - Thread the trust grant into the rename source resolution so renaming an entry inside a trusted external symlinked dir works like create/write - Clamp replace offsets to the line's start as well as its end, guarding against malformed negative column offsets - Bound Replace All to a sane match limit instead of an unbounded scan, and report when the cap is hit Co-authored-by: Claude Co-authored-by: GitButler --- src-tauri/src/git_attribution.rs | 8 +++++--- src-tauri/src/workspace.rs | 15 ++++++++++----- src/App.tsx | 16 +++++++++++++--- src/EditorPane.tsx | 12 ++++++++++-- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/git_attribution.rs b/src-tauri/src/git_attribution.rs index 215f441..33b2cff 100644 --- a/src-tauri/src/git_attribution.rs +++ b/src-tauri/src/git_attribution.rs @@ -62,9 +62,11 @@ struct RemoteTemplate { } pub(crate) async fn attribution_for_file(workspace_root: &Path, relative: &str) -> GitAttribution { - // Attribution is only requested for a file already open in the editor, which - // means its symlink (if any) was already trusted at open time. - let file_path = match resolve_existing_workspace_file_path(workspace_root, relative, true) { + // 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)), }; diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 973b0ee..1264bc1 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -669,7 +669,7 @@ pub fn rename_workspace_file( to: &str, allow_external_symlinks: bool, ) -> Result<(), WorkspaceError> { - let from_path = resolve_existing_workspace_entry_path(root, from)?; + 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)?; @@ -679,7 +679,9 @@ pub fn rename_workspace_file( } 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. @@ -973,12 +975,15 @@ fn resolve_existing_workspace_dir_path_following( // 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. +// 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 Ok(path); @@ -989,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); } diff --git a/src/App.tsx b/src/App.tsx index ecd8382..84599a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -292,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; @@ -2258,13 +2261,15 @@ export default function App() { return; } // The find preview caps results at currentFileSearchResultLimit; replacing - // only that capped set would silently skip later matches in large files. - // Recompute over the full file contents so "Replace All" really means all. + // 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, - Number.MAX_SAFE_INTEGER, + replaceAllMatchLimit, ); if (allMatches.length === 0) { setStatus("No matches to replace"); @@ -2272,6 +2277,11 @@ export default function App() { } 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(() => { diff --git a/src/EditorPane.tsx b/src/EditorPane.tsx index 651f8ae..538e35a 100644 --- a/src/EditorPane.tsx +++ b/src/EditorPane.tsx @@ -339,8 +339,16 @@ function applyReplace( const line = clampLineNumber(target.line, doc.lines); if (!line) continue; const docLine = doc.line(line); - const from = Math.min(docLine.from + target.matchStart, docLine.to); - const to = Math.min(docLine.from + target.matchEnd, docLine.to); + // 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 }); } From 4908f513a791619ae92231e9d2cf12cdca079846 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Wed, 1 Jul 2026 02:43:50 +1000 Subject: [PATCH 6/6] Keep find/replace open when focus moves to replace controls The find input auto-closes the overlay on blur when the query is empty. Clicking the Replace toggle or focusing the replace input blurred the find input and could close the whole find/replace UI before the user typed anything. The blur handler now ignores focus moves that stay inside the find/replace group. Co-authored-by: Claude Co-authored-by: GitButler --- src/App.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 84599a7..24c7cac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3925,9 +3925,21 @@ export default function App() { { + onBlur={(event) => { + // Keep the overlay open when focus moves to the replace + // toggle or replace input — only auto-close when focus + // leaves the whole find/replace group with an empty query. + const group = event.currentTarget.closest(".topbar-find-group"); + if ( + group && + event.relatedTarget instanceof Node && + group.contains(event.relatedTarget) + ) { + return; + } if (!currentFileQuery.trim()) { setCurrentFindOpen(false); + setReplaceVisible(false); } }} onChange={(event) => setCurrentFileQuery(event.target.value)}