diff --git a/src-tauri/src/git/checkout.rs b/src-tauri/src/git/checkout.rs new file mode 100644 index 0000000..da4f2cc --- /dev/null +++ b/src-tauri/src/git/checkout.rs @@ -0,0 +1,71 @@ +use super::cli::{run, GitError}; +use super::github::fetch_pr; +use std::path::Path; + +/// 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. +pub fn has_uncommitted_changes(repo: &Path) -> Result { + let output = run(repo, &["status", "--porcelain"])?; + // If status output is non-empty, there are changes + Ok(!output.trim().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: &Path, + pr_number: u64, + base_ref: &str, +) -> Result { + // 1. Check for uncommitted changes + if has_uncommitted_changes(repo)? { + return Err(GitError::CommandFailed( + "Cannot checkout PR: you have uncommitted changes. Commit or stash them first." + .into(), + )); + } + + // 2. Fetch PR (reuses existing fetch_pr logic) + let _diff_spec = fetch_pr(repo, base_ref, pr_number)?; + + // 3. Create local tracking branch "pr-{number}" + let branch_name = format!("pr-{}", pr_number); + let pr_ref = format!("refs/pull/{}/head", pr_number); + + // Check if branch exists + let branch_exists = run(repo, &["rev-parse", "--verify", &format!("refs/heads/{}", branch_name)]).is_ok(); + + if branch_exists { + // Update existing branch to point to PR head + run(repo, &["branch", "-f", &branch_name, &pr_ref])?; + } else { + // Create new branch + run(repo, &["branch", &branch_name, &pr_ref])?; + } + + // 4. Checkout the branch + run(repo, &["checkout", &branch_name])?; + + log::info!( + "Checked out PR #{} to branch '{}'", + pr_number, + branch_name + ); + + Ok(branch_name) +} diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index cac02ac..129ccc8 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -1,3 +1,4 @@ +mod checkout; mod cli; mod commit; mod diff; @@ -5,6 +6,7 @@ pub mod github; mod refs; mod types; +pub use checkout::{checkout_pr_branch, has_uncommitted_changes}; pub use cli::GitError; pub use commit::commit; pub use diff::{get_file_diff, list_diff_files}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4fe1f3e..7bcfccf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -182,6 +182,30 @@ async fn sync_review_to_github( .map_err(|e| e.to_string()) } +/// 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(rename_all = "camelCase")] +fn has_uncommitted_changes(repo_path: Option) -> Result { + let path = get_repo_path(repo_path.as_deref()); + git::has_uncommitted_changes(path).map_err(|e| e.to_string()) +} + +/// 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(rename_all = "camelCase")] +fn checkout_pr_branch( + repo_path: Option, + pr_number: u64, + base_ref: String, +) -> Result { + let path = get_repo_path(repo_path.as_deref()); + git::checkout_pr_branch(path, pr_number, &base_ref).map_err(|e| e.to_string()) +} + // ============================================================================= // Review Commands // ============================================================================= @@ -369,6 +393,8 @@ pub fn run() { fetch_pr, sync_review_to_github, invalidate_pr_cache, + has_uncommitted_changes, + checkout_pr_branch, // Review commands get_review, add_comment, diff --git a/src/App.svelte b/src/App.svelte index 5b4ad0b..5ab7a30 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,9 +5,11 @@ import DiffViewer from './lib/DiffViewer.svelte'; import EmptyState from './lib/EmptyState.svelte'; import TopBar from './lib/TopBar.svelte'; - import { listRefs } from './lib/services/git'; + import PRListView from './lib/PRListView.svelte'; + import ErrorModal from './lib/ErrorModal.svelte'; + import { listRefs, hasUncommittedChanges, checkoutPRBranch, fetchPR } from './lib/services/git'; import { DiffSpec, inferRefType } from './lib/types'; - import type { DiffSpec as DiffSpecType } from './lib/types'; + import type { DiffSpec as DiffSpecType, PullRequest } from './lib/types'; import { initWatcher, watchRepo, type Unsubscribe } from './lib/services/statusEvents'; import { preferences, @@ -15,6 +17,8 @@ loadSavedSyntaxTheme, registerPreferenceShortcuts, } from './lib/stores/preferences.svelte'; + import { loadPRSettings } from './lib/stores/prSettings.svelte'; + import { switchTab } from './lib/stores/viewState.svelte'; import { diffSelection, selectPreset, @@ -33,9 +37,10 @@ } from './lib/stores/diffState.svelte'; import { loadComments, setCurrentPath, clearComments } from './lib/stores/comments.svelte'; import { repoState, initRepoState, setCurrentRepo } from './lib/stores/repoState.svelte'; + import { viewState } from './lib/stores/viewState.svelte'; - // UI State - let unsubscribeWatcher: Unsubscribe | null = null; + // UI State (note: watcher is handled by initWatcher/watchRepo, no unsubscribe needed) + let errorMessage = $state(null); // Load files and comments for current spec async function loadAll() { @@ -129,53 +134,85 @@ return branchNames[0] ?? 'main'; } - let currentDiff = $derived(getCurrentDiff()); + // PR Checkout Flow + async function handlePRCheckout(event: CustomEvent<{ pr: PullRequest }>) { + const { pr } = event.detail; - // Show empty state when we have a repo, finished loading, no error, but no files + 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 { + // Fetch PR and get DiffSpec + const spec = await fetchPR(pr.base_ref, pr.number, repoState.currentPath ?? undefined); + await handleCustomDiff(spec, `PR #${pr.number}`, 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 && !diffState.loading && !diffState.error && diffState.files.length === 0 + repoState.currentPath && + !diffState.loading && + !diffState.error && + diffState.files.length === 0 ); - let isWorkingTree = $derived(diffSelection.spec.head.type === 'WorkingTree'); - // Lifecycle - let unregisterPreferenceShortcuts: (() => void) | null = null; - onMount(() => { loadSavedSize(); - unregisterPreferenceShortcuts = registerPreferenceShortcuts(); + loadPRSettings(); // Load PR settings from localStorage + registerPreferenceShortcuts(); (async () => { await loadSavedSyntaxTheme(); - // Initialize watcher listener once (handles all repos) - unsubscribeWatcher = await initWatcher(handleFilesChanged); - - // Initialize repo state (resolves canonical path, adds to recent repos) - const repoPath = await initRepoState(); + // Initialize repo state (loads recent repos, tries current directory) + const hasRepo = await initRepoState(); - if (repoPath) { - watchRepo(repoPath); + if (hasRepo && repoState.currentPath) { + // Initialize watcher + watchRepo(repoState.currentPath); // Load refs for autocomplete and detect default branch try { - const refs = await listRefs(repoPath); + const refs = await listRefs(repoState.currentPath); const defaultBranch = detectDefaultBranch(refs); setDefaultBranch(defaultBranch); - - await loadAll(); } catch (e) { - // Initial load failed - not a git repo or other error - diffState.loading = false; console.error('Failed to load refs:', e); } + + resetDiffSelection(); + await loadAll(); } })(); }); onDestroy(() => { - unregisterPreferenceShortcuts?.(); - unsubscribeWatcher?.(); + // Watcher cleanup is handled automatically by the watcher system }); @@ -184,46 +221,53 @@ onPresetSelect={handlePresetSelect} onCustomDiff={handleCustomDiff} onRepoChange={handleRepoChange} - onCommit={handleFilesChanged} /> -
- {#if showEmptyState} - -
- -
- {:else} -
- {#if diffState.loading} -
-

Loading...

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

{diffState.error}

-
- {:else} - + {#if !repoState.currentPath || 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..c4b27db --- /dev/null +++ b/src/lib/PRListItem.svelte @@ -0,0 +1,164 @@ + + +
+
+ #{pr.number} + {pr.title} + {#if pr.draft} + Draft + {/if} +
+ +
+ @{pr.author} + {pr.base_ref} ← {pr.head_ref} +
+ +
+ + +
+
+ + 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 f6462af..fbc0395 100644 --- a/src/lib/TopBar.svelte +++ b/src/lib/TopBar.svelte @@ -44,6 +44,7 @@ removeFromRecent, type RepoEntry, } from './stores/repoState.svelte'; + import { viewState, switchTab } from './stores/viewState.svelte'; import { registerShortcut } from './services/keyboard'; interface Props { @@ -207,8 +208,27 @@
- +
+
+ + +
+