Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,17 @@
background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard);
}

.option:not(:disabled):is(:hover, [data-active="true"]) {
/* Transient interaction must not override the persistent selected state. */
.option:where(:not(:disabled):is(:hover, [data-active="true"])) {
background: var(--openbitfun-color-action-neutral-surface);
}

.option[data-selected="true"] {
background: var(--openbitfun-color-selection-surface);
.option:where(:not(:disabled):active) {
background: var(--openbitfun-color-action-neutral-surface-pressed);
}

.option:not(:disabled):active {
background: var(--openbitfun-color-action-neutral-surface-pressed);
.option[data-selected="true"] {
background: var(--openbitfun-color-selection-surface);
}

.option:focus-visible {
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/locales/en-US/panels/git.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@
"current": "Current"
},
"quickSwitch": {
"refresh": "Refresh branches",
"menuLabel": "Switch branch",
"searchLabel": "Search branches",
"searchPlaceholder": "Search branches...",
Expand Down Expand Up @@ -225,6 +226,7 @@
},
"errors": {
"title": "Branch switch failed",
"refreshFailed": "Could not refresh branches. Showing the previous list.",
"loadFailed": "Could not load branches",
"switchFailed": "Failed to switch branch",
"switchFailedWithMessage": "Failed to switch branch: {{error}}",
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/locales/zh-CN/panels/git.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@
"current": "当前"
},
"quickSwitch": {
"refresh": "刷新分支",
"menuLabel": "切换分支",
"searchLabel": "搜索分支",
"searchPlaceholder": "搜索分支...",
Expand Down Expand Up @@ -225,6 +226,7 @@
},
"errors": {
"title": "分支切换失败",
"refreshFailed": "刷新分支失败,当前显示上次的列表。",
"loadFailed": "无法加载分支列表",
"switchFailed": "无法切换分支",
"switchFailedWithMessage": "无法切换分支:{{error}}",
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/locales/zh-TW/panels/git.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@
"current": "目前"
},
"quickSwitch": {
"refresh": "重新整理分支",
"menuLabel": "切換分支",
"searchLabel": "搜尋分支",
"searchPlaceholder": "搜尋分支...",
Expand Down Expand Up @@ -225,6 +226,7 @@
},
"errors": {
"title": "分支切換失敗",
"refreshFailed": "重新整理分支失敗,目前顯示上次的清單。",
"loadFailed": "無法載入分支清單",
"switchFailed": "無法切換分支",
"switchFailedWithMessage": "無法切換分支:{{error}}",
Expand Down
11 changes: 10 additions & 1 deletion src/web-ui/src/tools/git/components/BranchQuickSwitch.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,21 @@
}

.branch-quick-switch__search {
display: flex;
align-items: center;
gap: 4px;
padding: 8px;
border-bottom: 1px solid var(--openbitfun-color-border-subtle);
}

.branch-quick-switch__input-field {
width: 100%;
flex: 1;
min-width: 0;
}

.branch-quick-switch__error {
padding: 8px;
color: var(--openbitfun-color-status-danger-content);
}

.branch-quick-switch__list {
Expand Down
88 changes: 87 additions & 1 deletion src/web-ui/src/tools/git/components/BranchQuickSwitch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ vi.mock('@/infrastructure/i18n', () => ({
'quickSwitch.conflict.retrySwitchAction': 'Retry switch',
'quickSwitch.conflict.title': 'Commit changes to switch branch',
'quickSwitch.menuLabel': 'Switch branch',
'quickSwitch.refresh': 'Refresh branches',
'quickSwitch.searchLabel': 'Search branches',
};
return labels[key] ?? key;
Expand Down Expand Up @@ -219,17 +220,102 @@ describe('BranchQuickSwitch', () => {
expect(document.activeElement).toBe(trigger);
});

it('closes on Tab without cancelling the page tab sequence', async () => {
it('tabs from search to refresh before resuming the page tab sequence', async () => {
await act(async () => root.render(<Harness />));
const search = document.querySelector<HTMLInputElement>('input[type="search"]')!;
const trigger = container.querySelector<HTMLButtonElement>('[data-testid="branch-trigger"]')!;
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
act(() => search.dispatchEvent(event));
expect(event.defaultPrevented).toBe(false);
expect(trigger.getAttribute('aria-expanded')).toBe('true');
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh branches"]')!;
act(() => {
refresh.focus();
refresh.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
});
expect(trigger.getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(trigger);
});

it('refreshes the scoped branch list without closing or clearing search', async () => {
await act(async () => root.render(<Harness />));
const search = document.querySelector<HTMLInputElement>('input[type="search"]')!;
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(search, 'feature');
search.dispatchEvent(new Event('input', { bubbles: true }));
});
let resolve!: (value: typeof branches) => void;
mocks.getBranches.mockReturnValueOnce(new Promise<typeof branches>(done => { resolve = done; }));
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh branches"]')!;
act(() => refresh.click());
expect(refresh.disabled).toBe(true);
expect(refresh.getAttribute('aria-busy')).toBe('true');
expect(mocks.getBranches).toHaveBeenLastCalledWith(
{ workspaceId: 'workspace-1' }, true, { throwOnError: true },
);
act(() => refresh.click());
expect(mocks.getBranches).toHaveBeenCalledTimes(2);
await act(async () => resolve([...branches, { ...branches[1], name: 'feature-new' }]));
expect(search.value).toBe('feature');
expect(document.querySelector('[data-testid="branch-quick-switch-option-feature-new"]')).not.toBeNull();
expect(document.querySelector('[data-testid="branch-quick-switch-option-main"]')).toBeNull();
expect(refresh.disabled).toBe(false);
expect(container.querySelector('[data-testid="branch-trigger"]')?.getAttribute('aria-expanded')).toBe('true');
});

it('keeps the previous list on refresh failure and allows retry', async () => {
await act(async () => root.render(<Harness />));
mocks.getBranches.mockRejectedValueOnce(new Error('Host offline'));
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh branches"]')!;
await act(async () => refresh.click());
expect(document.querySelector('[data-testid="branch-quick-switch-option-feature"]')).not.toBeNull();
expect(document.querySelector('[role="status"]')?.textContent).toBe('quickSwitch.errors.refreshFailed');
expect(refresh.disabled).toBe(false);
await act(async () => refresh.click());
expect(document.querySelector('[role="status"]')).toBeNull();
});

it('reports initial load failure instead of presenting an empty repository', async () => {
mocks.getState.mockReturnValue(undefined);
mocks.getBranches.mockRejectedValueOnce(new Error('Host offline'));
await act(async () => root.render(<Harness />));
expect(document.querySelector('[role="status"]')?.textContent).toBe('quickSwitch.errors.loadFailed');
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh branches"]')!;
await act(async () => refresh.click());
expect(document.querySelector('[data-testid="branch-quick-switch-option-main"]')).not.toBeNull();
});

it('does not check out the keyboard-highlighted branch when Enter activates refresh', async () => {
await act(async () => root.render(<Harness />));
const search = document.querySelector<HTMLInputElement>('input[type="search"]')!;
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh branches"]')!;
act(() => search.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })));
await act(async () => {
refresh.focus();
refresh.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
refresh.click();
});
expect(mocks.checkoutBranch).not.toHaveBeenCalled();
expect(mocks.getBranches).toHaveBeenCalledTimes(2);
});

it('ignores a previous workspace request after switching workspaces', async () => {
const anchorRef = { current: document.createElement('button') };
let resolveOld!: (value: typeof branches) => void;
mocks.getBranches.mockReturnValueOnce(new Promise<typeof branches>(done => { resolveOld = done; }));
await act(async () => root.render(<BranchQuickSwitch isOpen onClose={vi.fn()}
repositoryPath={{ workspaceId: 'old', repositoryPath: '/remote/old' }} currentBranch="main" anchorRef={anchorRef} />));
mocks.getBranches.mockResolvedValueOnce([{ ...branches[0], name: 'new-main' }]);
await act(async () => root.render(<BranchQuickSwitch isOpen onClose={vi.fn()}
repositoryPath={{ workspaceId: 'new', repositoryPath: '/remote/new' }} currentBranch="new-main" anchorRef={anchorRef} />));
await act(async () => resolveOld(branches));
expect(document.querySelector('[data-testid="branch-quick-switch-option-new-main"]')).not.toBeNull();
expect(document.querySelector('[data-testid="branch-quick-switch-option-main"]')).toBeNull();
expect(mocks.getBranches).toHaveBeenLastCalledWith(
{ workspaceId: 'new', repositoryPath: '/remote/new' }, true, { throwOnError: true },
);
});

describe('motion lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down
58 changes: 35 additions & 23 deletions src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import React, {
import { createOverlayPortal, OverflowText,
Button,
Icon,
IconButton,
Input,
Listbox,
ListboxEmpty,
Expand Down Expand Up @@ -104,6 +105,8 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
const panelRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const switchInFlightRef = useRef(false);
const branchRequestRef = useRef(0);
const refreshButtonRef = useRef<HTMLButtonElement>(null);

// The "current" marker comes from the shared branch (passed in by the
// composer strip) rather than from the fetched list's own flag, so a switch
Expand Down Expand Up @@ -164,33 +167,20 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
}, [focusReady]);

const loadBranches = useCallback(async () => {
const request = ++branchRequestRef.current;
setIsLoading(true);
setLoadFailed(false);
try {
const cachedBranches = branchListFromCache(repositoryPath);
if (cachedBranches?.length) setBranches(cachedBranches);

await gitStateManager.refresh(repositoryPath, {
layers: ['detailed'],
force: true,
silent: true,
reason: 'manual',
source: 'branch_quick_switch',
});
const refreshedBranches = branchListFromCache(repositoryPath);
if (refreshedBranches) {
setBranches(refreshedBranches);
return;
}

const serviceBranches = await gitService.getBranches(repositoryPath, false);
setBranches(serviceBranches);
// Read only branches through the workspace-aware service. The passive
// detailed cache also loads commits and turns branch failures into [].
const serviceBranches = await gitService.getBranches(repositoryPath, true, { throwOnError: true });
if (request === branchRequestRef.current) setBranches(serviceBranches);
} catch (error) {
if (request !== branchRequestRef.current) return;
log.error('Failed to load branches', { repositoryPath, error });
setLoadFailed(true);
if (!branchListFromCache(repositoryPath)?.length) setBranches([]);
} finally {
setIsLoading(false);
if (request === branchRequestRef.current) setIsLoading(false);
}
}, [repositoryPath]);

Expand All @@ -204,8 +194,10 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({

useEffect(() => {
if (!isOpen) return;
setBranches(branchListFromCache(repositoryPath) ?? []);
void loadBranches();
}, [isOpen, loadBranches]);
return () => { branchRequestRef.current += 1; };
}, [isOpen, loadBranches, repositoryPath]);

useEffect(() => {
setBlocker(null);
Expand Down Expand Up @@ -343,10 +335,15 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
return;
}
if (event.key === 'Tab') {
// Resume the page's tab order at the trigger, outside the portal.
closePicker();
// Search and refresh are the two tab stops in this virtual-focus picker.
const leaving = event.shiftKey
? event.target === inputRef.current
: event.target !== inputRef.current || refreshButtonRef.current?.disabled;
if (leaving) closePicker();
return;
}
// Enter on the refresh button must not also check out the active option.
if (event.target !== inputRef.current) return;
if (filteredBranches.length === 0) return;

if (event.key === 'ArrowDown') {
Expand Down Expand Up @@ -476,12 +473,27 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
data-openbitfun-product-component="branch-quick-switch"
data-openbitfun-product-part="input"
/>
<IconButton
ref={refreshButtonRef}
aria-label={t('quickSwitch.refresh')}
title={t('quickSwitch.refresh')}
icon={<Icon name="refresh" size="sm" />}
loading={isLoading}
disabled={isSwitching}
onClick={() => void loadBranches()}
/>
</div>
{loadFailed && branches.length > 0 && (
<div className="branch-quick-switch__error" role="status">
{t('quickSwitch.errors.refreshFailed')}
</div>
)}
<Listbox
ref={listRef}
aria-label={t('quickSwitch.menuLabel')}
className="branch-quick-switch__list"
focusMode="virtual"
aria-busy={isLoading}
>
{isLoading && branches.length === 0 ? (
<ListboxEmpty
Expand Down
19 changes: 19 additions & 0 deletions src/web-ui/src/tools/git/services/GitService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const gitApiMocks = vi.hoisted(() => ({
isGitRepository: vi.fn(),
getRepository: vi.fn(),
getStatus: vi.fn(),
getBranches: vi.fn(),
}));

const gitStateManagerMock = vi.hoisted(() => ({
Expand All @@ -30,6 +31,24 @@ vi.mock('../state/GitStateManager', () => ({

const repositoryPath = { workspaceId: 'workspace-1', repositoryPath: 'D:/workspace/OpenBitFun' };

describe('GitService branch loading', () => {
it('preserves the legacy empty-list fallback but lets interactive callers receive errors', async () => {
const error = new Error('Remote host unavailable');
gitApiMocks.getBranches.mockRejectedValue(error);
await expect(gitService.getBranches(repositoryPath)).resolves.toEqual([]);
await expect(gitService.getBranches(repositoryPath, true, { throwOnError: true })).rejects.toBe(error);
});

it('forwards the workspace scope and adapts a successful strict read', async () => {
const scope = { workspaceId: 'ssh-workspace', repositoryPath: '/srv/project' };
gitApiMocks.getBranches.mockResolvedValue([{ name: 'main', current: true, remote: false }]);
await expect(gitService.getBranches(scope, true, { throwOnError: true })).resolves.toEqual([
expect.objectContaining({ name: 'main', current: true, ahead: 0, behind: 0 }),
]);
expect(gitApiMocks.getBranches).toHaveBeenLastCalledWith(scope, true);
});
});

describe('GitService dangerous operation refresh guard', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
8 changes: 7 additions & 1 deletion src/web-ui/src/tools/git/services/GitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,18 @@ export class GitService {
}
}

async getBranches(repositoryPath: GitWorkspaceScope, includeRemote: boolean = false): Promise<GitBranch[]> {
async getBranches(
repositoryPath: GitWorkspaceScope,
includeRemote: boolean = false,
options: { throwOnError?: boolean } = {},
): Promise<GitBranch[]> {
try {
const result = await gitAPI.getBranches(repositoryPath, includeRemote);
return this.adaptBranches(result);
} catch (error) {
log.error('Failed to get branches', error);
// Interactive callers must distinguish a failed read from an empty repository.
if (options.throwOnError) throw error;
return [];
}
}
Expand Down
Loading