fix: simplify workspace controls and branch search
Group the workspace view switch and refresh action in one header, show only file or Git actions for the active view, and remove the isolated root icon. Subdirectory breadcrumbs retain root navigation and restore keyboard focus. Use the shared form style for branch search in a bounded, scrollable panel. Confirm that fresh settings already default to automatic Execute tool authorization, and add regression coverage without changing saved policies. Update the workbar design and record isolated production Electron validation. Validation: 354 focused tests, typecheck, lint and development bundle passed. The full suite passed 3843 tests and skipped 66; an unchanged OpenCode stream lifecycle timing test failed once and passed its isolated rerun. Release note: 整理工作区文件与 Git 工具栏,移除孤立的根目录图标并保留子目录导航;分支搜索框、文字和按钮统一样式,宽窄工作栏下均保持紧凑布局。
This commit is contained in:
@@ -388,8 +388,11 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
|
||||
当前项目目录的文件浏览与 Git 交互遵循以下规则:
|
||||
|
||||
- FR-W1:顶部保留当前浏览目录及可返回父目录的路径导航,根目录使用图标入口,完整根路径通过可访问名称和提示提供,不重复显示项目名称或独立路径标题;工具栏依次提供刷新、新建文件、新建目录。
|
||||
路径导航与操作工具栏分行排列;操作使用带名称和提示的紧凑图标按钮,在 300px 工作栏内保持单行。
|
||||
- FR-W1:顶部同一行放置“文件 / Git 工作区”视图切换和右侧刷新;非 Git 目录仍保留刷新。
|
||||
下一行只提供当前视图的工具:文件视图提供新建文件、新建目录,Git 视图提供分支、Fetch 和更改显示方式,
|
||||
不在 Git 视图重复目录导航或新建文件操作。操作使用带名称和提示的紧凑图标按钮。
|
||||
文件视图在根目录不显示孤立的文件夹图标或空路径行;进入子目录后提供“项目目录名 / 子目录”
|
||||
文字路径导航和返回父目录按钮。根目录文字可点击返回,提示显示完整根路径;返回根目录后焦点移至顶部刷新。
|
||||
长目录名单行省略,完整路径通过提示查看;多级路径在导航内部横向滚动,不撑宽工作栏。
|
||||
选中目录时在该目录创建,选中文件时在其父目录创建,未选择时在当前浏览目录创建。
|
||||
无可用目录时隐藏无关操作。
|
||||
@@ -413,6 +416,8 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
不自动 force、reset、discard 或 stash,不因浏览分支而自动 Fetch。
|
||||
分支触发器复用共享模型选择按钮,带分支图标和展开箭头;Fetch 使用共享次按钮,二者在
|
||||
紧凑工作栏内保持同一行,长分支名省略并提供完整名称提示。
|
||||
展开的分支选择面板不超过 360px 或工作栏可用宽度;搜索使用共享表单标签、输入框与主题样式,
|
||||
分支列表独立限高滚动,创建按钮以紧凑次操作放在列表下方。Escape 收起面板并将焦点返回分支触发器。
|
||||
- FR-W5:未提交更改支持列表和目录树切换;两种视图均可打开单文件 Diff。
|
||||
- FR-W6:Git 工作区底部提供默认收起的提交历史区,独立滚动,可调整高度。按当前分支
|
||||
分页读取提交,展示摘要、作者、时间、短 OID 和 refs。选中提交显示该次修改文件,
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-10 工作区布局与分支输入样式
|
||||
|
||||
- 按 [FR-W1、FR-W4](./prd.md#76-工作区) 将视图切换和刷新合并到顶部行,
|
||||
文件与 Git 工具按视图显示;移除根目录的孤立图标及空导航行,子目录保留文字路径导航。
|
||||
分支搜索复用共享 `field`,面板限宽、分支列表限高滚动,创建按钮使用紧凑次操作。
|
||||
- 新增布局、子目录返回、焦点恢复、分支搜索/切换/创建回归;工作区、设置界面、设置存储、
|
||||
IPC 和 Sidebar 五个测试文件共 354 项通过。另确认安全与数据的默认 `toolApproval` 为
|
||||
`always`,对应“Execute 自动授权已启用的工具”;已有明确保存的禁止策略仍保留。
|
||||
本轮没有修改授权策略或默认值。
|
||||
- 当前构建的 Windows Electron 使用独立 profile 和临时 Git 项目,实际 App → Preload →
|
||||
Main 路径通过分支搜索、切换、创建和子目录新建文件;磁盘与 Git 分支结果均确认。
|
||||
文件/Git 切换保留浏览目录,返回根目录后导航行移除、焦点回到刷新;Escape 收起分支面板并恢复焦点。
|
||||
真实安全与数据页面的默认选中项也确认为自动授权。
|
||||
- 浅色与深色下,将实际工作栏布局宽度设为 300、420、650px 后均无工作区横向溢出。
|
||||
输入框为共享 13px 正文、标签 11px,分支面板最大 360px;15 个分支时列表可在 240px 内滚动。
|
||||
这些是布局检查,不作为分栏拖拽或原生浏览器鼠标交互的验证。
|
||||
- `npm run build:bundle`、`npm run typecheck`、`npm run lint` 通过。
|
||||
整库 `npm test` 为 3843 项通过、1 项失败、66 项跳过;失败项是未修改的
|
||||
`opencode-runtime-lifecycle.test.ts` 事件流关闭时序断言,单独复跑通过。
|
||||
未再次运行整库,不将此结果记为整库全绿。
|
||||
- 本轮产品改动仅为共享 Renderer 工作区布局,未改 Main/Agent 的工作区命令、
|
||||
desktop-to-Agent 协议或 Runtime 执行逻辑;远程项目继续使用同一组件和既有回调,
|
||||
本轮未做新的 Host 实测。无外部模型调用,所属测试应用、浏览器会话和调试监听已关闭。
|
||||
|
||||
## 2026-09-10 提交审查修复
|
||||
|
||||
- [FR-W3、FR-W6](./prd.md#76-工作区):同一目录项可仅修改大小写,仍拒绝覆盖其他文件、
|
||||
|
||||
@@ -80,6 +80,16 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('RuntimeSettingsStore', () => {
|
||||
it('defaults to automatic Execute tool authorization and preserves an explicitly saved deny policy', async () => {
|
||||
const { store, filePath } = await createStore()
|
||||
await expect(store.getPublicSettings()).resolves.toMatchObject({ toolApproval: 'always' })
|
||||
await expect(store.getPolicySettings()).resolves.toMatchObject({ toolApproval: 'always' })
|
||||
await store.update(settings({ toolApproval: 'policy' }))
|
||||
const reloaded = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(reloaded.getPublicSettings()).resolves.toMatchObject({ toolApproval: 'policy' })
|
||||
await expect(reloaded.getPolicySettings()).resolves.toMatchObject({ toolApproval: 'policy' })
|
||||
})
|
||||
|
||||
it('persists default references separately from fixed profiles and resolves each settings generation', async () => {
|
||||
const { store, filePath } = await createStore()
|
||||
const firstId = '00000000-0000-4000-8000-000000000041'
|
||||
|
||||
@@ -2789,6 +2789,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
const policy = await screen.findByLabelText(
|
||||
'直连模型工具安全策略'
|
||||
)
|
||||
expect(policy).toHaveValue('always')
|
||||
expect(
|
||||
within(policy).getByRole('option', {
|
||||
name: 'Execute 自动授权已启用的工具'
|
||||
|
||||
@@ -15,6 +15,104 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('WorkspaceFilesPanel', () => {
|
||||
it('groups view switching and refresh, and only shows actions for the active view', async () => {
|
||||
const onRefresh = vi.fn(async () => undefined)
|
||||
const { container } = render(<WorkspaceFilesPanel projectId="project" isRepository rootPath="D:\\workspace\\demo"
|
||||
changedFiles={[]} onRefresh={onRefresh}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
|
||||
onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
const createFile = await screen.findByRole('button', { name: '新建文件' })
|
||||
const header = container.querySelector<HTMLElement>('.workspace-files__header')!
|
||||
expect(within(header).getByRole('button', { name: '文件' })).toBeInTheDocument()
|
||||
expect(within(header).getByRole('button', { name: 'Git 工作区' })).toBeInTheDocument()
|
||||
expect(within(header).getByRole('button', { name: '刷新工作区文件' })).toBeInTheDocument()
|
||||
expect(header).not.toContainElement(createFile)
|
||||
expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'demo' })).not.toBeInTheDocument()
|
||||
fireEvent.click(within(header).getByRole('button', { name: 'Git 工作区' }))
|
||||
expect(await screen.findByRole('button', { name: 'main' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新建文件' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新建目录' })).not.toBeInTheDocument()
|
||||
fireEvent.click(within(header).getByRole('button', { name: '刷新工作区文件' }))
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce())
|
||||
fireEvent.click(within(header).getByRole('button', { name: '文件' }))
|
||||
expect(screen.getByRole('button', { name: '新建文件' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Fetch' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['D:\\workspace\\demo', '/workspace/demo'])('keeps text breadcrumbs only in subdirectories of %s', async (rootPath) => {
|
||||
const onListDirectory = vi.fn(async (path: string) => ({
|
||||
path, truncated: false,
|
||||
entries: path ? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file' as const }]
|
||||
: [{ name: 'docs', path: 'docs', type: 'directory' as const }]
|
||||
}))
|
||||
render(<WorkspaceFilesPanel projectId="project" isRepository rootPath={rootPath} changedFiles={[]}
|
||||
onListDirectory={onListDirectory} onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs 的更多操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '浏览此目录' }))
|
||||
await screen.findByRole('button', { name: 'guide.md' })
|
||||
const nav = screen.getByRole('navigation', { name: '路径' })
|
||||
expect(within(nav).getByRole('button', { name: 'demo' })).toHaveAttribute('title', rootPath)
|
||||
expect(within(nav).getByRole('button', { name: 'docs' })).toHaveAttribute('aria-current', 'location')
|
||||
expect(screen.getByRole('button', { name: '新建文件' })).toHaveAttribute('title', '新建文件: docs')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '文件' }))
|
||||
expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-current', 'location')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'demo' }))
|
||||
expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '刷新工作区文件' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '新建文件' })).toHaveAttribute('title', '新建文件: /')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs 的更多操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '浏览此目录' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回父目录' }))
|
||||
expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('styles branch search as a shared field 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') {
|
||||
current = action.branch
|
||||
return { kind: 'done' }
|
||||
}
|
||||
return { kind: 'branches', current, branches: [
|
||||
{ name: 'main', remote: false },
|
||||
{ name: 'origin/topic', remote: true }
|
||||
] }
|
||||
})
|
||||
vi.stubGlobal('goodbuddy', { workspace: { manage } })
|
||||
const onRefresh = vi.fn(async () => undefined)
|
||||
render(<WorkspaceFilesPanel projectId="project" isRepository changedFiles={[]} onRefresh={onRefresh}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
|
||||
onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
|
||||
const trigger = await screen.findByRole('button', { name: 'main' })
|
||||
fireEvent.click(trigger)
|
||||
const search = screen.getByRole('textbox', { name: '搜索本地或远程分支 / 新分支名称' })
|
||||
expect(search.closest('label')).toHaveClass('field')
|
||||
expect(search.previousElementSibling?.tagName).toBe('SPAN')
|
||||
expect(search).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '创建并切换分支' })).toBeDisabled()
|
||||
fireEvent.change(search, { target: { value: 'topic' } })
|
||||
expect(screen.queryByRole('button', { name: /main\s*本地/ })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /origin\/topic\s*远程/ }))
|
||||
await waitFor(() => expect(manage).toHaveBeenCalledWith('project', { kind: 'switchBranch', branch: 'origin/topic', remote: true }))
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce())
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-topic' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建并切换分支' }))
|
||||
await waitFor(() => expect(manage).toHaveBeenCalledWith('project', { kind: 'createBranch', branch: 'new-topic' }))
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(2))
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' })
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(manage.mock.calls.some(([, action]) => action.kind === 'fetch')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps Git operations usable when a task refresh occurs during history pagination (failure: %s)', async (fail) => {
|
||||
const head = 'a'.repeat(40)
|
||||
const initial = { oid: head, subject: 'Latest commit', author: 'Author', time: '2026-09-09T10:00:00Z', refs: 'HEAD -> main' }
|
||||
@@ -105,6 +203,10 @@ describe('WorkspaceFilesPanel', () => {
|
||||
expect(css).toMatch(/\.workspace-files__view-switch\s*\{[^}]*align-self: flex-start/)
|
||||
expect(css).toMatch(/\.workspace-git__toolbar > \.segmented-control\s*\{[^}]*width: max-content;[^}]*border: 0/)
|
||||
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__branch-list\s*\{[^}]*max-height: 240px;[^}]*overflow: auto/)
|
||||
expect(css).toMatch(/\.workspace-git__create-branch\s*\{[^}]*justify-self: start/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -294,7 +396,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '当前工作区' })).toHaveAttribute('aria-current', 'location')
|
||||
expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'guide.md' })
|
||||
@@ -384,7 +486,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
await changeUiLocale('en-US')
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Current workspace' })
|
||||
await screen.findByRole('button', { name: 'Files' })
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Git Workspace' }))
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Trash2,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderRoot,
|
||||
FolderOpen
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
@@ -151,6 +150,7 @@ export function WorkspaceFilesPanel({
|
||||
const activeView = showGit ? view : 'files'
|
||||
const [selected, setSelected] = useState<{ projectId?: string; path: string }>()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const refreshRef = useRef<HTMLButtonElement>(null)
|
||||
const backRef = useRef<HTMLButtonElement>(null)
|
||||
const returnContext = useRef<{ scrollTop: number; scrollLeft: number; trigger: HTMLButtonElement } | undefined>(undefined)
|
||||
const requestGeneration = useRef(0)
|
||||
@@ -352,6 +352,7 @@ export function WorkspaceFilesPanel({
|
||||
const browse = (path: string): void => {
|
||||
setBrowsedPath(path); setCreateTarget(path)
|
||||
void loadDirectory(path, requestGeneration.current)
|
||||
if (!path) refreshRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
const entryMenu = (entry: WorkspaceDirectoryEntry): React.JSX.Element => (
|
||||
<button className="icon-button workspace-files__more" type="button"
|
||||
@@ -450,6 +451,7 @@ export function WorkspaceFilesPanel({
|
||||
}
|
||||
|
||||
const root = listings[browsedPath]
|
||||
const rootName = rootPath?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) || t('files.currentWorkspace')
|
||||
const renderPatch = (patch: string): React.JSX.Element => (
|
||||
<pre className="assistant-sidebar__diff workspace-files__diff">
|
||||
{patch.split('\n').map((line, index) => (
|
||||
@@ -487,22 +489,24 @@ export function WorkspaceFilesPanel({
|
||||
</>}
|
||||
</section>}
|
||||
<div className="workspace-files__list" hidden={showingDiff}>
|
||||
<nav className="workspace-files__breadcrumbs" aria-label={t('management.path')}>
|
||||
{browsedPath && <button className="icon-button" type="button" aria-label={t('management.parent')} title={t('management.parent')} onClick={() => browse(browsedPath.split('/').slice(0, -1).join('/'))}><ChevronLeft size={14} /></button>}
|
||||
<div className="workspace-files__header">
|
||||
{showGit && <div className="workspace-files__view-switch"><SegmentedControl ariaLabel={t('files.view')} value={activeView} onChange={setView} options={[
|
||||
{ value: 'files', label: t('files.filesView') },
|
||||
{ value: 'changes', label: t('management.gitWorkspace') }
|
||||
]} /></div>}
|
||||
<button ref={refreshRef} className="icon-button" type="button" disabled={refreshing} aria-label={t('sidebar.workspace.refreshAriaLabel')} title={t('sidebar.workspace.refresh')} onClick={() => void refresh()}><RefreshCw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
{activeView === 'files' && root && <div className="workspace-files__toolbar workspace-files__actions">
|
||||
<button className="icon-button" type="button" aria-label={t('management.createFile')} title={`${t('management.createFile')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createFile', path: createTarget })}><FilePlus size={14} aria-hidden="true" /></button>
|
||||
<button className="icon-button" type="button" aria-label={t('management.createDirectory')} title={`${t('management.createDirectory')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createDirectory', path: createTarget })}><FolderPlus size={14} aria-hidden="true" /></button>
|
||||
</div>}
|
||||
{activeView === 'files' && browsedPath && <nav className="workspace-files__breadcrumbs" aria-label={t('management.path')}>
|
||||
<button className="icon-button" type="button" aria-label={t('management.parent')} title={t('management.parent')} onClick={() => browse(browsedPath.split('/').slice(0, -1).join('/'))}><ChevronLeft size={14} aria-hidden="true" /></button>
|
||||
<div className="workspace-files__breadcrumb-parts">
|
||||
<button className="icon-button" type="button" onClick={() => browse('')} aria-label={rootPath || t('files.currentWorkspace')} title={rootPath || t('files.currentWorkspace')} aria-current={!browsedPath ? 'location' : undefined}><FolderRoot size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onClick={() => browse('')} title={rootPath || t('files.currentWorkspace')}>{rootName}</button>
|
||||
{browsedPath.split('/').filter(Boolean).map((part, index, parts) => <button type="button" key={index} title={parts.slice(0, index + 1).join('/')} aria-current={index === parts.length - 1 ? 'location' : undefined} onClick={() => browse(parts.slice(0, index + 1).join('/'))}>{part}</button>)}
|
||||
</div>
|
||||
</nav>
|
||||
{root && <div className="workspace-files__toolbar workspace-files__actions">
|
||||
<button className="icon-button" type="button" disabled={refreshing} aria-label={t('sidebar.workspace.refreshAriaLabel')} title={t('sidebar.workspace.refresh')} onClick={() => void refresh()}><RefreshCw size={14} /></button>
|
||||
<button className="icon-button" type="button" aria-label={t('management.createFile')} title={`${t('management.createFile')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createFile', path: createTarget })}><FilePlus size={14} /></button>
|
||||
<button className="icon-button" type="button" aria-label={t('management.createDirectory')} title={`${t('management.createDirectory')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createDirectory', path: createTarget })}><FolderPlus size={14} /></button>
|
||||
</div>}
|
||||
{showGit && <div className="workspace-files__view-switch"><SegmentedControl ariaLabel={t('files.view')} value={activeView} onChange={setView} options={[
|
||||
{ value: 'files', label: t('files.filesView') },
|
||||
{ value: 'changes', label: t('management.gitWorkspace') }
|
||||
]} /></div>}
|
||||
</nav>}
|
||||
<div hidden={activeView !== 'changes'}>
|
||||
{showGit && <WorkspaceGitTools key={projectId} projectId={projectId} refreshToken={refreshToken} onRefresh={refresh} viewControl={
|
||||
<SegmentedControl ariaLabel={t('management.changedView')} value={changeView} onChange={setChangeView} options={[{ value: 'list', label: t('management.list') }, { value: 'tree', label: t('management.tree') }]} />
|
||||
|
||||
@@ -88,9 +88,9 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
|
||||
{viewControl}
|
||||
</div>
|
||||
{branchOpen && <div className="workspace-git__branches" onKeyDown={(event) => { if (event.key === 'Escape') { setBranchOpen(false); branchTrigger.current?.focus() } }}>
|
||||
<label>{t('management.searchBranch')}<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} /></label>
|
||||
<button className="secondary-button" type="button" disabled={busy || !query.trim()} onClick={() => void run({ kind: 'createBranch', branch: query })}>{t('management.createBranch')}</button>
|
||||
<div>{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>
|
||||
<label className="field"><span>{t('management.searchBranch')}</span><input autoFocus 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>}
|
||||
{children}
|
||||
<button className="workspace-files__changed-row workspace-git__history-toggle" type="button" aria-expanded={historyOpen} onClick={() => { setHistoryOpen(!historyOpen); if (!historyOpen && !history) void loadHistory() }}>{historyOpen ? <ChevronDown size={14} aria-hidden="true" /> : <ChevronRight size={14} aria-hidden="true" />}<span>{t('management.history')}</span></button>
|
||||
|
||||
@@ -4399,6 +4399,7 @@ button > svg {
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.workspace-files__breadcrumb-parts {
|
||||
@@ -4438,6 +4439,7 @@ button > svg {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-files__header .icon-button,
|
||||
.workspace-files__actions .icon-button,
|
||||
.workspace-files__breadcrumbs > .icon-button {
|
||||
width: 32px;
|
||||
@@ -17375,6 +17377,14 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.workspace-files__directory-picker .remote-directory-picker__content { max-height: 240px; }
|
||||
.workspace-git__working { display: flex; flex-direction: column; min-height: 0; gap: var(--space-2); }
|
||||
.workspace-git__working[hidden] { display: none; }
|
||||
.workspace-files__header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.workspace-files__header > .icon-button { margin-left: auto; }
|
||||
.workspace-files__view-switch { align-self: flex-start; max-width: 100%; }
|
||||
.workspace-files__view-switch > .segmented-control,
|
||||
.workspace-git__toolbar > .segmented-control {
|
||||
@@ -17401,6 +17411,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.workspace-git__repository-actions {
|
||||
@@ -17425,16 +17436,38 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
flex: 0 0 32px;
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
.workspace-git__branches { display: grid; min-width: 0; gap: var(--space-2); padding-bottom: var(--space-2); }
|
||||
.workspace-git__branches input { min-width: 0; width: 100%; }
|
||||
.workspace-git__branches {
|
||||
display: grid;
|
||||
width: min(100%, 360px);
|
||||
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);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
.workspace-git__branches .field {
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.workspace-git__branches .field > span { color: var(--text-secondary); }
|
||||
.workspace-git__branches input { min-width: 0; }
|
||||
.workspace-git__branch-list {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
.workspace-git__branch-list:empty { display: none; }
|
||||
.workspace-git__create-branch { justify-self: start; max-width: 100%; }
|
||||
.workspace-git__history-toggle { margin-top: var(--space-2); font-weight: 650; }
|
||||
.workspace-git__branches > div { display: grid; max-height: 240px; overflow: auto; }
|
||||
.workspace-git__branches label, .workspace-git__commit { display: grid; text-align: left; }
|
||||
.workspace-git__history { display: flex; flex-direction: column; flex: 0 0 auto; min-height: 120px; max-height: 600px; border-top: 1px solid var(--border-default); }
|
||||
.workspace-git__history[hidden] { display: none; }
|
||||
.workspace-git__history > [role="separator"] { height: var(--space-2); flex: 0 0 auto; cursor: ns-resize; touch-action: none; }
|
||||
.workspace-git__history-scroll { overflow: auto; min-height: 0; }
|
||||
.workspace-git__commit { width: 100%; padding: var(--space-2); grid-template-columns: minmax(0, 1fr); gap: var(--space-1); border-bottom: 1px solid var(--border-subtle); }
|
||||
.workspace-git__commit { display: grid; width: 100%; padding: var(--space-2); grid-template-columns: minmax(0, 1fr); gap: var(--space-1); border-bottom: 1px solid var(--border-subtle); text-align: left; }
|
||||
.workspace-git__commit strong, .workspace-git__commit small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-git__commit span, .workspace-git__commit small { color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
@media (hover: none), (pointer: coarse) { .workspace-files__more { opacity: 1; } }
|
||||
|
||||
Reference in New Issue
Block a user