fix: float Git branch picker above workspace content
Render the searchable branch picker in the shared floating portal so opening it no longer pushes down changes or commit history. Clamp it to the viewport and retain search focus, outside dismissal, and Escape focus restoration. Update existing migration fixtures for activity history and supervision schema changes. Focused suites passed: 149 storage tests, 41 UI tests, and the Electron overlay test; typecheck and lint passed. The full suite did not complete cleanly and reported failures outside this change. Release note: The Git branch picker now opens over the workspace without displacing the file list or commit history.
This commit is contained in:
@@ -69,7 +69,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('styles branch search as a shared field and preserves filtering, switching and creation', async () => {
|
||||
it('floats branch search outside the workspace and preserves filtering, switching and creation', async () => {
|
||||
let current = 'main'
|
||||
const manage = vi.fn(async (_project: string, action: WorkspaceManagementAction): Promise<WorkspaceManagementResult> => {
|
||||
if (action.kind === 'switchBranch' || action.kind === 'createBranch') {
|
||||
@@ -90,6 +90,10 @@ describe('WorkspaceFilesPanel', () => {
|
||||
const trigger = await screen.findByRole('button', { name: 'main' })
|
||||
fireEvent.click(trigger)
|
||||
const search = screen.getByRole('textbox', { name: '搜索本地或远程分支 / 新分支名称' })
|
||||
const panel = screen.getByRole('dialog', { name: '分支' })
|
||||
expect(panel.closest('.workspace-git')).toBeNull()
|
||||
expect(panel.closest('.floating-portal')).not.toBeNull()
|
||||
expect(trigger).toHaveAttribute('aria-controls', panel.id)
|
||||
expect(search.closest('label')).toHaveClass('field')
|
||||
expect(search.previousElementSibling?.tagName).toBe('SPAN')
|
||||
expect(search).toHaveFocus()
|
||||
@@ -110,6 +114,9 @@ describe('WorkspaceFilesPanel', () => {
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' })
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('dialog', { name: '分支' })).not.toBeInTheDocument()
|
||||
expect(manage.mock.calls.some(([, action]) => action.kind === 'fetch')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -205,6 +212,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
expect(css).toMatch(/\.workspace-git__branch-trigger\s*\{[^}]*width: auto;[^}]*height: 32px;[^}]*flex: 0 1 auto;[^}]*text-align: left/)
|
||||
expect(css).toMatch(/\.workspace-files__header\s*\{[^}]*justify-content: space-between/)
|
||||
expect(css).toMatch(/\.workspace-git__branches\s*\{[^}]*width: min\(100%, 360px\);[^}]*border-radius: var\(--radius-control\)/)
|
||||
expect(css).toMatch(/\.workspace-git__branches\s*\{[^}]*position: fixed;[^}]*max-height: calc\(100vh - 32px\);[^}]*overflow: auto/)
|
||||
expect(css).toMatch(/\.workspace-git__branch-list\s*\{[^}]*max-height: 240px;[^}]*overflow: auto/)
|
||||
expect(css).toMatch(/\.workspace-git__create-branch\s*\{[^}]*justify-self: start/)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Download, FileText, GitBranch } from 'lucide-react'
|
||||
import type { WorkspaceManagementAction, WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
|
||||
import { FloatingPortal } from './FloatingPortal'
|
||||
|
||||
type Result<K extends WorkspaceManagementResult['kind']> = Extract<WorkspaceManagementResult, { kind: K }>
|
||||
|
||||
@@ -27,6 +28,8 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
|
||||
const returnTrigger = useRef<HTMLElement | null>(null)
|
||||
const fileTrigger = useRef<HTMLElement | null>(null)
|
||||
const branchTrigger = useRef<HTMLButtonElement>(null)
|
||||
const branchPanel = useRef<HTMLDivElement>(null)
|
||||
const branchPanelId = useId()
|
||||
const diffBack = useRef<HTMLButtonElement>(null)
|
||||
const commitBack = useRef<HTMLButtonElement>(null)
|
||||
const action = (value: WorkspaceManagementAction): Promise<WorkspaceManagementResult> => window.goodbuddy.workspace.manage(projectId, value)
|
||||
@@ -43,6 +46,53 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
|
||||
return () => { branchCounter.current++ }
|
||||
}, [projectId, refreshToken])
|
||||
useEffect(() => { if (path) diffBack.current?.focus(); else if (commit) commitBack.current?.focus() }, [path, commit])
|
||||
const positionBranches = useEffectEvent(() => {
|
||||
const panel = branchPanel.current
|
||||
const anchor = branchTrigger.current
|
||||
if (!panel || !anchor) return
|
||||
const viewport = window.visualViewport
|
||||
const left = viewport?.offsetLeft ?? 0
|
||||
const top = viewport?.offsetTop ?? 0
|
||||
const width = viewport?.width ?? window.innerWidth
|
||||
const height = viewport?.height ?? window.innerHeight
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const panelWidth = Math.max(0, Math.min(360, width - 32))
|
||||
panel.style.width = `${panelWidth}px`
|
||||
panel.style.maxHeight = `${Math.max(0, height - 32)}px`
|
||||
const panelHeight = panel.getBoundingClientRect().height
|
||||
const below = rect.bottom + 8
|
||||
const y = below + panelHeight <= top + height - 16 ? below : rect.top - 8 - panelHeight
|
||||
panel.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - panelWidth - 16))}px`
|
||||
panel.style.top = `${Math.max(top + 16, Math.min(y, top + height - panelHeight - 16))}px`
|
||||
})
|
||||
useLayoutEffect(() => { if (branchOpen) positionBranches() })
|
||||
useEffect(() => {
|
||||
if (!branchOpen) return
|
||||
branchPanel.current?.querySelector('input')?.focus({ preventScroll: true })
|
||||
const outside = (event: Event): void => {
|
||||
if (event.target instanceof Node && !branchTrigger.current?.contains(event.target) &&
|
||||
!branchPanel.current?.contains(event.target)) setBranchOpen(false)
|
||||
}
|
||||
const reposition = (): void => positionBranches()
|
||||
const observer = new ResizeObserver(reposition)
|
||||
if (branchPanel.current) observer.observe(branchPanel.current)
|
||||
if (branchTrigger.current) observer.observe(branchTrigger.current)
|
||||
document.addEventListener('pointerdown', outside)
|
||||
document.addEventListener('focusin', outside)
|
||||
window.addEventListener('resize', reposition)
|
||||
window.addEventListener('scroll', reposition, true)
|
||||
window.visualViewport?.addEventListener('resize', reposition)
|
||||
window.visualViewport?.addEventListener('scroll', reposition)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
document.removeEventListener('pointerdown', outside)
|
||||
document.removeEventListener('focusin', outside)
|
||||
window.removeEventListener('resize', reposition)
|
||||
window.removeEventListener('scroll', reposition, true)
|
||||
window.visualViewport?.removeEventListener('resize', reposition)
|
||||
window.visualViewport?.removeEventListener('scroll', reposition)
|
||||
}
|
||||
}, [branchOpen])
|
||||
const run = async (value: WorkspaceManagementAction): Promise<void> => {
|
||||
if (busy) return
|
||||
setBusy(true); setError('')
|
||||
@@ -92,16 +142,16 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
|
||||
<div className="workspace-git__current">
|
||||
<div className="workspace-git__toolbar">
|
||||
<div className="workspace-git__repository-actions">
|
||||
<button ref={branchTrigger} className="model-button workspace-git__branch-trigger" type="button" aria-expanded={branchOpen} onClick={() => setBranchOpen(!branchOpen)}><GitBranch size={14} aria-hidden="true" /><span className="model-button__label" title={branches?.current}>{branches?.current || t('management.branch')}</span><ChevronDown size={14} aria-hidden="true" /></button>
|
||||
<button ref={branchTrigger} className="model-button workspace-git__branch-trigger" type="button" aria-haspopup="dialog" aria-controls={branchOpen ? branchPanelId : undefined} aria-expanded={branchOpen} onClick={() => setBranchOpen(!branchOpen)}><GitBranch size={14} aria-hidden="true" /><span className="model-button__label" title={branches?.current}>{branches?.current || t('management.branch')}</span><ChevronDown size={14} aria-hidden="true" /></button>
|
||||
<button className="secondary-button workspace-git__fetch" type="button" title={t('management.fetch')} aria-label={t('management.fetch')} disabled={busy} onClick={() => void run({ kind: 'fetch' })}><Download size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
{viewControl}
|
||||
</div>
|
||||
{branchOpen && <div className="workspace-git__branches" onKeyDown={(event) => { if (event.key === 'Escape') { setBranchOpen(false); branchTrigger.current?.focus() } }}>
|
||||
<label className="field"><span>{t('management.searchBranch')}</span><input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} /></label>
|
||||
{branchOpen && <FloatingPortal anchorRef={branchTrigger}><div ref={branchPanel} id={branchPanelId} role="dialog" aria-label={t('management.branch')} className="workspace-git__branches" onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setBranchOpen(false); branchTrigger.current?.focus() } }}>
|
||||
<label className="field"><span>{t('management.searchBranch')}</span><input value={query} onChange={(event) => setQuery(event.target.value)} /></label>
|
||||
<div className="workspace-git__branch-list">{branches?.branches.filter((branch) => branch.name.toLowerCase().includes(query.toLowerCase())).map((branch) => <button className="workspace-files__changed-row" type="button" key={`${branch.remote}:${branch.name}`} disabled={busy} aria-current={!branch.remote && branches.current === branch.name ? 'true' : undefined} onClick={() => void run({ kind: 'switchBranch', branch: branch.name, remote: branch.remote })}><GitBranch size={14} aria-hidden="true" /><span title={branch.name}>{branch.name}</span><small>{t(branch.remote ? 'management.remote' : 'management.local')}</small></button>)}</div>
|
||||
<button className="secondary-button workspace-git__create-branch" type="button" disabled={busy || !query.trim()} onClick={() => void run({ kind: 'createBranch', branch: query })}>{t('management.createBranch')}</button>
|
||||
</div>}
|
||||
</div></FloatingPortal>}
|
||||
<div className="workspace-git__changes">{children}</div>
|
||||
</div>
|
||||
<div className="workspace-git__history-dock" style={{ flexBasis: historyOpen ? `${historySize}%` : undefined }}>
|
||||
|
||||
@@ -17995,14 +17995,18 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
.workspace-git__branches {
|
||||
position: fixed;
|
||||
display: grid;
|
||||
width: min(100%, 360px);
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user