diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index 261e5a6ae..21f2b900d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -24,6 +24,8 @@ beforeEach(() => { vi.mocked(api.createSession).mockResolvedValue({ authenticated: true }); vi.mocked(api.deleteSession).mockResolvedValue(undefined); vi.mocked(api.listDeployments).mockResolvedValue([]); + vi.mocked(api.searchBranches).mockResolvedValue([]); + vi.mocked(api.searchCommits).mockResolvedValue([]); vi.mocked(api.createDeployment).mockResolvedValue({ id: 'preview-1', branch: 'master', @@ -108,3 +110,86 @@ test('shows health and web url, then confirms uninstall', async () => { expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2'); }); }); + +test('shows debounced branch results and selects one with the keyboard', async () => { + const user = userEvent.setup(); + vi.mocked(api.searchBranches).mockResolvedValue([ + { name: 'feature/search-one', commitHash: '111111111111' }, + { name: 'feature/search-two', commitHash: '222222222222' }, + ]); + render(); + + const branchInput = await screen.findByRole('combobox', { name: '分支名' }); + await user.clear(branchInput); + await user.type(branchInput, 'feature/search'); + + expect( + await screen.findByRole('option', { name: /feature\/search-one/u }), + ).toBeTruthy(); + expect(api.searchBranches).toHaveBeenLastCalledWith( + 'feature/search', + expect.any(AbortSignal), + ); + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + expect((branchInput as HTMLInputElement).value).toBe('feature/search-two'); +}); + +test('searches commits in the current branch and selects a result', async () => { + const user = userEvent.setup(); + vi.mocked(api.searchCommits).mockResolvedValue([ + { + commitHash: 'aa5221abcdef0123456789', + shortHash: 'aa5221a', + subject: '补充预览搜索', + }, + ]); + render(); + + const commitInput = await screen.findByRole('combobox', { + name: 'Commit Hash', + }); + await user.type(commitInput, 'aa5221a'); + + const option = await screen.findByRole('option', { + name: /aa5221a.*补充预览搜索/u, + }); + expect(api.searchCommits).toHaveBeenCalledWith( + 'master', + 'aa5221a', + expect.any(AbortSignal), + ); + await user.click(option); + expect((commitInput as HTMLInputElement).value).toBe( + 'aa5221abcdef0123456789', + ); +}); + +test('clears commit and ignores stale commit responses when branch changes', async () => { + const user = userEvent.setup(); + let resolveOldSearch: ((items: api.CommitRef[]) => void) | undefined; + vi.mocked(api.searchCommits).mockImplementation( + () => + new Promise((resolve) => { + resolveOldSearch = resolve; + }), + ); + render(); + + const branchInput = await screen.findByRole('combobox', { name: '分支名' }); + const commitInput = screen.getByRole('combobox', { name: 'Commit Hash' }); + await user.type(commitInput, 'abcdef1'); + await waitFor(() => expect(api.searchCommits).toHaveBeenCalled()); + await user.clear(branchInput); + await user.type(branchInput, 'feature/new'); + + expect((commitInput as HTMLInputElement).value).toBe(''); + resolveOldSearch?.([ + { + commitHash: 'abcdef1234567890', + shortHash: 'abcdef1', + subject: '旧分支提交', + }, + ]); + await Promise.resolve(); + expect(screen.queryByText('旧分支提交')).toBeNull(); +}); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index 2bfd2482a..45f7e7d80 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -13,15 +13,27 @@ import { TriangleAlert, XCircle, } from 'lucide-react'; -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { + FormEvent, + KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { + type BranchRef, + type CommitRef, createDeployment, createSession, deleteSession, getSession, listDeployments, PreviewDeployerApiError, + searchBranches, + searchCommits, uninstallDeployment, } from './api'; import type { @@ -32,6 +44,7 @@ import type { import { validateBranch, validateCommitHash } from './validation'; const POLL_INTERVAL_MS = 5000; +const SEARCH_DEBOUNCE_MS = 300; const STATUS_LABELS: Record = { queued: '排队中', @@ -66,6 +79,16 @@ export function PreviewDeployerApp() { const [submitting, setSubmitting] = useState(false); const [notice, setNotice] = useState(''); const [error, setError] = useState(''); + const [branchSearch, setBranchSearch] = + useState>(emptySearchState()); + const [commitSearch, setCommitSearch] = + useState>(emptySearchState()); + const [branchActiveIndex, setBranchActiveIndex] = useState(-1); + const [commitActiveIndex, setCommitActiveIndex] = useState(-1); + const [branchSearchEnabled, setBranchSearchEnabled] = useState(false); + const [commitSearchEnabled, setCommitSearchEnabled] = useState(false); + const branchRequestId = useRef(0); + const commitRequestId = useRef(0); const [pendingUninstall, setPendingUninstall] = useState(null); const [uninstallingId, setUninstallingId] = useState(''); @@ -129,6 +152,94 @@ export function PreviewDeployerApp() { return () => window.clearInterval(interval); }, [refresh, sessionStatus]); + useEffect(() => { + const query = branch.trim(); + const requestId = ++branchRequestId.current; + const controller = new AbortController(); + if (sessionStatus !== 'authenticated' || !branchSearchEnabled || !query) { + setBranchSearch(emptySearchState()); + setBranchActiveIndex(-1); + return () => controller.abort(); + } + setBranchSearch({ items: [], loading: true, open: true, error: '' }); + setBranchActiveIndex(-1); + const timer = window.setTimeout(() => { + void searchBranches(query, controller.signal) + .then((items) => { + if (requestId !== branchRequestId.current) return; + setBranchSearch({ items, loading: false, open: true, error: '' }); + }) + .catch((searchError) => { + if ( + controller.signal.aborted || + requestId !== branchRequestId.current + ) + return; + if (isUnauthorized(searchError)) { + handleUnauthorized(); + return; + } + setBranchSearch({ + items: [], + loading: false, + open: true, + error: formatError(searchError), + }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [branch, branchSearchEnabled, sessionStatus]); + + useEffect(() => { + const query = commitHash.trim(); + const selectedBranch = branch.trim(); + const requestId = ++commitRequestId.current; + const controller = new AbortController(); + if ( + sessionStatus !== 'authenticated' || + !commitSearchEnabled || + !query || + validateBranch(selectedBranch) + ) { + setCommitSearch(emptySearchState()); + setCommitActiveIndex(-1); + return () => controller.abort(); + } + setCommitSearch({ items: [], loading: true, open: true, error: '' }); + setCommitActiveIndex(-1); + const timer = window.setTimeout(() => { + void searchCommits(selectedBranch, query, controller.signal) + .then((items) => { + if (requestId !== commitRequestId.current) return; + setCommitSearch({ items, loading: false, open: true, error: '' }); + }) + .catch((searchError) => { + if ( + controller.signal.aborted || + requestId !== commitRequestId.current + ) + return; + if (isUnauthorized(searchError)) { + handleUnauthorized(); + return; + } + setCommitSearch({ + items: [], + loading: false, + open: true, + error: formatError(searchError), + }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [branch, commitHash, commitSearchEnabled, sessionStatus]); + const runningCount = useMemo( () => deployments.filter((deployment) => deployment.status === 'running') @@ -356,36 +467,166 @@ export function PreviewDeployerApp() { className="deploy-form" onSubmit={(event) => void handleSubmit(event)} > -