Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .pnpm-store/v11/index.db
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,33 @@
pointer-events: none;
}

&--create {
border-bottom: 1px solid var(--border-subtle);

.branch-quick-switch__item-icon--create {
color: var(--color-accent-500);
}

.branch-quick-switch__item-name strong {
color: var(--color-accent-500);
font-weight: 600;
}

&:hover,
&.branch-quick-switch__item--selected {
background: color-mix(in srgb, var(--color-accent-500) 8%, transparent);
}
}

&:active:not(.branch-quick-switch__item--current) {
transform: scale(0.985);
}
}

.branch-quick-switch__item-icon { flex-shrink: 0; color: var(--color-text-primary); }
.branch-quick-switch__item-name { flex: 1; font-size: var(--font-size-xs); color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.branch-quick-switch__item-name { flex: 1; font-size: var(--font-size-xs); color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
strong { font-weight: 500; }
}
.branch-quick-switch__item-check { flex-shrink: 0; color: var(--color-text-primary); }

.branch-quick-switch__spinner {
Expand Down
143 changes: 110 additions & 33 deletions src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/**
* Branch quick switch overlay.
* Shown when clicking the branch badge in NavPanel Git item.
* Supports search and checkout.
* Shown when clicking the branch badge in NavPanel Git item
* or the branch chip in ChatInputWorkspaceStrip.
* Supports search, checkout, and creating new branches.
*/

import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { GitBranch, Check, Loader2 } from 'lucide-react';
import { GitBranch, Check, Loader2, Plus } from 'lucide-react';
import { type GitBranch as GitBranchType } from '../../../../infrastructure/api/service-api/GitAPI';
import { useI18n } from '@/infrastructure/i18n';
import { gitService, gitEventService } from '../../../../tools/git/services';
Expand All @@ -23,6 +24,7 @@ export interface BranchQuickSwitchProps {
currentBranch: string;
anchorRef: React.RefObject<HTMLElement>;
onSwitchSuccess?: (branchName: string) => void;
onCreateSuccess?: (branchName: string) => void;
}

export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
Expand All @@ -31,14 +33,17 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
repositoryPath,
currentBranch,
anchorRef,
onSwitchSuccess
onSwitchSuccess,
onCreateSuccess
}) => {
const { t } = useI18n('panels/git');
const [branches, setBranches] = useState<GitBranchType[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [switchingBranch, setSwitchingBranch] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [creatingBranch, setCreatingBranch] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 });

Expand Down Expand Up @@ -157,10 +162,19 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
});
}, [branches, searchTerm]);

useEffect(() => { setSelectedIndex(0); }, [filteredBranches.length]);
const canCreateNewBranch = useMemo(() => {
const trimmed = searchTerm.trim();
if (!trimmed) return false;
return !branches.some(b => b.name.toLowerCase() === trimmed.toLowerCase());
}, [branches, searchTerm]);

const branchStartIndex = canCreateNewBranch ? 1 : 0;
const totalItems = filteredBranches.length + (canCreateNewBranch ? 1 : 0);

useEffect(() => { setSelectedIndex(0); }, [filteredBranches.length, canCreateNewBranch]);

const handleSwitchBranch = useCallback(async (branchName: string) => {
if (branchName === currentBranch || isSwitching) return;
if (branchName === currentBranch || isSwitching || isCreating) return;
setIsSwitching(true);
setSwitchingBranch(branchName);
try {
Expand Down Expand Up @@ -192,35 +206,75 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
setIsSwitching(false);
setSwitchingBranch(null);
}
}, [repositoryPath, currentBranch, isSwitching, onSwitchSuccess, onClose, t]);
}, [repositoryPath, currentBranch, isSwitching, isCreating, onSwitchSuccess, onClose, t]);

