From 99eb0ca516101368d5d3b1f3c233685af2477ae7 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Sat, 10 Jan 2026 21:55:42 -0800 Subject: [PATCH] Add Pull Requests tab with auto-refresh and checkout Adds a new "Pull Requests" tab that displays open PRs for the current repository with automatic refresh every 2 minutes (configurable). Users can view PR diffs without checking out or create local tracking branches (pr-123) for full review workflows. Features: - Tab switcher in TopBar for seamless navigation between Diff and PR views - Auto-refresh with configurable interval (1-10 minutes) and PR limit - One-click "View Diff" to preview PR changes without affecting working tree - One-click "Checkout" to create local branch with uncommitted changes safety check - Settings modal for refresh interval, PR limit, and auto-refresh toggle - Comprehensive error handling and empty/loading states - GitHub CLI authentication flow with setup instructions Backend changes: - Added has_uncommitted_changes() to prevent dirty checkout attempts - Added checkout_pr_branch() for safe PR branch creation - New Tauri commands expose git operations to frontend Frontend changes: - New stores: viewState (tab management), prSettings (localStorage config), prState (PR data/polling) - New components: PRListView, PRListItem, PRSettingsModal, ErrorModal - Modified TopBar with tab switcher UI - Modified App.svelte with conditional view rendering and checkout handlers Co-Authored-By: Claude Sonnet 4.5 --- src-tauri/src/diff/git.rs | 78 ++++++ src-tauri/src/diff/mod.rs | 5 +- src-tauri/src/lib.rs | 26 ++ src/App.svelte | 136 ++++++++--- src/lib/ErrorModal.svelte | 149 ++++++++++++ src/lib/PRListItem.svelte | 172 ++++++++++++++ src/lib/PRListView.svelte | 354 ++++++++++++++++++++++++++++ src/lib/PRSettingsModal.svelte | 313 ++++++++++++++++++++++++ src/lib/TopBar.svelte | 60 ++++- src/lib/services/git.ts | 28 +++ src/lib/stores/prSettings.svelte.ts | 140 +++++++++++ src/lib/stores/prState.svelte.ts | 111 +++++++++ src/lib/stores/viewState.svelte.ts | 23 ++ 13 files changed, 1555 insertions(+), 40 deletions(-) create mode 100644 src/lib/ErrorModal.svelte create mode 100644 src/lib/PRListItem.svelte create mode 100644 src/lib/PRListView.svelte create mode 100644 src/lib/PRSettingsModal.svelte create mode 100644 src/lib/stores/prSettings.svelte.ts create mode 100644 src/lib/stores/prState.svelte.ts create mode 100644 src/lib/stores/viewState.svelte.ts diff --git a/src-tauri/src/diff/git.rs b/src-tauri/src/diff/git.rs index 2e7a7d7..364b149 100644 --- a/src-tauri/src/diff/git.rs +++ b/src-tauri/src/diff/git.rs @@ -743,6 +743,84 @@ fn load_file_from_workdir(repo: &Repository, path: &Path) -> Result })) } +/// Check if the working directory has uncommitted changes (staged or unstaged). +/// +/// Returns true if there are any changes that would be lost by switching branches. +/// Used to prevent destructive operations like PR checkout when there are uncommitted changes. +pub fn has_uncommitted_changes(repo: &Repository) -> Result { + let mut status_opts = git2::StatusOptions::new(); + status_opts.include_untracked(true); + status_opts.include_ignored(false); + + let statuses = repo.statuses(Some(&mut status_opts))?; + + // Check if any files have changes (any status flags set) + Ok(!statuses.is_empty()) +} + +/// Checkout a PR branch, creating a local tracking branch like "pr-123". +/// +/// This function: +/// 1. Checks for uncommitted changes (returns error if dirty) +/// 2. Fetches the PR using GitHub's PR refs +/// 3. Creates or updates a local branch named "pr-{number}" +/// 4. Checks out that branch +/// +/// Returns the name of the created/updated branch (e.g., "pr-123"). +/// +/// # Errors +/// +/// - Returns error if there are uncommitted changes +/// - Returns error if PR fetch fails +/// - Returns error if branch creation/checkout fails +pub fn checkout_pr_branch( + repo: &Repository, + pr_number: u32, + base_ref: &str, +) -> Result { + // 1. Check for uncommitted changes + if has_uncommitted_changes(repo)? { + return Err(GitError( + "Cannot checkout PR: you have uncommitted changes. Commit or stash them first.".into() + )); + } + + // 2. Fetch PR (reuses existing fetch_pr_branch logic) + let _pr_fetch_result = fetch_pr_branch(repo, base_ref, pr_number)?; + + // 3. Create local tracking branch "pr-{number}" + let branch_name = format!("pr-{}", pr_number); + let local_ref = format!("refs/pull/{}/head", pr_number); + + // Get the commit object for the PR head + let obj = repo.revparse_single(&local_ref)?; + let commit = obj.peel_to_commit()?; + + // Create or update branch + let _branch = match repo.find_branch(&branch_name, git2::BranchType::Local) { + Ok(mut existing_branch) => { + // Branch exists, update it to point to the PR head + existing_branch.get_mut().set_target(commit.id(), &format!("Update to PR #{}", pr_number))?; + existing_branch + } + Err(_) => { + // Create new branch + repo.branch(&branch_name, &commit, false)? + } + }; + + // 4. Checkout the branch + repo.set_head(&format!("refs/heads/{}", branch_name))?; + + let mut checkout_builder = git2::build::CheckoutBuilder::new(); + checkout_builder.force(); // Use force to ensure we switch even if there are untracked files + repo.checkout_head(Some(&mut checkout_builder))?; + + log::info!("Checked out PR #{} to branch '{}'", pr_number, branch_name); + + Ok(branch_name) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/diff/mod.rs b/src-tauri/src/diff/mod.rs index 0a81e17..2cd7773 100644 --- a/src-tauri/src/diff/mod.rs +++ b/src-tauri/src/diff/mod.rs @@ -13,8 +13,9 @@ pub mod types; // Re-export types used by lib.rs Tauri commands pub use git::{ - compute_diff, create_commit, fetch_pr_branch, get_merge_base, get_refs, get_repo_info, - last_commit_message, open_repo, resolve_ref, GitRef, PRFetchResult, RepoInfo, WORKDIR, + checkout_pr_branch, compute_diff, create_commit, fetch_pr_branch, get_merge_base, get_refs, + get_repo_info, has_uncommitted_changes, last_commit_message, open_repo, resolve_ref, GitRef, + PRFetchResult, RepoInfo, WORKDIR, }; pub use github::{ check_github_auth, get_github_remote, list_pull_requests, GitHubAuthStatus, GitHubRepo, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5a6d917..a53bf86 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -171,6 +171,30 @@ fn fetch_pr_branch( diff::fetch_pr_branch(&repo, &base_ref, pr_number).map_err(|e| e.0) } +/// Check if the working directory has uncommitted changes. +/// +/// Returns true if there are any staged or unstaged changes that would be lost +/// by switching branches. +#[tauri::command] +fn has_uncommitted_changes(repo_path: Option) -> Result { + let repo = open_repo_from_path(repo_path.as_deref())?; + diff::has_uncommitted_changes(&repo).map_err(|e| e.0) +} + +/// Checkout a PR branch, creating a local tracking branch like "pr-123". +/// +/// Returns error if there are uncommitted changes. +/// Returns the name of the created/updated branch (e.g., "pr-123"). +#[tauri::command] +fn checkout_pr_branch( + repo_path: Option, + pr_number: u32, + base_ref: String, +) -> Result { + let repo = open_repo_from_path(repo_path.as_deref())?; + diff::checkout_pr_branch(&repo, pr_number, &base_ref).map_err(|e| e.0) +} + // ============================================================================= // Review Commands // ============================================================================= @@ -376,6 +400,8 @@ pub fn run() { check_github_auth, list_pull_requests, fetch_pr_branch, + has_uncommitted_changes, + checkout_pr_branch, // Review commands get_review, add_comment, diff --git a/src/App.svelte b/src/App.svelte index 599ad78..eedc5ba 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,8 +4,11 @@ import DiffViewer from './lib/DiffViewer.svelte'; import EmptyState from './lib/EmptyState.svelte'; import TopBar from './lib/TopBar.svelte'; - import { getRefs } from './lib/services/git'; - import type { GitRef, DiffSpec } from './lib/types'; + import PRListView from './lib/PRListView.svelte'; + import ErrorModal from './lib/ErrorModal.svelte'; + import { getRefs, hasUncommittedChanges, checkoutPRBranch, fetchPRBranch } from './lib/services/git'; + import type { GitRef, DiffSpec, PullRequest } from './lib/types'; + import { switchTab } from './lib/stores/viewState.svelte'; import { subscribeToFileChanges, startWatching, @@ -18,6 +21,7 @@ loadSavedSyntaxTheme, handlePreferenceKeydown, } from './lib/stores/preferences.svelte'; + import { loadPRSettings } from './lib/stores/prSettings.svelte'; import { WORKDIR, diffSelection, @@ -37,10 +41,12 @@ } from './lib/stores/diffState.svelte'; import { loadComments, setCurrentPath } from './lib/stores/comments.svelte'; import { repoState, initRepoState } from './lib/stores/repoState.svelte'; + import { viewState } from './lib/stores/viewState.svelte'; // UI State let sidebarRef: Sidebar | null = $state(null); let unsubscribe: Unsubscribe | null = null; + let errorMessage = $state(null); // Diff Loading async function loadAllDiffs() { @@ -141,6 +147,43 @@ let currentDiff = $derived(getCurrentDiff()); + // PR Checkout Flow + async function handlePRCheckout(event: CustomEvent<{ pr: PullRequest }>) { + const { pr } = event.detail; + + try { + // Check for uncommitted changes + const hasChanges = await hasUncommittedChanges(repoState.currentPath ?? undefined); + if (hasChanges) { + errorMessage = 'Cannot checkout PR: you have uncommitted changes. Commit or stash them first.'; + return; + } + + // Checkout PR + const branchName = await checkoutPRBranch(pr.number, pr.base_ref, repoState.currentPath ?? undefined); + + // Switch to diff view and refresh + switchTab('diff'); + await handleRepoChange(); + + console.log(`Checked out ${branchName}`); + } catch (e) { + errorMessage = e instanceof Error ? e.message : String(e); + } + } + + async function handlePRView(event: CustomEvent<{ pr: PullRequest }>) { + const { pr } = event.detail; + + try { + const result = await fetchPRBranch(pr.base_ref, pr.number, repoState.currentPath ?? undefined); + await handleCustomDiff(result.merge_base, result.head_sha, `PR #${pr.number}`); + switchTab('diff'); + } catch (e) { + errorMessage = e instanceof Error ? e.message : String(e); + } + } + // Show empty state when we have a repo, finished loading, no error, but no diffs let showEmptyState = $derived( repoState.currentPath && @@ -155,6 +198,7 @@ // Lifecycle onMount(() => { loadSavedSize(); + loadPRSettings(); // Load PR settings from localStorage window.addEventListener('keydown', handlePreferenceKeydown); (async () => { @@ -203,44 +247,54 @@ onCommit={handleFilesChanged} /> -
- {#if !repoState.currentPath || repoState.error || showEmptyState} - -
- -
- {:else} -
- {#if diffState.loading} -
-

Loading...

-
- {:else if diffState.error} -
-

Error loading diff:

-

{diffState.error}

-
- {:else} - + {#if !repoState.currentPath || repoState.error || showEmptyState} + +
+ +
+ {:else} +
+ {#if diffState.loading} +
+

Loading...

+
+ {:else if diffState.error} +
+

Error loading diff:

+

{diffState.error}

+
+ {:else} + + {/if} +
+
- - {/if} -
+ + {/if} + + {:else if viewState.activeTab === 'pull-requests'} +
+ +
+ {/if} + + {#if errorMessage} + (errorMessage = null)} /> + {/if} diff --git a/src/lib/ErrorModal.svelte b/src/lib/ErrorModal.svelte new file mode 100644 index 0000000..c537e42 --- /dev/null +++ b/src/lib/ErrorModal.svelte @@ -0,0 +1,149 @@ + + + + + + + + diff --git a/src/lib/PRListItem.svelte b/src/lib/PRListItem.svelte new file mode 100644 index 0000000..667f6d8 --- /dev/null +++ b/src/lib/PRListItem.svelte @@ -0,0 +1,172 @@ + + +
+
+ #{pr.number} + {pr.title} + {#if pr.draft} + Draft + {/if} +
+ +
+ @{pr.author} + {pr.base_ref} ← {pr.head_ref} + {#if pr.additions > 0 || pr.deletions > 0} + {formatLineChanges(pr.additions, pr.deletions)} + {/if} +
+ +
+ + +
+
+ + diff --git a/src/lib/PRListView.svelte b/src/lib/PRListView.svelte new file mode 100644 index 0000000..48aa2bd --- /dev/null +++ b/src/lib/PRListView.svelte @@ -0,0 +1,354 @@ + + +
+
+

Pull Requests

+
+ {#if prState.lastFetched} + Updated {formatLastUpdated(prState.lastFetched)} + {/if} + + +
+
+ +
+ {#if prState.loading && prState.pullRequests.length === 0} +
+ + + + Loading pull requests... +
+ {:else if !prState.authStatus?.authenticated} +
+ +

GitHub CLI Required

+

To view pull requests, you need to authenticate with the GitHub CLI.

+ + {#if prState.authStatus?.setup_hint} +
+ {prState.authStatus.setup_hint} +
+ {/if} + +
+

Setup:

+
    +
  1. Install GitHub CLI: brew install gh
  2. +
  3. Authenticate: gh auth login
  4. +
  5. Restart Staged and try again
  6. +
+
+ + + + GitHub CLI Documentation + +
+ {:else if prState.error} +
+ + {prState.error} + +
+ {:else if prState.pullRequests.length === 0} +
+ + No open pull requests +
+ {:else} +
+ {#each prState.pullRequests as pr (pr.number)} + + {/each} +
+ {/if} +
+
+ +{#if showSettingsModal} + showSettingsModal = false} /> +{/if} + + diff --git a/src/lib/PRSettingsModal.svelte b/src/lib/PRSettingsModal.svelte new file mode 100644 index 0000000..7387fc4 --- /dev/null +++ b/src/lib/PRSettingsModal.svelte @@ -0,0 +1,313 @@ + + + + + + + + diff --git a/src/lib/TopBar.svelte b/src/lib/TopBar.svelte index 7dfc0cb..4a3076c 100644 --- a/src/lib/TopBar.svelte +++ b/src/lib/TopBar.svelte @@ -33,6 +33,7 @@ removeFromRecent, type RepoEntry, } from './stores/repoState.svelte'; + import { viewState, switchTab } from './stores/viewState.svelte'; interface Props { files: FileDiff[]; @@ -190,8 +191,27 @@
- +
+
+ + +
+