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) => )} } -