From 0a425cd882a8b64af95871bac57d18125f381d7c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 8 Feb 2026 12:16:39 +0200 Subject: [PATCH] feat: reorganize git view layout and add history/branch actions (#354) * feat(ui): redesigned Git view layout * feat: stabilize git views layout with min-h-0 and scroll - Introduce min-h-0 and flex-1 on git layout containers - Apply min-h-0 on PR checks dialog content and related areas - Configure ScrollableOverlay to disable horizontal scroll and overscroll --- .../ui/src/components/ui/animated-tabs.tsx | 19 +- packages/ui/src/components/views/GitView.tsx | 261 ++++++--- .../views/git/BranchIntegrationSection.tsx | 506 +++++++++--------- .../components/views/git/ChangesSection.tsx | 21 +- .../components/views/git/CommitSection.tsx | 29 +- .../components/views/git/ConflictDialog.tsx | 2 +- .../components/views/git/GitEmptyState.tsx | 4 +- .../ui/src/components/views/git/GitHeader.tsx | 53 +- .../components/views/git/HistorySection.tsx | 62 ++- .../views/git/IntegrateCommitsSection.tsx | 49 +- .../views/git/PullRequestSection.tsx | 128 +++-- .../src/components/views/git/StashDialog.tsx | 10 +- packages/vscode/src/githubPr.ts | 79 +-- packages/web/server/index.js | 56 +- 14 files changed, 770 insertions(+), 509 deletions(-) diff --git a/packages/ui/src/components/ui/animated-tabs.tsx b/packages/ui/src/components/ui/animated-tabs.tsx index 8f29a859..5f8759a7 100644 --- a/packages/ui/src/components/ui/animated-tabs.tsx +++ b/packages/ui/src/components/ui/animated-tabs.tsx @@ -14,6 +14,7 @@ interface AnimatedTabsProps { className?: string; isInteractive?: boolean; animate?: boolean; + collapseLabelsOnSmall?: boolean; } export function AnimatedTabs({ @@ -23,6 +24,7 @@ export function AnimatedTabs({ className, isInteractive = true, animate = true, + collapseLabelsOnSmall = false, }: AnimatedTabsProps) { const containerRef = React.useRef(null); const activeTabRef = React.useRef(null); @@ -73,10 +75,15 @@ export function AnimatedTabs({ return (
{Icon ? : null} - {tab.label} + + {tab.label} +
); @@ -99,11 +106,13 @@ export function AnimatedTabs({ onValueChange(tab.value); }} className={cn( - 'flex h-7 flex-1 items-center justify-center gap-1.25 rounded-lg px-2.5 text-sm font-semibold transition-colors duration-150', + 'flex h-7 flex-1 items-center justify-center rounded-lg px-2.5 text-sm font-semibold transition-colors duration-150', + collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25', isActive ? 'text-accent-foreground' : 'text-muted-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background' )} aria-pressed={isActive} + aria-label={tab.label} aria-disabled={!isInteractive} tabIndex={isInteractive ? 0 : -1} > @@ -112,7 +121,9 @@ export function AnimatedTabs({ className={cn('h-4 w-4', isActive ? 'text-accent-foreground' : 'text-muted-foreground')} /> ) : null} - {tab.label} + + {tab.label} + ); diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 876131c1..44e3f5db 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -15,11 +15,20 @@ import { useIsGitRepo, } from '@/stores/useGitStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { RiGitBranchLine, RiLoader4Line } from '@remixicon/react'; +import { + RiGitBranchLine, + RiGitMergeLine, + RiGitCommitLine, + RiGitPullRequestLine, + RiLoader4Line, + RiSplitCellsHorizontal, +} from '@remixicon/react'; import { toast } from '@/components/ui'; +import { AnimatedTabs } from '@/components/ui/animated-tabs'; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; @@ -46,7 +55,7 @@ import { PullRequestSection } from './git/PullRequestSection'; import { ConflictDialog } from './git/ConflictDialog'; import { StashDialog } from './git/StashDialog'; import { InProgressOperationBanner } from './git/InProgressOperationBanner'; -import type { OperationLogEntry } from './git/BranchIntegrationSection'; +import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection'; import type { GitRemote } from '@/lib/gitApi'; import { BranchPickerDialog } from '@/components/session/BranchPickerDialog'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; @@ -54,6 +63,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; type SyncAction = 'fetch' | 'pull' | 'push' | null; type CommitAction = 'commit' | 'commitAndPush' | null; type BranchOperation = 'merge' | 'rebase' | null; +type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree'; type GitViewSnapshot = { @@ -235,8 +245,6 @@ export const GitView: React.FC = () => { const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false); const [rootBranchHint, setRootBranchHint] = React.useState(null); - const baseBranch = worktreeMetadata?.createdFromBranch || status?.current || 'HEAD'; - React.useEffect(() => { const projectRoot = worktreeMetadata?.projectDirectory; if (!projectRoot) { @@ -363,6 +371,8 @@ export const GitView: React.FC = () => { const [remoteUrl, setRemoteUrl] = React.useState(null); const [gitmojiEmojis, setGitmojiEmojis] = React.useState([]); const [gitmojiSearch, setGitmojiSearch] = React.useState(''); + const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false); + const [actionTab, setActionTab] = React.useState('commit'); const [remotes, setRemotes] = React.useState([]); const [branchOperation, setBranchOperation] = React.useState(null); const [operationLogs, setOperationLogs] = React.useState([]); @@ -915,6 +925,21 @@ export const GitView: React.FC = () => { .sort(); }, [branches]); + const baseBranch = React.useMemo(() => { + const fromMeta = typeof worktreeMetadata?.createdFromBranch === 'string' + ? worktreeMetadata.createdFromBranch.trim() + : ''; + if (fromMeta && fromMeta !== 'HEAD') return fromMeta; + + const fromHint = typeof rootBranchHint === 'string' ? rootBranchHint.trim() : ''; + if (fromHint && fromHint !== 'HEAD') return fromHint; + + if (localBranches.includes('main')) return 'main'; + if (localBranches.includes('master')) return 'master'; + if (localBranches.includes('develop')) return 'develop'; + return 'main'; + }, [localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]); + const availableIdentities = React.useMemo(() => { const unique = new Map(); if (globalIdentity) { @@ -998,6 +1023,29 @@ export const GitView: React.FC = () => { const selectedCount = selectedPaths.size; const isBusy = isLoading || syncAction !== null || commitAction !== null; const hasChanges = uniqueChangeCount > 0; + const canShowIntegrateCommitsSection = Boolean( + worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits + ); + const canShowPullRequestSection = Boolean( + currentDirectory && status?.current && status?.tracking && status.current !== baseBranch + ); + const canShowBranchWorkflows = Boolean(status?.current); + const integrateCommitsProps = + canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata + ? { + repoRoot: repoRootForIntegrate, + sourceBranch: sourceBranchForIntegrate, + worktreeMetadata, + } + : null; + const pullRequestProps = + canShowPullRequestSection && currentDirectory && status?.current + ? { + directory: currentDirectory, + branch: status.current, + } + : null; + // Keep these sections stable in layout; individual cards render placeholders when unavailable. const toggleFileSelection = (path: string) => { setSelectedPaths((previous) => { @@ -1484,12 +1532,7 @@ export const GitView: React.FC = () => { onSelectIdentity={handleApplyIdentity} isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} - onMerge={handleMerge} - onRebase={handleRebase} - branchOperation={branchOperation} - operationLogs={operationLogs} - onOperationComplete={handleOperationComplete} - isBusy={isBusy} + onOpenHistory={() => setIsHistoryDialogOpen(true)} onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined} /> @@ -1509,12 +1552,12 @@ export const GitView: React.FC = () => { /> )} - -
- {/* Two-column layout on large screens: Changes + Commit */} -
+
+
+
{hasChanges ? ( { onRevertFile={handleRevertFile} /> ) : ( -
+
{ @@ -1540,66 +1583,144 @@ export const GitView: React.FC = () => { />
)} - - {changeEntries.length > 0 && ( - handleCommit({ pushAfter: false })} - onCommitAndPush={() => handleCommit({ pushAfter: true })} - commitAction={commitAction} - isBusy={isBusy} - gitmojiEnabled={settingsGitmojiEnabled} - onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} - /> - )}
- {worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? ( - { - if (!currentDirectory) return; - fetchStatus(currentDirectory, git); - fetchBranches(currentDirectory, git); - fetchLog(currentDirectory, git, logMaxCountLocal); - }} - /> - ) : null} +
+
+ + value={actionTab} + onValueChange={setActionTab} + collapseLabelsOnSmall + tabs={[ + { value: 'commit', label: 'Commit', icon: RiGitCommitLine }, + { value: 'branch', label: 'Update branch', icon: RiGitMergeLine }, + { value: 'pr', label: 'PR', icon: RiGitPullRequestLine }, + { value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal }, + ]} + /> +
+
- {currentDirectory && status?.current && status?.tracking ? ( - - ) : null} + + {actionTab === 'commit' ? ( + handleCommit({ pushAfter: false })} + onCommitAndPush={() => handleCommit({ pushAfter: true })} + commitAction={commitAction} + isBusy={isBusy} + gitmojiEnabled={settingsGitmojiEnabled} + onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} + /> + ) : null} - {/* History below, constrained width */} - + {actionTab === 'branch' ? ( +
+ {canShowBranchWorkflows ? ( + + ) : ( +

Branch actions unavailable.

+ )} +
+ ) : null} + + {actionTab === 'worktree' ? ( + integrateCommitsProps ? ( + { + if (!currentDirectory) return; + fetchStatus(currentDirectory, git); + fetchBranches(currentDirectory, git); + fetchLog(currentDirectory, git, logMaxCountLocal); + }} + /> + ) : ( +
+
Re-integrate commits
+
+ Available in worktree mode. +
+
+ ) + ) : null} + + {actionTab === 'pr' ? ( + pullRequestProps ? ( + + ) : ( +
+
Pull Request
+
+ Push a non-base branch (with upstream) to create a PR. +
+
+ ) + ) : null} +
+
- +
+ + + + + History + + Browse recent commits and inspect file-level changes. + + +
+ +
+
+
diff --git a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx index 112cb6d7..0ab0ede2 100644 --- a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx +++ b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx @@ -51,6 +51,7 @@ interface BranchIntegrationSectionProps { isOperating?: boolean; operationLogs?: OperationLogEntry[]; onOperationComplete?: () => void; + mode?: 'dialog' | 'inline'; } export const BranchIntegrationSection: React.FC = ({ @@ -63,6 +64,7 @@ export const BranchIntegrationSection: React.FC = isOperating = false, operationLogs = [], onOperationComplete, + mode = 'dialog', }) => { const [dialogOpen, setDialogOpen] = React.useState(false); const [operation, setOperation] = React.useState('merge'); @@ -73,6 +75,7 @@ export const BranchIntegrationSection: React.FC = const logContainerRef = React.useRef(null); const isDisabled = disabled || isOperating; + const targetBranchLabel = currentBranch || 'current branch'; // Check if operation completed (all logs are done or error) const operationCompleted = operationLogs.length > 0 && @@ -159,12 +162,260 @@ export const BranchIntegrationSection: React.FC = } }, [branchDropdownOpen]); + const renderOperating = () => ( +
+
+
+ {operationLogs.map((log, index) => ( +
+
+ {log.status === 'running' && ( + + )} + {log.status === 'done' && ( + + )} + {log.status === 'error' && ( + + )} + {log.status === 'pending' && ( +
+ )} +
+ + {log.message} + +
+ ))} +
+
+ + {operationCompleted ? ( + mode === 'dialog' ? ( + + + + ) : ( +
+ +
+ ) + ) : null} +
+ ); + + const renderForm = () => ( + <> + {/* Operation Selection */} +
+

Operation

+
+ + + +
+
+ + {/* Branch Selection */} +
+

+ {operation === 'merge' ? `Branch to merge into ${targetBranchLabel}` : 'Branch to rebase onto'} +

+ + + + + + + + + No branches found. + + {filteredLocal.length > 0 && ( + + {filteredLocal.map((branch) => ( + handleSelectBranch(branch)}> + {branch} + + ))} + + )} + + {filteredLocal.length > 0 && filteredRemote.length > 0 ? : null} + + {filteredRemote.length > 0 && ( + + {filteredRemote.map((branch) => ( + handleSelectBranch(branch)}> + {branch} + + ))} + + )} + + + + +
+ + {/* Summary */} + {selectedBranch ? ( +
+

+ {operation === 'merge' ? ( + <> + This will merge {selectedBranch} into{' '} + {targetBranchLabel} + + ) : ( + <> + This will rebase {targetBranchLabel} onto{' '} + {selectedBranch} + + )} +

+
+ ) : null} + + {mode === 'dialog' ? ( + + + + + ) : ( +
+ +
+ +
+ )} + + ); + + const body = isOperating ? renderOperating() : renderForm(); + + if (mode === 'inline') { + return ( +
+
+
Update branch
+
+ Bring changes from another branch into{' '} + {targetBranchLabel}. +
+
+ {body} +
+ ); + } + return ( <> - Merge or rebase another branch + Merge or rebase changes from another branch. @@ -190,10 +441,10 @@ export const BranchIntegrationSection: React.FC = setDialogOpen(true); } }}> - - - Integrate Branch - + + + Update Branch + {isOperating ? ( operationCompleted ? ( hasError ? 'Operation failed' : 'Operation completed' @@ -202,248 +453,15 @@ export const BranchIntegrationSection: React.FC = ) ) : ( <> - Choose how to integrate changes from another branch into{' '} - {currentBranch || 'current branch'} + Choose how to bring changes from another branch into{' '} + {targetBranchLabel} + . )} - {/* Show operation log when operating */} - {isOperating ? ( -
-
-
- {operationLogs.map((log, index) => ( -
-
- {log.status === 'running' && ( - - )} - {log.status === 'done' && ( - - )} - {log.status === 'error' && ( - - )} - {log.status === 'pending' && ( -
- )} -
- - {log.message} - -
- ))} -
-
- - {operationCompleted && ( - - - - )} -
- ) : ( - <> - {/* Operation Selection */} -
-

Operation

-
- - - -
-
- - {/* Branch Selection */} -
-

- {operation === 'merge' ? 'Branch to merge' : 'Branch to rebase onto'} -

- - - - - - - - - No branches found. - - {filteredLocal.length > 0 && ( - - {filteredLocal.map((branch) => ( - handleSelectBranch(branch)} - > - - {branch} - - - ))} - - )} - - {filteredLocal.length > 0 && filteredRemote.length > 0 && ( - - )} - - {filteredRemote.length > 0 && ( - - {filteredRemote.map((branch) => ( - handleSelectBranch(branch)} - > - - {branch} - - - ))} - - )} - - - - -
- - {/* Summary */} - {selectedBranch && ( -
-

- {operation === 'merge' ? ( - <> - This will merge{' '} - {selectedBranch} - {' '}into{' '} - {currentBranch} - - ) : ( - <> - This will rebase{' '} - {currentBranch} - {' '}onto{' '} - {selectedBranch} - - )} -

-
- )} - - - - - - - )} + {body}
diff --git a/packages/ui/src/components/views/git/ChangesSection.tsx b/packages/ui/src/components/views/git/ChangesSection.tsx index 738ade60..9ca11609 100644 --- a/packages/ui/src/components/views/git/ChangesSection.tsx +++ b/packages/ui/src/components/views/git/ChangesSection.tsx @@ -14,6 +14,7 @@ interface ChangesSectionProps { onClearSelection: () => void; onViewDiff: (path: string) => void; onRevertFile: (path: string) => void; + variant?: 'framed' | 'plain'; } export const ChangesSection: React.FC = ({ @@ -26,13 +27,27 @@ export const ChangesSection: React.FC = ({ onClearSelection, onViewDiff, onRevertFile, + variant = 'framed', }) => { const selectedCount = selectedPaths.size; const totalCount = changeEntries.length; + const containerClassName = + variant === 'framed' + ? 'flex flex-col rounded-xl border border-border/60 bg-background/70' + : 'flex flex-col flex-1 min-h-0'; + const headerClassName = + variant === 'framed' + ? 'flex items-center justify-between gap-2 px-3 py-2 border-b border-border/40' + : 'flex items-center justify-between gap-2 px-4 py-3 border-b border-border/40'; + const scrollOuterClassName = + variant === 'framed' + ? 'flex-1 min-h-0 max-h-[30vh]' + : 'flex-1 min-h-0'; + return ( -
-
+
+

Changes

@@ -61,7 +76,7 @@ export const ChangesSection: React.FC = ({ )}
- +
    {changeEntries.map((file) => ( void; + variant?: 'framed' | 'plain'; } export const CommitSection: React.FC = ({ @@ -50,18 +51,32 @@ export const CommitSection: React.FC = ({ isBusy, gitmojiEnabled, onOpenGitmojiPicker, + variant = 'framed', }) => { const hasSelectedFiles = selectedCount > 0; const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null; const { isMobile } = useDeviceInfo(); + const containerClassName = + variant === 'framed' + ? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden' + : 'border-0 bg-transparent rounded-none'; + const headerClassName = + variant === 'framed' + ? 'flex w-full items-center justify-between px-3 py-2' + : 'flex w-full items-center justify-between px-4 py-3 border-b border-border/40'; + const contentClassName = + variant === 'framed' + ? 'flex flex-col gap-3 p-3 pt-0' + : 'flex flex-col gap-3 px-4 py-3'; + return ( -
    +

    Commit

    {hasSelectedFiles @@ -71,7 +86,13 @@ export const CommitSection: React.FC = ({
    -
    +
    + {!hasSelectedFiles ? ( +

    + Select files in Changes to enable commit. +

    + ) : null} + -

    Head information:

    +

    HEAD information:

    {conflictDetails.headInfo}
    diff --git a/packages/ui/src/components/views/git/GitEmptyState.tsx b/packages/ui/src/components/views/git/GitEmptyState.tsx index 4c39920b..565e85b4 100644 --- a/packages/ui/src/components/views/git/GitEmptyState.tsx +++ b/packages/ui/src/components/views/git/GitEmptyState.tsx @@ -14,8 +14,8 @@ export const GitEmptyState: React.FC = ({ isPulling, }) => { return ( -
    - +
    +

    Working tree clean

    diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index c232f17c..2c304f37 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -5,12 +5,13 @@ import { RiArrowDownSLine, RiLoader4Line, RiGitBranchLine, + RiGitRepositoryLine, RiBriefcaseLine, RiHomeLine, RiGraduationCapLine, RiCodeLine, RiHeartLine, - RiGitRepositoryLine, + RiHistoryLine, RiUser3Line, } from '@remixicon/react'; import { Button } from '@/components/ui/button'; @@ -24,11 +25,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { BranchSelector } from './BranchSelector'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { SyncActions } from './SyncActions'; -import { BranchIntegrationSection, type OperationLogEntry } from './BranchIntegrationSection'; import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types'; type SyncAction = 'fetch' | 'pull' | 'push' | null; -type BranchOperation = 'merge' | 'rebase' | null; interface GitHeaderProps { status: GitStatus | null; @@ -48,13 +47,7 @@ interface GitHeaderProps { onSelectIdentity: (profile: GitIdentityProfile) => void; isApplyingIdentity: boolean; isWorktreeMode: boolean; - // Branch integration (merge/rebase) - onMerge: (branch: string) => void; - onRebase: (branch: string) => void; - branchOperation: BranchOperation; - operationLogs: OperationLogEntry[]; - onOperationComplete: () => void; - isBusy: boolean; + onOpenHistory?: () => void; onOpenBranchPicker?: () => void; } @@ -208,12 +201,7 @@ export const GitHeader: React.FC = ({ onSelectIdentity, isApplyingIdentity, isWorktreeMode, - onMerge, - onRebase, - branchOperation, - operationLogs, - onOperationComplete, - isBusy, + onOpenHistory, onOpenBranchPicker, }) => { if (!status) { @@ -271,20 +259,6 @@ export const GitHeader: React.FC = ({ disabled={!status} /> -
    - - -
    {onOpenBranchPicker ? ( @@ -297,13 +271,30 @@ export const GitHeader: React.FC = ({ onClick={onOpenBranchPicker} > - Manage Branches + Manage branches Manage branches ) : null} + {onOpenHistory ? ( + + + + + Show commit history + + ) : null} + ; loadingCommitHashes: Set; onCopyHash: (hash: string) => void; + showHeader?: boolean; } export const HistorySection: React.FC = ({ @@ -44,6 +45,7 @@ export const HistorySection: React.FC = ({ commitFilesMap, loadingCommitHashes, onCopyHash, + showHeader = true, }) => { const [isOpen, setIsOpen] = React.useState(true); @@ -51,6 +53,40 @@ export const HistorySection: React.FC = ({ return null; } + const content = ( + + {log.all.length === 0 ? ( +
    +

    + No commits found +

    +
    + ) : ( +
      + {log.all.map((entry) => ( + onToggleCommit(entry.hash)} + files={commitFilesMap.get(entry.hash) ?? []} + isLoadingFiles={loadingCommitHashes.has(entry.hash)} + onCopyHash={onCopyHash} + /> + ))} +
    + )} +
    + ); + + if (!showHeader) { + return ( +
    + {content} +
    + ); + } + return ( = ({
    - - - {log.all.length === 0 ? ( -
    -

    - No commits found -

    -
    - ) : ( -
      - {log.all.map((entry) => ( - onToggleCommit(entry.hash)} - files={commitFilesMap.get(entry.hash) ?? []} - isLoadingFiles={loadingCommitHashes.has(entry.hash)} - onCopyHash={onCopyHash} - /> - ))} -
    - )} -
    -
    + {content} ); }; diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx index a9a7210b..976f3a1e 100644 --- a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -6,12 +6,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; - import { Command, CommandEmpty, @@ -52,6 +46,7 @@ export const IntegrateCommitsSection: React.FC<{ defaultTargetBranch: string; refreshKey?: number; onRefresh?: () => void; + variant?: 'framed' | 'plain'; }> = ({ repoRoot, sourceBranch, @@ -60,10 +55,10 @@ export const IntegrateCommitsSection: React.FC<{ defaultTargetBranch, refreshKey, onRefresh, + variant = 'framed', }) => { const currentSessionId = useSessionStore((s) => s.currentSessionId); const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); - const [isOpen, setIsOpen] = React.useState(true); const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); const searchInputRef = React.useRef(null); @@ -250,7 +245,7 @@ Important: // Use current session - set pending input text and synthetic parts if (!currentSessionId) { - toast.error('No active session', { description: 'Open a chat session first or use "New Session".' }); + toast.error('No active session', { description: 'Open a chat session first or start a new session.' }); return; } @@ -281,7 +276,7 @@ Important: return; } if (result.kind === 'conflict') { - toast.error('Cherry-pick conflict', { description: 'Resolve conflicts, then Continue.' }); + toast.error('Cherry-pick conflict', { description: 'Resolve conflicts, then continue.' }); setUi({ kind: 'conflict', state: result.state, details: result.details }); if (conflictStorageKey && typeof window !== 'undefined') { window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state)); @@ -342,13 +337,19 @@ Important: return null; } + const containerClassName = + variant === 'framed' + ? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden' + : 'border-0 bg-transparent rounded-none'; + const headerClassName = + variant === 'framed' + ? 'px-3 py-2 border-b border-border/40 flex items-center justify-between gap-2' + : 'px-0 py-3 border-b border-border/40 flex items-center justify-between gap-2'; + const bodyClassName = variant === 'framed' ? 'flex flex-col gap-3 p-3' : 'flex flex-col gap-3 py-3'; + return ( - - +
    +

    Re-integrate commits

    @@ -361,14 +362,12 @@ Important: ) : null}
    - +
    - -
    -
    -
    -
    -
    Move commits
    +
    +
    +
    +
    Move commits
    {sourceBranch} → {targetBranch}
    @@ -519,9 +518,7 @@ Important:
    )} -
    -
    - - +
    +
    ); }; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 31543653..d3c4eb82 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -4,6 +4,9 @@ import { RiCheckboxBlankLine, RiCheckboxLine, RiExternalLinkLine, + RiGitClosePullRequestLine, + RiGitMergeLine, + RiGitPrDraftLine, RiGitPullRequestLine, RiLoader4Line, } from '@remixicon/react'; @@ -54,6 +57,28 @@ const statusColor = (state: string | undefined | null): string => { } }; +const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'open' | 'blocked' | 'merged' | 'closed' | null => { + const pr = status?.pr; + if (!pr) { + return null; + } + if (pr.state === 'merged') { + return 'merged'; + } + if (pr.state === 'closed') { + return 'closed'; + } + if (pr.draft) { + return 'draft'; + } + const checksFailed = status?.checks?.state === 'failure'; + const notMergeable = status?.canMerge === false || pr.mergeable === false; + if (checksFailed || notMergeable) { + return 'blocked'; + } + return 'open'; +}; + const branchToTitle = (branch: string): string => { return branch .replace(/^refs\/heads\//, '') @@ -67,7 +92,6 @@ type PullRequestDraftSnapshot = { title: string; body: string; draft: boolean; - isOpen: boolean; additionalContext: string; }; @@ -103,7 +127,8 @@ export const PullRequestSection: React.FC<{ directory: string; branch: string; baseBranch: string; -}> = ({ directory, branch, baseBranch }) => { + variant?: 'framed' | 'plain'; +}> = ({ directory, branch, baseBranch, variant = 'framed' }) => { const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); @@ -124,7 +149,6 @@ export const PullRequestSection: React.FC<{ [snapshotKey] ); - const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true); const [isLoading, setIsLoading] = React.useState(false); const [status, setStatus] = React.useState(null); const [error, setError] = React.useState(null); @@ -401,7 +425,6 @@ export const PullRequestSection: React.FC<{ setTitle(snapshot?.title ?? branchToTitle(branch)); setBody(snapshot?.body ?? ''); setDraft(snapshot?.draft ?? false); - setIsOpen(snapshot?.isOpen ?? true); void refresh(); }, [branch, refresh, snapshotKey]); @@ -420,10 +443,9 @@ export const PullRequestSection: React.FC<{ title, body, draft, - isOpen, additionalContext, }); - }, [snapshotKey, title, body, draft, isOpen, additionalContext, directory, branch]); + }, [snapshotKey, title, body, draft, additionalContext, directory, branch]); const generateDescription = React.useCallback(async () => { if (isGenerating) return; @@ -538,16 +560,31 @@ export const PullRequestSection: React.FC<{ const canMerge = Boolean(status?.canMerge); const isConnected = Boolean(status?.connected); const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false; + const prVisualState = getPrVisualState(status); + const prColorVar = prVisualState ? `var(--pr-${prVisualState})` : 'var(--status-info)'; + const PrStateIcon = prVisualState === 'draft' + ? RiGitPrDraftLine + : prVisualState === 'merged' + ? RiGitMergeLine + : prVisualState === 'closed' + ? RiGitClosePullRequestLine + : RiGitPullRequestLine; + + const containerClassName = + variant === 'framed' + ? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden' + : 'border-0 bg-transparent rounded-none'; + const headerClassName = + variant === 'framed' + ? 'px-3 py-2 border-b border-border/40 flex items-center justify-between gap-2' + : 'px-0 py-3 border-b border-border/40 flex items-center justify-between gap-2'; + const bodyClassName = variant === 'framed' ? 'flex flex-col gap-3 p-3' : 'flex flex-col gap-3 py-3'; return ( - - +
    +
    - +

    Pull Request

    {pr ? ( #{pr.number} @@ -562,16 +599,14 @@ export const PullRequestSection: React.FC<{ ) : null}
    - +
    - -
    -
    - {shouldShowConnectionNotice ? ( -
    -
    - GitHub not connected. Connect your GitHub account in settings. -
    +
    + {shouldShowConnectionNotice ? ( +
    +
    + GitHub not connected. Connect your GitHub account in settings. +
    @@ -599,30 +634,43 @@ export const PullRequestSection: React.FC<{
    {pr.title}
    - {pr.state}{pr.draft ? ' (draft)' : ''} + + {pr.state}{pr.draft ? ' (draft)' : ''} + {pr.mergeable === false ? ' · not mergeable' : ''} - {typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''} + {pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown' + ? ` · ${pr.mergeableState}` + : ''}
    -
    - {checks ? ( +
    +
    + {checks ? ( + + ) : null} - ) : null} +
    + {checks?.failure ? ( - ) : null} -
    {canMerge && pr.draft ? (
    @@ -843,12 +891,10 @@ export const PullRequestSection: React.FC<{
    )} -
    -
    - +
    - + @@ -859,7 +905,7 @@ export const PullRequestSection: React.FC<{ -
    +
    {isLoadingCheckDetails ? (
    @@ -890,6 +936,6 @@ export const PullRequestSection: React.FC<{
    - +
    ); }; diff --git a/packages/ui/src/components/views/git/StashDialog.tsx b/packages/ui/src/components/views/git/StashDialog.tsx index a6c04c34..a8214bac 100644 --- a/packages/ui/src/components/views/git/StashDialog.tsx +++ b/packages/ui/src/components/views/git/StashDialog.tsx @@ -61,7 +61,7 @@ export const StashDialog: React.FC = ({ Uncommitted Changes
    - You have uncommitted changes that would be overwritten by {operation}. + You have uncommitted changes that would be overwritten by this {operation}. Would you like to stash them temporarily? @@ -72,7 +72,11 @@ export const StashDialog: React.FC = ({

    1. Stash your uncommitted changes
    2. -
    3. {operationLabel} {targetBranch}
    4. +
    5. + {operation === 'merge' ? 'Merge' : 'Rebase'}{' '} + {operation === 'merge' ? 'with' : 'onto'}{' '} + {targetBranch} +
    6. {restoreAfter &&
    7. Restore your stashed changes
    8. }
    @@ -88,7 +92,7 @@ export const StashDialog: React.FC = ({ className="typography-ui-label text-foreground cursor-pointer select-none" onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)} > - Restore changes after {operation} + Restore changes after the {operation}
    diff --git a/packages/vscode/src/githubPr.ts b/packages/vscode/src/githubPr.ts index a4103765..8472e4d4 100644 --- a/packages/vscode/src/githubPr.ts +++ b/packages/vscode/src/githubPr.ts @@ -138,38 +138,55 @@ export const getPullRequestStatus = async ( return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false }; } - const listUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); - listUrl.searchParams.set('state', 'open'); - listUrl.searchParams.set('head', `${repo.owner}:${branch}`); - listUrl.searchParams.set('per_page', '10'); + const listNumberByHead = async (state: 'open' | 'closed'): Promise => { + const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); + url.searchParams.set('state', state); + url.searchParams.set('head', `${repo.owner}:${branch}`); + url.searchParams.set('per_page', '10'); - const listResp = await githubFetch(listUrl.toString(), accessToken); - if (listResp.status === 401) { - return { connected: false }; - } - const list = await jsonOrNull>(listResp); - let number = (listResp.ok && Array.isArray(list) && list.length > 0) - ? list[0].number - : null; - - // Fork PR support: head owner differs -> head filter yields empty. - if (!number) { - const openListUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); - openListUrl.searchParams.set('state', 'open'); - openListUrl.searchParams.set('per_page', '100'); - const openResp = await githubFetch(openListUrl.toString(), accessToken); - if (openResp.status === 401) { - return { connected: false }; + const resp = await githubFetch(url.toString(), accessToken); + if (resp.status === 401) { + return null; } - const openList = await jsonOrNull>(openResp); - if (openResp.ok && Array.isArray(openList)) { - const match = openList.find((prItem) => { - const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null; - return readString(head?.ref) === branch; - }); - if (match && typeof match.number === 'number') { - number = match.number; - } + const list = await jsonOrNull>(resp); + return (resp.ok && Array.isArray(list) && list.length > 0) ? list[0].number : null; + }; + + const listNumberByHeadRef = async (state: 'open' | 'closed'): Promise => { + const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); + url.searchParams.set('state', state); + url.searchParams.set('per_page', '100'); + const resp = await githubFetch(url.toString(), accessToken); + if (resp.status === 401) { + return null; + } + const list = await jsonOrNull>(resp); + if (!resp.ok || !Array.isArray(list)) return null; + + const match = list.find((prItem) => { + const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null; + return readString(head?.ref) === branch; + }); + return match && typeof match.number === 'number' ? match.number : null; + }; + + // PR status by branch: + // - Prefer open PRs. + // - If none, surface closed/merged PRs. + // - Fork PR support: head owner differs -> head filter yields empty; fall back to matching head.ref. + let number = await listNumberByHead('open'); + if (!number) number = await listNumberByHead('closed'); + if (!number) number = await listNumberByHeadRef('open'); + if (!number) number = await listNumberByHeadRef('closed'); + + // Detect auth revocation (best-effort) + if (number === null) { + const probeUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); + probeUrl.searchParams.set('state', 'open'); + probeUrl.searchParams.set('per_page', '1'); + const probeResp = await githubFetch(probeUrl.toString(), accessToken); + if (probeResp.status === 401) { + return { connected: false }; } } @@ -185,7 +202,7 @@ export const getPullRequestStatus = async ( throw new Error('Failed to load PR'); } - const merged = Boolean(prJson.merged); + const merged = Boolean(prJson.merged || prJson.merged_at); const prState = readString(prJson.state); const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open'); const pr: GitHubPullRequest = { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 6f0ac758..b3281bee 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -5844,31 +5844,38 @@ async function main(options = {}) { return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false }); } - // Find PR for this branch (same-repo assumption) - const list = await octokit.rest.pulls.list({ - owner: repo.owner, - repo: repo.repo, - state: 'open', - head: `${repo.owner}:${branch}`, - per_page: 10, - }); + const listByHead = async (state) => { + const resp = await octokit.rest.pulls.list({ + owner: repo.owner, + repo: repo.repo, + state, + head: `${repo.owner}:${branch}`, + per_page: 10, + }); + return Array.isArray(resp?.data) ? resp.data[0] : null; + }; - let first = Array.isArray(list?.data) ? list.data[0] : null; + const listByHeadRef = async (state) => { + const resp = await octokit.rest.pulls.list({ + owner: repo.owner, + repo: repo.repo, + state, + per_page: 100, + }); + const matches = Array.isArray(resp?.data) + ? resp.data.filter((pr) => pr?.head?.ref === branch) + : []; + return matches[0] ?? null; + }; - // Fork PR support: head owner != base owner. If no PR found via head filter, - // fall back to listing open PRs and matching by head ref name. - if (!first) { - const openList = await octokit.rest.pulls.list({ - owner: repo.owner, - repo: repo.repo, - state: 'open', - per_page: 100, - }); - const matches = Array.isArray(openList?.data) - ? openList.data.filter((pr) => pr?.head?.ref === branch) - : []; - first = matches[0] ?? null; - } + // PR status by branch: + // - Prefer open PRs. + // - If none, also surface closed/merged PRs. + // - Fork PR support: head owner != base owner -> head filter yields empty; fall back to matching head.ref. + let first = await listByHead('open'); + if (!first) first = await listByHead('closed'); + if (!first) first = await listByHeadRef('open'); + if (!first) first = await listByHeadRef('closed'); if (!first) { return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false }); } @@ -5964,7 +5971,8 @@ async function main(options = {}) { canMerge = false; } - const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); + const isMerged = Boolean(prData.merged || prData.merged_at); + const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); return res.json({ connected: true,