From abb39125303d326c92083512c3dbb6ee3bf306e9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 17 Aug 2026 12:04:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=88=86=E6=94=AF=E4=B8=8E?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=90=9C=E7=B4=A2=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加分支和 commit 的输入搜索下拉构建前复核固定仓库中的分支与 commit 归属补充预览控制面接口合同、配置和测试 --- .../src/PreviewDeployerApp.test.tsx | 85 ++++ .../src/PreviewDeployerApp.tsx | 380 +++++++++++++++++- apps/preview-deployer-web/src/api.test.ts | 44 ++ apps/preview-deployer-web/src/api.ts | 31 ++ apps/preview-deployer-web/src/styles.css | 67 ++- deploy/env/preview-deployer.env.example | 4 + ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 5 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../crates/preview-deployer-server/Cargo.toml | 2 +- .../preview-deployer-server/src/config.rs | 40 +- .../preview-deployer-server/src/git_refs.rs | 282 +++++++++++++ .../crates/preview-deployer-server/src/lib.rs | 153 ++++++- .../preview-deployer-server/src/tests.rs | 76 +++- 13 files changed, 1147 insertions(+), 24 deletions(-) create mode 100644 server-rs/crates/preview-deployer-server/src/git_refs.rs 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)} > -