const handleCreateBranch = useCallback(async (branchName: string) => {
if (isCreating || isSwitching) return;
const trimmed = branchName.trim();
if (!trimmed) return;
setIsCreating(true);
setCreatingBranch(trimmed);
try {
const result = await gitService.createBranch(repositoryPath, trimmed, currentBranch || undefined);
if (result.success) {
notificationService.success(
t('quickSwitch.notifications.switchSuccess', { branch: trimmed }),
{ duration: 3000 }
);
gitEventService.emit('branch:changed', {
repositoryPath,
branch: { name: trimmed, current: true, remote: false, ahead: 0, behind: 0 },
timestamp: new Date(),
});
onCreateSuccess?.(trimmed);
onClose();
} else {
const errorMessage = result.error
? t('quickSwitch.errors.switchFailedWithMessage', { error: result.error })
: t('quickSwitch.errors.createFailed');
notificationService.error(errorMessage, { title: t('quickSwitch.errors.title'), duration: 5000 });
}
} catch (error) {
log.error('Failed to create branch', error);
notificationService.error(t('quickSwitch.errors.unexpected'), { duration: 5000 });
} finally {
setIsCreating(false);
setCreatingBranch(null);
}
}, [repositoryPath, currentBranch, isCreating, isSwitching, onCreateSuccess, onClose, t]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (filteredBranches.length === 0) return;
if (totalItems === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => prev < filteredBranches.length - 1 ? prev + 1 : prev);
setSelectedIndex(prev => prev < totalItems - 1 ? prev + 1 : prev);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : prev);
break;
case 'Enter': {
e.preventDefault();
const sel = filteredBranches[selectedIndex];
if (sel && !sel.current) handleSwitchBranch(sel.name);
if (canCreateNewBranch && selectedIndex === 0) {
handleCreateBranch(searchTerm.trim());
} else {
const branchIndex = selectedIndex - branchStartIndex;
const sel = filteredBranches[branchIndex];
if (sel && !sel.current) handleSwitchBranch(sel.name);
}
break;
}
}
}, [filteredBranches, selectedIndex, handleSwitchBranch]);
}, [totalItems, canCreateNewBranch, selectedIndex, branchStartIndex, searchTerm, filteredBranches, handleSwitchBranch, handleCreateBranch]);

useEffect(() => {
if (listRef.current && filteredBranches.length > 0) {
if (listRef.current && totalItems > 0) {
const items = listRef.current.querySelectorAll('.branch-quick-switch__item');
const selectedItem = items[selectedIndex] as HTMLElement;
if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex, filteredBranches.length]);
}, [selectedIndex, totalItems]);

if (!isOpen) return null;

Expand All @@ -247,29 +301,52 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
<Loader2 size={16} className="branch-quick-switch__spinner" />
<span>{t('quickSwitch.loading')}</span>
</div>
) : filteredBranches.length === 0 ? (
) : totalItems === 0 ? (
<div className="branch-quick-switch__empty">
{searchTerm ? t('empty.noMatchingBranches') : t('empty.noBranches')}
</div>
) : (
filteredBranches.map((branch, index) => (
<div
key={branch.name}
className={[
'branch-quick-switch__item',
branch.current && 'branch-quick-switch__item--current',
index === selectedIndex && 'branch-quick-switch__item--selected',
switchingBranch === branch.name && 'branch-quick-switch__item--switching',
].filter(Boolean).join(' ')}
onClick={() => handleSwitchBranch(branch.name)}
onMouseEnter={() => setSelectedIndex(index)}
>
<GitBranch size={14} className="branch-quick-switch__item-icon" />
<span className="branch-quick-switch__item-name">{branch.name}</span>
{branch.current && <Check size={14} className="branch-quick-switch__item-check" />}
{switchingBranch === branch.name && <Loader2 size={14} className="branch-quick-switch__spinner" />}
</div>
))
<>
{canCreateNewBranch && (
<div
className={[
'branch-quick-switch__item',
'branch-quick-switch__item--create',
selectedIndex === 0 && 'branch-quick-switch__item--selected',
creatingBranch === searchTerm.trim() && 'branch-quick-switch__item--switching',
].filter(Boolean).join(' ')}
onClick={() => handleCreateBranch(searchTerm.trim())}
onMouseEnter={() => setSelectedIndex(0)}
>
<Plus size={14} className="branch-quick-switch__item-icon branch-quick-switch__item-icon--create" />
<span className="branch-quick-switch__item-name">
{t('quickSwitch.createBranchLabel')} <strong>{searchTerm.trim()}</strong>
</span>
{creatingBranch === searchTerm.trim() && <Loader2 size={14} className="branch-quick-switch__spinner" />}
</div>
)}
{filteredBranches.map((branch, index) => {
const itemIndex = index + branchStartIndex;
return (
<div
key={branch.name}
className={[
'branch-quick-switch__item',
branch.current && 'branch-quick-switch__item--current',
itemIndex === selectedIndex && 'branch-quick-switch__item--selected',
switchingBranch === branch.name && 'branch-quick-switch__item--switching',
].filter(Boolean).join(' ')}
onClick={() => handleSwitchBranch(branch.name)}
onMouseEnter={() => setSelectedIndex(itemIndex)}
>
<GitBranch size={14} className="branch-quick-switch__item-icon" />
<span className="branch-quick-switch__item-name">{branch.name}</span>
{branch.current && <Check size={14} className="branch-quick-switch__item-check" />}
{switchingBranch === branch.name && <Loader2 size={14} className="branch-quick-switch__spinner" />}
</div>
);
})}
</>
)}
</div>
</div>
Expand Down
15 changes: 15 additions & 0 deletions src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,21 @@
gap: 3px;
}

