diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index adb36c19..11fd6e09 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -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,分别显示已暂存和未暂存差异,增删行使用对应状态色; diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 67bba0e5..98753b9d 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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(); + 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>>(); + vi.spyOn(api.workspace, "getChanges") + .mockResolvedValueOnce(repositoryChanges) + .mockReturnValueOnce(next.promise); + render(); + 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(); + 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(() => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7ad29e17..1764b2ee 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2033,7 +2033,10 @@ function App(): React.JSX.Element { const assistantTasksRef = useRef(assistantTasks); const [tokenUsage, setTokenUsage] = useState(emptyTokenUsage); - const [workspaceChanges, setWorkspaceChanges] = useState(); + 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} /> diff --git a/src/renderer/src/WorkspaceFilesPanel.test.tsx b/src/renderer/src/WorkspaceFilesPanel.test.tsx index cd4314ea..1b08332b 100644 --- a/src/renderer/src/WorkspaceFilesPanel.test.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.test.tsx @@ -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() + 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() + 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 })) diff --git a/src/renderer/src/WorkspaceFilesPanel.tsx b/src/renderer/src/WorkspaceFilesPanel.tsx index af7eecb6..e805ab27 100644 --- a/src/renderer/src/WorkspaceFilesPanel.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.tsx @@ -507,7 +507,7 @@ export function WorkspaceFilesPanel({ {browsedPath.split('/').filter(Boolean).map((part, index, parts) => browse(parts.slice(0, index + 1).join('/'))}>{part})} } - + {showGit && }> diff --git a/src/renderer/src/WorkspaceGitTools.tsx b/src/renderer/src/WorkspaceGitTools.tsx index 4274b874..465d019a 100644 --- a/src/renderer/src/WorkspaceGitTools.tsx +++ b/src/renderer/src/WorkspaceGitTools.tsx @@ -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>() - const [height, setHeight] = useState(240) + const [historySize, setHistorySize] = useState(50) + const workingRef = useRef(null) const [commit, setCommit] = useState() const [files, setFiles] = useState>() const [patch, setPatch] = useState>() @@ -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 => { const generation = ++request.current setBusy(true); setError('') @@ -79,7 +87,9 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont } return {error && {error} { if (commit) void loadCommit(commit, path); else void loadHistory() }}>{t('files.retry')}} - + {busy && {t('files.reading')}} + + setBranchOpen(!branchOpen)}>{branches?.current || t('management.branch')} @@ -92,16 +102,18 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont {branches?.branches.filter((branch) => branch.name.toLowerCase().includes(query.toLowerCase())).map((branch) => void run({ kind: 'switchBranch', branch: branch.name, remote: branch.remote })}>{branch.name}{t(branch.remote ? 'management.remote' : 'management.local')})} void run({ kind: 'createBranch', branch: query })}>{t('management.createBranch')} } - {children} + {children} + + + {historyOpen && { + 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)} />} { setHistoryOpen(!historyOpen); if (!historyOpen && !history) void loadHistory() }}>{historyOpen ? : }{t('management.history')} - - { - 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)} /> + - void loadHistory()}>{t('sidebar.workspace.refresh')} {history?.commits.map((item) => { returnTrigger.current = event.currentTarget; void loadCommit(item.oid) }}> {item.subject}{item.author} · {new Date(item.time).toLocaleString()}{item.oid.slice(0, 8)} {item.refs} )} @@ -109,6 +121,7 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont {history?.hasMore && void loadHistory(true)}>{t('management.loadMore')}} + {commit && @@ -121,6 +134,5 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont {patch?.truncated && {t('files.diffTruncated')}} } } - {busy && {t('files.reading')}} } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 0b205d16..dfd84b3c 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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); }
{error} { if (commit) void loadCommit(commit, path); else void loadHistory() }}>{t('files.retry')}
{t('files.reading')}
{t('files.diffTruncated')}