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
This commit is contained in:
committed by
GitHub
parent
b432437b02
commit
0a425cd882
@@ -14,6 +14,7 @@ interface AnimatedTabsProps<T extends string> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
isInteractive?: boolean;
|
isInteractive?: boolean;
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
|
collapseLabelsOnSmall?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimatedTabs<T extends string>({
|
export function AnimatedTabs<T extends string>({
|
||||||
@@ -23,6 +24,7 @@ export function AnimatedTabs<T extends string>({
|
|||||||
className,
|
className,
|
||||||
isInteractive = true,
|
isInteractive = true,
|
||||||
animate = true,
|
animate = true,
|
||||||
|
collapseLabelsOnSmall = false,
|
||||||
}: AnimatedTabsProps<T>) {
|
}: AnimatedTabsProps<T>) {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
||||||
@@ -73,10 +75,15 @@ export function AnimatedTabs<T extends string>({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
className="flex h-7 flex-1 items-center justify-center gap-1.25 rounded-lg px-2.5 text-sm font-semibold"
|
className={cn(
|
||||||
|
'flex h-7 flex-1 items-center justify-center rounded-lg px-2.5 text-sm font-semibold',
|
||||||
|
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||||||
<span className="truncate">{tab.label}</span>
|
<span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
@@ -99,11 +106,13 @@ export function AnimatedTabs<T extends string>({
|
|||||||
onValueChange(tab.value);
|
onValueChange(tab.value);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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',
|
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'
|
'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-pressed={isActive}
|
||||||
|
aria-label={tab.label}
|
||||||
aria-disabled={!isInteractive}
|
aria-disabled={!isInteractive}
|
||||||
tabIndex={isInteractive ? 0 : -1}
|
tabIndex={isInteractive ? 0 : -1}
|
||||||
>
|
>
|
||||||
@@ -112,7 +121,9 @@ export function AnimatedTabs<T extends string>({
|
|||||||
className={cn('h-4 w-4', isActive ? 'text-accent-foreground' : 'text-muted-foreground')}
|
className={cn('h-4 w-4', isActive ? 'text-accent-foreground' : 'text-muted-foreground')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">{tab.label}</span>
|
<span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ import {
|
|||||||
useIsGitRepo,
|
useIsGitRepo,
|
||||||
} from '@/stores/useGitStore';
|
} from '@/stores/useGitStore';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
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 { toast } from '@/components/ui';
|
||||||
|
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -46,7 +55,7 @@ import { PullRequestSection } from './git/PullRequestSection';
|
|||||||
import { ConflictDialog } from './git/ConflictDialog';
|
import { ConflictDialog } from './git/ConflictDialog';
|
||||||
import { StashDialog } from './git/StashDialog';
|
import { StashDialog } from './git/StashDialog';
|
||||||
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
|
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 type { GitRemote } from '@/lib/gitApi';
|
||||||
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
|
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
@@ -54,6 +63,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
|||||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||||
type BranchOperation = 'merge' | 'rebase' | null;
|
type BranchOperation = 'merge' | 'rebase' | null;
|
||||||
|
type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree';
|
||||||
|
|
||||||
|
|
||||||
type GitViewSnapshot = {
|
type GitViewSnapshot = {
|
||||||
@@ -235,8 +245,6 @@ export const GitView: React.FC = () => {
|
|||||||
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
|
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
|
||||||
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
|
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const baseBranch = worktreeMetadata?.createdFromBranch || status?.current || 'HEAD';
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const projectRoot = worktreeMetadata?.projectDirectory;
|
const projectRoot = worktreeMetadata?.projectDirectory;
|
||||||
if (!projectRoot) {
|
if (!projectRoot) {
|
||||||
@@ -363,6 +371,8 @@ export const GitView: React.FC = () => {
|
|||||||
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
||||||
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
|
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
|
||||||
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
||||||
|
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
|
||||||
|
const [actionTab, setActionTab] = React.useState<ActionTab>('commit');
|
||||||
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
||||||
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
|
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
|
||||||
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
|
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
|
||||||
@@ -915,6 +925,21 @@ export const GitView: React.FC = () => {
|
|||||||
.sort();
|
.sort();
|
||||||
}, [branches]);
|
}, [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 availableIdentities = React.useMemo(() => {
|
||||||
const unique = new Map<string, GitIdentityProfile>();
|
const unique = new Map<string, GitIdentityProfile>();
|
||||||
if (globalIdentity) {
|
if (globalIdentity) {
|
||||||
@@ -998,6 +1023,29 @@ export const GitView: React.FC = () => {
|
|||||||
const selectedCount = selectedPaths.size;
|
const selectedCount = selectedPaths.size;
|
||||||
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
||||||
const hasChanges = uniqueChangeCount > 0;
|
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) => {
|
const toggleFileSelection = (path: string) => {
|
||||||
setSelectedPaths((previous) => {
|
setSelectedPaths((previous) => {
|
||||||
@@ -1484,12 +1532,7 @@ export const GitView: React.FC = () => {
|
|||||||
onSelectIdentity={handleApplyIdentity}
|
onSelectIdentity={handleApplyIdentity}
|
||||||
isApplyingIdentity={isSettingIdentity}
|
isApplyingIdentity={isSettingIdentity}
|
||||||
isWorktreeMode={!!worktreeMetadata}
|
isWorktreeMode={!!worktreeMetadata}
|
||||||
onMerge={handleMerge}
|
onOpenHistory={() => setIsHistoryDialogOpen(true)}
|
||||||
onRebase={handleRebase}
|
|
||||||
branchOperation={branchOperation}
|
|
||||||
operationLogs={operationLogs}
|
|
||||||
onOperationComplete={handleOperationComplete}
|
|
||||||
isBusy={isBusy}
|
|
||||||
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
|
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1509,12 +1552,12 @@ export const GitView: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="h-full min-h-0 grid grid-cols-1 xl:grid-cols-[minmax(520px,1fr)_480px]">
|
||||||
{/* Two-column layout on large screens: Changes + Commit */}
|
<div className="min-w-0 min-h-0 h-full flex flex-col">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
{hasChanges ? (
|
{hasChanges ? (
|
||||||
<ChangesSection
|
<ChangesSection
|
||||||
|
variant="plain"
|
||||||
changeEntries={changeEntries}
|
changeEntries={changeEntries}
|
||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
diffStats={status?.diffStats}
|
diffStats={status?.diffStats}
|
||||||
@@ -1526,7 +1569,7 @@ export const GitView: React.FC = () => {
|
|||||||
onRevertFile={handleRevertFile}
|
onRevertFile={handleRevertFile}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="lg:col-span-2 flex justify-center">
|
<div className="flex-1 min-h-0 flex items-center justify-center px-6">
|
||||||
<GitEmptyState
|
<GitEmptyState
|
||||||
behind={status?.behind ?? 0}
|
behind={status?.behind ?? 0}
|
||||||
onPull={() => {
|
onPull={() => {
|
||||||
@@ -1540,66 +1583,144 @@ export const GitView: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{changeEntries.length > 0 && (
|
|
||||||
<CommitSection
|
|
||||||
selectedCount={selectedCount}
|
|
||||||
commitMessage={commitMessage}
|
|
||||||
onCommitMessageChange={setCommitMessage}
|
|
||||||
generatedHighlights={generatedHighlights}
|
|
||||||
onInsertHighlights={handleInsertHighlights}
|
|
||||||
onClearHighlights={clearGeneratedHighlights}
|
|
||||||
onGenerateMessage={handleGenerateCommitMessage}
|
|
||||||
isGeneratingMessage={isGeneratingMessage}
|
|
||||||
onCommit={() => handleCommit({ pushAfter: false })}
|
|
||||||
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
|
||||||
commitAction={commitAction}
|
|
||||||
isBusy={isBusy}
|
|
||||||
gitmojiEnabled={settingsGitmojiEnabled}
|
|
||||||
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? (
|
<div className="min-w-0 min-h-0 h-full border-t xl:border-t-0 xl:border-l border-border/40 bg-muted/10 flex flex-col">
|
||||||
<IntegrateCommitsSection
|
<div className="px-3 py-3">
|
||||||
repoRoot={repoRootForIntegrate}
|
<AnimatedTabs<ActionTab>
|
||||||
sourceBranch={sourceBranchForIntegrate}
|
value={actionTab}
|
||||||
worktreeMetadata={worktreeMetadata}
|
onValueChange={setActionTab}
|
||||||
localBranches={localBranches}
|
collapseLabelsOnSmall
|
||||||
defaultTargetBranch={defaultTargetBranch}
|
tabs={[
|
||||||
refreshKey={integrateRefreshKey}
|
{ value: 'commit', label: 'Commit', icon: RiGitCommitLine },
|
||||||
onRefresh={() => {
|
{ value: 'branch', label: 'Update branch', icon: RiGitMergeLine },
|
||||||
if (!currentDirectory) return;
|
{ value: 'pr', label: 'PR', icon: RiGitPullRequestLine },
|
||||||
fetchStatus(currentDirectory, git);
|
{ value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal },
|
||||||
fetchBranches(currentDirectory, git);
|
]}
|
||||||
fetchLog(currentDirectory, git, logMaxCountLocal);
|
/>
|
||||||
}}
|
</div>
|
||||||
/>
|
<div className="h-px bg-border/40" />
|
||||||
) : null}
|
|
||||||
|
|
||||||
{currentDirectory && status?.current && status?.tracking ? (
|
<ScrollableOverlay
|
||||||
<PullRequestSection
|
outerClassName="flex-1 min-h-0"
|
||||||
directory={currentDirectory}
|
className="px-4 py-4"
|
||||||
branch={status.current}
|
disableHorizontal
|
||||||
baseBranch={baseBranch}
|
preventOverscroll
|
||||||
/>
|
>
|
||||||
) : null}
|
{actionTab === 'commit' ? (
|
||||||
|
<CommitSection
|
||||||
|
variant="plain"
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
commitMessage={commitMessage}
|
||||||
|
onCommitMessageChange={setCommitMessage}
|
||||||
|
generatedHighlights={generatedHighlights}
|
||||||
|
onInsertHighlights={handleInsertHighlights}
|
||||||
|
onClearHighlights={clearGeneratedHighlights}
|
||||||
|
onGenerateMessage={handleGenerateCommitMessage}
|
||||||
|
isGeneratingMessage={isGeneratingMessage}
|
||||||
|
onCommit={() => handleCommit({ pushAfter: false })}
|
||||||
|
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
||||||
|
commitAction={commitAction}
|
||||||
|
isBusy={isBusy}
|
||||||
|
gitmojiEnabled={settingsGitmojiEnabled}
|
||||||
|
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* History below, constrained width */}
|
{actionTab === 'branch' ? (
|
||||||
<HistorySection
|
<div className="space-y-4">
|
||||||
log={log}
|
{canShowBranchWorkflows ? (
|
||||||
isLogLoading={isLogLoading}
|
<BranchIntegrationSection
|
||||||
logMaxCount={logMaxCountLocal}
|
mode="inline"
|
||||||
onLogMaxCountChange={handleLogMaxCountChange}
|
currentBranch={status?.current}
|
||||||
expandedCommitHashes={expandedCommitHashes}
|
localBranches={localBranches}
|
||||||
onToggleCommit={handleToggleCommit}
|
remoteBranches={remoteBranches}
|
||||||
commitFilesMap={commitFilesMap}
|
onMerge={handleMerge}
|
||||||
loadingCommitHashes={loadingCommitHashes}
|
onRebase={handleRebase}
|
||||||
onCopyHash={handleCopyCommitHash}
|
disabled={isBusy}
|
||||||
/>
|
isOperating={branchOperation !== null}
|
||||||
|
operationLogs={operationLogs}
|
||||||
|
onOperationComplete={handleOperationComplete}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="typography-meta text-muted-foreground">Branch actions unavailable.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{actionTab === 'worktree' ? (
|
||||||
|
integrateCommitsProps ? (
|
||||||
|
<IntegrateCommitsSection
|
||||||
|
variant="plain"
|
||||||
|
repoRoot={integrateCommitsProps.repoRoot}
|
||||||
|
sourceBranch={integrateCommitsProps.sourceBranch}
|
||||||
|
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
|
||||||
|
localBranches={localBranches}
|
||||||
|
defaultTargetBranch={defaultTargetBranch}
|
||||||
|
refreshKey={integrateRefreshKey}
|
||||||
|
onRefresh={() => {
|
||||||
|
if (!currentDirectory) return;
|
||||||
|
fetchStatus(currentDirectory, git);
|
||||||
|
fetchBranches(currentDirectory, git);
|
||||||
|
fetchLog(currentDirectory, git, logMaxCountLocal);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="typography-ui-header font-semibold text-foreground">Re-integrate commits</div>
|
||||||
|
<div className="typography-micro text-muted-foreground">
|
||||||
|
Available in worktree mode.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{actionTab === 'pr' ? (
|
||||||
|
pullRequestProps ? (
|
||||||
|
<PullRequestSection
|
||||||
|
variant="plain"
|
||||||
|
directory={pullRequestProps.directory}
|
||||||
|
branch={pullRequestProps.branch}
|
||||||
|
baseBranch={baseBranch}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="typography-ui-header font-semibold text-foreground">Pull Request</div>
|
||||||
|
<div className="typography-micro text-muted-foreground">
|
||||||
|
Push a non-base branch (with upstream) to create a PR.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollableOverlay>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isHistoryDialogOpen} onOpenChange={setIsHistoryDialogOpen}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>History</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Browse recent commits and inspect file-level changes.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<HistorySection
|
||||||
|
log={log}
|
||||||
|
isLogLoading={isLogLoading}
|
||||||
|
logMaxCount={logMaxCountLocal}
|
||||||
|
onLogMaxCountChange={handleLogMaxCountChange}
|
||||||
|
expandedCommitHashes={expandedCommitHashes}
|
||||||
|
onToggleCommit={handleToggleCommit}
|
||||||
|
commitFilesMap={commitFilesMap}
|
||||||
|
loadingCommitHashes={loadingCommitHashes}
|
||||||
|
onCopyHash={handleCopyCommitHash}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
|
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
|
||||||
<DialogContent className="max-w-md p-0 overflow-hidden">
|
<DialogContent className="max-w-md p-0 overflow-hidden">
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ interface BranchIntegrationSectionProps {
|
|||||||
isOperating?: boolean;
|
isOperating?: boolean;
|
||||||
operationLogs?: OperationLogEntry[];
|
operationLogs?: OperationLogEntry[];
|
||||||
onOperationComplete?: () => void;
|
onOperationComplete?: () => void;
|
||||||
|
mode?: 'dialog' | 'inline';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> = ({
|
export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> = ({
|
||||||
@@ -63,6 +64,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
|||||||
isOperating = false,
|
isOperating = false,
|
||||||
operationLogs = [],
|
operationLogs = [],
|
||||||
onOperationComplete,
|
onOperationComplete,
|
||||||
|
mode = 'dialog',
|
||||||
}) => {
|
}) => {
|
||||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
const [operation, setOperation] = React.useState<OperationType>('merge');
|
const [operation, setOperation] = React.useState<OperationType>('merge');
|
||||||
@@ -73,6 +75,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
|||||||
const logContainerRef = React.useRef<HTMLDivElement>(null);
|
const logContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isDisabled = disabled || isOperating;
|
const isDisabled = disabled || isOperating;
|
||||||
|
const targetBranchLabel = currentBranch || 'current branch';
|
||||||
|
|
||||||
// Check if operation completed (all logs are done or error)
|
// Check if operation completed (all logs are done or error)
|
||||||
const operationCompleted = operationLogs.length > 0 &&
|
const operationCompleted = operationLogs.length > 0 &&
|
||||||
@@ -159,12 +162,260 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
|||||||
}
|
}
|
||||||
}, [branchDropdownOpen]);
|
}, [branchDropdownOpen]);
|
||||||
|
|
||||||
|
const renderOperating = () => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
ref={logContainerRef}
|
||||||
|
className="rounded-lg border border-border bg-muted/30 p-3 max-h-48 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{operationLogs.map((log, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-2">
|
||||||
|
<div className="mt-0.5 shrink-0">
|
||||||
|
{log.status === 'running' && (
|
||||||
|
<RiLoader4Line className="size-3.5 animate-spin text-primary" />
|
||||||
|
)}
|
||||||
|
{log.status === 'done' && (
|
||||||
|
<RiCheckLine className="size-3.5 text-success" />
|
||||||
|
)}
|
||||||
|
{log.status === 'error' && (
|
||||||
|
<RiCloseLine className="size-3.5 text-destructive" />
|
||||||
|
)}
|
||||||
|
{log.status === 'pending' && (
|
||||||
|
<div className="size-3.5 rounded-full border border-muted-foreground/30" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'typography-micro',
|
||||||
|
log.status === 'error' && 'text-destructive',
|
||||||
|
log.status === 'done' && 'text-muted-foreground',
|
||||||
|
log.status === 'running' && 'text-foreground',
|
||||||
|
log.status === 'pending' && 'text-muted-foreground/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{operationCompleted ? (
|
||||||
|
mode === 'dialog' ? (
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="default" size="sm" onClick={handleClose}>
|
||||||
|
{hasError ? 'Close' : 'Done'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="default" size="sm" onClick={handleClose}>
|
||||||
|
{hasError ? 'Close' : 'Done'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderForm = () => (
|
||||||
|
<>
|
||||||
|
{/* Operation Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="typography-meta text-muted-foreground">Operation</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOperation('merge')}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
|
||||||
|
operation === 'merge'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-border/80 hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RiGitMergeLine
|
||||||
|
className={cn('size-4', operation === 'merge' ? 'text-primary' : 'text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'typography-ui-label',
|
||||||
|
operation === 'merge' ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Merge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="typography-micro text-muted-foreground">
|
||||||
|
Combines branches with a merge commit and preserves history.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOperation('rebase')}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
|
||||||
|
operation === 'rebase'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-border/80 hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RiGitBranchLine
|
||||||
|
className={cn('size-4', operation === 'rebase' ? 'text-primary' : 'text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'typography-ui-label',
|
||||||
|
operation === 'rebase' ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Rebase
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="typography-micro text-muted-foreground">
|
||||||
|
Moves your commits to be on top of another branch. Creates linear history.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
{operation === 'merge' ? `Branch to merge into ${targetBranchLabel}` : 'Branch to rebase onto'}
|
||||||
|
</p>
|
||||||
|
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full justify-between h-10">
|
||||||
|
<span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
|
||||||
|
{selectedBranch || 'Select a branch...'}
|
||||||
|
</span>
|
||||||
|
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-[300px]">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
ref={searchInputRef}
|
||||||
|
placeholder="Search branches..."
|
||||||
|
value={branchSearch}
|
||||||
|
onValueChange={setBranchSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No branches found.</CommandEmpty>
|
||||||
|
|
||||||
|
{filteredLocal.length > 0 && (
|
||||||
|
<CommandGroup heading="Local branches">
|
||||||
|
{filteredLocal.map((branch) => (
|
||||||
|
<CommandItem key={`local-${branch}`} onSelect={() => handleSelectBranch(branch)}>
|
||||||
|
<span className="typography-ui-label text-foreground truncate">{branch}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredLocal.length > 0 && filteredRemote.length > 0 ? <CommandSeparator /> : null}
|
||||||
|
|
||||||
|
{filteredRemote.length > 0 && (
|
||||||
|
<CommandGroup heading="Remote branches">
|
||||||
|
{filteredRemote.map((branch) => (
|
||||||
|
<CommandItem key={`remote-${branch}`} onSelect={() => handleSelectBranch(branch)}>
|
||||||
|
<span className="typography-ui-label text-foreground truncate">{branch}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{selectedBranch ? (
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
{operation === 'merge' ? (
|
||||||
|
<>
|
||||||
|
This will merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
|
||||||
|
<span className="font-mono text-foreground">{targetBranchLabel}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
This will rebase <span className="font-mono text-foreground">{targetBranchLabel}</span> onto{' '}
|
||||||
|
<span className="font-mono text-foreground">{selectedBranch}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mode === 'dialog' ? (
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedBranch}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{operation === 'merge' ? (
|
||||||
|
<>
|
||||||
|
<RiGitMergeLine className="size-4" />
|
||||||
|
Merge
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiGitBranchLine className="size-4" />
|
||||||
|
Rebase
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={isDisabled}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button variant="default" size="sm" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
|
||||||
|
{operation === 'merge' ? 'Merge' : 'Rebase'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = isOperating ? renderOperating() : renderForm();
|
||||||
|
|
||||||
|
if (mode === 'inline') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="typography-ui-header font-semibold text-foreground">Update branch</div>
|
||||||
|
<div className="typography-micro text-muted-foreground">
|
||||||
|
Bring changes from another branch into{' '}
|
||||||
|
<span className="font-mono text-foreground">{targetBranchLabel}</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={1000}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-2 gap-1.5"
|
className="h-8 px-2 gap-1.5"
|
||||||
onClick={handleOpenDialog}
|
onClick={handleOpenDialog}
|
||||||
@@ -175,11 +426,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
|||||||
) : (
|
) : (
|
||||||
<RiGitMergeLine className="size-4" />
|
<RiGitMergeLine className="size-4" />
|
||||||
)}
|
)}
|
||||||
<span className="hidden sm:inline">Integrate</span>
|
<span>Merge/Rebase</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8}>
|
<TooltipContent sideOffset={8}>
|
||||||
Merge or rebase another branch
|
Merge or rebase changes from another branch.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -190,10 +441,10 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Integrate Branch</DialogTitle>
|
<DialogTitle>Update Branch</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isOperating ? (
|
{isOperating ? (
|
||||||
operationCompleted ? (
|
operationCompleted ? (
|
||||||
hasError ? 'Operation failed' : 'Operation completed'
|
hasError ? 'Operation failed' : 'Operation completed'
|
||||||
@@ -202,248 +453,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Choose how to integrate changes from another branch into{' '}
|
Choose how to bring changes from another branch into{' '}
|
||||||
<span className="font-mono text-foreground">{currentBranch || 'current branch'}</span>
|
<span className="font-mono text-foreground">{targetBranchLabel}</span>
|
||||||
|
.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Show operation log when operating */}
|
{body}
|
||||||
{isOperating ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div
|
|
||||||
ref={logContainerRef}
|
|
||||||
className="rounded-lg border border-border bg-muted/30 p-3 max-h-48 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{operationLogs.map((log, index) => (
|
|
||||||
<div key={index} className="flex items-start gap-2">
|
|
||||||
<div className="mt-0.5 shrink-0">
|
|
||||||
{log.status === 'running' && (
|
|
||||||
<RiLoader4Line className="size-3.5 animate-spin text-primary" />
|
|
||||||
)}
|
|
||||||
{log.status === 'done' && (
|
|
||||||
<RiCheckLine className="size-3.5 text-success" />
|
|
||||||
)}
|
|
||||||
{log.status === 'error' && (
|
|
||||||
<RiCloseLine className="size-3.5 text-destructive" />
|
|
||||||
)}
|
|
||||||
{log.status === 'pending' && (
|
|
||||||
<div className="size-3.5 rounded-full border border-muted-foreground/30" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={cn(
|
|
||||||
'typography-micro',
|
|
||||||
log.status === 'error' && 'text-destructive',
|
|
||||||
log.status === 'done' && 'text-muted-foreground',
|
|
||||||
log.status === 'running' && 'text-foreground',
|
|
||||||
log.status === 'pending' && 'text-muted-foreground/60'
|
|
||||||
)}>
|
|
||||||
{log.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{operationCompleted && (
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
{hasError ? 'Close' : 'Done'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Operation Selection */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="typography-meta text-muted-foreground">Operation</p>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOperation('merge')}
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
|
|
||||||
operation === 'merge'
|
|
||||||
? 'border-primary bg-primary/5'
|
|
||||||
: 'border-border hover:border-border/80 hover:bg-muted/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RiGitMergeLine className={cn(
|
|
||||||
'size-4',
|
|
||||||
operation === 'merge' ? 'text-primary' : 'text-muted-foreground'
|
|
||||||
)} />
|
|
||||||
<span className={cn(
|
|
||||||
'typography-ui-label',
|
|
||||||
operation === 'merge' ? 'text-foreground' : 'text-muted-foreground'
|
|
||||||
)}>
|
|
||||||
Merge
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="typography-micro text-muted-foreground">
|
|
||||||
Combines branches with a merge commit. Preserves history.
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOperation('rebase')}
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
|
|
||||||
operation === 'rebase'
|
|
||||||
? 'border-primary bg-primary/5'
|
|
||||||
: 'border-border hover:border-border/80 hover:bg-muted/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RiGitBranchLine className={cn(
|
|
||||||
'size-4',
|
|
||||||
operation === 'rebase' ? 'text-primary' : 'text-muted-foreground'
|
|
||||||
)} />
|
|
||||||
<span className={cn(
|
|
||||||
'typography-ui-label',
|
|
||||||
operation === 'rebase' ? 'text-foreground' : 'text-muted-foreground'
|
|
||||||
)}>
|
|
||||||
Rebase
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="typography-micro text-muted-foreground">
|
|
||||||
Replays commits on top. Creates linear history.
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Branch Selection */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="typography-meta text-muted-foreground">
|
|
||||||
{operation === 'merge' ? 'Branch to merge' : 'Branch to rebase onto'}
|
|
||||||
</p>
|
|
||||||
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-between h-10"
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
'truncate',
|
|
||||||
!selectedBranch && 'text-muted-foreground'
|
|
||||||
)}>
|
|
||||||
{selectedBranch || 'Select a branch...'}
|
|
||||||
</span>
|
|
||||||
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-[300px]">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
ref={searchInputRef}
|
|
||||||
placeholder="Search branches..."
|
|
||||||
value={branchSearch}
|
|
||||||
onValueChange={setBranchSearch}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No branches found.</CommandEmpty>
|
|
||||||
|
|
||||||
{filteredLocal.length > 0 && (
|
|
||||||
<CommandGroup heading="Local branches">
|
|
||||||
{filteredLocal.map((branch) => (
|
|
||||||
<CommandItem
|
|
||||||
key={`local-${branch}`}
|
|
||||||
onSelect={() => handleSelectBranch(branch)}
|
|
||||||
>
|
|
||||||
<span className="typography-ui-label text-foreground truncate">
|
|
||||||
{branch}
|
|
||||||
</span>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredLocal.length > 0 && filteredRemote.length > 0 && (
|
|
||||||
<CommandSeparator />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredRemote.length > 0 && (
|
|
||||||
<CommandGroup heading="Remote branches">
|
|
||||||
{filteredRemote.map((branch) => (
|
|
||||||
<CommandItem
|
|
||||||
key={`remote-${branch}`}
|
|
||||||
onSelect={() => handleSelectBranch(branch)}
|
|
||||||
>
|
|
||||||
<span className="typography-ui-label text-foreground truncate">
|
|
||||||
{branch}
|
|
||||||
</span>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
{selectedBranch && (
|
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
|
||||||
<p className="typography-meta text-muted-foreground">
|
|
||||||
{operation === 'merge' ? (
|
|
||||||
<>
|
|
||||||
This will merge{' '}
|
|
||||||
<span className="font-mono text-foreground">{selectedBranch}</span>
|
|
||||||
{' '}into{' '}
|
|
||||||
<span className="font-mono text-foreground">{currentBranch}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
This will rebase{' '}
|
|
||||||
<span className="font-mono text-foreground">{currentBranch}</span>
|
|
||||||
{' '}onto{' '}
|
|
||||||
<span className="font-mono text-foreground">{selectedBranch}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={!selectedBranch}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
{operation === 'merge' ? (
|
|
||||||
<>
|
|
||||||
<RiGitMergeLine className="size-4" />
|
|
||||||
Merge
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiGitBranchLine className="size-4" />
|
|
||||||
Rebase
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface ChangesSectionProps {
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onViewDiff: (path: string) => void;
|
onViewDiff: (path: string) => void;
|
||||||
onRevertFile: (path: string) => void;
|
onRevertFile: (path: string) => void;
|
||||||
|
variant?: 'framed' | 'plain';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||||
@@ -26,13 +27,27 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
onClearSelection,
|
onClearSelection,
|
||||||
onViewDiff,
|
onViewDiff,
|
||||||
onRevertFile,
|
onRevertFile,
|
||||||
|
variant = 'framed',
|
||||||
}) => {
|
}) => {
|
||||||
const selectedCount = selectedPaths.size;
|
const selectedCount = selectedPaths.size;
|
||||||
const totalCount = changeEntries.length;
|
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 (
|
return (
|
||||||
<section className="flex flex-col rounded-xl border border-border/60 bg-background/70">
|
<section className={containerClassName}>
|
||||||
<header className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border/40">
|
<header className={headerClassName}>
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
|
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="typography-meta text-muted-foreground">
|
<span className="typography-meta text-muted-foreground">
|
||||||
@@ -61,7 +76,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ScrollableOverlay outerClassName="flex-1 min-h-0 max-h-[30vh]" className="w-full">
|
<ScrollableOverlay outerClassName={scrollOuterClassName} className="w-full">
|
||||||
<ul className="divide-y divide-border/60">
|
<ul className="divide-y divide-border/60">
|
||||||
{changeEntries.map((file) => (
|
{changeEntries.map((file) => (
|
||||||
<ChangeRow
|
<ChangeRow
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface CommitSectionProps {
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
gitmojiEnabled: boolean;
|
gitmojiEnabled: boolean;
|
||||||
onOpenGitmojiPicker: () => void;
|
onOpenGitmojiPicker: () => void;
|
||||||
|
variant?: 'framed' | 'plain';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommitSection: React.FC<CommitSectionProps> = ({
|
export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||||
@@ -50,18 +51,32 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
|||||||
isBusy,
|
isBusy,
|
||||||
gitmojiEnabled,
|
gitmojiEnabled,
|
||||||
onOpenGitmojiPicker,
|
onOpenGitmojiPicker,
|
||||||
|
variant = 'framed',
|
||||||
}) => {
|
}) => {
|
||||||
const hasSelectedFiles = selectedCount > 0;
|
const hasSelectedFiles = selectedCount > 0;
|
||||||
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
|
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
|
||||||
const { isMobile } = useDeviceInfo();
|
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 (
|
return (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={hasSelectedFiles}
|
open={variant === 'plain' ? true : hasSelectedFiles}
|
||||||
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
|
className={containerClassName}
|
||||||
data-keyboard-avoid="true"
|
data-keyboard-avoid="true"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between px-3 py-2">
|
<div className={headerClassName}>
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
|
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
|
||||||
<span className="typography-meta text-muted-foreground">
|
<span className="typography-meta text-muted-foreground">
|
||||||
{hasSelectedFiles
|
{hasSelectedFiles
|
||||||
@@ -71,7 +86,13 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="flex flex-col gap-3 p-3 pt-0">
|
<div className={contentClassName}>
|
||||||
|
{!hasSelectedFiles ? (
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
Select files in Changes to enable commit.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<AIHighlightsBox
|
<AIHighlightsBox
|
||||||
highlights={generatedHighlights}
|
highlights={generatedHighlights}
|
||||||
onInsert={onInsertHighlights}
|
onInsert={onInsertHighlights}
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ Important:
|
|||||||
|
|
||||||
{conflictDetails?.headInfo && (
|
{conflictDetails?.headInfo && (
|
||||||
<div className="space-y-1 overflow-hidden">
|
<div className="space-y-1 overflow-hidden">
|
||||||
<p className="typography-meta text-muted-foreground">Head information:</p>
|
<p className="typography-meta text-muted-foreground">HEAD information:</p>
|
||||||
<div className="typography-micro text-foreground font-mono bg-[var(--surface-elevated)] rounded-lg p-3 max-h-24 overflow-y-auto break-words whitespace-pre-wrap">
|
<div className="typography-micro text-foreground font-mono bg-[var(--surface-elevated)] rounded-lg p-3 max-h-24 overflow-y-auto break-words whitespace-pre-wrap">
|
||||||
{conflictDetails.headInfo}
|
{conflictDetails.headInfo}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
|
|||||||
isPulling,
|
isPulling,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
|
||||||
<RiGitCommitLine className="size-10 text-emerald-500/60 mb-4" />
|
<RiGitCommitLine className="size-10 text-muted-foreground/70 mb-4" />
|
||||||
<p className="typography-ui-label font-semibold text-foreground mb-1">
|
<p className="typography-ui-label font-semibold text-foreground mb-1">
|
||||||
Working tree clean
|
Working tree clean
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import {
|
|||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
RiLoader4Line,
|
RiLoader4Line,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
|
RiGitRepositoryLine,
|
||||||
RiBriefcaseLine,
|
RiBriefcaseLine,
|
||||||
RiHomeLine,
|
RiHomeLine,
|
||||||
RiGraduationCapLine,
|
RiGraduationCapLine,
|
||||||
RiCodeLine,
|
RiCodeLine,
|
||||||
RiHeartLine,
|
RiHeartLine,
|
||||||
RiGitRepositoryLine,
|
RiHistoryLine,
|
||||||
RiUser3Line,
|
RiUser3Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -24,11 +25,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
import { BranchSelector } from './BranchSelector';
|
import { BranchSelector } from './BranchSelector';
|
||||||
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
|
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
|
||||||
import { SyncActions } from './SyncActions';
|
import { SyncActions } from './SyncActions';
|
||||||
import { BranchIntegrationSection, type OperationLogEntry } from './BranchIntegrationSection';
|
|
||||||
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
|
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
|
||||||
|
|
||||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||||
type BranchOperation = 'merge' | 'rebase' | null;
|
|
||||||
|
|
||||||
interface GitHeaderProps {
|
interface GitHeaderProps {
|
||||||
status: GitStatus | null;
|
status: GitStatus | null;
|
||||||
@@ -48,13 +47,7 @@ interface GitHeaderProps {
|
|||||||
onSelectIdentity: (profile: GitIdentityProfile) => void;
|
onSelectIdentity: (profile: GitIdentityProfile) => void;
|
||||||
isApplyingIdentity: boolean;
|
isApplyingIdentity: boolean;
|
||||||
isWorktreeMode: boolean;
|
isWorktreeMode: boolean;
|
||||||
// Branch integration (merge/rebase)
|
onOpenHistory?: () => void;
|
||||||
onMerge: (branch: string) => void;
|
|
||||||
onRebase: (branch: string) => void;
|
|
||||||
branchOperation: BranchOperation;
|
|
||||||
operationLogs: OperationLogEntry[];
|
|
||||||
onOperationComplete: () => void;
|
|
||||||
isBusy: boolean;
|
|
||||||
onOpenBranchPicker?: () => void;
|
onOpenBranchPicker?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +201,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
onSelectIdentity,
|
onSelectIdentity,
|
||||||
isApplyingIdentity,
|
isApplyingIdentity,
|
||||||
isWorktreeMode,
|
isWorktreeMode,
|
||||||
onMerge,
|
onOpenHistory,
|
||||||
onRebase,
|
|
||||||
branchOperation,
|
|
||||||
operationLogs,
|
|
||||||
onOperationComplete,
|
|
||||||
isBusy,
|
|
||||||
onOpenBranchPicker,
|
onOpenBranchPicker,
|
||||||
}) => {
|
}) => {
|
||||||
if (!status) {
|
if (!status) {
|
||||||
@@ -271,20 +259,6 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
disabled={!status}
|
disabled={!status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="h-4 w-px bg-border/60" />
|
|
||||||
|
|
||||||
<BranchIntegrationSection
|
|
||||||
currentBranch={status?.current}
|
|
||||||
localBranches={localBranches}
|
|
||||||
remoteBranches={remoteBranches}
|
|
||||||
onMerge={onMerge}
|
|
||||||
onRebase={onRebase}
|
|
||||||
disabled={isBusy}
|
|
||||||
isOperating={branchOperation !== null}
|
|
||||||
operationLogs={operationLogs}
|
|
||||||
onOperationComplete={onOperationComplete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{onOpenBranchPicker ? (
|
{onOpenBranchPicker ? (
|
||||||
@@ -297,13 +271,30 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
onClick={onOpenBranchPicker}
|
onClick={onOpenBranchPicker}
|
||||||
>
|
>
|
||||||
<RiGitRepositoryLine className="size-4" />
|
<RiGitRepositoryLine className="size-4" />
|
||||||
<span className="hidden sm:inline">Manage Branches</span>
|
<span className="hidden sm:inline">Manage branches</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
|
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{onOpenHistory ? (
|
||||||
|
<Tooltip delayDuration={1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
|
||||||
|
onClick={onOpenHistory}
|
||||||
|
>
|
||||||
|
<RiHistoryLine className="size-4" />
|
||||||
|
<span className="hidden sm:inline">History</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={8}>Show commit history</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<IdentityDropdown
|
<IdentityDropdown
|
||||||
activeProfile={activeIdentityProfile}
|
activeProfile={activeIdentityProfile}
|
||||||
identities={availableIdentities}
|
identities={availableIdentities}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface HistorySectionProps {
|
|||||||
commitFilesMap: Map<string, CommitFileEntry[]>;
|
commitFilesMap: Map<string, CommitFileEntry[]>;
|
||||||
loadingCommitHashes: Set<string>;
|
loadingCommitHashes: Set<string>;
|
||||||
onCopyHash: (hash: string) => void;
|
onCopyHash: (hash: string) => void;
|
||||||
|
showHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistorySection: React.FC<HistorySectionProps> = ({
|
export const HistorySection: React.FC<HistorySectionProps> = ({
|
||||||
@@ -44,6 +45,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
|||||||
commitFilesMap,
|
commitFilesMap,
|
||||||
loadingCommitHashes,
|
loadingCommitHashes,
|
||||||
onCopyHash,
|
onCopyHash,
|
||||||
|
showHeader = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(true);
|
const [isOpen, setIsOpen] = React.useState(true);
|
||||||
|
|
||||||
@@ -51,6 +53,40 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<ScrollableOverlay outerClassName="min-h-0 max-h-[50vh]" className="w-full">
|
||||||
|
{log.all.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
|
<p className="typography-ui-label text-muted-foreground">
|
||||||
|
No commits found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border/60">
|
||||||
|
{log.all.map((entry) => (
|
||||||
|
<HistoryCommitRow
|
||||||
|
key={entry.hash}
|
||||||
|
entry={entry}
|
||||||
|
isExpanded={expandedCommitHashes.has(entry.hash)}
|
||||||
|
onToggle={() => onToggleCommit(entry.hash)}
|
||||||
|
files={commitFilesMap.get(entry.hash) ?? []}
|
||||||
|
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
|
||||||
|
onCopyHash={onCopyHash}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ScrollableOverlay>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showHeader) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
|
||||||
|
{content}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
@@ -95,31 +131,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>{content}</CollapsibleContent>
|
||||||
<ScrollableOverlay outerClassName="min-h-0 max-h-[50vh]" className="w-full">
|
|
||||||
{log.all.length === 0 ? (
|
|
||||||
<div className="flex h-full items-center justify-center p-4">
|
|
||||||
<p className="typography-ui-label text-muted-foreground">
|
|
||||||
No commits found
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-border/60">
|
|
||||||
{log.all.map((entry) => (
|
|
||||||
<HistoryCommitRow
|
|
||||||
key={entry.hash}
|
|
||||||
entry={entry}
|
|
||||||
isExpanded={expandedCommitHashes.has(entry.hash)}
|
|
||||||
onToggle={() => onToggleCommit(entry.hash)}
|
|
||||||
files={commitFilesMap.get(entry.hash) ?? []}
|
|
||||||
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
|
|
||||||
onCopyHash={onCopyHash}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</ScrollableOverlay>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -52,6 +46,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
defaultTargetBranch: string;
|
defaultTargetBranch: string;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
|
variant?: 'framed' | 'plain';
|
||||||
}> = ({
|
}> = ({
|
||||||
repoRoot,
|
repoRoot,
|
||||||
sourceBranch,
|
sourceBranch,
|
||||||
@@ -60,10 +55,10 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
defaultTargetBranch,
|
defaultTargetBranch,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
variant = 'framed',
|
||||||
}) => {
|
}) => {
|
||||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||||
const [isOpen, setIsOpen] = React.useState(true);
|
|
||||||
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
||||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -250,7 +245,7 @@ Important:
|
|||||||
|
|
||||||
// Use current session - set pending input text and synthetic parts
|
// Use current session - set pending input text and synthetic parts
|
||||||
if (!currentSessionId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +276,7 @@ Important:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.kind === 'conflict') {
|
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 });
|
setUi({ kind: 'conflict', state: result.state, details: result.details });
|
||||||
if (conflictStorageKey && typeof window !== 'undefined') {
|
if (conflictStorageKey && typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state));
|
window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state));
|
||||||
@@ -342,13 +337,19 @@ Important:
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<Collapsible
|
<section className={containerClassName}>
|
||||||
open={isOpen}
|
<div className={headerClassName}>
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<RiSplitCellsHorizontal className="size-4 text-muted-foreground" />
|
<RiSplitCellsHorizontal className="size-4 text-muted-foreground" />
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground truncate">Re-integrate commits</h3>
|
<h3 className="typography-ui-header font-semibold text-foreground truncate">Re-integrate commits</h3>
|
||||||
@@ -361,14 +362,12 @@ Important:
|
|||||||
<RiLoader4Line className="size-4 animate-spin text-muted-foreground" />
|
<RiLoader4Line className="size-4 animate-spin text-muted-foreground" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</div>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<div className={bodyClassName}>
|
||||||
<div className="border-t border-border/40">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="typography-ui-label text-foreground">Move commits</div>
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="typography-ui-label text-foreground">Move commits</div>
|
|
||||||
<div className="typography-micro text-muted-foreground truncate">
|
<div className="typography-micro text-muted-foreground truncate">
|
||||||
{sourceBranch} → {targetBranch}
|
{sourceBranch} → {targetBranch}
|
||||||
</div>
|
</div>
|
||||||
@@ -519,9 +518,7 @@ Important:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
RiCheckboxBlankLine,
|
RiCheckboxBlankLine,
|
||||||
RiCheckboxLine,
|
RiCheckboxLine,
|
||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
|
RiGitClosePullRequestLine,
|
||||||
|
RiGitMergeLine,
|
||||||
|
RiGitPrDraftLine,
|
||||||
RiGitPullRequestLine,
|
RiGitPullRequestLine,
|
||||||
RiLoader4Line,
|
RiLoader4Line,
|
||||||
} from '@remixicon/react';
|
} 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 => {
|
const branchToTitle = (branch: string): string => {
|
||||||
return branch
|
return branch
|
||||||
.replace(/^refs\/heads\//, '')
|
.replace(/^refs\/heads\//, '')
|
||||||
@@ -67,7 +92,6 @@ type PullRequestDraftSnapshot = {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
isOpen: boolean;
|
|
||||||
additionalContext: string;
|
additionalContext: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,7 +127,8 @@ export const PullRequestSection: React.FC<{
|
|||||||
directory: string;
|
directory: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
}> = ({ directory, branch, baseBranch }) => {
|
variant?: 'framed' | 'plain';
|
||||||
|
}> = ({ directory, branch, baseBranch, variant = 'framed' }) => {
|
||||||
const { github } = useRuntimeAPIs();
|
const { github } = useRuntimeAPIs();
|
||||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||||
@@ -124,7 +149,6 @@ export const PullRequestSection: React.FC<{
|
|||||||
[snapshotKey]
|
[snapshotKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true);
|
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
|
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
@@ -401,7 +425,6 @@ export const PullRequestSection: React.FC<{
|
|||||||
setTitle(snapshot?.title ?? branchToTitle(branch));
|
setTitle(snapshot?.title ?? branchToTitle(branch));
|
||||||
setBody(snapshot?.body ?? '');
|
setBody(snapshot?.body ?? '');
|
||||||
setDraft(snapshot?.draft ?? false);
|
setDraft(snapshot?.draft ?? false);
|
||||||
setIsOpen(snapshot?.isOpen ?? true);
|
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [branch, refresh, snapshotKey]);
|
}, [branch, refresh, snapshotKey]);
|
||||||
|
|
||||||
@@ -420,10 +443,9 @@ export const PullRequestSection: React.FC<{
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
draft,
|
draft,
|
||||||
isOpen,
|
|
||||||
additionalContext,
|
additionalContext,
|
||||||
});
|
});
|
||||||
}, [snapshotKey, title, body, draft, isOpen, additionalContext, directory, branch]);
|
}, [snapshotKey, title, body, draft, additionalContext, directory, branch]);
|
||||||
|
|
||||||
const generateDescription = React.useCallback(async () => {
|
const generateDescription = React.useCallback(async () => {
|
||||||
if (isGenerating) return;
|
if (isGenerating) return;
|
||||||
@@ -538,16 +560,31 @@ export const PullRequestSection: React.FC<{
|
|||||||
const canMerge = Boolean(status?.canMerge);
|
const canMerge = Boolean(status?.canMerge);
|
||||||
const isConnected = Boolean(status?.connected);
|
const isConnected = Boolean(status?.connected);
|
||||||
const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false;
|
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 (
|
return (
|
||||||
<Collapsible
|
<section className={containerClassName}>
|
||||||
open={isOpen}
|
<div className={headerClassName}>
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<RiGitPullRequestLine className="size-4 text-muted-foreground" />
|
<PrStateIcon className="size-4 shrink-0" style={{ color: pr ? prColorVar : 'var(--surface-muted-foreground)' }} />
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3>
|
<h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3>
|
||||||
{pr ? (
|
{pr ? (
|
||||||
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
|
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
|
||||||
@@ -562,16 +599,14 @@ export const PullRequestSection: React.FC<{
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</div>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<div className={bodyClassName}>
|
||||||
<div className="border-t border-border/40">
|
{shouldShowConnectionNotice ? (
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="space-y-2">
|
||||||
{shouldShowConnectionNotice ? (
|
<div className="typography-meta text-muted-foreground">
|
||||||
<div className="space-y-2">
|
GitHub not connected. Connect your GitHub account in settings.
|
||||||
<div className="typography-meta text-muted-foreground">
|
</div>
|
||||||
GitHub not connected. Connect your GitHub account in settings.
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit">
|
<Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit">
|
||||||
Open settings
|
Open settings
|
||||||
</Button>
|
</Button>
|
||||||
@@ -599,30 +634,43 @@ export const PullRequestSection: React.FC<{
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="typography-ui-label text-foreground truncate">{pr.title}</div>
|
<div className="typography-ui-label text-foreground truncate">{pr.title}</div>
|
||||||
<div className="typography-micro text-muted-foreground truncate">
|
<div className="typography-micro text-muted-foreground truncate">
|
||||||
{pr.state}{pr.draft ? ' (draft)' : ''}
|
<span style={{ color: prColorVar }}>
|
||||||
|
{pr.state}{pr.draft ? ' (draft)' : ''}
|
||||||
|
</span>
|
||||||
{pr.mergeable === false ? ' · not mergeable' : ''}
|
{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}`
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 space-y-2">
|
||||||
{checks ? (
|
<div className="flex flex-col sm:flex-row items-stretch gap-2">
|
||||||
|
{checks ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={openChecksDialog}
|
||||||
|
disabled={isLoadingCheckDetails}
|
||||||
|
className="justify-center sm:flex-1"
|
||||||
|
>
|
||||||
|
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
||||||
|
Check details
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={openChecksDialog}
|
onClick={sendCommentsToChat}
|
||||||
disabled={isLoadingCheckDetails}
|
className={checks ? 'justify-center sm:flex-1' : 'justify-center'}
|
||||||
>
|
>
|
||||||
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
Send PR comments to chat
|
||||||
Check details
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
{checks?.failure ? (
|
{checks?.failure ? (
|
||||||
<Button variant="outline" size="sm" onClick={sendFailedChecksToChat}>
|
<Button variant="outline" size="sm" onClick={sendFailedChecksToChat} className="w-full justify-center">
|
||||||
Send failed checks to chat
|
Send failed checks to chat
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button variant="outline" size="sm" onClick={sendCommentsToChat}>
|
|
||||||
Send PR comments to chat
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{canMerge && pr.draft ? (
|
{canMerge && pr.draft ? (
|
||||||
<div className="typography-micro text-muted-foreground">
|
<div className="typography-micro text-muted-foreground">
|
||||||
@@ -843,12 +891,10 @@ export const PullRequestSection: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
|
|
||||||
<Dialog open={checksDialogOpen} onOpenChange={setChecksDialogOpen}>
|
<Dialog open={checksDialogOpen} onOpenChange={setChecksDialogOpen}>
|
||||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col min-h-0">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<RiGitPullRequestLine className="h-5 w-5" />
|
<RiGitPullRequestLine className="h-5 w-5" />
|
||||||
@@ -859,7 +905,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto mt-2">
|
<div className="flex-1 min-h-0 overflow-y-auto mt-2">
|
||||||
{isLoadingCheckDetails ? (
|
{isLoadingCheckDetails ? (
|
||||||
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
||||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
@@ -890,6 +936,6 @@ export const PullRequestSection: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Collapsible>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const StashDialog: React.FC<StashDialogProps> = ({
|
|||||||
<DialogTitle>Uncommitted Changes</DialogTitle>
|
<DialogTitle>Uncommitted Changes</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
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?
|
Would you like to stash them temporarily?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -72,7 +72,11 @@ export const StashDialog: React.FC<StashDialogProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
<ol className="list-decimal list-inside space-y-1 typography-meta text-foreground">
|
<ol className="list-decimal list-inside space-y-1 typography-meta text-foreground">
|
||||||
<li>Stash your uncommitted changes</li>
|
<li>Stash your uncommitted changes</li>
|
||||||
<li>{operationLabel} <span className="font-mono text-primary">{targetBranch}</span></li>
|
<li>
|
||||||
|
{operation === 'merge' ? 'Merge' : 'Rebase'}{' '}
|
||||||
|
{operation === 'merge' ? 'with' : 'onto'}{' '}
|
||||||
|
<span className="font-mono text-primary">{targetBranch}</span>
|
||||||
|
</li>
|
||||||
{restoreAfter && <li>Restore your stashed changes</li>}
|
{restoreAfter && <li>Restore your stashed changes</li>}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +92,7 @@ export const StashDialog: React.FC<StashDialogProps> = ({
|
|||||||
className="typography-ui-label text-foreground cursor-pointer select-none"
|
className="typography-ui-label text-foreground cursor-pointer select-none"
|
||||||
onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)}
|
onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)}
|
||||||
>
|
>
|
||||||
Restore changes after {operation}
|
Restore changes after the {operation}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -138,38 +138,55 @@ export const getPullRequestStatus = async (
|
|||||||
return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false };
|
return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const listUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
const listNumberByHead = async (state: 'open' | 'closed'): Promise<number | null> => {
|
||||||
listUrl.searchParams.set('state', 'open');
|
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||||
listUrl.searchParams.set('head', `${repo.owner}:${branch}`);
|
url.searchParams.set('state', state);
|
||||||
listUrl.searchParams.set('per_page', '10');
|
url.searchParams.set('head', `${repo.owner}:${branch}`);
|
||||||
|
url.searchParams.set('per_page', '10');
|
||||||
|
|
||||||
const listResp = await githubFetch(listUrl.toString(), accessToken);
|
const resp = await githubFetch(url.toString(), accessToken);
|
||||||
if (listResp.status === 401) {
|
if (resp.status === 401) {
|
||||||
return { connected: false };
|
return null;
|
||||||
}
|
|
||||||
const list = await jsonOrNull<Array<{ number: number }>>(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 openList = await jsonOrNull<Array<JsonRecord>>(openResp);
|
const list = await jsonOrNull<Array<{ number: number }>>(resp);
|
||||||
if (openResp.ok && Array.isArray(openList)) {
|
return (resp.ok && Array.isArray(list) && list.length > 0) ? list[0].number : null;
|
||||||
const match = openList.find((prItem) => {
|
};
|
||||||
const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null;
|
|
||||||
return readString(head?.ref) === branch;
|
const listNumberByHeadRef = async (state: 'open' | 'closed'): Promise<number | null> => {
|
||||||
});
|
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||||
if (match && typeof match.number === 'number') {
|
url.searchParams.set('state', state);
|
||||||
number = match.number;
|
url.searchParams.set('per_page', '100');
|
||||||
}
|
const resp = await githubFetch(url.toString(), accessToken);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const list = await jsonOrNull<Array<JsonRecord>>(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');
|
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 prState = readString(prJson.state);
|
||||||
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
|
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
|
||||||
const pr: GitHubPullRequest = {
|
const pr: GitHubPullRequest = {
|
||||||
|
|||||||
@@ -5844,31 +5844,38 @@ async function main(options = {}) {
|
|||||||
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false });
|
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find PR for this branch (same-repo assumption)
|
const listByHead = async (state) => {
|
||||||
const list = await octokit.rest.pulls.list({
|
const resp = await octokit.rest.pulls.list({
|
||||||
owner: repo.owner,
|
owner: repo.owner,
|
||||||
repo: repo.repo,
|
repo: repo.repo,
|
||||||
state: 'open',
|
state,
|
||||||
head: `${repo.owner}:${branch}`,
|
head: `${repo.owner}:${branch}`,
|
||||||
per_page: 10,
|
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,
|
// PR status by branch:
|
||||||
// fall back to listing open PRs and matching by head ref name.
|
// - Prefer open PRs.
|
||||||
if (!first) {
|
// - If none, also surface closed/merged PRs.
|
||||||
const openList = await octokit.rest.pulls.list({
|
// - Fork PR support: head owner != base owner -> head filter yields empty; fall back to matching head.ref.
|
||||||
owner: repo.owner,
|
let first = await listByHead('open');
|
||||||
repo: repo.repo,
|
if (!first) first = await listByHead('closed');
|
||||||
state: 'open',
|
if (!first) first = await listByHeadRef('open');
|
||||||
per_page: 100,
|
if (!first) first = await listByHeadRef('closed');
|
||||||
});
|
|
||||||
const matches = Array.isArray(openList?.data)
|
|
||||||
? openList.data.filter((pr) => pr?.head?.ref === branch)
|
|
||||||
: [];
|
|
||||||
first = matches[0] ?? null;
|
|
||||||
}
|
|
||||||
if (!first) {
|
if (!first) {
|
||||||
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
|
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
|
||||||
}
|
}
|
||||||
@@ -5964,7 +5971,8 @@ async function main(options = {}) {
|
|||||||
canMerge = false;
|
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({
|
return res.json({
|
||||||
connected: true,
|
connected: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user