&--branch-clickable {
cursor: pointer;
transition: background-color $motion-fast $easing-standard, color $motion-fast $easing-standard;

&:hover {
background: var(--element-bg-medium);
color: var(--color-text-secondary);
}

&:focus-visible {
outline: 2px solid var(--color-accent-500);
outline-offset: 1px;
}
}

/* Checkbox-style chip: reads as a control without competing with the branch. */
&--worktree {
align-items: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ vi.mock('@/tools/git/hooks/useGitState', () => ({
useGitState: mocks.useGitState,
}));

vi.mock('@/app/components/NavPanel/components/BranchQuickSwitch', () => ({
BranchQuickSwitch: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
isOpen ? <div data-testid="branch-quick-switch-mock" onClick={onClose} /> : null,
}));

describe('ChatInputWorkspaceStrip git refresh behavior', () => {
let container: HTMLDivElement;
let root: Root;
Expand Down Expand Up @@ -316,4 +321,56 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => {

expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).toBeNull();
});

it('opens branch switch panel when branch chip is clicked', async () => {
await act(async () => {
root.render(
<ChatInputWorkspaceStrip
repositoryPath="D:/workspace/BitFun"
workspaceLabel="BitFun"
/>
);
});

const branchChip = container.querySelector<HTMLElement>('[data-testid="chat-input-branch-chip"]');
expect(branchChip).not.toBeNull();
expect(branchChip?.getAttribute('role')).toBe('button');
expect(branchChip?.tabIndex).toBe(0);
expect(container.querySelector('[data-testid="branch-quick-switch-mock"]')).toBeNull();

await act(async () => {
branchChip?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(document.querySelector('[data-testid="branch-quick-switch-mock"]')).not.toBeNull();

await act(async () => {
document
.querySelector<HTMLElement>('[data-testid="branch-quick-switch-mock"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(document.querySelector('[data-testid="branch-quick-switch-mock"]')).toBeNull();
});

it('does not expose branch click when repository is unavailable', async () => {
mocks.useGitState.mockReturnValue({
currentBranch: '',
isRepository: false,
});

await act(async () => {
root.render(
<ChatInputWorkspaceStrip
repositoryPath="D:/workspace/BitFun"
workspaceLabel="BitFun"
/>
);
});

const branchChip = container.querySelector<HTMLElement>('[data-testid="chat-input-branch-chip"]');
expect(branchChip?.getAttribute('role')).toBeNull();
expect(branchChip?.tabIndex).toBe(-1);
expect(container.querySelector('[data-testid="branch-quick-switch-mock"]')).toBeNull();
});
});
Loading