diff --git a/package-lock.json b/package-lock.json index 107c38b86..e03e714d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1848,6 +1849,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1908,6 +1910,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2114,7 +2117,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/acorn": { "version": "8.15.0", @@ -2122,6 +2126,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2433,6 +2438,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3114,6 +3120,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5995,6 +6002,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6118,6 +6126,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6127,6 +6136,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7078,6 +7088,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7267,6 +7278,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index ebbb30ea4..12fb848d6 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,102 +1,21 @@ -use git2::{BranchType, DiffOptions, Repository, Sort, Status, StatusOptions, Tree}; +use std::path::{Path, PathBuf}; + +use git2::{BranchType, DiffOptions, Repository, Sort, Status, StatusOptions}; use serde_json::json; use tauri::State; use tokio::process::Command; -use std::path::Path; +use crate::git_utils::{ + checkout_branch, commit_to_entry, diff_patch_to_string, diff_stats_for_path, + list_git_roots as scan_git_roots, parse_github_repo, resolve_git_root, +}; use crate::state::AppState; use crate::types::{ - BranchInfo, GitFileDiff, GitFileStatus, GitHubIssue, GitHubIssuesResponse, GitLogEntry, - GitLogResponse, GitHubPullRequest, GitHubPullRequestsResponse, GitHubPullRequestDiff, + BranchInfo, GitFileDiff, GitFileStatus, GitHubIssue, GitHubIssuesResponse, GitHubPullRequest, + GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, }; use crate::utils::normalize_git_path; -fn commit_to_entry(commit: git2::Commit) -> GitLogEntry { - let summary = commit.summary().unwrap_or("").to_string(); - let author = commit.author().name().unwrap_or("").to_string(); - let timestamp = commit.time().seconds(); - GitLogEntry { - sha: commit.id().to_string(), - summary, - author, - timestamp, - } -} - -fn checkout_branch(repo: &Repository, name: &str) -> Result<(), git2::Error> { - let refname = format!("refs/heads/{name}"); - repo.set_head(&refname)?; - let mut options = git2::build::CheckoutBuilder::new(); - options.safe(); - repo.checkout_head(Some(&mut options))?; - Ok(()) -} - -fn diff_stats_for_path( - repo: &Repository, - head_tree: Option<&Tree>, - path: &str, - include_index: bool, - include_workdir: bool, -) -> Result<(i64, i64), git2::Error> { - let mut additions = 0i64; - let mut deletions = 0i64; - - if include_index { - let mut options = DiffOptions::new(); - options.pathspec(path).include_untracked(true); - let diff = repo.diff_tree_to_index(head_tree, None, Some(&mut options))?; - let stats = diff.stats()?; - additions += stats.insertions() as i64; - deletions += stats.deletions() as i64; - } - - if include_workdir { - let mut options = DiffOptions::new(); - options - .pathspec(path) - .include_untracked(true) - .recurse_untracked_dirs(true) - .show_untracked_content(true); - let diff = repo.diff_index_to_workdir(None, Some(&mut options))?; - let stats = diff.stats()?; - additions += stats.insertions() as i64; - deletions += stats.deletions() as i64; - } - - Ok((additions, deletions)) -} - -fn diff_patch_to_string(patch: &mut git2::Patch) -> Result { - let buf = patch.to_buf()?; - Ok(buf - .as_str() - .map(|value| value.to_string()) - .unwrap_or_else(|| String::from_utf8_lossy(&buf).to_string())) -} - -fn parse_github_repo(remote_url: &str) -> Option { - let trimmed = remote_url.trim(); - if trimmed.is_empty() { - return None; - } - let mut path = if trimmed.starts_with("git@github.com:") { - trimmed.trim_start_matches("git@github.com:").to_string() - } else if trimmed.starts_with("ssh://git@github.com/") { - trimmed.trim_start_matches("ssh://git@github.com/").to_string() - } else if let Some(index) = trimmed.find("github.com/") { - trimmed[index + "github.com/".len()..].to_string() - } else { - return None; - }; - path = path.trim_end_matches(".git").trim_end_matches('/').to_string(); - if path.is_empty() { - None - } else { - Some(path) - } -} - fn github_repo_from_path(path: &Path) -> Result { let repo = Repository::open(path).map_err(|e| e.to_string())?; let remotes = repo.remotes().map_err(|e| e.to_string())?; @@ -211,7 +130,6 @@ fn parse_pr_diff(diff: &str) -> Vec { entries } - #[tauri::command] pub(crate) async fn get_git_status( workspace_id: String, @@ -223,7 +141,8 @@ pub(crate) async fn get_git_status( .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let branch_name = repo .head() @@ -310,6 +229,23 @@ pub(crate) async fn get_git_status( })) } +#[tauri::command] +pub(crate) async fn list_git_roots( + workspace_id: String, + depth: Option, + state: State<'_, AppState>, +) -> Result, String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let root = PathBuf::from(&entry.path); + let depth = depth.unwrap_or(2).clamp(1, 6); + Ok(scan_git_roots(&root, depth, 200)) +} + #[tauri::command] pub(crate) async fn get_git_diffs( workspace_id: String, @@ -321,7 +257,8 @@ pub(crate) async fn get_git_diffs( .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let head_tree = repo .head() .ok() @@ -386,7 +323,8 @@ pub(crate) async fn get_git_log( .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let max_items = limit.unwrap_or(40); let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; revwalk.push_head().map_err(|e| e.to_string())?; @@ -492,7 +430,8 @@ pub(crate) async fn get_git_remote( .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let remotes = repo.remotes().map_err(|e| e.to_string())?; let name = if remotes.iter().any(|remote| remote == Some("origin")) { "origin".to_string() @@ -522,7 +461,8 @@ pub(crate) async fn get_github_issues( .ok_or("workspace not found")? .clone(); - let repo_name = github_repo_from_path(Path::new(&entry.path))?; + let repo_root = resolve_git_root(&entry)?; + let repo_name = github_repo_from_path(&repo_root)?; let output = Command::new("gh") .args([ @@ -535,7 +475,7 @@ pub(crate) async fn get_github_issues( "--json", "number,title,url,updatedAt", ]) - .current_dir(&entry.path) + .current_dir(&repo_root) .output() .await .map_err(|e| format!("Failed to run gh: {e}"))?; @@ -566,7 +506,7 @@ pub(crate) async fn get_github_issues( "--jq", ".total_count", ]) - .current_dir(&entry.path) + .current_dir(&repo_root) .output() .await { @@ -591,7 +531,8 @@ pub(crate) async fn get_github_pull_requests( .ok_or("workspace not found")? .clone(); - let repo_name = github_repo_from_path(Path::new(&entry.path))?; + let repo_root = resolve_git_root(&entry)?; + let repo_name = github_repo_from_path(&repo_root)?; let output = Command::new("gh") .args([ @@ -606,7 +547,7 @@ pub(crate) async fn get_github_pull_requests( "--json", "number,title,url,updatedAt,headRefName,baseRefName,isDraft,author", ]) - .current_dir(&entry.path) + .current_dir(&repo_root) .output() .await .map_err(|e| format!("Failed to run gh: {e}"))?; @@ -637,7 +578,7 @@ pub(crate) async fn get_github_pull_requests( "--jq", ".total_count", ]) - .current_dir(&entry.path) + .current_dir(&repo_root) .output() .await { @@ -666,7 +607,8 @@ pub(crate) async fn get_github_pull_request_diff( .ok_or("workspace not found")? .clone(); - let repo_name = github_repo_from_path(Path::new(&entry.path))?; + let repo_root = resolve_git_root(&entry)?; + let repo_name = github_repo_from_path(&repo_root)?; let output = Command::new("gh") .args([ @@ -678,7 +620,7 @@ pub(crate) async fn get_github_pull_request_diff( "--color", "never", ]) - .current_dir(&entry.path) + .current_dir(&repo_root) .output() .await .map_err(|e| format!("Failed to run gh: {e}"))?; @@ -711,7 +653,8 @@ pub(crate) async fn list_git_branches( .get(&workspace_id) .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let mut branches = Vec::new(); let refs = repo .branches(Some(BranchType::Local)) @@ -745,7 +688,8 @@ pub(crate) async fn checkout_git_branch( .get(&workspace_id) .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; checkout_branch(&repo, &name).map_err(|e| e.to_string()) } @@ -760,7 +704,8 @@ pub(crate) async fn create_git_branch( .get(&workspace_id) .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let head = repo.head().map_err(|e| e.to_string())?; let target = head.peel_to_commit().map_err(|e| e.to_string())?; repo.branch(&name, &target, false) diff --git a/src-tauri/src/git_utils.rs b/src-tauri/src/git_utils.rs new file mode 100644 index 000000000..0118bb672 --- /dev/null +++ b/src-tauri/src/git_utils.rs @@ -0,0 +1,188 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use git2::{DiffOptions, Repository, Tree}; +use ignore::WalkBuilder; + +use crate::types::{GitLogEntry, WorkspaceEntry}; +use crate::utils::normalize_git_path; + +pub(crate) fn commit_to_entry(commit: git2::Commit) -> GitLogEntry { + let summary = commit.summary().unwrap_or("").to_string(); + let author = commit.author().name().unwrap_or("").to_string(); + let timestamp = commit.time().seconds(); + GitLogEntry { + sha: commit.id().to_string(), + summary, + author, + timestamp, + } +} + +pub(crate) fn checkout_branch(repo: &Repository, name: &str) -> Result<(), git2::Error> { + let refname = format!("refs/heads/{name}"); + repo.set_head(&refname)?; + let mut options = git2::build::CheckoutBuilder::new(); + options.safe(); + repo.checkout_head(Some(&mut options))?; + Ok(()) +} + +pub(crate) fn diff_stats_for_path( + repo: &Repository, + head_tree: Option<&Tree>, + path: &str, + include_index: bool, + include_workdir: bool, +) -> Result<(i64, i64), git2::Error> { + let mut additions = 0i64; + let mut deletions = 0i64; + + if include_index { + let mut options = DiffOptions::new(); + options.pathspec(path).include_untracked(true); + let diff = repo.diff_tree_to_index(head_tree, None, Some(&mut options))?; + let stats = diff.stats()?; + additions += stats.insertions() as i64; + deletions += stats.deletions() as i64; + } + + if include_workdir { + let mut options = DiffOptions::new(); + options + .pathspec(path) + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true); + let diff = repo.diff_index_to_workdir(None, Some(&mut options))?; + let stats = diff.stats()?; + additions += stats.insertions() as i64; + deletions += stats.deletions() as i64; + } + + Ok((additions, deletions)) +} + +pub(crate) fn diff_patch_to_string(patch: &mut git2::Patch) -> Result { + let buf = patch.to_buf()?; + Ok(buf + .as_str() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from_utf8_lossy(&buf).to_string())) +} + +pub(crate) fn parse_github_repo(remote_url: &str) -> Option { + let trimmed = remote_url.trim(); + if trimmed.is_empty() { + return None; + } + let mut path = if trimmed.starts_with("git@github.com:") { + trimmed.trim_start_matches("git@github.com:").to_string() + } else if trimmed.starts_with("ssh://git@github.com/") { + trimmed.trim_start_matches("ssh://git@github.com/").to_string() + } else if let Some(index) = trimmed.find("github.com/") { + trimmed[index + "github.com/".len()..].to_string() + } else { + return None; + }; + path = path.trim_end_matches(".git").trim_end_matches('/').to_string(); + if path.is_empty() { + None + } else { + Some(path) + } +} + +pub(crate) fn resolve_git_root(entry: &WorkspaceEntry) -> Result { + let base = PathBuf::from(&entry.path); + let root = entry + .settings + .git_root + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + let Some(root) = root else { + return Ok(base); + }; + let root_path = if Path::new(root).is_absolute() { + PathBuf::from(root) + } else { + base.join(root) + }; + if root_path.is_dir() { + Ok(root_path) + } else { + Err(format!("Git root not found: {root}")) + } +} + +fn should_skip_dir(name: &str) -> bool { + matches!( + name, + ".git" | "node_modules" | "dist" | "target" | "release-artifacts" + ) +} + +pub(crate) fn list_git_roots( + root: &Path, + max_depth: usize, + max_results: usize, +) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + + let mut results = Vec::new(); + let mut seen = HashSet::new(); + let max_depth = max_depth.max(1); + let walker = WalkBuilder::new(root) + .hidden(false) + .follow_links(false) + .max_depth(Some(max_depth)) + .filter_entry(|entry| { + if entry.depth() == 0 { + return true; + } + if entry.file_type().is_some_and(|ft| ft.is_dir()) { + let name = entry.file_name().to_string_lossy(); + if should_skip_dir(&name) { + return false; + } + } + true + }) + .build(); + + for entry in walker { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + if !entry.file_type().is_some_and(|ft| ft.is_dir()) { + continue; + } + if entry.depth() == 0 { + continue; + } + let candidate = entry.path(); + let git_marker = candidate.join(".git"); + if !git_marker.is_dir() && !git_marker.is_file() { + continue; + } + let rel = match candidate.strip_prefix(root) { + Ok(rel) => rel, + Err(_) => continue, + }; + let normalized = normalize_git_path(&rel.to_string_lossy()); + if normalized.is_empty() || !seen.insert(normalized.clone()) { + continue; + } + results.push(normalized); + if results.len() >= max_results { + break; + } + } + + results.sort(); + results +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1f655140..435f14de4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod codex_config; mod dictation; mod event_sink; mod git; +mod git_utils; mod prompts; mod settings; mod state; @@ -159,6 +160,7 @@ pub fn run() { codex::collaboration_mode_list, workspaces::connect_workspace, git::get_git_status, + git::list_git_roots, git::get_git_diffs, git::get_git_log, git::get_git_remote, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 4d6f33131..cff8e0054 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -158,6 +158,8 @@ pub(crate) struct WorkspaceSettings { pub(crate) sidebar_collapsed: bool, #[serde(default, rename = "sortOrder")] pub(crate) sort_order: Option, + #[serde(default, rename = "gitRoot")] + pub(crate) git_root: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 1d4fcad81..340cecb7b 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -586,6 +586,7 @@ mod tests { settings: WorkspaceSettings { sidebar_collapsed: false, sort_order, + git_root: None, }, } } diff --git a/src/App.tsx b/src/App.tsx index 719280a95..fcb5f3813 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,7 @@ import { useGitHubIssues } from "./features/git/hooks/useGitHubIssues"; import { useGitHubPullRequests } from "./features/git/hooks/useGitHubPullRequests"; import { useGitHubPullRequestDiffs } from "./features/git/hooks/useGitHubPullRequestDiffs"; import { useGitRemote } from "./features/git/hooks/useGitRemote"; +import { useGitRepoScan } from "./features/git/hooks/useGitRepoScan"; import { useModels } from "./features/models/hooks/useModels"; import { useCollaborationModes } from "./features/collaboration/hooks/useCollaborationModes"; import { useSkills } from "./features/skills/hooks/useSkills"; @@ -75,6 +76,7 @@ import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { usePanelVisibility } from "./features/layout/hooks/usePanelVisibility"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; import { playNotificationSound } from "./utils/notificationSounds"; +import { pickWorkspacePath } from "./services/tauri"; import type { AccessMode, GitHubPullRequest, @@ -346,6 +348,16 @@ function MainApp() { shouldLoadDiffs && diffSource === "pr" ); const { remote: gitRemoteUrl } = useGitRemote(activeWorkspace); + const { + repos: gitRootCandidates, + isLoading: gitRootScanLoading, + error: gitRootScanError, + depth: gitRootScanDepth, + hasScanned: gitRootScanHasScanned, + scan: scanGitRoots, + setDepth: setGitRootScanDepth, + clear: clearGitRootCandidates, + } = useGitRepoScan(activeWorkspace); const { models, selectedModel, @@ -385,12 +397,57 @@ function MainApp() { }; const resolvedModel = selectedModel?.model ?? null; + const activeGitRoot = activeWorkspace?.settings.gitRoot ?? null; + const normalizePath = useCallback((value: string) => { + return value.replace(/\\/g, "/").replace(/\/+$/, ""); + }, []); + const handleSetGitRoot = useCallback( + async (path: string | null) => { + if (!activeWorkspace) { + return; + } + await updateWorkspaceSettings(activeWorkspace.id, { + ...activeWorkspace.settings, + gitRoot: path, + }); + clearGitRootCandidates(); + refreshGitStatus(); + }, + [ + activeWorkspace, + clearGitRootCandidates, + refreshGitStatus, + updateWorkspaceSettings, + ], + ); + const handlePickGitRoot = useCallback(async () => { + if (!activeWorkspace) { + return; + } + const selection = await pickWorkspacePath(); + if (!selection) { + return; + } + const workspacePath = normalizePath(activeWorkspace.path); + const selectedPath = normalizePath(selection); + let nextRoot: string | null = null; + if (selectedPath === workspacePath) { + nextRoot = null; + } else if (selectedPath.startsWith(`${workspacePath}/`)) { + nextRoot = selectedPath.slice(workspacePath.length + 1); + } else { + nextRoot = selectedPath; + } + await handleSetGitRoot(nextRoot); + }, [activeWorkspace, handleSetGitRoot, normalizePath]); const fileStatus = - gitStatus.files.length > 0 - ? `${gitStatus.files.length} file${ - gitStatus.files.length === 1 ? "" : "s" - } changed` - : "Working tree clean"; + gitStatus.error + ? "Git status unavailable" + : gitStatus.files.length > 0 + ? `${gitStatus.files.length} file${ + gitStatus.files.length === 1 ? "" : "s" + } changed` + : "Working tree clean"; const activeDiffs = diffSource === "pr" ? gitPullRequestDiffs : gitDiffs; const activeDiffLoading = @@ -690,6 +747,10 @@ function MainApp() { } } + const handleActiveDiffPath = useCallback((path: string) => { + setSelectedDiffPath(path); + }, []); + function handleSelectPullRequest(pullRequest: GitHubPullRequest) { setSelectedPullRequest(pullRequest); setDiffSource("pr"); @@ -963,9 +1024,25 @@ function MainApp() { selectedPullRequestNumber: selectedPullRequest?.number ?? null, onSelectPullRequest: handleSelectPullRequest, gitRemoteUrl, + gitRoot: activeGitRoot, + gitRootCandidates, + gitRootScanDepth, + gitRootScanLoading, + gitRootScanError, + gitRootScanHasScanned, + onGitRootScanDepthChange: setGitRootScanDepth, + onScanGitRoots: scanGitRoots, + onSelectGitRoot: (path) => { + void handleSetGitRoot(path); + }, + onClearGitRoot: () => { + void handleSetGitRoot(null); + }, + onPickGitRoot: handlePickGitRoot, gitDiffs: activeDiffs, gitDiffLoading: activeDiffLoading, gitDiffError: activeDiffError, + onDiffActivePathChange: handleActiveDiffPath, onSend: handleSend, onQueue: queueMessage, onStop: interruptTurn, diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 8f31eb154..015b69036 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -35,6 +35,18 @@ type GitDiffPanelProps = { selectedPullRequest?: number | null; onSelectPullRequest?: (pullRequest: GitHubPullRequest) => void; gitRemoteUrl?: string | null; + gitRoot?: string | null; + gitRootCandidates?: string[]; + gitRootScanDepth?: number; + gitRootScanLoading?: boolean; + gitRootScanError?: string | null; + gitRootScanHasScanned?: boolean; + onGitRootScanDepthChange?: (depth: number) => void; + onScanGitRoots?: () => void; + onSelectGitRoot?: (path: string) => void; + onClearGitRoot?: () => void; + onPickGitRoot?: () => void | Promise; + selectedPath?: string | null; onSelectFile?: (path: string) => void; files: { path: string; @@ -64,6 +76,13 @@ function splitNameAndExtension(name: string) { }; } +function normalizeRootPath(value: string | null | undefined) { + if (!value) { + return ""; + } + return value.replace(/\\/g, "/").replace(/\/+$/, ""); +} + function getStatusSymbol(status: string) { switch (status) { case "A": @@ -98,6 +117,20 @@ function getStatusClass(status: string) { } } +function isMissingRepo(error: string | null | undefined) { + if (!error) { + return false; + } + const normalized = error.toLowerCase(); + return ( + normalized.includes("could not find repository") || + normalized.includes("not a git repository") || + (normalized.includes("repository") && normalized.includes("notfound")) || + normalized.includes("repository not found") || + normalized.includes("git root not found") + ); +} + export function GitDiffPanel({ mode, onModeChange, @@ -129,6 +162,17 @@ export function GitDiffPanel({ pullRequestsError = null, selectedPullRequest = null, onSelectPullRequest, + gitRoot = null, + gitRootCandidates = [], + gitRootScanDepth = 2, + gitRootScanLoading = false, + gitRootScanError = null, + gitRootScanHasScanned = false, + onGitRootScanDepthChange, + onScanGitRoots, + onSelectGitRoot, + onClearGitRoot, + onPickGitRoot, }: GitDiffPanelProps) { const githubBaseUrl = (() => { if (!gitRemoteUrl) { @@ -215,6 +259,15 @@ export function GitDiffPanel({ : logUpstream ? `${logSyncLabel} ยท ${fileStatus}` : fileStatus; + const hasGitRoot = Boolean(gitRoot && gitRoot.trim()); + const showGitRootPanel = + isMissingRepo(error) || + gitRootScanLoading || + gitRootScanHasScanned || + Boolean(gitRootScanError) || + gitRootCandidates.length > 0; + const normalizedGitRoot = normalizeRootPath(gitRoot); + const depthOptions = [1, 2, 3, 4, 5, 6]; return (