fix: dock workspace history and detect Git on project switches

Keep commit history at the sidebar bottom, expand it to half the Git content area, and preserve independently scrolling panes when resizing. Use the shared refresh action and remove the redundant changed-files border.

Load Git status on project changes regardless of restored sidebar selection, and keep status and error fallback scoped to the selected project.

Release note: 提交历史默认贴底,展开占用一半内容区,支持拖拽调整并统一使用顶部刷新。切换项目后自动识别 Git 工作区,无需手动刷新。
This commit is contained in:
mesalogo
2026-09-11 00:51:25 +08:00
parent 3bf720a32c
commit 073eb0d2f5
7 changed files with 250 additions and 36 deletions
+6 -2
View File
@@ -419,14 +419,18 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
展开的分支选择面板不超过 360px 或工作栏可用宽度;搜索使用共享表单标签、输入框与主题样式,
分支列表独立限高滚动,创建按钮以紧凑次操作放在列表下方。Escape 收起面板并将焦点返回分支触发器。
- FR-W5:未提交更改支持列表和目录树切换;两种视图均可打开单文件 Diff。
- FR-W6:Git 工作区底部提供默认收起的提交历史区,独立滚动,可调整高度。按当前分支
分页读取提交,展示摘要、作者、时间、短 OID 和 refs。选中提交显示该次修改文件,
- FR-W6:Git 工作区底部提供默认收起的提交历史区,独立滚动,可调整高度。
历史标题默认停在面板底部,首次展开向上占用当前 Git 内容区的一半高度;收起后标题回到底部。
上方变更列表和下方提交历史独立滚动;分隔条支持指针及键盘调整比例,底部位置保持不变。
顶部刷新入口同时更新已展开的提交历史,历史区内不重复提供刷新按钮。
按当前分支分页读取提交,展示摘要、作者、时间、短 OID 和 refs。选中提交显示该次修改文件,
再选文件进入内联提交 Diff;逐层返回保留分支、分页、选择、滚动位置与焦点。
提交 Diff 保留重命名前后路径和实际内容修改;后台工作区刷新不得使正在加载的历史或详情
永久停留在忙碌状态。
历史展开入口使用带方向箭头的紧凑列表行;提交行沿用文件列表的表面、悬停和焦点样式,
摘要与元信息左对齐分行展示,长内容省略并提供完整提示,不撑宽侧栏。
- Git 错误和无更改提示只在 Git 工作区显示,不阻断文件浏览。
切换项目时自动检测 Git 状态,无需手动刷新;检测结果和失败时保留的数据仅用于所属项目。
- 未提交更改以彩色 Git 字母标识状态:M 修改、A 新增、D 删除、R 重命名、C 复制、
U 未跟踪或冲突、T 类型变化;冲突用危险色区别于未跟踪文件,同时保留原始状态。
- 点击更改项进入该文件 Diff,分别显示已暂存和未暂存差异,增删行使用对应状态色;
+118 -7
View File
@@ -5843,6 +5843,115 @@ describe("App", () => {
expect(await screen.findByText("tree-only.txt")).toBeInTheDocument();
});
describe("Git project switch detection", () => {
const secondProject = {
...project,
id: "00000000-0000-4000-8000-000000000102",
name: "Second project",
rootPath: "C:\\Second",
executionSpace: { kind: "local" as const, rootPath: "C:\\Second" },
};
const repositoryChanges = {
rootPath: project.rootPath,
available: true,
status: "?? first-only.md",
patch: "",
files: [{ path: "first-only.md", status: "??" }],
truncated: false,
};
const nonRepositoryChanges = {
rootPath: secondProject.rootPath,
available: false,
status: "",
patch: "",
files: [],
truncated: false,
};
beforeEach(() => {
localStorage.setItem("goodbuddy.active-project.v1", projectId);
localStorage.setItem("goodbuddy.workbar-layout.v1", JSON.stringify({
instances: [
{ id: "10000000-0000-4000-8000-000000000001", appId: "tasks", title: "任务中心" },
{ id: "10000000-0000-4000-8000-000000000002", appId: "workspace", title: "工作区" },
],
activeInstanceId: "10000000-0000-4000-8000-000000000002",
expanded: true,
dock: "right",
widthRatio: 0.3,
taskScope: "current-project",
}));
vi.spyOn(api.projects, "list").mockResolvedValue([project, secondProject]);
vi.spyOn(api.workspace, "listDirectory").mockResolvedValue({
path: "", entries: [], truncated: false,
});
});
it("detects a Git project with restored workspace selection without refresh", async () => {
vi.spyOn(api.workspace, "getChanges")
.mockResolvedValueOnce({ ...nonRepositoryChanges, rootPath: project.rootPath })
.mockResolvedValueOnce({ ...repositoryChanges, rootPath: secondProject.rootPath });
render(<App />);
fireEvent.click(screen.getByLabelText("切换助手工作栏"));
expect(await screen.findByRole("tab", { name: "工作区" })).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId));
expect(screen.queryByRole("button", { name: "Git 工作区" })).not.toBeInTheDocument();
selectProjectOption(secondProject.name);
expect(await screen.findByRole("button", { name: "Git 工作区" })).toBeVisible();
expect(api.workspace.getChanges).toHaveBeenLastCalledWith(secondProject.id);
expect(api.workspace.getChanges).toHaveBeenCalledTimes(2);
});
it.each(["non-repository", "error result", "rejection"] as const)(
"clears previous Git flags and files when switching to a %s",
async (outcome) => {
const next = deferred<Awaited<ReturnType<DesktopApi["workspace"]["getChanges"]>>>();
vi.spyOn(api.workspace, "getChanges")
.mockResolvedValueOnce(repositoryChanges)
.mockReturnValueOnce(next.promise);
render(<App />);
fireEvent.click(screen.getByLabelText("切换助手工作栏"));
fireEvent.click(await screen.findByRole("button", { name: "Git 工作区" }));
expect(await screen.findByText("first-only.md")).toBeVisible();
selectProjectOption(secondProject.name);
await waitFor(() => expect(api.workspace.getChanges).toHaveBeenLastCalledWith(secondProject.id));
expect(screen.queryByRole("button", { name: "Git 工作区" })).not.toBeInTheDocument();
expect(screen.queryByText("first-only.md")).not.toBeInTheDocument();
await act(async () => {
if (outcome === "rejection") next.reject(new Error("Git offline"));
else next.resolve({ ...nonRepositoryChanges, ...(outcome === "error result" ? { error: "Git offline" } : {}) });
});
expect(screen.queryByRole("button", { name: "Git 工作区" })).not.toBeInTheDocument();
expect(screen.queryByText("first-only.md")).not.toBeInTheDocument();
},
);
it.each(["error result", "rejection"] as const)(
"preserves same-project Git flags and files after a refresh %s",
async (outcome) => {
const getChanges = vi.spyOn(api.workspace, "getChanges").mockResolvedValueOnce(repositoryChanges);
render(<App />);
fireEvent.click(screen.getByLabelText("切换助手工作栏"));
fireEvent.click(await screen.findByRole("button", { name: "Git 工作区" }));
expect(await screen.findByText("first-only.md")).toBeVisible();
if (outcome === "rejection") getChanges.mockRejectedValueOnce(new Error("Git offline"));
else getChanges.mockResolvedValueOnce({ ...nonRepositoryChanges, rootPath: project.rootPath, error: "Git offline" });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "刷新工作区文件" }));
});
await waitFor(() => expect(getChanges).toHaveBeenCalledTimes(2));
expect(screen.getByRole("button", { name: "Git 工作区" })).toBeVisible();
expect(screen.getByText("first-only.md")).toBeVisible();
},
);
});
it("ignores stale Git changes after switching projects", async () => {
const secondProject = {
...project,
@@ -5904,13 +6013,15 @@ describe("App", () => {
});
fireEvent.click(screen.getByLabelText("切换助手工作栏"));
expect(await screen.findByText("second.md")).toBeInTheDocument();
resolveFirst?.({
rootPath: project.rootPath,
available: true,
status: "?? stale.md",
patch: "",
files: [{ path: "stale.md", status: "??" }],
truncated: false,
await act(async () => {
resolveFirst?.({
rootPath: project.rootPath,
available: true,
status: "?? stale.md",
patch: "",
files: [{ path: "stale.md", status: "??" }],
truncated: false,
});
});
await waitFor(() =>
+19 -9
View File
@@ -2033,7 +2033,10 @@ function App(): React.JSX.Element {
const assistantTasksRef = useRef(assistantTasks);
const [tokenUsage, setTokenUsage] =
useState<TokenUsageSummary>(emptyTokenUsage);
const [workspaceChanges, setWorkspaceChanges] = useState<WorkspaceChanges>();
const [workspaceChanges, setWorkspaceChanges] = useState<{
projectId: string;
changes: WorkspaceChanges;
}>();
const [assistantArtifacts, setAssistantArtifacts] = useState<
AssistantArtifact[]
>([]);
@@ -4060,9 +4063,15 @@ function App(): React.JSX.Element {
workspaceChangesRequestRef.current === requestId &&
activeProjectIdRef.current === projectId
) {
setWorkspaceChanges((current) => changes.error && current?.available
? { ...current, error: changes.error }
: changes);
setWorkspaceChanges((current) => ({
projectId,
changes:
changes.error &&
current?.projectId === projectId &&
current.changes.available
? { ...current.changes, error: changes.error }
: changes,
}));
}
},
[],
@@ -5626,9 +5635,6 @@ function App(): React.JSX.Element {
);
useEffect(() => {
if (assistantSidebarTab !== "workspace") {
return;
}
const timeout = setTimeout(() => {
void refreshWorkspaceChanges().catch(() => {
notify({
@@ -5638,7 +5644,7 @@ function App(): React.JSX.Element {
});
}, 0);
return () => clearTimeout(timeout);
}, [assistantSidebarTab, refreshWorkspaceChanges]);
}, [refreshWorkspaceChanges]);
useEffect(() => {
void window.goodbuddy.experts
@@ -10857,7 +10863,11 @@ function App(): React.JSX.Element {
open={assistantSidebarOpen}
restoreFocusRef={assistantSidebarToggleRef}
tab={assistantSidebarTab}
workspaceChanges={workspaceChanges}
workspaceChanges={
workspaceChanges?.projectId === activeProjectId
? workspaceChanges.changes
: undefined
}
workspaceProjectId={activeProjectId || undefined}
/>
</div>
@@ -209,6 +209,24 @@ describe('WorkspaceFilesPanel', () => {
expect(css).toMatch(/\.workspace-git__create-branch\s*\{[^}]*justify-self: start/)
})
it('docks history in both states with independently scrolling changes and commits', () => {
const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8')
const navigation = css.match(/\.workspace-files__navigation:has\(\.workspace-files__list:not\(\[hidden\]\) .*?\)\s*\{([^}]*)\}/)?.[1]
expect(navigation).toContain('height: 100%')
expect(navigation).toContain('padding-bottom: 0')
const history = css.match(/^\.workspace-git__history\s*\{([^}]*)\}/m)?.[1]
expect(history).toContain('flex: 1')
expect(history).toContain('min-height: 0')
expect(history).not.toContain('max-height')
expect(css).toMatch(/\.workspace-git__history-scroll\s*\{[^}]*flex: 1;[^}]*overflow: auto;[^}]*min-height: 0/)
expect(css).toMatch(/\.workspace-git__changes\s*\{[^}]*flex: 1;[^}]*min-height: 0;[^}]*overflow: auto/)
expect(css).toMatch(/\.workspace-git__history-dock\s*\{[^}]*flex: 0 0 auto;[^}]*min-height: 0/)
expect(css).toContain('min-height: min(80px, 50%)')
expect(css).toContain('max-height: max(50%, calc(100% - 80px))')
expect(css.match(/^\.workspace-files__changed\s*\{([^}]*)\}/m)?.[1]).not.toContain('border-bottom')
expect(css).toMatch(/\.workspace-git__history-toggle\s*\{[^}]*flex-shrink: 0/)
})
it.each([
['D:\\project', 'd:\\project\\target', 'target/notes.txt'],
['/project', '/project', 'notes.txt'],
@@ -299,10 +317,37 @@ describe('WorkspaceFilesPanel', () => {
fireEvent.click(history)
const commit = (await screen.findByText('Initial commit')).closest('button')!
expect(commit).toHaveClass('workspace-files__changed-row', 'workspace-git__commit')
expect(commit.closest('.workspace-files__git-view')).not.toHaveAttribute('hidden')
const separator = screen.getByRole('separator')
expect(separator.closest('.workspace-git__history-dock')).toHaveStyle({ flexBasis: '50%' })
fireEvent.keyDown(separator, { key: 'ArrowUp' })
expect(separator.closest('.workspace-git__history-dock')).toHaveStyle({ flexBasis: '55%' })
fireEvent.click(history)
expect(screen.queryByRole('separator')).not.toBeInTheDocument()
fireEvent.click(history)
expect(screen.getByRole('separator').closest('.workspace-git__history-dock')).toHaveStyle({ flexBasis: '55%' })
fireEvent.click(commit)
expect(await screen.findByRole('button', { name: /notes\.txt/ })).toHaveClass('workspace-files__changed-row')
})
it('refreshes expanded history from the shared workspace refresh without a duplicate button', async () => {
let version = 0
const manage = vi.fn(async (_project: string, action: { kind: string }) => action.kind === 'history'
? { kind: 'history', commits: [{ oid: 'a'.repeat(40), subject: `Commit ${version}`, author: 'Author', time: '2026-09-09T10:00:00Z', refs: '' }], hasMore: false }
: { kind: 'branches', current: 'main', branches: [] })
vi.stubGlobal('goodbuddy', { workspace: { manage } })
const props = { projectId: 'project', isRepository: true, changedFiles: [], onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })), onLoadDiff: vi.fn(), onOpenFile: vi.fn() }
const { rerender } = render(<WorkspaceFilesPanel {...props} refreshToken={version} />)
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
fireEvent.click(screen.getByRole('button', { name: '提交历史' }))
const first = await screen.findByText('Commit 0')
expect(within(first.closest('section')!).queryByRole('button', { name: '刷新' })).not.toBeInTheDocument()
version++
rerender(<WorkspaceFilesPanel {...props} refreshToken={version} />)
await screen.findByText('Commit 1')
expect(screen.queryByText('Commit 0')).not.toBeInTheDocument()
})
it('cancels an in-flight remote directory browse without moving or reopening the dialog', async () => {
let resolveBrowse!: (value: unknown) => void
const browseDirectories = vi.fn(() => new Promise(resolve => { resolveBrowse = resolve }))
+1 -1
View File
@@ -507,7 +507,7 @@ export function WorkspaceFilesPanel({
{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>}
<div hidden={activeView !== 'changes'}>
<div className="workspace-files__git-view" 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') }]} />
}>
+24 -12
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useEffectEvent, 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'
@@ -16,7 +16,8 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
const [error, setError] = useState('')
const [historyOpen, setHistoryOpen] = useState(false)
const [history, setHistory] = useState<Result<'history'>>()
const [height, setHeight] = useState(240)
const [historySize, setHistorySize] = useState(50)
const workingRef = useRef<HTMLDivElement>(null)
const [commit, setCommit] = useState<string>()
const [files, setFiles] = useState<Result<'commitFiles'>>()
const [patch, setPatch] = useState<Result<'commitDiff'>>()
@@ -65,6 +66,13 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
} catch (reason) { if (generation === request.current) setError(String(reason)) }
finally { if (generation === request.current) setBusy(false) }
}
const refreshHistory = useEffectEvent(() => {
if (historyOpen && !commit && !busy) void loadHistory()
})
useEffect(() => {
const timeout = setTimeout(() => refreshHistory(), 0)
return () => clearTimeout(timeout)
}, [projectId, refreshToken])
const loadCommit = async (oid: string, selectedPath?: string): Promise<void> => {
const generation = ++request.current
setBusy(true); setError('')
@@ -79,7 +87,9 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
}
return <div className="workspace-git">
{error && <p role="alert">{error}<button type="button" disabled={busy} onClick={() => { if (commit) void loadCommit(commit, path); else void loadHistory() }}>{t('files.retry')}</button></p>}
<div hidden={Boolean(commit)} className="workspace-git__working">
{busy && <p role="status">{t('files.reading')}</p>}
<div ref={workingRef} hidden={Boolean(commit)} className="workspace-git__working">
<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>
@@ -92,16 +102,18 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
<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}
<div className="workspace-git__changes">{children}</div>
</div>
<div className="workspace-git__history-dock" style={{ flexBasis: historyOpen ? `${historySize}%` : undefined }}>
{historyOpen && <div role="separator" tabIndex={0} aria-label={t('management.resizeHistory')} aria-orientation="horizontal" aria-valuemin={10} aria-valuemax={90} aria-valuenow={historySize} onKeyDown={(event) => {
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { event.preventDefault(); setHistorySize((current) => event.key === 'Home' ? 10 : event.key === 'End' ? 90 : Math.max(10, Math.min(90, current + (event.key === 'ArrowUp' ? 5 : -5)))) }
}} onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.dataset.startY = String(event.clientY); event.currentTarget.dataset.startSize = String(historySize) }} onPointerMove={(event) => {
const availableHeight = workingRef.current?.getBoundingClientRect().height ?? 0
if (availableHeight > 0 && event.currentTarget.hasPointerCapture(event.pointerId)) setHistorySize(Math.max(10, Math.min(90, Number(event.currentTarget.dataset.startSize) + (Number(event.currentTarget.dataset.startY) - event.clientY) / availableHeight * 100)))
}} onPointerUp={(event) => event.currentTarget.releasePointerCapture(event.pointerId)} />}
<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>
<section hidden={!historyOpen} className="workspace-git__history" style={{ height }}>
<div role="separator" tabIndex={0} aria-label={t('management.resizeHistory')} aria-orientation="horizontal" aria-valuemin={120} aria-valuemax={600} aria-valuenow={height} onKeyDown={(event) => {
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { event.preventDefault(); setHeight((current) => event.key === 'Home' ? 120 : event.key === 'End' ? 600 : Math.max(120, Math.min(600, current + (event.key === 'ArrowUp' ? 20 : -20)))) }
}} onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.dataset.startY = String(event.clientY); event.currentTarget.dataset.startHeight = String(height) }} onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) setHeight(Math.max(120, Math.min(600, Number(event.currentTarget.dataset.startHeight) + Number(event.currentTarget.dataset.startY) - event.clientY)))
}} onPointerUp={(event) => event.currentTarget.releasePointerCapture(event.pointerId)} />
<section hidden={!historyOpen} className="workspace-git__history">
<div className="workspace-git__history-scroll">
<button className="secondary-button" type="button" disabled={busy} onClick={() => void loadHistory()}>{t('sidebar.workspace.refresh')}</button>
{history?.commits.map((item) => <button type="button" className="workspace-files__changed-row workspace-git__commit" key={item.oid} disabled={busy} onClick={(event) => { returnTrigger.current = event.currentTarget; void loadCommit(item.oid) }}>
<strong title={item.subject}>{item.subject}</strong><span title={`${item.author} · ${new Date(item.time).toLocaleString()}`}>{item.author} · {new Date(item.time).toLocaleString()}</span><small title={`${item.oid} ${item.refs}`}>{item.oid.slice(0, 8)} {item.refs}</small>
</button>)}
@@ -109,6 +121,7 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
{history?.hasMore && <button className="secondary-button" type="button" disabled={busy} onClick={() => void loadHistory(true)}>{t('management.loadMore')}</button>}
</div>
</section>
</div>
</div>
{commit && <section>
<div hidden={Boolean(path)}>
@@ -121,6 +134,5 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
{patch?.truncated && <p>{t('files.diffTruncated')}</p>}
</div>}
</section>}
{busy && <p role="status">{t('files.reading')}</p>}
</div>
}
+37 -5
View File
@@ -4509,7 +4509,6 @@ button > svg {
display: flex;
min-width: 0;
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
flex-direction: column;
gap: var(--space-1);
}
@@ -17525,11 +17524,44 @@ details.settings-section > :not(summary) + :not(summary) {
}
.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__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-toggle { font-weight: 650; }
.workspace-git__history-dock { display: flex; flex-direction: column; flex: 0 0 auto; min-height: 0; border-top: 1px solid var(--border-default); }
.workspace-git__history-dock:has(.workspace-git__history:not([hidden])) {
min-height: min(80px, 50%);
max-height: max(50%, calc(100% - 80px));
}
.workspace-git__history { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.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__history-dock > [role="separator"] { height: var(--space-2); flex: 0 0 auto; cursor: ns-resize; touch-action: none; }
.workspace-git__history-scroll { flex: 1; overflow: auto; min-height: 0; }
/* Keep the history header docked even when its content is collapsed. */
.workspace-files__navigation:has(.workspace-files__list:not([hidden]) .workspace-files__git-view:not([hidden]) .workspace-git__working:not([hidden])) {
height: 100%;
min-height: 0;
padding-bottom: 0;
}
.workspace-files__navigation:has(.workspace-git) .workspace-files,
.workspace-files__navigation:has(.workspace-git) .workspace-files__list:not([hidden]),
.workspace-files__git-view:not([hidden]),
.workspace-git,
.workspace-git__current {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
flex-direction: column;
}
.workspace-git__working { flex: 1; }
.workspace-git__current { gap: var(--space-2); overflow: auto; }
.workspace-git > [role="status"] { margin: 0; flex-shrink: 0; }
.workspace-files__header,
.workspace-git__toolbar,
.workspace-git__history-toggle { flex-shrink: 0; }
.workspace-git__changes {
flex: 1;
min-height: 0;
overflow: auto;
}
.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); }