From f669e490b8cb27d7b28b823f5f05db644a93b3b9 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Wed, 9 Sep 2026 15:47:24 +0800 Subject: [PATCH] feat: add workspace file management and embedded browsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users previously had to leave the workbar to manage project files and inspect Git branches or commit history. Add file and directory creation, rename, move, delete and properties, plus searchable branches, branch creation and switching, explicit fetch, and commit file diffs. Keep file previews in the right workspace pane with directory expansion, selection, scroll and focus preserved on return. Add compact action menus, directory pickers, preview refresh and source switching. Embed the shared browser session directly in the workbar with navigation, resizing and fullscreen controls instead of a separate interaction window. Moves stay within the current workspace, deletion is permanent after confirmation, and Git switch reports conflicts without discarding or stashing changes. Typecheck, lint and staged whitespace checks pass; existing full-suite failures and the blocked real remote directory validation remain recorded in the workbar progress document. Release note: 可在右侧工作区管理文件、切换 Git 分支并查看提交历史,返回预览后保留浏览位置;浏览器支持在工作栏内直接交互和全屏显示。 --- FEATURES.md | 7 +- docs/features/assistant-workbar/prd.md | 46 +- docs/features/assistant-workbar/progress.md | 54 +++ .../workspace-protocol-methods.ts | 13 + src/agent-daemon/workspace-registry.ts | 11 +- src/main/browser/browser-service.test.ts | 103 +++-- src/main/browser/browser-service.ts | 100 +++-- .../browser/electron-browser-session.test.ts | 144 ++---- src/main/browser/electron-browser-session.ts | 259 +++++------ .../execution-space-resolver.test.ts | 1 + src/main/ipc.test.ts | 28 +- src/main/ipc.ts | 54 ++- .../remote-agent/agent-protocol-client.ts | 6 + .../managed-remote-acp-runtime.test.ts | 1 + .../protocol-remote-workspace-transport.ts | 3 + src/main/workspace/local-workspace-access.ts | 4 + .../workspace/remote-workspace-access.test.ts | 1 + src/main/workspace/remote-workspace-access.ts | 12 + .../unsupported-remote-workspace-access.ts | 2 + src/main/workspace/workspace-access.ts | 1 + .../workspace/workspace-management.test.ts | 67 +++ src/main/workspace/workspace-management.ts | 107 +++++ src/preload/index.ts | 11 +- src/renderer/src/App.test.tsx | 106 ++--- src/renderer/src/App.tsx | 14 +- .../src/RightAssistantSidebar.resize.test.tsx | 141 +++++- src/renderer/src/RightAssistantSidebar.tsx | 425 ++++++++++++------ src/renderer/src/WorkspaceActionDialog.tsx | 152 +++++++ src/renderer/src/WorkspaceFilesPanel.test.tsx | 312 ++++++++++++- src/renderer/src/WorkspaceFilesPanel.tsx | 332 ++++++++++---- src/renderer/src/WorkspaceGitTools.tsx | 123 +++++ .../src/i18n/locales/en-US/workspace.ts | 30 +- .../src/i18n/locales/zh-CN/workspace.ts | 30 +- src/renderer/src/styles.css | 333 +++++++++++--- src/shared/contracts.ts | 30 +- src/shared/ipc-channels.ts | 3 +- src/shared/remote-agent-contracts.ts | 6 + src/shared/workspace-management-contracts.ts | 36 ++ 38 files changed, 2336 insertions(+), 772 deletions(-) create mode 100644 src/main/workspace/workspace-management.test.ts create mode 100644 src/main/workspace/workspace-management.ts create mode 100644 src/renderer/src/WorkspaceActionDialog.tsx create mode 100644 src/renderer/src/WorkspaceGitTools.tsx create mode 100644 src/shared/workspace-management-contracts.ts diff --git a/FEATURES.md b/FEATURES.md index 4038da6e..7d10a057 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -575,9 +575,10 @@ otherwise. draft, diagnostic choice, and request ID. Conversations, Prompts, credentials, file contents, paths, raw provider responses, and remote Agent logs are never attached. -- [x] **Private-network compatibility mode**: Enabled by default and permits - in-app HTTP plus invalid, self-signed, or expired HTTPS certificates. - Disabling it restores strict address and certificate validation. +- [x] **Private-network compatibility**: Permits in-app HTTP plus invalid, + self-signed, or expired HTTPS certificates. Fixed platform services such as + feedback and WeChat credential or media endpoints retain strict certificate + validation. ### Open source, builds, and releases diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index 0466e922..60256dac 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -136,7 +136,7 @@ Runtime 稳定能力目录和已打开面板必须准确呈现能力差异: - 当前 Runtime 不支持后台 Job 时,Runtime 能力仍可发现;打开后说明当前可监督的内容。 -- 当前执行空间没有 Git 仓库时,工作区文件功能仍可使用,Git 区域显示不可用原因。 +- 当前执行空间没有 Git 仓库时,工作区文件功能仍可使用,仅确认是 Git 仓库后显示 Git 工作区入口。 - 没有活动进程时,进程面板提供创建终端或启动 Runtime 的入口。 - 没有项目时,终端使用当前用户本机 Home;工作区等允许选择路径的能力仍必须先取得有界 临时 Execution Space Grant,不能把可选路径直接传给文件、Git、Process 或 Runtime。 @@ -372,19 +372,57 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 当前项目目录的文件浏览与 Git 交互遵循以下规则: -- 页签下不重复显示“项目工作区”标题,保留刷新按钮。 +- FR-W1:顶部保留当前浏览目录及可返回父目录的路径导航,根目录使用图标入口,完整根路径通过可访问名称和提示提供,不重复显示项目名称或独立路径标题;工具栏依次提供刷新、新建文件、新建目录。 + 路径导航与操作工具栏分行排列;操作使用带名称和提示的紧凑图标按钮,在 300px 工作栏内保持单行。 + 长目录名单行省略,完整路径通过提示查看;多级路径在导航内部横向滚动,不撑宽工作栏。 + 选中目录时在该目录创建,选中文件时在其父目录创建,未选择时在当前浏览目录创建。 + 无可用目录时隐藏无关操作。 +- 使用共享 `SegmentedControl` 切换“文件”和“Git 工作区”,默认显示文件; + 选择只在当前面板挂载期间保存在内存中,不持久化。未提交更改包含当前项目的全部 + Git 更改,不按 AI、用户或其他工具的修改来源筛选。 +- FR-W2:只有已确认的 Git 仓库显示 Git 工作区入口。临时读取或连接错误保留同一项目 + 已确认的 Git 可用性和内容,提供重试;切换项目不能沿用上一项目的确认结果。 +- FR-W3:文件和目录行的更多按钮与左侧会话行一致,在悬停、键盘聚焦或菜单打开时显示。 + 菜单复用会话操作的图标、间距和悬停样式,浮层限制在窗口内,不受文件列表滚动边界裁切; + 支持方向键、Home、End 和 Escape,Escape 或选定操作后焦点返回触发按钮,删除入口独立分隔。 + 菜单提供重命名、移动、删除、属性、复制路径和本机默认应用打开;删除须明确确认对象 + 与不可恢复性。操作失败保留输入与上下文。本机和远程均支持文件管理,系统默认打开仅本机可用。 + 移动通过与创建项目相同的目录选择流程指定目标:本机打开系统目录选择器,远程浏览当前 + SSH Host 的目录。选择目录后保留原名,确认后执行;取消不移动文件。目标限于当前工作区, + 选择外部目录时在弹窗内说明限制,不切换项目或工作区范围。取消按钮使用共享次按钮样式, + 更多菜单中的删除图标和文字与其他菜单项一样左对齐。 +- FR-W4:Git 分支标签打开可搜索的本地与远程分支选择器,支持创建和切换分支,以及显式 + Fetch。切换使用普通 Git switch,保留可携带的未提交修改;冲突时显示 Git 错误。 + 不自动 force、reset、discard 或 stash,不因浏览分支而自动 Fetch。 + 分支触发器复用共享模型选择按钮,带分支图标和展开箭头;Fetch 使用共享次按钮,二者在 + 紧凑工作栏内保持同一行,长分支名省略并提供完整名称提示。 +- FR-W5:未提交更改支持列表和目录树切换;两种视图均可打开单文件 Diff。 +- FR-W6:Git 工作区底部提供默认收起的提交历史区,独立滚动,可调整高度。按当前分支 + 分页读取提交,展示摘要、作者、时间、短 OID 和 refs。选中提交显示该次修改文件, + 再选文件进入内联提交 Diff;逐层返回保留分支、分页、选择、滚动位置与焦点。 + 历史展开入口使用带方向箭头的紧凑列表行;提交行沿用文件列表的表面、悬停和焦点样式, + 摘要与元信息左对齐分行展示,长内容省略并提供完整提示,不撑宽侧栏。 +- Git 错误和无更改提示只在 Git 工作区显示,不阻断文件浏览。 - 未提交更改以彩色 Git 字母标识状态:M 修改、A 新增、D 删除、R 重命名、C 复制、 U 未跟踪或冲突、T 类型变化;冲突用危险色区别于未跟踪文件,同时保留原始状态。 - 点击更改项进入该文件 Diff,分别显示已暂存和未暂存差异,增删行使用对应状态色; 删除文件也可查看。目录树中的文件仍打开内容预览。不再提供底部全仓库 Diff 入口。 +- 文件预览保留在右侧工作区内;预览期间隐藏列表但保持目录树挂载,返回后保留已展开 + 目录、滚动位置和选中行,并恢复触发行焦点。Diff 返回未提交更改列表时同样恢复 + 滚动位置、选中行和焦点。 +- 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用默认应用打开”。 + 刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。 +- Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。 - 手动刷新或任务结束更新 Git 状态时,同步重读根目录和已展开目录,保留目录展开状态, - 清除未展开目录的旧缓存;正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。 + 刷新失败保留已有目录列表;重新展开目录时重读。目录读取错误在对应目录旁显示, + 可就地重试。正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。 无首次提交的仓库也应显示新增和暂存文件。 - 未提交更改首批显示 50 项,可继续分批加载其余变更,不能使后续文件尤其是已删除 文件失去 Diff 入口。 - 系统默认应用和资源管理器打开按钮仅用于本机项目,SSH 项目不显示这两个按钮。 -目录切换、下载副本、终端打开及 HTML 安全预览仍属于后续能力,不能据此视为已实现。 +项目内目录导航属于 FR-W1;项目外目录选择、下载副本、终端打开及 HTML 安全预览仍属于后续能力。 +本轮工作区交互不包含项目文件搜索,也不调整其他工作栏页签。 本机和远程访问都必须由 Main 或远程 GoodBuddy Agent 在对应文件系统上执行路径规范化、相对路径和 符号链接边界检查。Renderer 只能提交受约束的相对路径和已授权范围 ID。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 8d675d0c..e1b7293d 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,59 @@ # 工作栏实现与验证进度 +## 2026-09-09 移动目录选择与 Git 控件修正 + +- 本轮在已有文件管理改动上修正 [FR-W3、FR-W4、FR-W6](./prd.md#76-工作区): + 本机移动调用创建项目使用的 `settings.selectWorkspace()`;远程移动调用 + `sshHosts.browseDirectories()`,按项目 Host 浏览。所选目录映射为工作区相对目标, + 保留原名;取消不提交移动,外部目录在弹窗内提示限制。 +- 取消、分支、Fetch、历史展开和提交行接入共享控件样式;删除菜单项左对齐。 + 远程目录列表限制滚动高度,保留加载、空目录、失败和截断提示。 +- `npm test -- src/renderer/src/WorkspaceFilesPanel.test.tsx src/renderer/src/RightAssistantSidebar.resize.test.tsx src/main/workspace/workspace-management.test.ts src/main/ssh/ssh-host-directory-browser.test.ts` + 通过 56 项,覆盖本机目录选择桥接、Windows 与 POSIX 目标映射、取消与越界选择、 + 远程 Host 和目录参数、取消未完成的浏览,以及 Git 控件与真实文件管理服务。 +- `npm test -- src/renderer/src/App.test.tsx -t "workspace|stale Git|opens the global assistant sidebar"` + 通过 12 项。工作栏切换测试改为等待项目目录加载后出现刷新按钮。 +- `npm run typecheck`、`npm run lint` 和 `git diff --check` 通过。 +- 本轮整库 `npm test` 完整运行 944 秒,3642 项通过、61 项跳过、6 项失败。 + 工作栏加载时序断言已修正并定向复测通过;其余 5 项涉及 Agent 离线依赖测试超时、 + DeepSeek Harness MCP 加载、Runtime 版本探测,以及 portable/release 打包产物检查。 + 未再次运行整库测试,不记为整库通过。 +- 隔离 Electron 窗口加载当前源码组件和 CSS,检查浅色、深色主题下 300px、420px 工作栏: + 无横向溢出,分支与 Fetch 同行,提交行左对齐;移动弹窗没有路径输入框,取消为次按钮, + 删除项计算样式为左对齐。截图保留于本机临时验证目录。此检查使用桥接替身,未自动操作 + 系统原生目录对话框,也不替代完整应用中的人工验收。 +- 尝试从隔离 Electron 配置调用真实 SSH 目录浏览服务时,已有凭据在 + `safeStorage.decryptString` 解密失败,尚未建立 Host 连接。真实远程目录选择验收仍受阻; + 未修改用户配置或远端文件,本轮未调用真实模型。 + +## 2026-09-09 文件管理与 Git 工作区扩展 + +- 已只读核对根目录规范、UI 设计系统、目录浏览组件、WorkspaceAccess 与 Agent 协议。 + 本轮范围定义于 [PRD 工作区 FR-W1 至 FR-W6](./prd.md#76-工作区)。 +- 当前生产路径具备目录、属性、文本预览和工作树 Diff;Agent 的写入及 Git operation + 仍是只读拒绝接口。文件管理、分支与提交历史尚未实现或验证,不能视为完成。 +- 待完成源码实现、整库 test/typecheck/lint、真实桌面路径与当前源码 Linux Host 验证。 + 本轮开始前已有浏览器与工作区未提交改动,保留这些改动;不提交或推送。 + +## 2026-09-09 工作区页签交互 + +- 已核对当前工作树中的 `RightAssistantSidebar.tsx` 和 `WorkspaceFilesPanel.tsx` 差异: + 文件预览期间目录树保持挂载,接入返回位置与焦点恢复、文件与未提交更改分段视图、 + 目录就地重试,以及文件预览和 Diff 操作。行为定义见 [PRD 工作区](./prd.md#76-工作区)。 +- `npm test -- src/renderer/src/RightAssistantSidebar.resize.test.tsx src/renderer/src/WorkspaceFilesPanel.test.tsx` + 通过 35 项,覆盖目录展开、返回位置与焦点、视图选择、刷新失败保留内容、Markdown + 源码切换,以及切换项目后丢弃未完成的文件预览。 +- `npm test -- src/renderer/src/App.test.tsx -t "workspace|stale Git"` 通过 11 项, + 覆盖应用中的文件预览、路径复制、默认应用打开、返回和任务结束刷新。 +- 本轮 `npm test` 完整运行结果为 3629 通过、61 跳过、5 失败。失败位于 Agent 离线 + 依赖打包超时、DeepSeek Harness MCP 加载、Runtime 版本探测,以及两个打包产物检查, + 不将该结果记为整库回归通过。 +- `npm run lint` 和差异空白检查通过。最新 `npm run typecheck` 被同时进行的浏览器 + 接口变更阻塞:测试替身缺少 `hide`、`setBounds`、`setViewport`,部分侧栏测试仍传递 + 已移除的浏览器回调。该轮类型检查未通过,未改动另一项任务的接口或测试替身。 +- 尚未进行真实桌面视觉验收;组件测试的滚动断言不代替实际窗口中的布局和滚动检查。 +- 范围仅限工作区页签,不包含项目文件搜索或其他页签改动。 + ## 2026-09-09 托管 Runtime 并行阻塞 - Windows 完整隔离测试程序通过界面发送 OpenCode 原生双子代理、直连与 Continue 文件 diff --git a/src/agent-daemon/workspace-protocol-methods.ts b/src/agent-daemon/workspace-protocol-methods.ts index 6162d386..63d5327f 100644 --- a/src/agent-daemon/workspace-protocol-methods.ts +++ b/src/agent-daemon/workspace-protocol-methods.ts @@ -1,4 +1,5 @@ import { + remoteWorkspaceManageRequestSchema, remoteGitDiffRequestSchema, remoteGitDiffResultSchema, remoteGitStatusRequestSchema, @@ -25,6 +26,8 @@ import type { ProtocolMethodHandler } from './protocol-server' import type { WorkspaceGitService } from './workspace-git-service' +import { manageWorkspace } from '../main/workspace/workspace-management' +import { workspaceManagementResultSchema } from '../shared/workspace-management-contracts' import type { WorkspaceRegistry } from './workspace-registry' import { WorkspaceServiceError, @@ -35,6 +38,7 @@ const DEFAULT_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000 const MAXIMUM_WORKSPACE_REQUEST_TIMEOUT_MS = 120_000 export const WORKSPACE_PROTOCOL_METHODS = [ + 'workspace/manage', 'workspace/validate', 'workspace/open', 'workspace/resume', @@ -77,6 +81,15 @@ export function createWorkspaceProtocolMethods(options: { } return { + 'workspace/manage': async (params, context) => { + const request = remoteWorkspaceManageRequestSchema.parse(params) + return await invoke(context, async (io) => { + const workspace = await options.workspaces.get(request.workspaceId, request.generation, context.controller, io) + if (workspace.handle.access !== 'read-write') throw new WorkspaceServiceError('Workspace management requires read-write access', 'read-only') + await workspace.access.assertCurrent(io) + return workspaceManagementResultSchema.parse(await manageWorkspace(workspace.access.root.canonicalPath, request.action, io.signal)) + }) + }, 'workspace/validate': async (params, context) => await invoke(context, async (io) => remoteWorkspaceValidateResultSchema.parse( diff --git a/src/agent-daemon/workspace-registry.ts b/src/agent-daemon/workspace-registry.ts index ea11120c..39602efd 100644 --- a/src/agent-daemon/workspace-registry.ts +++ b/src/agent-daemon/workspace-registry.ts @@ -161,7 +161,8 @@ export class WorkspaceRegistry { controller, true, undefined, - options + options, + request.requestedAccess ) } catch (error) { if (existing === undefined) { @@ -413,7 +414,8 @@ export class WorkspaceRegistry { controller: ControllerLease, authorizesOpen: boolean, grantWorkspaceId: string | undefined, - options: WorkspaceIoOptions + options: WorkspaceIoOptions, + requestedAccess: 'read-only' | 'read-write' = 'read-only' ): Promise { this.#assertCapacity(controller.controllerId) await root.access.assertCurrent(options) @@ -439,7 +441,7 @@ export class WorkspaceRegistry { workspaceId, workspaceIdentity, canonicalDisplayPath: root.access.root.canonicalPath, - access: 'read-only', + access: requestedAccess, git: root.git, capabilities: capabilitiesFor(root.git), generation @@ -486,11 +488,10 @@ export class WorkspaceRegistry { } #assertReadOnly( - requestedAccess: 'read-only' | 'read-write', + _requestedAccess: 'read-only' | 'read-write', capabilities: readonly string[] ): void { if ( - requestedAccess !== 'read-only' || capabilities.includes('write-text-atomic') || capabilities.includes('apply-change-set') ) { diff --git a/src/main/browser/browser-service.test.ts b/src/main/browser/browser-service.test.ts index a018af3b..ee9938ac 100644 --- a/src/main/browser/browser-service.test.ts +++ b/src/main/browser/browser-service.test.ts @@ -14,6 +14,7 @@ type HarnessSlot = { currentUrl?: string approvedOrigin?: string emitLoading(isLoading: boolean): void + emitNavigation(url: string): void session: BrowserSessionLike driver: BrowserDriverLike } @@ -55,6 +56,7 @@ function createHarness(options: { getURL: () => slot.currentUrl ?? `${slot.currentOrigin}/page` } as BrowserWebContents const loadingListeners = new Set<(isLoading: boolean) => void>() + const navigationListeners = new Set<(url: string) => void>() let isLoading = false const session: BrowserSessionLike = { webContents, @@ -67,7 +69,11 @@ function createHarness(options: { loadingListeners.add(listener) return () => loadingListeners.delete(listener) }), - openInteraction: vi.fn(async () => undefined), + onNavigationChange: vi.fn((listener) => { + navigationListeners.add(listener) + return () => navigationListeners.delete(listener) + }), + setViewport: vi.fn(), stopLoading: vi.fn(), ...(options.captureScreenshot ? { captureScreenshot: vi.fn(options.captureScreenshot) } @@ -123,6 +129,13 @@ function createHarness(options: { for (const listener of loadingListeners) { listener(loading) } + }, + emitNavigation: (url: string) => { + slot.currentUrl = url + slot.currentOrigin = canonicalizeBrowserUrl(url).origin + for (const listener of navigationListeners) { + listener(url) + } } }) slots.push(slot) @@ -152,6 +165,42 @@ afterEach(() => { }) describe('BrowserService', () => { + it('presents one session in the shared viewport and tracks user navigation', async () => { + const harness = createHarness() + const bounds = { x: 900, y: 120, width: 420, height: 640 } + const states: BrowserLiveState[] = [] + harness.service.onState((state) => states.push(state)) + harness.service.setViewport('conversation', bounds) + + await harness.service.navigate( + 'conversation', + 'https://example.com/', + new AbortController().signal + ) + const slot = harness.slots[0] + expect(slot?.session.setViewport).toHaveBeenCalledWith(bounds) + + vi.mocked(slot!.driver.getNavigationMetadata).mockResolvedValueOnce({ + url: 'https://example.com/account', + canGoBack: true + }) + slot?.emitNavigation('https://example.com/account') + await vi.waitFor(() => { + expect(states.at(-1)).toMatchObject({ + status: 'ready', + url: 'https://example.com/account', + canGoBack: true + }) + }) + expect(harness.service.getOrigin('conversation')).toBe( + 'https://example.com' + ) + + harness.service.setViewport() + expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined) + await harness.service.dispose() + }) + it('publishes browser status and live frames through session cleanup', async () => { const harness = createHarness() const states: Array<{ @@ -464,58 +513,6 @@ describe('BrowserService', () => { await harness.service.dispose() }) - it('pauses agent operations while the user interacts with the same session', async () => { - const harness = createHarness() - const signal = new AbortController().signal - const states: BrowserLiveState[] = [] - harness.service.onState((state) => states.push(state)) - await harness.service.navigate( - 'conversation', - 'https://a.example/', - signal - ) - const interactionGate = deferred< - Awaited> - >() - const slot = harness.slots[0] - if (!slot) { - throw new Error('slot missing') - } - vi.mocked(slot.session.openInteraction).mockReturnValueOnce( - interactionGate.promise - ) - - const interaction = harness.service.interact( - 'conversation', - signal - ) - await vi.waitFor(() => - expect(slot.session.openInteraction).toHaveBeenCalledOnce() - ) - const snapshot = harness.service.snapshot('conversation', signal) - await new Promise((resolve) => setTimeout(resolve, 10)) - expect(slot.driver.snapshot).not.toHaveBeenCalled() - - interactionGate.resolve({ - type: 'image', - mimeType: 'image/jpeg', - data: 'closing-frame' - }) - await interaction - expect(states.slice(-2).map((state) => state.status)).toEqual([ - 'interactive', - 'ready' - ]) - expect(states.at(-1)?.frameDataUrl).toBe( - 'data:image/jpeg;base64,closing-frame' - ) - expect(harness.service.getSessionCount()).toBe(1) - expect(slot.session.dispose).not.toHaveBeenCalled() - await snapshot - expect(slot.driver.snapshot).toHaveBeenCalledOnce() - await harness.service.dispose() - }) - it('does not let a canceled queued waiter clear the active operation owner', async () => { const harness = createHarness() const signal = new AbortController().signal diff --git a/src/main/browser/browser-service.ts b/src/main/browser/browser-service.ts index 908edd45..5ac5cc3c 100644 --- a/src/main/browser/browser-service.ts +++ b/src/main/browser/browser-service.ts @@ -9,6 +9,7 @@ import type { BrowserScreenshot } from './browser-screenshot' import { ElectronBrowserSession, type BrowserParentWindowHandle, + type BrowserWindowBounds, type BrowserWebContents } from './electron-browser-session' import type { BrowserLiveState } from '../../shared/contracts' @@ -31,7 +32,8 @@ export type BrowserSessionLike = { getCurrentOrigin(): string | undefined isLoading(): boolean onLoadingChange(listener: (isLoading: boolean) => void): () => void - openInteraction(): Promise + onNavigationChange?(listener: (url: string) => void): () => void + setViewport?(bounds?: BrowserWindowBounds): void stopLoading(): void captureScreenshot?(signal: AbortSignal): Promise dispose(): Promise @@ -84,6 +86,7 @@ type BrowserSlot = { isLoading: boolean stopLoadingRequested: boolean removeLoadingListener: () => void + removeNavigationListener: () => void idleTimer?: ReturnType lastUsedAt: number released: boolean @@ -172,6 +175,8 @@ export class BrowserService { (state: BrowserLiveState) => void >() private readonly liveStates = new Map() + private visibleConversationId?: string + private viewportBounds?: BrowserWindowBounds private lifecycle = new AbortController() private clearOperation?: Promise private clearing = false @@ -221,6 +226,19 @@ export class BrowserService { } } + setViewport( + conversationId?: string, + bounds?: BrowserWindowBounds + ): void { + this.visibleConversationId = conversationId + this.viewportBounds = bounds + for (const [id, slot] of this.slots) { + slot.session.setViewport?.( + id === conversationId ? bounds : undefined + ) + } + } + private emitState( conversationId: string, status: BrowserLiveState['status'], @@ -500,10 +518,16 @@ export class BrowserService { isLoading: session.isLoading(), stopLoadingRequested: false, removeLoadingListener: () => undefined, + removeNavigationListener: () => undefined, lastUsedAt: Date.now(), released: false } this.slots.set(conversationId, slot) + slot.session.setViewport?.( + this.visibleConversationId === conversationId + ? this.viewportBounds + : undefined + ) slot.removeLoadingListener = session.onLoadingChange((isLoading) => { if (!isLoading) { slot.stopLoadingRequested = false @@ -518,9 +542,49 @@ export class BrowserService { slot.isLoading = isLoading const current = this.liveStates.get(conversationId) if (current) { - this.emitState(conversationId, current.status, { isLoading }) + const status = + slot.active?.status ?? + (isLoading + ? 'loading' + : current.status === 'loading' + ? 'ready' + : current.status) + this.emitState(conversationId, status, { isLoading }) } }) + slot.removeNavigationListener = + session.onNavigationChange?.((url) => { + if (slot.released || this.slots.get(conversationId) !== slot) { + return + } + try { + const target = canonicalizeBrowserUrl(url) + slot.origin = target.origin + slot.lastUsedAt = Date.now() + this.scheduleIdleExpiry(slot) + const status = slot.active?.status ?? 'ready' + this.emitState(conversationId, status, { url: target.href }) + void slot.driver + .getNavigationMetadata(AbortSignal.timeout(2_000)) + .then((metadata) => { + if ( + slot.released || + this.slots.get(conversationId) !== slot || + canonicalizeBrowserUrl(metadata.url).href !== target.href + ) { + return + } + this.emitState( + conversationId, + slot.active?.status ?? 'ready', + { url: target.href, canGoBack: metadata.canGoBack } + ) + }) + .catch(() => undefined) + } catch { + // The session rejects unsupported top-level URLs. + } + }) ?? (() => undefined) this.scheduleIdleExpiry(slot) return slot } @@ -878,37 +942,6 @@ export class BrowserService { ) } - async interact( - conversationId: string, - signal: AbortSignal - ): Promise { - await this.runInSession( - conversationId, - signal, - 'interactive', - false, - '浏览器交互', - async (slot, effectiveSignal) => { - await this.verifyCurrentOriginOrRelease(slot) - const closingFrame = await waitFor( - slot.session.openInteraction(), - effectiveSignal - ) - const currentUrl = canonicalizeBrowserUrl( - slot.session.webContents.getURL() - ) - slot.origin = currentUrl.origin - await this.captureFrame( - conversationId, - slot, - effectiveSignal, - currentUrl.href, - closingFrame - ) - } - ) - } - async stopLoading(conversationId: string): Promise { const slot = this.slots.get(conversationId) const active = slot?.active @@ -1043,6 +1076,7 @@ export class BrowserService { slot.idleTimer = undefined } slot.removeLoadingListener() + slot.removeNavigationListener() slot.active?.controller.abort(new Error('浏览器会话已释放')) try { slot.driver.dispose() diff --git a/src/main/browser/electron-browser-session.test.ts b/src/main/browser/electron-browser-session.test.ts index 3c34dd0f..56ac8e71 100644 --- a/src/main/browser/electron-browser-session.test.ts +++ b/src/main/browser/electron-browser-session.test.ts @@ -5,7 +5,7 @@ import { ElectronBrowserSession, type BrowserPartitionSession, type BrowserWebContents, - type BrowserWindowHandle, + type BrowserViewHandle, type FilteringProxyLike } from './electron-browser-session' @@ -21,7 +21,6 @@ function createHarness() { const debuggerEvents = new EventEmitter() const contentEvents = new EventEmitter() const partitionEvents = new EventEmitter() - const windowEvents = new EventEmitter() let currentUrl = '' let loadingMainFrame = false let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined @@ -63,34 +62,20 @@ function createHarness() { openHandler = handler }), capturePage: vi.fn(async () => capturedImage), + loadURL: vi.fn(async (url: string) => { + currentUrl = url + }), getURL: vi.fn(() => currentUrl), isLoadingMainFrame: vi.fn(() => loadingMainFrame), stop: vi.fn(), destroy: vi.fn(), isDestroyed: vi.fn(() => false) } - const window: BrowserWindowHandle = { + const view: BrowserViewHandle = { + nativeView: {}, webContents, - loadURL: vi.fn(async (url: string) => { - currentUrl = url - }), - show: vi.fn(), - minimize: vi.fn(), - restore: vi.fn(), - isMinimized: vi.fn(() => false), - focus: vi.fn(), - on: (event, listener) => - windowEvents.on( - event, - listener as (...argumentsValue: unknown[]) => void - ), - off: (event, listener) => - windowEvents.off( - event, - listener as (...argumentsValue: unknown[]) => void - ), - destroy: vi.fn(), - isDestroyed: vi.fn(() => false) + setVisible: vi.fn(), + setBounds: vi.fn(), } let permissionCheck: ((...values: unknown[]) => boolean) | undefined let permissionRequest: @@ -143,13 +128,12 @@ function createHarness() { contentEvents, debuggerEvents, partitionEvents, - windowEvents, partition, proxy, policy, sendCommand, webContents, - window, + window: view, setCurrentUrl(value: string) { currentUrl = value }, @@ -166,7 +150,7 @@ function createHarness() { describe('ElectronBrowserSession', () => { it('creates an isolated sandboxed partition and denies privileged capabilities', async () => { const harness = createHarness() - const createWindow = vi.fn(async (options: Record) => { + const createView = vi.fn(async (options: Record) => { const preferences = options.webPreferences as Record expect(preferences).toMatchObject({ sandbox: true, @@ -181,7 +165,7 @@ describe('ElectronBrowserSession', () => { const session = await ElectronBrowserSession.create({ policy: harness.policy, createPartition: vi.fn(async () => harness.partition), - createWindow, + createView, createProxy: () => harness.proxy }) @@ -208,9 +192,9 @@ describe('ElectronBrowserSession', () => { expect(harness.getOpenHandler()?.({ url: 'https://example.com' })).toEqual({ action: 'deny' }) - expect(harness.window.loadURL).toHaveBeenCalledWith('about:blank') + expect(harness.webContents.loadURL).toHaveBeenCalledWith('about:blank') expect( - vi.mocked(harness.window.loadURL).mock.invocationCallOrder[0] + vi.mocked(harness.webContents.loadURL!).mock.invocationCallOrder[0] ).toBeLessThan( vi.mocked(harness.webContents.debugger.attach).mock .invocationCallOrder[0] ?? Number.POSITIVE_INFINITY @@ -230,6 +214,16 @@ describe('ElectronBrowserSession', () => { }) session.stopLoading() expect(harness.webContents.stop).toHaveBeenCalledOnce() + session.setViewport({ x: 900, y: 120, width: 420, height: 640 }) + expect(harness.window.setBounds).toHaveBeenCalledWith({ + x: 900, + y: 120, + width: 420, + height: 640 + }) + expect(harness.window.setVisible).toHaveBeenLastCalledWith(true) + session.setViewport() + expect(harness.window.setVisible).toHaveBeenLastCalledWith(false) const downloadEvent = { preventDefault: vi.fn() } const item = { cancel: vi.fn() } @@ -256,7 +250,7 @@ describe('ElectronBrowserSession', () => { const session = await ElectronBrowserSession.create({ policy: harness.policy, createPartition: async () => harness.partition, - createWindow: async () => harness.window, + createView: async () => harness.window, createProxy: () => harness.proxy }) const target = await harness.policy.validate( @@ -306,7 +300,7 @@ describe('ElectronBrowserSession', () => { const session = await ElectronBrowserSession.create({ policy: harness.policy, createPartition: async () => harness.partition, - createWindow: async () => harness.window, + createView: async () => harness.window, createProxy: () => harness.proxy }) expect(session.isLoading()).toBe(true) @@ -328,72 +322,30 @@ describe('ElectronBrowserSession', () => { await session.dispose() }) - it('restores the browser for interaction and minimizes it on close', async () => { + it('attaches the browser view to the parent content tree and removes it on dispose', async () => { const harness = createHarness() const parentWindow = { - setEnabled: vi.fn(), - focus: vi.fn(), + contentView: { + addChildView: vi.fn(), + removeChildView: vi.fn() + }, isDestroyed: vi.fn(() => false) } - let createdWindowOptions: Record | undefined - const createWindow = vi.fn( - async (options: Record) => { - createdWindowOptions = options - return harness.window - } - ) const session = await ElectronBrowserSession.create({ policy: harness.policy, parentWindow, createPartition: async () => harness.partition, - createWindow, + createView: async () => harness.window, createProxy: () => harness.proxy }) - expect(createWindow).toHaveBeenCalledWith( - expect.objectContaining({ - parent: parentWindow, - show: false, - title: 'GoodBuddy 浏览器交互' - }) + expect(parentWindow.contentView.addChildView).toHaveBeenCalledWith( + harness.window.nativeView ) - expect(createdWindowOptions?.modal).toBeUndefined() - const interaction = session.openInteraction() - expect(parentWindow.setEnabled).toHaveBeenCalledWith(false) - expect(harness.window.show).toHaveBeenCalledOnce() - expect(harness.window.focus).toHaveBeenCalledOnce() - const closeEvent = { preventDefault: vi.fn() } - harness.windowEvents.emit('close', closeEvent) - - await expect(interaction).resolves.toEqual({ - type: 'image', - mimeType: 'image/jpeg', - data: '/9j/2Q==' - }) - expect(closeEvent.preventDefault).toHaveBeenCalledOnce() - expect(harness.webContents.capturePage).toHaveBeenCalledOnce() - expect(harness.window.minimize).toHaveBeenCalledOnce() - expect(parentWindow.setEnabled).toHaveBeenLastCalledWith(true) - expect(parentWindow.focus).toHaveBeenCalledOnce() - expect( - vi.mocked(harness.webContents.capturePage!).mock - .invocationCallOrder[0] - ).toBeLessThan( - vi.mocked(harness.window.minimize).mock.invocationCallOrder[0] ?? - Number.POSITIVE_INFINITY - ) - const repeatedCloseEvent = { preventDefault: vi.fn() } - harness.windowEvents.emit('close', repeatedCloseEvent) - expect(repeatedCloseEvent.preventDefault).toHaveBeenCalledOnce() - expect(harness.window.minimize).toHaveBeenCalledTimes(2) - expect(harness.window.destroy).not.toHaveBeenCalled() - vi.mocked(harness.window.isMinimized).mockReturnValue(true) - const reopenedInteraction = session.openInteraction() - expect(harness.window.restore).toHaveBeenCalledOnce() - harness.windowEvents.emit('close', { preventDefault: vi.fn() }) - await reopenedInteraction await session.dispose() - expect(harness.window.destroy).toHaveBeenCalledOnce() + expect(parentWindow.contentView.removeChildView).toHaveBeenCalledWith( + harness.window.nativeView + ) }) it('detaches listeners and clears isolated data on idempotent disposal', async () => { @@ -401,14 +353,14 @@ describe('ElectronBrowserSession', () => { const session = await ElectronBrowserSession.create({ policy: harness.policy, createPartition: async () => harness.partition, - createWindow: async () => harness.window, + createView: async () => harness.window, createProxy: () => harness.proxy }) await session.dispose() await session.dispose() expect(harness.webContents.debugger.detach).toHaveBeenCalledOnce() - expect(harness.window.destroy).toHaveBeenCalledOnce() + expect(harness.webContents.destroy).toHaveBeenCalledOnce() expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce() expect(harness.partition.clearData).toHaveBeenCalledOnce() expect(harness.proxy.dispose).toHaveBeenCalledOnce() @@ -423,11 +375,11 @@ describe('ElectronBrowserSession', () => { ElectronBrowserSession.create({ policy: harness.policy, createPartition: async () => harness.partition, - createWindow: async () => harness.window, + createView: async () => harness.window, createProxy: () => harness.proxy }) ).rejects.toThrow('无法创建安全浏览器会话') - expect(harness.window.destroy).toHaveBeenCalled() + expect(harness.webContents.destroy).toHaveBeenCalled() expect(harness.partition.clearData).toHaveBeenCalled() expect(harness.proxy.dispose).toHaveBeenCalled() }) @@ -439,7 +391,7 @@ describe('ElectronBrowserSession', () => { policy: harness.policy, setupTimeoutMs: 5, createPartition: () => partitionGate.promise, - createWindow: async () => harness.window, + createView: async () => harness.window, createProxy: () => harness.proxy }) @@ -451,20 +403,22 @@ describe('ElectronBrowserSession', () => { expect(harness.partition.closeAllConnections).toHaveBeenCalledOnce() }) - it('destroys a hidden window that resolves after setup times out', async () => { + it('destroys a detached view that resolves after setup times out', async () => { const harness = createHarness() - const windowGate = deferred() + const viewGate = deferred() const creation = ElectronBrowserSession.create({ policy: harness.policy, setupTimeoutMs: 5, createPartition: async () => harness.partition, - createWindow: () => windowGate.promise, + createView: () => viewGate.promise, createProxy: () => harness.proxy }) await expect(creation).rejects.toThrow('无法创建安全浏览器会话') - windowGate.resolve(harness.window) - await vi.waitFor(() => expect(harness.window.destroy).toHaveBeenCalledOnce()) + viewGate.resolve(harness.window) + await vi.waitFor(() => + expect(harness.webContents.destroy).toHaveBeenCalledOnce() + ) expect(harness.partition.clearData).toHaveBeenCalledOnce() expect(harness.proxy.dispose).toHaveBeenCalledOnce() }) @@ -480,7 +434,7 @@ describe('ElectronBrowserSession', () => { policy: harness.policy, setupTimeoutMs: 5, createPartition: async () => harness.partition, - createWindow: async () => harness.window, + createView: async () => harness.window, createProxy: () => proxy }) diff --git a/src/main/browser/electron-browser-session.ts b/src/main/browser/electron-browser-session.ts index f27a8f07..fe1297bc 100644 --- a/src/main/browser/electron-browser-session.ts +++ b/src/main/browser/electron-browser-session.ts @@ -39,6 +39,7 @@ export type BrowserWebContents = { handler: (details: { url: string }) => { action: 'deny' } ): void capturePage?(): Promise + loadURL?(url: string): Promise getURL(): string isLoadingMainFrame?(): boolean stop(): void @@ -47,26 +48,28 @@ export type BrowserWebContents = { isDestroyed(): boolean } -export type BrowserWindowHandle = { +export type BrowserViewHandle = { webContents: BrowserWebContents - loadURL(url: string): Promise - show(): void - minimize(): void - restore(): void - isMinimized(): boolean - focus(): void - on(event: string, listener: BrowserEventListener): unknown - off(event: string, listener: BrowserEventListener): unknown - destroy(): void - isDestroyed(): boolean + nativeView?: unknown + setVisible(visible: boolean): void + setBounds(bounds: BrowserWindowBounds): void } export type BrowserParentWindowHandle = { - setEnabled?(enabled: boolean): void - focus?(): void + contentView?: { + addChildView(view: unknown): void + removeChildView(view: unknown): void + } isDestroyed?(): boolean } +export type BrowserWindowBounds = { + x: number + y: number + width: number + height: number +} + export type BrowserPartitionSession = { setPermissionCheckHandler( handler: (...argumentsValue: never[]) => boolean @@ -110,9 +113,9 @@ export type ElectronBrowserSessionOptions = { cleanupTimeoutMs?: number setupTimeoutMs?: number createPartition?: (partition: string) => Promise - createWindow?: ( + createView?: ( options: Record - ) => Promise + ) => Promise createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike parentWindow?: BrowserParentWindowHandle } @@ -214,11 +217,18 @@ async function defaultCreatePartition( ) as unknown as BrowserPartitionSession } -async function defaultCreateWindow( +async function defaultCreateView( options: Record -): Promise { +): Promise { const electron = await import('electron') - return new electron.BrowserWindow(options) as unknown as BrowserWindowHandle + const view = new electron.WebContentsView(options) + view.setVisible(false) + return { + nativeView: view, + webContents: view.webContents as unknown as BrowserWebContents, + setVisible: (visible) => view.setVisible(visible), + setBounds: (bounds) => view.setBounds(bounds) + } } export class ElectronBrowserSession { @@ -226,26 +236,22 @@ export class ElectronBrowserSession { readonly webContents: BrowserWebContents private approvedOrigin?: string private readonly listeners: Listener[] = [] - private interaction?: { - promise: Promise - resolve(frame?: BrowserScreenshot): void - } - private interactionClosing?: Promise private readonly loadingListeners = new Set<(isLoading: boolean) => void>() + private readonly navigationListeners = new Set<(url: string) => void>() private loading = false private disposed = false private constructor( private readonly policy: BrowserUrlPolicy, private readonly partitionSession: BrowserPartitionSession, - private readonly window: BrowserWindowHandle, + private readonly view: BrowserViewHandle, private readonly proxy: FilteringProxyLike, partition: string, private readonly cleanupTimeoutMs: number, private readonly parentWindow?: BrowserParentWindowHandle ) { this.partition = partition - this.webContents = window.webContents + this.webContents = view.webContents } static async create( @@ -254,7 +260,7 @@ export class ElectronBrowserSession { ): Promise { const partition = `browser-${randomUUID()}` const createPartition = options.createPartition ?? defaultCreatePartition - const createWindow = options.createWindow ?? defaultCreateWindow + const createView = options.createView ?? defaultCreateView const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 5_000 const setupTimeoutMs = options.setupTimeoutMs ?? 15_000 if (!Number.isSafeInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1) { @@ -275,7 +281,8 @@ export class ElectronBrowserSession { } } let partitionSession: BrowserPartitionSession | undefined - let window: BrowserWindowHandle | undefined + let view: BrowserViewHandle | undefined + let viewAttached = false let result: ElectronBrowserSession | undefined let setupStage = '启动代理' try { @@ -318,19 +325,9 @@ export class ElectronBrowserSession { signal, setupTimeoutMs ) - setupStage = '创建浏览器窗口' - window = await boundedSetup( - createWindow({ - show: false, - width: 1280, - height: 900, - title: 'GoodBuddy 浏览器交互', - autoHideMenuBar: true, - ...(options.parentWindow - ? { - parent: options.parentWindow - } - : {}), + setupStage = '创建浏览器视图' + view = await boundedSetup( + createView({ webPreferences: { partition, sandbox: true, @@ -348,24 +345,32 @@ export class ElectronBrowserSession { }), signal, setupTimeoutMs, - (lateWindow) => { - if (!lateWindow.isDestroyed()) { - lateWindow.destroy() - } else if (!lateWindow.webContents.isDestroyed()) { - lateWindow.webContents.destroy() + (lateView) => { + if (!lateView.webContents.isDestroyed()) { + lateView.webContents.close?.({ waitForBeforeUnload: false }) + if (!lateView.webContents.isDestroyed()) { + lateView.webContents.destroy() + } } } ) + if (options.parentWindow?.contentView && view.nativeView) { + options.parentWindow.contentView.addChildView(view.nativeView) + viewAttached = true + } setupStage = '加载初始页面' + if (!view.webContents.loadURL) { + throw new Error('浏览器视图导航不可用') + } await boundedSetup( - window.loadURL('about:blank'), + view.webContents.loadURL('about:blank'), signal, setupTimeoutMs ) result = new ElectronBrowserSession( options.policy, partitionSession, - window, + view, managedProxy, partition, cleanupTimeoutMs, @@ -377,8 +382,16 @@ export class ElectronBrowserSession { } catch (error) { if (result) { await result.dispose().catch(() => undefined) - } else if (window && !window.isDestroyed()) { - window.destroy() + } else if (view) { + if (viewAttached && view.nativeView) { + options.parentWindow?.contentView?.removeChildView(view.nativeView) + } + if (!view.webContents.isDestroyed()) { + view.webContents.close?.({ waitForBeforeUnload: false }) + if (!view.webContents.isDestroyed()) { + view.webContents.destroy() + } + } await cleanupIsolatedState( partitionSession, managedProxy, @@ -422,21 +435,6 @@ export class ElectronBrowserSession { this.listen(contents, 'did-stop-loading', () => { this.setLoading(contents.isLoadingMainFrame?.() ?? false) }) - this.listen( - this.window, - 'close', - (event: { preventDefault(): void }) => { - if (this.disposed) { - return - } - event.preventDefault() - if (this.interaction) { - void this.captureAndFinishInteraction() - } else { - this.window.minimize() - } - } - ) contents.setWindowOpenHandler(() => ({ action: 'deny' })) this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => { const url = typeof details === 'string' ? details : details.url @@ -471,6 +469,15 @@ export class ElectronBrowserSession { this.listen(contents, 'did-navigate', (_event: unknown, url: string) => { if (url && !this.updateOriginFromUrl(url)) { contents.stop() + return + } + if (url) { + this.emitNavigation(url) + } + }) + this.listen(contents, 'did-navigate-in-page', (_event: unknown, url: string) => { + if (url) { + this.emitNavigation(url) } }) this.listen( @@ -553,6 +560,30 @@ export class ElectronBrowserSession { } } + onNavigationChange(listener: (url: string) => void): () => void { + this.assertOpen() + this.navigationListeners.add(listener) + return () => { + this.navigationListeners.delete(listener) + } + } + + private emitNavigation(url: string): void { + for (const listener of this.navigationListeners) { + listener(url) + } + } + + setViewport(bounds?: BrowserWindowBounds): void { + this.assertOpen() + if (!bounds) { + this.view.setVisible(false) + return + } + this.view.setBounds(bounds) + this.view.setVisible(true) + } + private setLoading(isLoading: boolean): void { if (this.loading === isLoading) { return @@ -594,91 +625,6 @@ export class ElectronBrowserSession { this.approvedOrigin = target.origin } - openInteraction(): Promise { - this.assertOpen() - if (this.interaction) { - this.setParentEnabled(false) - if (this.window.isMinimized()) { - this.window.restore() - } - this.window.show() - this.window.focus() - return this.interaction.promise - } - let resolve!: (frame?: BrowserScreenshot) => void - const promise = new Promise( - (resolvePromise) => { - resolve = resolvePromise - } - ) - this.interaction = { promise, resolve } - this.setParentEnabled(false) - try { - if (this.window.isMinimized()) { - this.window.restore() - } - this.window.show() - this.window.focus() - } catch (error) { - this.finishInteraction() - throw error - } - return promise - } - - private captureAndFinishInteraction(): Promise { - if (this.interactionClosing) { - return this.interactionClosing - } - const operation = (async (): Promise => { - let frame: BrowserScreenshot | undefined - try { - frame = await this.captureScreenshot(AbortSignal.timeout(2_000)) - } catch { - // The session remains usable even if the final visible frame fails. - } - try { - if (!this.disposed && !this.window.isDestroyed()) { - this.window.minimize() - } - } catch { - // Resolving interaction must not depend on native minimize success. - } - this.finishInteraction(frame) - })() - this.interactionClosing = operation - void operation.finally(() => { - if (this.interactionClosing === operation) { - this.interactionClosing = undefined - } - }) - return operation - } - - private finishInteraction(frame?: BrowserScreenshot): void { - const interaction = this.interaction - this.interaction = undefined - this.setParentEnabled(true) - interaction?.resolve(frame) - } - - private setParentEnabled(enabled: boolean): void { - try { - if ( - !this.parentWindow || - this.parentWindow.isDestroyed?.() === true - ) { - return - } - this.parentWindow.setEnabled?.(enabled) - if (enabled) { - this.parentWindow.focus?.() - } - } catch { - // Parent-window state must not break browser-session cleanup. - } - } - async dispose(): Promise { if (this.disposed) { return @@ -686,7 +632,7 @@ export class ElectronBrowserSession { this.disposed = true this.approvedOrigin = undefined this.loadingListeners.clear() - this.finishInteraction() + this.navigationListeners.clear() for (const { target, event, listener } of this.listeners.splice(0)) { target.off(event, listener) } @@ -694,10 +640,15 @@ export class ElectronBrowserSession { this.webContents.debugger.detach() } this.webContents.stop() - if (!this.window.isDestroyed()) { - this.window.destroy() - } else if (!this.webContents.isDestroyed()) { - this.webContents.destroy() + this.view.setVisible(false) + if (this.view.nativeView) { + this.parentWindow?.contentView?.removeChildView(this.view.nativeView) + } + if (!this.webContents.isDestroyed()) { + this.webContents.close?.({ waitForBeforeUnload: false }) + if (!this.webContents.isDestroyed()) { + this.webContents.destroy() + } } await cleanupIsolatedState( this.partitionSession, diff --git a/src/main/execution-space/execution-space-resolver.test.ts b/src/main/execution-space/execution-space-resolver.test.ts index bb58dd38..68ae9015 100644 --- a/src/main/execution-space/execution-space-resolver.test.ts +++ b/src/main/execution-space/execution-space-resolver.test.ts @@ -50,6 +50,7 @@ describe('ExecutionSpaceResolver', () => { it('binds remote access only to the configured Host and path', () => { const workspaceAccess = { + manage: vi.fn(), getIdentity: vi.fn(), listDirectory: vi.fn(), stat: vi.fn(), diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index b5424abf..d96adb92 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -429,7 +429,6 @@ describe('registerIpcHandlers computer capabilities', () => { })) } const onRuntimeSettingsChanged = vi.fn(async () => {}) - const interact = vi.fn(async () => {}) const navigate = vi.fn(async () => ({ url: 'https://example.com/', origin: 'https://example.com' @@ -443,6 +442,7 @@ describe('registerIpcHandlers computer capabilities', () => { origin: 'https://example.com' })) const stopLoading = vi.fn(async () => true) + const setViewport = vi.fn() const releaseConversation = vi.fn(async () => {}) const selectFiles = vi.fn( async ( @@ -509,7 +509,7 @@ describe('registerIpcHandlers computer capabilities', () => { back, reload, stopLoading, - interact, + setViewport, releaseConversation, onState: (listener) => { browserStateListener = listener @@ -741,16 +741,22 @@ describe('registerIpcHandlers computer capabilities', () => { ) ).resolves.toBeUndefined() expect(stopLoading).toHaveBeenCalledWith('browser-conversation') - await expect( - electronMocks.handlers.get(ipcChannels.browserInteract)?.(event, { - conversationId: 'browser-conversation' + expect( + electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, { + conversationId: 'browser-conversation', + bounds: { x: 900, y: 120, width: 320, height: 600 } }) - ).resolves.toBeUndefined() - expect(interact).toHaveBeenCalledWith( - 'browser-conversation', - expect.any(AbortSignal) - ) - + ).toBeUndefined() + expect(setViewport).toHaveBeenCalledWith('browser-conversation', { + x: 900, + y: 120, + width: 320, + height: 600 + }) + expect( + electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {}) + ).toBeUndefined() + expect(setViewport).toHaveBeenLastCalledWith() navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError()) await expect( electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ccda05d4..b0877312 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -27,9 +27,9 @@ import { agentQuestionResponseSchema, agentRequestSchema, browserBackRequestSchema, - browserInteractRequestSchema, browserNavigateRequestSchema, browserReloadRequestSchema, + browserSetViewportRequestSchema, browserStopLoadingRequestSchema, browserStopRequestSchema, clipboardTextSchema, @@ -1100,10 +1100,10 @@ export function registerIpcHandlers( signal: AbortSignal ): Promise<{ url: string; origin: string }> stopLoading(conversationId: string): boolean | Promise - interact( - conversationId: string, - signal: AbortSignal - ): Promise + setViewport( + conversationId?: string, + bounds?: { x: number; y: number; width: number; height: number } + ): void releaseConversation(conversationId: string): Promise onState(listener: (state: BrowserLiveState) => void): () => void }, @@ -3644,19 +3644,6 @@ export function registerIpcHandlers( } ) - registerHandler( - ipcChannels.browserInteract, - async (event, input: unknown) => { - assertTrustedSender(event, window) - const request = browserInteractRequestSchema.parse(input) - const control = await requireBrowserControl() - await control.interact( - request.conversationId, - new AbortController().signal - ) - } - ) - registerHandler(ipcChannels.agentRun, async (event, input: unknown) => { assertTrustedSender(event, window) if (executionPaused || shuttingDown) { @@ -6595,6 +6582,19 @@ export function registerIpcHandlers( } ) + registerHandler(ipcChannels.workspaceManage, async (event, input: unknown) => { + assertTrustedSender(event, window) + const { workspaceManagementRequestSchema, workspaceManagementResultSchema } = await import('../shared/workspace-management-contracts') + const value = workspaceManagementRequestSchema.parse(input) + const project = assistantDatabase.getProject(value.projectId) + if (project.executionSpace?.kind === 'ssh') await requireRemoteProjectsEnabled() + const executionSpace = spaceResolver.resolveProject(project) + try { + return workspaceManagementResultSchema.parse(await executionSpace.workspaceAccess.manage(value.action)) + } finally { + await executionSpace.workspaceAccess.dispose() + } + }) registerHandler( ipcChannels.workspaceChangesGet, async (event, input: unknown) => { @@ -6632,6 +6632,24 @@ export function registerIpcHandlers( } } ) + + registerHandler( + ipcChannels.browserSetViewport, + (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserSetViewportRequestSchema.parse(input) + if (!request.conversationId || !request.bounds) { + browserControl?.setViewport() + return + } + browserControl?.setViewport(request.conversationId, { + x: request.bounds.x, + y: request.bounds.y, + width: request.bounds.width, + height: request.bounds.height + }) + } + ) registerHandler( ipcChannels.workspaceDirectoryList, async (event, input: unknown) => { diff --git a/src/main/remote-agent/agent-protocol-client.ts b/src/main/remote-agent/agent-protocol-client.ts index 95155660..aba5fdd4 100644 --- a/src/main/remote-agent/agent-protocol-client.ts +++ b/src/main/remote-agent/agent-protocol-client.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto' import { z } from 'zod' +import { workspaceManagementResultSchema } from '../../shared/workspace-management-contracts' import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, @@ -43,6 +44,7 @@ import { remoteSemanticTranscriptAckResultSchema, remoteSemanticTranscriptPageRequestSchema, remoteSemanticTranscriptPageResultSchema, + remoteWorkspaceManageRequestSchema, remoteGitDiffRequestSchema, remoteGitDiffResultSchema, remoteGitStatusRequestSchema, @@ -149,6 +151,10 @@ export const AGENT_PROTOCOL_METHODS = { params: remoteGitStatusRequestSchema, result: remoteGitStatusResultSchema }, + 'workspace/manage': { + params: remoteWorkspaceManageRequestSchema, + result: workspaceManagementResultSchema + }, 'git/diff': { params: remoteGitDiffRequestSchema, result: remoteGitDiffResultSchema diff --git a/src/main/remote-agent/managed-remote-acp-runtime.test.ts b/src/main/remote-agent/managed-remote-acp-runtime.test.ts index a613a682..53076e3a 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.test.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.test.ts @@ -89,6 +89,7 @@ function descriptor(): SshExecutionSpaceDescriptor { cacheIdentity: 'cache-1', routeIdentity: 'route-1', workspaceAccess: { + manage: vi.fn(), getIdentity: vi.fn(), listDirectory: vi.fn(), stat: vi.fn(), diff --git a/src/main/remote-agent/protocol-remote-workspace-transport.ts b/src/main/remote-agent/protocol-remote-workspace-transport.ts index 163d834a..ba9efc47 100644 --- a/src/main/remote-agent/protocol-remote-workspace-transport.ts +++ b/src/main/remote-agent/protocol-remote-workspace-transport.ts @@ -247,6 +247,8 @@ function createLease(state: LeaseState): RemoteWorkspaceTransportLease { await request('git/status', value, signal), getGitDiff: async (value, signal) => await request('git/diff', value, signal), + manageWorkspace: async (value, signal) => + await request('workspace/manage', value, signal), release: () => { if (released) { return @@ -282,6 +284,7 @@ function releaseLeaseResources( } type WorkspaceReadProtocolMethod = + | 'workspace/manage' | 'workspace/validate' | 'workspace/close' | 'workspace/list' diff --git a/src/main/workspace/local-workspace-access.ts b/src/main/workspace/local-workspace-access.ts index b34e7dec..4dbeee09 100644 --- a/src/main/workspace/local-workspace-access.ts +++ b/src/main/workspace/local-workspace-access.ts @@ -399,6 +399,10 @@ function formatChangedFiles(files: WorkspaceChangedFile[]): string { } export class LocalWorkspaceAccess implements WorkspaceAccess { + async manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise { + const { manageWorkspace } = await import('./workspace-management') + return manageWorkspace(await this.getWorkspace(), action, signal) + } private canonicalWorkspace?: Promise constructor(private readonly rootPath: string) {} diff --git a/src/main/workspace/remote-workspace-access.test.ts b/src/main/workspace/remote-workspace-access.test.ts index 9838343d..d0a3a210 100644 --- a/src/main/workspace/remote-workspace-access.test.ts +++ b/src/main/workspace/remote-workspace-access.test.ts @@ -36,6 +36,7 @@ function createLease( overrides: Partial = {} ): RemoteWorkspaceTransportLease { return { + manageWorkspace: vi.fn(), binding: { hostId: binding.hostId, hostRevision: 2, diff --git a/src/main/workspace/remote-workspace-access.ts b/src/main/workspace/remote-workspace-access.ts index 8fb79f0c..a69da565 100644 --- a/src/main/workspace/remote-workspace-access.ts +++ b/src/main/workspace/remote-workspace-access.ts @@ -1,6 +1,7 @@ import { REMOTE_WORKSPACE_READ_CAPABILITIES, REMOTE_WORKSPACE_LIMITS, + remoteWorkspaceManageRequestSchema, remoteGitDiffRequestSchema, remoteGitDiffResultSchema, remoteGitStatusRequestSchema, @@ -80,6 +81,7 @@ export type RemoteWorkspaceTransportBinding = { * exposes neither an SSH connection nor a generic JSON-RPC method. */ export interface RemoteWorkspaceTransportLease { + manageWorkspace(request: import('zod').infer, signal?: AbortSignal): Promise readonly binding: RemoteWorkspaceTransportBinding validateWorkspace( request: RemoteWorkspaceValidateRequest, @@ -266,6 +268,16 @@ function formatChangedFiles(files: WorkspaceChangedFile[]): string { } export class RemoteWorkspaceAccess implements WorkspaceAccess { + async manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise { + const opened = await this.open(signal) + const validation = remoteWorkspaceValidateResultSchema.parse(await opened.lease.validateWorkspace({ remoteRootPath: this.binding.remoteRootPath, requestedAccess: 'read-write', requiredCapabilities: [] }, signal)) + const handle = validation.handle + try { + return await opened.lease.manageWorkspace(remoteWorkspaceManageRequestSchema.parse({ workspaceId: handle.workspaceId, generation: handle.generation, action }), signal) + } finally { + await opened.lease.closeWorkspace({ workspaceId: handle.workspaceId, generation: handle.generation }) + } + } private opening?: Promise private opened?: OpenRemoteWorkspace private disposed = false diff --git a/src/main/workspace/unsupported-remote-workspace-access.ts b/src/main/workspace/unsupported-remote-workspace-access.ts index 0c7f02ac..db48815a 100644 --- a/src/main/workspace/unsupported-remote-workspace-access.ts +++ b/src/main/workspace/unsupported-remote-workspace-access.ts @@ -27,6 +27,8 @@ implements WorkspaceAccess { throw new Error(this.message) } + async manage(): Promise { return this.unsupported() } + async getIdentity(): Promise { return this.unsupported() } diff --git a/src/main/workspace/workspace-access.ts b/src/main/workspace/workspace-access.ts index 63dd21c4..14af82c8 100644 --- a/src/main/workspace/workspace-access.ts +++ b/src/main/workspace/workspace-access.ts @@ -95,6 +95,7 @@ export type WorkspaceChangesInput = { export type { WorkspaceChanges, WorkspaceChangedFile } export interface WorkspaceAccess { + manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise getIdentity(): Promise listDirectory(input: WorkspaceDirectoryInput): Promise stat(input: WorkspacePathInput): Promise diff --git a/src/main/workspace/workspace-management.test.ts b/src/main/workspace/workspace-management.test.ts new file mode 100644 index 00000000..8267f5ca --- /dev/null +++ b/src/main/workspace/workspace-management.test.ts @@ -0,0 +1,67 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { manageWorkspace } from './workspace-management' + +const roots: string[] = [] +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) +async function repository(): Promise { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-workspace-management-')) + roots.push(root) + git(root, 'init', '-b', 'main') + return root +} +function git(root: string, ...args: string[]): string { + return execFileSync('git', ['-C', root, '-c', 'user.name=Workspace Test', '-c', 'user.email=workspace@example.invalid', ...args], { encoding: 'utf8', windowsHide: true }).trim() +} +it('creates, moves, inspects and deletes without overwriting an existing file', async () => { + const root = await repository() + await manageWorkspace(root, { kind: 'createDirectory', path: 'docs' }) + await manageWorkspace(root, { kind: 'createFile', path: 'docs/test.txt' }) + await writeFile(join(root, 'docs/test.txt'), 'keep') + await expect(manageWorkspace(root, { kind: 'createFile', path: 'docs/test.txt' })).rejects.toThrow() + await manageWorkspace(root, { kind: 'move', path: 'docs/test.txt', destination: 'docs/new.txt' }) + expect(await readFile(join(root, 'docs/new.txt'), 'utf8')).toBe('keep') + await expect(manageWorkspace(root, { kind: 'properties', path: 'docs/new.txt' })).resolves.toMatchObject({ kind: 'properties', size: 4, type: 'file' }) + await expect(manageWorkspace(root, { kind: 'delete', path: '../outside' })).rejects.toThrow() + await manageWorkspace(root, { kind: 'delete', path: 'docs' }) +}) +it('reads unborn history, initial commit files and literal paths, and switches with dirty files intact', async () => { + const root = await repository() + await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ commits: [] }) + await writeFile(join(root, '[test].txt'), 'initial\n') + git(root, 'add', '.') + git(root, 'commit', '-m', 'Initial subject') + const oid = git(root, 'rev-parse', 'HEAD') + await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: 'Workspace Test' }] }) + await expect(manageWorkspace(root, { kind: 'commitFiles', oid })).resolves.toMatchObject({ files: [{ path: '[test].txt', status: 'A' }] }) + await expect(manageWorkspace(root, { kind: 'commitDiff', oid, path: '[test].txt' })).resolves.toMatchObject({ patch: expect.stringContaining('+initial') }) + await writeFile(join(root, '[test].txt'), 'dirty\n') + await manageWorkspace(root, { kind: 'createBranch', branch: 'feature/test' }) + await manageWorkspace(root, { kind: 'switchBranch', branch: 'main', remote: false }) + expect(await readFile(join(root, '[test].txt'), 'utf8')).toBe('dirty\n') + expect(git(root, 'stash', 'list')).toBe('') + await expect(manageWorkspace(root, { kind: 'branches' })).resolves.toMatchObject({ current: 'main', branches: expect.arrayContaining([{ name: 'feature/test', remote: false }]) }) +}) +it('reports switch conflicts without discarding dirty content and pages stable history', async () => { + const root = await repository() + await writeFile(join(root, 'test.txt'), 'initial') + git(root, 'add', '.'); git(root, 'commit', '-m', 'initial') + git(root, 'switch', '-c', 'other') + await writeFile(join(root, 'test.txt'), 'other') + git(root, 'commit', '-am', 'other') + git(root, 'switch', 'main') + await writeFile(join(root, 'test.txt'), 'dirty') + await expect(manageWorkspace(root, { kind: 'switchBranch', branch: 'other', remote: false })).rejects.toThrow(/overwritten/) + expect(await readFile(join(root, 'test.txt'), 'utf8')).toBe('dirty') + for (let index = 0; index < 51; index++) git(root, 'commit', '--allow-empty', '-m', `page ${index}`) + const first = await manageWorkspace(root, { kind: 'history', offset: 0 }) + expect(first.kind).toBe('history') + if (first.kind !== 'history') return + expect(first.commits).toHaveLength(50) + expect(first.hasMore).toBe(true) + const second = await manageWorkspace(root, { kind: 'history', offset: 50, head: first.head }) + expect(second).toMatchObject({ hasMore: false, commits: [{ subject: 'page 0' }, { subject: 'initial' }] }) +}, 30_000) diff --git a/src/main/workspace/workspace-management.ts b/src/main/workspace/workspace-management.ts new file mode 100644 index 00000000..353d3c6d --- /dev/null +++ b/src/main/workspace/workspace-management.ts @@ -0,0 +1,107 @@ +import { execFile } from 'node:child_process' +import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { workspaceManagementActionSchema, type WorkspaceManagementAction, type WorkspaceManagementResult } from '../../shared/workspace-management-contracts' +import { getCanonicalWorkspace, isPathInside } from '../workspace-file-access' + +// Shared by the local workspace and the Agent after resolving its workspace handle. +export async function manageWorkspace(rootPath: string, input: WorkspaceManagementAction, signal?: AbortSignal): Promise { + const action = workspaceManagementActionSchema.parse(input) + signal?.throwIfAborted() + const root = await getCanonicalWorkspace(rootPath) + const git = (args: string[]): Promise => new Promise((resolveOutput, reject) => { + execFile('git', ['--no-pager', '--literal-pathspecs', '-C', root, ...args], { + encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 4 * 1024 * 1024, + signal, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat' } + }, (error, stdout, stderr) => { + if (error) reject(new Error(stderr.trim() || error.message)) + else resolveOutput(stdout) + }) + }) + if (['createFile', 'createDirectory', 'move', 'delete', 'properties'].includes(action.kind) && 'path' in action) { + const target = resolve(root, action.path) + const parent = await realpath(dirname(target)) + if (target === root || !isPathInside(root, target) || !isPathInside(root, parent)) throw new Error('Path is outside the workspace') + if (action.kind === 'createFile') { + const handle = await open(target, 'wx') + await handle.close() + } else if (action.kind === 'createDirectory') { + await mkdir(target) + } else { + const metadata = await lstat(target) + if (metadata.isSymbolicLink()) throw new Error('Cannot modify a symbolic link through workspace management') + if (action.kind === 'properties') return { kind: 'properties', path: target, type: metadata.isDirectory() ? 'directory' : metadata.isFile() ? 'file' : 'other', size: metadata.size, modifiedAt: metadata.mtime.toISOString() } + if (action.kind === 'delete') await rm(target, { recursive: metadata.isDirectory() }) + if (action.kind === 'move') { + const destination = resolve(root, action.destination) + const destinationParent = await realpath(dirname(destination)) + if (!isPathInside(root, destinationParent) || !isPathInside(root, destination) || destination === root) throw new Error('Destination is outside the workspace') + try { await lstat(destination) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + signal?.throwIfAborted() + await rename(target, destination) + return { kind: 'done' } + } + throw new Error('Destination already exists') + } + } + return { kind: 'done' } + } + switch (action.kind) { + case 'branches': { + const current = (await git(['branch', '--show-current'])).trim() + const output = await git(['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/heads', 'refs/remotes']) + return { kind: 'branches', current, branches: output.trim().split('\n').filter(Boolean).flatMap((line) => { + const [ref, symbolic] = line.split('\0') + if (!ref || symbolic) return [] + return [{ name: ref.replace(/^refs\/(heads|remotes)\//, ''), remote: ref.startsWith('refs/remotes/') }] + }) } + } + case 'createBranch': + await git(['check-ref-format', '--branch', action.branch]) + await git(['switch', '-c', action.branch]) + return { kind: 'done' } + case 'switchBranch': + await git(['check-ref-format', '--branch', action.branch]) + await git(action.remote ? ['switch', '--track', action.branch] : ['switch', action.branch]) + return { kind: 'done' } + case 'fetch': + await git(['fetch', '--all']) + return { kind: 'done' } + case 'history': { + let head = action.head + if (!head) { + const refs = (await git(['rev-parse', '--verify', '--quiet', 'HEAD']).catch(async (error: unknown) => { + // An unborn branch has no commits; other repository errors remain visible. + await git(['symbolic-ref', 'HEAD']) + if ((await git(['rev-list', '--all', '--max-count=1'])).trim()) throw error + return '' + })).trim() + if (!refs) return { kind: 'history', commits: [], hasMore: false } + head = refs + } + const output = await git(['log', head, `--skip=${action.offset}`, '-n', '51', '--format=%H%x00%s%x00%an%x00%aI%x00%D%x00']) + const fields = output.split('\0') + const commits: Extract['commits'] = [] + for (let index = 0; index + 4 < fields.length; index += 5) commits.push({ oid: fields[index]!.trim(), subject: fields[index + 1]!, author: fields[index + 2]!, time: fields[index + 3]!, refs: fields[index + 4]! }) + return { kind: 'history', head, commits: commits.slice(0, 50), hasMore: commits.length > 50 } + } + case 'commitFiles': { + const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', action.oid]) + const fields = output.split('\0') + const files: Extract['files'] = [] + for (let index = 0; index + 1 < fields.length;) { + const status = fields[index++]! + const first = fields[index++]! + const renamed = /^[RC]/.test(status) + files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) }) + } + return { kind: 'commitFiles', files } + } + case 'commitDiff': { + const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '--no-ext-diff', '--no-textconv', action.oid, '--', action.path]) + return { kind: 'commitDiff', patch: patch.slice(0, 256 * 1024), truncated: patch.length > 256 * 1024 } + } + default: throw new Error('Unsupported workspace action') + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ce27c12f..d90e44f6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -328,11 +328,11 @@ const desktopApi: DesktopApi = { conversationId }) }, - interact: async (conversationId: string) => { - await ipcRenderer.invoke( - ipcChannels.browserInteract, - { conversationId } - ) + setViewport: async (conversationId, bounds) => { + await ipcRenderer.invoke(ipcChannels.browserSetViewport, { + ...(conversationId ? { conversationId } : {}), + ...(bounds ? { bounds } : {}) + }) }, stop: async (conversationId: string) => { await ipcRenderer.invoke( @@ -1089,6 +1089,7 @@ const desktopApi: DesktopApi = { } }, workspace: { + manage: (projectId, action) => ipcRenderer.invoke(ipcChannels.workspaceManage, { projectId, action }), getFileDiff: (projectId: string, path: string) => ipcRenderer.invoke(ipcChannels.workspaceFileDiff, { projectId, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index c0218a5f..5cd1c795 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -250,7 +250,7 @@ const api: DesktopApi = { back: vi.fn(async () => {}), reload: vi.fn(async () => {}), stopLoading: vi.fn(async () => {}), - interact: vi.fn(async () => {}), + setViewport: vi.fn(async () => {}), stop: vi.fn(async () => {}), onState: vi.fn((listener) => { browserListener = listener; @@ -508,6 +508,7 @@ const api: DesktopApi = { }), }, workspace: { + manage: vi.fn(async () => ({ kind: 'branches' as const, current: 'main', branches: [] })), getFileDiff: vi.fn(), getChanges: vi.fn(async () => ({ rootPath: "C:\\Workspace", @@ -5469,6 +5470,14 @@ describe("App", () => { "README.md", 0, ); + fireEvent.click(screen.getByRole("button", { name: "复制相对路径" })); + await waitFor(() => + expect(api.clipboard.writeText).toHaveBeenCalledWith("README.md"), + ); + fireEvent.click(screen.getByRole("button", { name: "用默认应用打开" })); + await waitFor(() => + expect(api.workspace.openPath).toHaveBeenCalledWith(projectId, "README.md", "file"), + ); fireEvent.click(screen.getByRole("button", { name: "返回工作区" })); expect( await screen.findByRole("button", { name: "README.md" }), @@ -5515,16 +5524,10 @@ describe("App", () => { fireEvent.click(screen.getByLabelText("切换助手工作栏")); fireEvent.click(await screen.findByRole("tab", { name: "工作区" })); - fireEvent.click( - await screen.findByRole("button", { - name: "在系统资源管理器中打开文件夹 docs", - }), - ); - fireEvent.click( - screen.getByRole("button", { - name: "使用默认应用打开文件 README.md", - }), - ); + fireEvent.click(await screen.findByLabelText("docs 的更多操作")); + fireEvent.click(within(screen.getByRole("menu", { name: "docs" })).getByRole("menuitem", { name: "使用默认应用打开" })); + fireEvent.click(screen.getByLabelText("README.md 的更多操作")); + fireEvent.click(within(screen.getByRole("menu", { name: "README.md" })).getByRole("menuitem", { name: "使用默认应用打开" })); await waitFor(() => expect(api.workspace.openPath).toHaveBeenCalledWith( projectId, @@ -9960,14 +9963,26 @@ describe("App", () => { screen.queryByRole("heading", { name: "项目工作区" }), ).not.toBeInTheDocument(); expect( - screen.getByRole("button", { name: "刷新工作区文件" }), + await screen.findByRole("button", { name: "刷新工作区文件" }), ).toBeInTheDocument(); expect( screen.queryByText(/选择文件后在当前工作区内预览/), ).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); - expect(screen.getByText("实时浏览器")).toBeInTheDocument(); + expect(screen.queryByText("实时浏览器")).not.toBeInTheDocument(); + expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument(); expect(screen.getByText(/Agent 打开网页后/)).toBeInTheDocument(); + const fullscreen = screen.getByRole("button", { + name: "全屏显示浏览器", + }); + fireEvent.click(fullscreen); + expect(sidebar).toHaveClass("assistant-sidebar--browser-fullscreen"); + const exitFullscreen = screen.getByRole("button", { + name: "退出浏览器全屏", + }); + expect(exitFullscreen).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(exitFullscreen); + expect(sidebar).not.toHaveClass("assistant-sidebar--browser-fullscreen"); fireEvent.click(screen.getByRole("tab", { name: "成果" })); expect( screen.getByRole("button", { name: "导入 PDF、图片或网页" }), @@ -10044,11 +10059,11 @@ describe("App", () => { within(toolbar).getByRole("button", { name: "刷新" }), ).toBeDisabled(); expect( - within(toolbar).getByRole("button", { name: "交互" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "交互" }), + ).not.toBeInTheDocument(); expect( - within(toolbar).getByRole("button", { name: "关闭浏览器" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "关闭浏览器" }), + ).not.toBeInTheDocument(); expect(go).toBeDisabled(); expect(screen.getByText(/输入地址并前往可打开页面/u)).toBeInTheDocument(); @@ -10143,11 +10158,11 @@ describe("App", () => { expect(address).toBeDisabled(); expect(go).toBeDisabled(); expect( - within(toolbar).getByRole("button", { name: "交互" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "交互" }), + ).not.toBeInTheDocument(); expect( - within(toolbar).getByRole("button", { name: "关闭浏览器" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "关闭浏览器" }), + ).not.toBeInTheDocument(); const stopLoading = within(toolbar).getByRole("button", { name: "停止加载", }); @@ -10213,10 +10228,7 @@ describe("App", () => { "aria-selected", "true", ); - expect(screen.getByAltText("Agent 实时浏览器画面")).toHaveAttribute( - "src", - "data:image/jpeg;base64,/9j/2Q==", - ); + expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument(); const toolbar = screen.getByRole("form", { name: "浏览器工具栏", }); @@ -10241,10 +10253,7 @@ describe("App", () => { ); }); expect(address).toHaveValue("https://typed.example/draft"); - expect(screen.getByAltText("Agent 实时浏览器画面")).toHaveAttribute( - "src", - "data:image/jpeg;base64,/9j/2Q==", - ); + expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument(); fireEvent.keyDown(address, { key: "Escape" }); expect(address).toHaveValue("https://example.com/redirected"); @@ -10292,10 +10301,7 @@ describe("App", () => { ), ); expect(address).toHaveValue("https://example.com/canonical/"); - fireEvent.click(within(toolbar).getByRole("button", { name: "交互" })); - await waitFor(() => - expect(api.browser.interact).toHaveBeenCalledWith(conversationId), - ); + expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument(); act(() => { browserListener?.( @@ -10314,11 +10320,11 @@ describe("App", () => { ).toBeDisabled(); expect(address).toBeDisabled(); expect( - within(toolbar).getByRole("button", { name: "交互" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "交互" }), + ).not.toBeInTheDocument(); expect( - within(toolbar).getByRole("button", { name: "关闭浏览器" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "关闭浏览器" }), + ).not.toBeInTheDocument(); act(() => { browserListener?.( @@ -10339,11 +10345,11 @@ describe("App", () => { within(toolbar).getByRole("button", { name: "前往" }), ).toBeDisabled(); expect( - within(toolbar).getByRole("button", { name: "交互" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "交互" }), + ).not.toBeInTheDocument(); expect( - within(toolbar).getByRole("button", { name: "关闭浏览器" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "关闭浏览器" }), + ).not.toBeInTheDocument(); act(() => { browserListener?.( @@ -10364,8 +10370,8 @@ describe("App", () => { }); expect(stopLoading).toBeEnabled(); expect( - within(toolbar).getByRole("button", { name: "关闭浏览器" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "关闭浏览器" }), + ).not.toBeInTheDocument(); fireEvent.click(stopLoading); await waitFor(() => expect(api.browser.stopLoading).toHaveBeenCalledWith(conversationId), @@ -10387,11 +10393,11 @@ describe("App", () => { ).toBeDisabled(); expect(address).toBeDisabled(); expect( - within(toolbar).getByRole("button", { name: "交互" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "交互" }), + ).not.toBeInTheDocument(); expect( - within(toolbar).getByRole("button", { name: "关闭浏览器" }), - ).toBeDisabled(); + within(toolbar).queryByRole("button", { name: "关闭浏览器" }), + ).not.toBeInTheDocument(); act(() => { browserListener?.( @@ -10402,12 +10408,6 @@ describe("App", () => { }), ); }); - fireEvent.click( - within(toolbar).getByRole("button", { name: "关闭浏览器" }), - ); - await waitFor(() => - expect(api.browser.stop).toHaveBeenCalledWith(conversationId), - ); }); it("opens Smart Heartbeat as a first-class workspace", async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6e8dad87..3dee1d45 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4050,7 +4050,9 @@ function App(): React.JSX.Element { workspaceChangesRequestRef.current === requestId && activeProjectIdRef.current === projectId ) { - setWorkspaceChanges(changes); + setWorkspaceChanges((current) => changes.error && current?.available + ? { ...current, error: changes.error } + : changes); } }, [], @@ -10746,11 +10748,6 @@ function App(): React.JSX.Element { browserApi.back(conversationId), ) } - onInteractBrowser={() => - runBrowserCommand((browserApi, conversationId) => - browserApi.interact(conversationId), - ) - } onNavigateBrowser={(url) => runBrowserCommand((browserApi, conversationId) => browserApi.navigate(conversationId, url), @@ -10766,11 +10763,6 @@ function App(): React.JSX.Element { browserApi.stopLoading(conversationId), ) } - onStopBrowser={() => - runBrowserCommand((browserApi, conversationId) => - browserApi.stop(conversationId), - ) - } onImportArtifacts={async () => { const imported = await window.goodbuddy.artifacts.importFiles( activeProjectId || undefined, diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 4d24fc49..8b4b4394 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -78,14 +78,12 @@ function renderSidebar({ onLoadWorkspaceFile={onLoadWorkspaceFile} onLoadWorkspaceDiff={vi.fn()} onOpenWorkspaceEntry={vi.fn(async () => undefined)} - onInteractBrowser={vi.fn(async () => undefined)} onRefreshChanges={vi.fn(async () => undefined)} onRemoveSchedule={vi.fn(async () => undefined)} onRespondApproval={vi.fn()} onRunSchedule={vi.fn(async () => undefined)} onSetScheduleEnabled={vi.fn(async () => undefined)} onOpenTask={vi.fn()} - onStopBrowser={vi.fn(async () => undefined)} onTabChange={vi.fn()} open restoreFocusRef={restoreFocusRef} @@ -101,7 +99,7 @@ function renderSidebar({ } describe('RightAssistantSidebar resizing', () => { - it('resizes with pointer capture and preserves equal pane minima', () => { + it('resizes with pointer capture and preserves compact pane minima', () => { const sidebar = renderSidebar() const separator = screen.getByRole('separator', { name: '调整助手工作栏宽度' @@ -128,9 +126,9 @@ describe('RightAssistantSidebar resizing', () => { expect(sidebar).toHaveClass('assistant-sidebar--resizing') expect( sidebar.style.getPropertyValue('--assistant-sidebar-width') - ).toBe('1100px') - expect(separator).toHaveAttribute('aria-valuemin', '300') - expect(separator).toHaveAttribute('aria-valuemax', '1100') + ).toBe('1240px') + expect(separator).toHaveAttribute('aria-valuemin', '160') + expect(separator).toHaveAttribute('aria-valuemax', '1240') fireEvent.pointerUp(separator, { pointerId: 7 }) expect(releasePointerCapture).toHaveBeenCalledWith(7) @@ -152,12 +150,12 @@ describe('RightAssistantSidebar resizing', () => { fireEvent.keyDown(separator, { key: 'Home' }) expect( sidebar.style.getPropertyValue('--assistant-sidebar-width') - ).toBe('300px') + ).toBe('160px') fireEvent.keyDown(separator, { key: 'End' }) expect( sidebar.style.getPropertyValue('--assistant-sidebar-width') - ).toBe('1100px') + ).toBe('1240px') }) it('remains resizable when the sidebar docks in a medium window', () => { @@ -191,7 +189,7 @@ describe('RightAssistantSidebar resizing', () => { ).toBe('424px') }) - it('keeps the equal minimum dock resizable in a narrow window', () => { + it('keeps the compact dock resizable in a narrow window', () => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 680 @@ -203,16 +201,16 @@ describe('RightAssistantSidebar resizing', () => { expect( sidebar.style.getPropertyValue('--assistant-sidebar-width') - ).toBe('300px') + ).toBe('204px') expect(sidebar).not.toHaveAttribute('aria-modal') expect(separator).toHaveAttribute('tabindex', '0') - expect(separator).toHaveAttribute('aria-valuemin', '300') - expect(separator).toHaveAttribute('aria-valuemax', '380') + expect(separator).toHaveAttribute('aria-valuemin', '160') + expect(separator).toHaveAttribute('aria-valuemax', '520') fireEvent.keyDown(separator, { key: 'End' }) expect( sidebar.style.getPropertyValue('--assistant-sidebar-width') - ).toBe('380px') + ).toBe('520px') }) it('excludes the primary sidebar from the equal pane limits', async () => { @@ -242,13 +240,13 @@ describe('RightAssistantSidebar resizing', () => { name: '调整助手工作栏宽度' }) await waitFor(() => - expect(separator).toHaveAttribute('aria-valuemax', '600') + expect(separator).toHaveAttribute('aria-valuemax', '740') ) fireEvent.keyDown(separator, { key: 'End' }) expect( sidebar.style.getPropertyValue('--assistant-sidebar-width') - ).toBe('600px') + ).toBe('740px') } finally { getBoundingClientRect.mockRestore() } @@ -265,6 +263,32 @@ describe('RightAssistantSidebar resizing', () => { ).not.toBeInTheDocument() }) + it('uses the browser panel for only the toolbar and page viewport', () => { + const sidebar = renderSidebar({ tab: 'browser' }) + const viewport = screen.getByLabelText('浏览器页面') + const browserPanel = viewport.parentElement + + expect(browserPanel).toHaveClass('assistant-sidebar__browser') + expect(browserPanel?.children).toHaveLength(2) + expect(screen.queryByText('实时浏览器')).not.toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { name: '全屏显示浏览器' }) + ) + expect(sidebar).toHaveClass('assistant-sidebar--browser-fullscreen') + expect( + screen.getByRole('button', { name: '退出浏览器全屏' }) + ).toHaveAttribute('aria-pressed', 'true') + expect( + screen.getByRole('separator', { name: '调整助手工作栏宽度' }) + ).toHaveAttribute('aria-disabled', 'false') + + fireEvent.keyDown(window, { key: 'Escape' }) + expect(sidebar).not.toHaveClass( + 'assistant-sidebar--browser-fullscreen' + ) + }) + it('keeps the docked sidebar non-modal', () => { const sidebar = renderSidebar() @@ -369,7 +393,7 @@ describe('RightAssistantSidebar resizing', () => { rejectPreview?.(new Error('临时网络错误')) const error = await screen.findByRole('alert') expect(error).toHaveTextContent('临时网络错误') - expect(screen.getByText('README.md')).toBeInTheDocument() + expect(screen.getByText('README.md', { selector: 'strong' })).toBeVisible() fireEvent.click( within(error).getByRole('button', { name: '刷新' }) ) @@ -384,6 +408,91 @@ describe('RightAssistantSidebar resizing', () => { ) }) + it('returns to the expanded directory, selected file and scroll position without reloading', async () => { + const onListWorkspaceDirectory = vi.fn(async (path: string) => ({ + path, + entries: path === '' + ? [{ name: 'src', path: 'src', type: 'directory' as const }] + : [{ name: 'nested.txt', path: 'src/nested.txt', type: 'file' as const }], + truncated: false + })) + renderSidebar({ + tab: 'workspace', + workspaceProjectId: 'project-1', + onListWorkspaceDirectory, + onLoadWorkspaceFile: vi.fn().mockResolvedValue({ + path: 'src/nested.txt', name: 'nested.txt', content: 'Nested content', + mimeType: 'text/plain', size: 14, offsetBytes: 0, nextOffsetBytes: 14, truncated: false + }) + }) + fireEvent.click(await screen.findByRole('button', { name: 'src' })) + const file = await screen.findByRole('button', { name: 'nested.txt' }) + const body = file.closest('.assistant-sidebar__body') as HTMLElement + body.scrollTop = 240 + fireEvent.click(file) + expect(await screen.findByText('Nested content')).toBeVisible() + expect(file).not.toBeVisible() + expect(body.scrollTop).toBe(0) + body.scrollTop = 80 + fireEvent.click(screen.getByRole('button', { name: '返回工作区' })) + expect(screen.getByRole('button', { name: 'src' })).toHaveAttribute('aria-expanded', 'true') + expect(file).toBeVisible() + expect(file).toHaveAttribute('aria-current', 'true') + expect(file).toHaveFocus() + expect(body.scrollTop).toBe(240) + expect(onListWorkspaceDirectory).toHaveBeenCalledTimes(2) + }) + + it('keeps preview content and reading position when refreshing fails', async () => { + const onLoadWorkspaceFile = vi.fn().mockResolvedValueOnce({ + path: 'README.md', name: 'README.md', content: '# Original heading', + mimeType: 'text/markdown', size: 18, offsetBytes: 0, nextOffsetBytes: 18, truncated: false + }).mockRejectedValueOnce(new Error('Refresh unavailable')) + renderSidebar({ + tab: 'workspace', workspaceProjectId: 'project-1', onLoadWorkspaceFile, + onListWorkspaceDirectory: vi.fn(async (path: string) => ({ + path, entries: [{ name: 'README.md', path: 'README.md', type: 'file' as const }], truncated: false + })) + }) + fireEvent.click(await screen.findByRole('button', { name: 'README.md' })) + const heading = await screen.findByRole('heading', { name: 'Original heading' }) + const body = heading.closest('.assistant-sidebar__body') as HTMLElement + body.scrollTop = 120 + fireEvent.click(screen.getByRole('button', { name: '刷新' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Refresh unavailable') + expect(heading).toBeVisible() + expect(body.scrollTop).toBe(120) + fireEvent.click(screen.getByRole('button', { name: '源码' })) + expect(screen.getByText('# Original heading')).toBeVisible() + expect(screen.queryByRole('heading', { name: 'Original heading' })).not.toBeInTheDocument() + }) + + it('discards an unfinished file preview when switching projects', async () => { + let resolveRead: (value: WorkspaceFilePreview) => void = () => undefined + const props = { + open: true, tab: 'workspace' as const, approvals: [], artifacts: [], schedules: [], tasks: [], + conversationTitles: new Map(), projectNames: new Map(), + onCreateCustomTask: vi.fn(), onImportArtifacts: vi.fn(), onLoadArtifact: vi.fn(), + onLoadWorkspaceFile: vi.fn(() => new Promise((resolve) => { resolveRead = resolve })), + onLoadWorkspaceDiff: vi.fn(), onOpenWorkspaceEntry: vi.fn(), + onRefreshChanges: vi.fn(), onRemoveSchedule: vi.fn(), onRespondApproval: vi.fn(), + onRunSchedule: vi.fn(), onSetScheduleEnabled: vi.fn(), onOpenTask: vi.fn(), onTabChange: vi.fn(), + onListWorkspaceDirectory: vi.fn(async (path: string) => ({ + path, entries: [{ name: 'file.txt', path: 'file.txt', type: 'file' as const }], truncated: false + })) + } + const { rerender } = render() + fireEvent.click(await screen.findByRole('button', { name: 'file.txt' })) + expect(screen.getByRole('status', { name: '正在读取文件…' })).toBeVisible() + rerender() + resolveRead({ path: 'file.txt', name: 'file.txt', content: 'Stale preview', mimeType: 'text/plain', size: 13, offsetBytes: 0, nextOffsetBytes: 13, truncated: false }) + await screen.findByRole('button', { name: 'file.txt' }) + rerender() + expect(await screen.findByRole('button', { name: 'file.txt' })).toBeVisible() + expect(screen.queryByRole('status', { name: '正在读取文件…' })).not.toBeInTheDocument() + expect(screen.queryByText('Stale preview')).not.toBeInTheDocument() + }) + it('loads and appends the rest of a large workspace file', async () => { const onLoadWorkspaceFile = vi .fn() diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index d4687764..a727b4bd 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -4,22 +4,24 @@ import { ChevronLeft, ChevronRight, CircleAlert, + Copy, ExternalLink, FileText, ClockFading, - Monitor, + Maximize2, + Minimize2, Plus, RefreshCw, ShieldAlert, Square, - Upload, - X + Upload } from 'lucide-react' import { lazy, Suspense, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState @@ -107,11 +109,9 @@ type RightAssistantSidebarProps = { currentProject?: AssistantProject restoreFocusRef?: { current: HTMLElement | null } onBackBrowser?: () => Promise - onInteractBrowser: () => Promise onNavigateBrowser?: (url: string) => Promise onReloadBrowser?: () => Promise onStopLoadingBrowser?: () => Promise - onStopBrowser: () => Promise onCreateCustomTask: () => void onImportArtifacts: () => Promise onLoadArtifact: (artifactId: string) => Promise @@ -150,7 +150,7 @@ const tabIds: AssistantSidebarTab[] = [ ] const emptyChangedFiles: WorkspaceChanges['files'] = [] const defaultSidebarRatio = 0.3 -const minimumPaneWidth = 300 +const minimumPaneWidth = 160 const keyboardResizeStep = 16 const workbarStorageKey = 'goodbuddy.workbar-layout.v1' @@ -255,20 +255,20 @@ function addDefaultBrowserProtocol(address: string): string { function BrowserToolbar({ activeConversationId, browserState, + fullscreen, onBack, - onInteract, + onToggleFullscreen, onNavigate, onReload, - onStop, onStopLoading }: { activeConversationId?: string browserState?: BrowserLiveState + fullscreen: boolean onBack: () => Promise - onInteract: () => Promise + onToggleFullscreen: () => void onNavigate: (url: string) => Promise onReload: () => Promise - onStop: () => Promise onStopLoading: () => Promise }): React.JSX.Element { const { t } = useTranslation('workspace') @@ -308,15 +308,6 @@ function BrowserToolbar({ operationBlocked || !sessionActive || isLoading - const interactDisabled = - operationBlocked || - !sessionActive || - isLoading || - (status !== 'ready' && status !== 'failed') - const closeDisabled = - operationBlocked || - !sessionActive || - isLoading const goDisabled = actionPending || !activeConversationId || @@ -449,31 +440,143 @@ function BrowserToolbar({ {t('sidebar.browser.toolbar.go')} - ) } +function BrowserViewport({ + browserState, + conversationId, + visible +}: { + browserState?: BrowserLiveState + conversationId?: string + visible: boolean +}): React.JSX.Element { + const { t } = useTranslation('workspace') + const hostRef = useRef(null) + + useLayoutEffect(() => { + const host = hostRef.current + const browserApi = window.goodbuddy?.browser + if (!host || !conversationId || !browserApi?.setViewport) { + return + } + let animationFrame: number | undefined + let lastRequest = '' + let resizeObserver: ResizeObserver | undefined + const panel = host.closest('[role="tabpanel"]') + const syncViewport = (): void => { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame) + } + animationFrame = requestAnimationFrame(() => { + animationFrame = undefined + const rect = host.getBoundingClientRect() + const isVisible = + visible && + Boolean(conversationId) && + panel?.hidden !== true && + rect.width >= 1 && + rect.height >= 1 + const bounds = isVisible + ? { + x: Math.max(0, Math.round(rect.left)), + y: Math.max(0, Math.round(rect.top)), + width: Math.round(rect.width), + height: Math.round(rect.height) + } + : undefined + const request = JSON.stringify({ conversationId, bounds }) + if (request === lastRequest) { + return + } + lastRequest = request + void browserApi + .setViewport(isVisible ? conversationId : undefined, bounds) + .catch(() => undefined) + }) + } + + const updateObservation = (): void => { + if (panel?.hidden !== false) { + resizeObserver?.disconnect() + resizeObserver = undefined + syncViewport() + return + } + if (!resizeObserver && typeof ResizeObserver === 'function') { + resizeObserver = new ResizeObserver(syncViewport) + resizeObserver.observe(host) + } + syncViewport() + } + const mutationObserver = new MutationObserver(updateObservation) + if (panel) { + mutationObserver.observe(panel, { + attributes: true, + attributeFilter: ['hidden'] + }) + } + window.addEventListener('resize', syncViewport) + window.addEventListener('scroll', syncViewport, true) + updateObservation() + return () => { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame) + } + resizeObserver?.disconnect() + mutationObserver.disconnect() + window.removeEventListener('resize', syncViewport) + window.removeEventListener('scroll', syncViewport, true) + void browserApi.setViewport().catch(() => undefined) + } + }, [conversationId, visible]) + + return ( +
+ {browserState?.sessionActive !== true ? ( +
+ {browserState?.status === 'creating' + ? t('sidebar.browser.statuses.creating') + : browserState?.status === 'failed' + ? browserState.error ?? t('sidebar.browser.statuses.failed') + : t('sidebar.browser.empty')} +
+ ) : null} +
+ ) +} + export function RightAssistantSidebar({ open, tab, @@ -491,11 +594,9 @@ export function RightAssistantSidebar({ currentProject, restoreFocusRef, onBackBrowser = async () => {}, - onInteractBrowser, onNavigateBrowser = async () => {}, onReloadBrowser = async () => {}, onStopLoadingBrowser = async () => {}, - onStopBrowser, onCreateCustomTask, onImportArtifacts, onLoadArtifact, @@ -586,6 +687,9 @@ export function RightAssistantSidebar({ defaultInstances[0]?.id ?? null ) + const activeWorkbarApp = workbarInstances.find( + (instance) => instance.id === activeWorkbarInstanceId + )?.appId const [splitLayoutWidth, setSplitLayoutWidth] = useState( window.innerWidth ) @@ -593,6 +697,8 @@ export function RightAssistantSidebar({ initialLayout?.widthRatio ?? defaultSidebarRatio ) const [isResizing, setIsResizing] = useState(false) + const [browserFullscreen, setBrowserFullscreen] = useState(false) + const browserRestoreRatio = useRef(sidebarRatio) const sidebarRef = useRef(null) const wasOpen = useRef(false) const sidebarWidth = clampSidebarWidth( @@ -626,6 +732,12 @@ export function RightAssistantSidebar({ } >() const workspacePreviewRequest = useRef(0) + const workspaceListRef = useRef(null) + const workspaceBackRef = useRef(null) + const workspaceReturnPosition = useRef<{ scrollTop: number; trigger?: HTMLElement }>({ scrollTop: 0 }) + const workspaceTrigger = useRef(undefined) + const [workspacePreviewRefreshing, setWorkspacePreviewRefreshing] = useState(false) + const [workspaceSource, setWorkspaceSource] = useState(false) const [workspacePreviewLoadingMore, setWorkspacePreviewLoadingMore] = useState(false) const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] = @@ -655,6 +767,29 @@ export function RightAssistantSidebar({ workspacePreview?.projectId === workspaceProjectId ? workspacePreview : undefined + if (workspacePreview && workspacePreview.projectId !== workspaceProjectId) { + setWorkspacePreview(undefined) + setWorkspacePreviewRefreshing(false) + setWorkspacePreviewLoadingMore(false) + setWorkspacePreviewLoadMoreError('') + } + const workspacePreviewVisible = Boolean(currentWorkspacePreview) + useLayoutEffect(() => { + const body = workspaceListRef.current?.closest('.assistant-sidebar__body') + if (!body) return + if (workspacePreviewVisible) { + body.scrollTop = 0 + workspaceBackRef.current?.focus({ preventScroll: true }) + } else { + body.scrollTop = workspaceReturnPosition.current.scrollTop + workspaceReturnPosition.current.trigger?.focus({ preventScroll: true }) + } + }, [workspacePreviewVisible]) + useEffect(() => () => { + workspacePreviewRequest.current += 1 + workspaceReturnPosition.current = { scrollTop: 0 } + workspaceTrigger.current = undefined + }, [workspaceProjectId]) const terminalAdapter = useMemo( () => ({ create: (request) => window.goodbuddy.terminal.create(request), @@ -718,6 +853,40 @@ export function RightAssistantSidebar({ } }, [tab, workbarInstances]) + const browserFullscreenActive = + open && activeWorkbarApp === 'browser' && browserFullscreen + const toggleBrowserFullscreen = useCallback((): void => { + if (browserFullscreen) { + setSidebarRatio(browserRestoreRatio.current) + setBrowserFullscreen(false) + return + } + const layoutWidth = measureSplitLayoutWidth( + sidebarRef.current, + splitLayoutWidth + ) + browserRestoreRatio.current = sidebarRatio + setSidebarRatio( + layoutWidth > 0 + ? getSidebarWidthLimits(layoutWidth).maximum / layoutWidth + : sidebarRatio + ) + setBrowserFullscreen(true) + }, [browserFullscreen, sidebarRatio, splitLayoutWidth]) + + useEffect(() => { + if (!browserFullscreenActive) { + return + } + const exitFullscreen = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + toggleBrowserFullscreen() + } + } + window.addEventListener('keydown', exitFullscreen) + return () => window.removeEventListener('keydown', exitFullscreen) + }, [browserFullscreenActive, toggleBrowserFullscreen]) + useEffect(() => { persistWorkbarLayout( workbarInstances, @@ -1033,17 +1202,26 @@ export function RightAssistantSidebar({ return } event.preventDefault() + setBrowserFullscreen(false) const width = clampSidebarWidth(nextWidth, layoutWidth) setSidebarRatio( layoutWidth > 0 ? width / layoutWidth : defaultSidebarRatio ) } - const openWorkspaceFile = (path: string): void => { + const openWorkspaceFile = (path: string, refresh = false): void => { + if (!currentWorkspacePreview) { + workspaceReturnPosition.current = { + scrollTop: workspaceListRef.current?.closest('.assistant-sidebar__body')?.scrollTop ?? 0, + trigger: workspaceTrigger.current + } + setWorkspaceSource(false) + } const requestId = workspacePreviewRequest.current + 1 workspacePreviewRequest.current = requestId const projectId = workspaceProjectId - setWorkspacePreview({ projectId, path, state: 'loading' }) + if (!refresh) setWorkspacePreview({ projectId, path, state: 'loading' }) + setWorkspacePreviewRefreshing(refresh) setWorkspacePreviewLoadingMore(false) setWorkspacePreviewLoadMoreError('') setActionError('') @@ -1059,8 +1237,15 @@ export function RightAssistantSidebar({ setSelectedArtifactId(undefined) } }) + .finally(() => { + if (workspacePreviewRequest.current === requestId) setWorkspacePreviewRefreshing(false) + }) .catch((reason: unknown) => { if (workspacePreviewRequest.current === requestId) { + if (refresh) { + setWorkspacePreviewLoadMoreError(reason instanceof Error ? reason.message : t('sidebar.errors.workspacePreview')) + return + } setWorkspacePreview({ path, projectId, @@ -1079,7 +1264,7 @@ export function RightAssistantSidebar({ if ( preview?.state !== 'ready' || !preview.file.truncated || - workspacePreviewLoadingMore + workspacePreviewLoadingMore || workspacePreviewRefreshing ) { return } @@ -1152,7 +1337,7 @@ export function RightAssistantSidebar({ aria-hidden={!open} className={ open - ? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}` + ? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}${browserFullscreenActive ? ' assistant-sidebar--browser-fullscreen' : ''}` : 'assistant-sidebar' } id="assistant-sidebar" @@ -1198,6 +1383,7 @@ export function RightAssistantSidebar({ return } event.preventDefault() + setBrowserFullscreen(false) resizePointerId.current = event.pointerId event.currentTarget.setPointerCapture(event.pointerId) resizeFromClientX(event.clientX, true) @@ -1237,7 +1423,9 @@ export function RightAssistantSidebar({ ) : null } renderPanel={(instance) => ( -
+
{actionError ? (

{actionError} @@ -1476,19 +1664,22 @@ export function RightAssistantSidebar({ )} {instance.appId === 'workspace' && ( - currentWorkspacePreview ? ( + <> + {currentWorkspacePreview && (

- {currentWorkspacePreview.path} + {currentWorkspacePreview.path} {currentWorkspacePreview.state === 'ready' ? t('sidebar.workspace.fileSize', { @@ -1511,6 +1702,27 @@ export function RightAssistantSidebar({
+
+ {currentProject?.executionSpace?.kind !== 'ssh' && ( + + )} + + {currentWorkspacePreview.state === 'ready' && ( + + )} + {currentWorkspacePreview.file?.mimeType === 'text/markdown' && ( + setWorkspaceSource(value === 'source')} options={[ + { value: 'preview', label: t('sidebar.workspace.preview') }, + { value: 'source', label: t('sidebar.workspace.source') } + ]} /> + )} +
{currentWorkspacePreview.state === 'loading' ? (

{currentWorkspacePreview.file.mimeType === - 'text/markdown' ? ( + 'text/markdown' && !workspaceSource ? ( {currentWorkspacePreview.file.content} @@ -1560,7 +1772,7 @@ export function RightAssistantSidebar({

- ) : ( -
-
- -
+ )} + - ) + )} {instance.appId === 'results' && ( @@ -1738,18 +1932,13 @@ export function RightAssistantSidebar({ runAction( onBackBrowser, t('sidebar.errors.backBrowser') ) } - onInteract={() => - runAction( - onInteractBrowser, - t('sidebar.errors.interactBrowser') - ) - } onNavigate={(url) => runAction( () => onNavigateBrowser(url), @@ -1762,77 +1951,21 @@ export function RightAssistantSidebar({ t('sidebar.errors.reloadBrowser') ) } - onStop={() => - runAction( - onStopBrowser, - t('sidebar.errors.stopBrowser') - ) - } onStopLoading={() => runAction( onStopLoadingBrowser, t('sidebar.errors.stopLoadingBrowser') ) } + onToggleFullscreen={() => + toggleBrowserFullscreen() + } + /> + -
- - - {t('sidebar.browser.title')} - -
- {!browserState ? ( -

- {t('sidebar.browser.empty')} -

- ) : ( - <> -
- {browserState.status === 'creating' - ? t('sidebar.browser.statuses.creating') - : browserState.status === 'loading' - ? t('sidebar.browser.statuses.loading') - : browserState.status === 'acting' - ? t('sidebar.browser.statuses.acting') - : browserState.status === 'interactive' - ? t( - 'sidebar.browser.statuses.interactive' - ) - : browserState.status === 'ready' - ? t('sidebar.browser.statuses.ready') - : browserState.status === 'failed' - ? browserState.error ?? - t('sidebar.browser.statuses.failed') - : t('sidebar.browser.statuses.stopped')} -
- {browserState.frameDataUrl ? ( - {t('sidebar.browser.frameAlt')} - ) : ( -
- - - {browserState.status === 'failed' - ? t('sidebar.browser.noFrame') - : t('sidebar.browser.waitingFrame')} - -
- )} - - )}
)}
diff --git a/src/renderer/src/WorkspaceActionDialog.tsx b/src/renderer/src/WorkspaceActionDialog.tsx new file mode 100644 index 00000000..ab58d721 --- /dev/null +++ b/src/renderer/src/WorkspaceActionDialog.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { ArrowUp, Folder, FolderOpen, RefreshCw } from 'lucide-react' +import { activateModalFocus, trapTabFocus } from './dialog-focus' +import type { WorkspaceManagementAction, WorkspaceManagementResult } from '../../shared/workspace-management-contracts' +import type { SshDirectoryBrowseResult } from '../../shared/ssh-host-contracts' + +export function WorkspaceActionDialog({ projectId, rootPath, kind, path, onClose, onComplete }: { + projectId: string + rootPath?: string + kind: 'rename' | 'move' | 'delete' | 'properties' | 'createFile' | 'createDirectory' + path: string + onClose: () => void + onComplete: () => void +}): React.JSX.Element { + const { t } = useTranslation('workspace') + const [value, setValue] = useState(kind === 'rename' ? path.split('/').at(-1)! : '') + const [destinationDirectory, setDestinationDirectory] = useState('') + const [remotePicker, setRemotePicker] = useState<{ hostId: string; root: string; listing?: SshDirectoryBrowseResult }>() + const browseRequest = useRef(0) + const remoteBrowsing = useRef(false) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [properties, setProperties] = useState>() + const cancel = useRef(null) + useEffect(() => activateModalFocus(() => cancel.current), []) + useEffect(() => () => { + browseRequest.current++ + if (remoteBrowsing.current) void window.goodbuddy.sshHosts?.cancelDirectoryBrowse().catch(() => undefined) + }, []) + useEffect(() => { + if (kind !== 'properties') return + let active = true + void window.goodbuddy.workspace.manage(projectId, { kind, path }).then((result) => { + if (active && result.kind === 'properties') setProperties(result) + }).catch((reason: unknown) => { if (active) setError(String(reason)) }) + return () => { active = false } + }, [kind, path, projectId]) + const selectDestination = (directory: string, root: string, remote: boolean): void => { + const windows = !remote && /^(?:[a-z]:[\\/]|\\\\)/i.test(root) + const normalize = (input: string): string => { + const parts: string[] = [] + for (const part of (windows ? input.replaceAll('\\', '/') : input).split('/')) { + if (part === '..') parts.pop() + else if (part && part !== '.') parts.push(part) + } + return `/${parts.join('/')}` + } + const base = normalize(root) + const selected = normalize(directory) + const compare = (input: string): string => windows ? input.toLowerCase() : input + const prefix = base === '/' ? '/' : `${base}/` + if (compare(selected) !== compare(base) && !compare(selected).startsWith(compare(prefix))) { + setValue('') + setDestinationDirectory(directory) + setError(t('management.destinationOutside', { root })) + return + } + const relative = compare(selected) === compare(base) ? '' : selected.slice(prefix.length) + setValue([relative, path.split('/').at(-1)!].filter(Boolean).join('/')) + setDestinationDirectory(directory) + setError('') + setRemotePicker(undefined) + remoteBrowsing.current = false + } + const browseRemote = async (hostId: string, root: string, directory: string): Promise => { + const generation = ++browseRequest.current + setBusy(true); setError(''); remoteBrowsing.current = true + try { + const api = window.goodbuddy.sshHosts + if (!api) throw new Error(t('management.destinationUnavailable')) + const listing = await api.browseDirectories(hostId, directory) + if (generation === browseRequest.current) setRemotePicker({ hostId, root, listing }) + } catch (reason) { if (generation === browseRequest.current) setError(String(reason)) } + finally { if (generation === browseRequest.current) setBusy(false) } + } + const chooseDirectory = async (): Promise => { + setBusy(true); setError('') + try { + const project = (await window.goodbuddy.projects.list()).find((item) => item.id === projectId) + if (!project) throw new Error(t('management.destinationUnavailable')) + const space = project.executionSpace + if (space?.kind === 'ssh') { + const root = rootPath || space.remoteRootPath + setRemotePicker({ hostId: space.hostId, root }) + await browseRemote(space.hostId, root, root) + } else { + const root = rootPath || space?.rootPath || project.rootPath + if (!root) throw new Error(t('management.destinationUnavailable')) + const directory = await window.goodbuddy.settings.selectWorkspace() + if (directory) selectDestination(directory, root, false) + } + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } + finally { setBusy(false) } + } + const submit = async (): Promise => { + setError('') + if (busy) return + setBusy(true) + try { + const parent = path.split('/').slice(0, -1).join('/') + const destination = kind === 'rename' ? [parent, value].filter(Boolean).join('/') : value + if (kind === 'rename' && /[\\/]/.test(value)) throw new Error(t('management.nameOnly')) + const action: WorkspaceManagementAction = kind === 'rename' || kind === 'move' ? { kind: 'move', path, destination } + : kind === 'createFile' || kind === 'createDirectory' ? { kind, path: [path, value].filter(Boolean).join('/') } + : { kind, path } + await window.goodbuddy.workspace.manage(projectId, action) + onComplete() + onClose() + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } + finally { setBusy(false) } + } + return createPortal(
{ + trapTabFocus(event, event.currentTarget) + if (event.key === 'Escape' && (!busy || remotePicker)) onClose() + }}> +
+

{t(`management.${kind}`)}

+

{path || '/'}

+ {kind === 'delete' ?

{t('management.deleteWarning')}

: kind === 'properties' ? properties &&
+
{t('management.path')}
{properties.path}
+
{t('management.type')}
{properties.type}
+
{t('management.size')}
{properties.size.toLocaleString()} B
+
{t('management.modified')}
{new Date(properties.modifiedAt).toLocaleString()}
+
: kind === 'move' ?
+ {t('management.destination')} + + {t('management.moveHelp')} + {remotePicker &&
+
{remotePicker.listing?.path || remotePicker.root}
+
+ + +
+
+ {busy ?

{t('files.reading')}

: remotePicker.listing?.entries.length === 0 ?

{t('projectSwitcher.remote.directoryPicker.empty')}

:
    {remotePicker.listing?.entries.map((entry) =>
  • )}
} +
+ {remotePicker.listing?.truncated && {t('files.directoryTruncated')}} + +
} +
: } + {error && } +
+ + {kind !== 'properties' && } +
+
+
, document.body) +} diff --git a/src/renderer/src/WorkspaceFilesPanel.test.tsx b/src/renderer/src/WorkspaceFilesPanel.test.tsx index f896bd8c..89856bcf 100644 --- a/src/renderer/src/WorkspaceFilesPanel.test.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.test.tsx @@ -1,14 +1,178 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { changeUiLocale } from './i18n' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { gitStatusLetter, WorkspaceFilesPanel } from './WorkspaceFilesPanel' +beforeEach(() => vi.stubGlobal('goodbuddy', { workspace: { manage: vi.fn(async () => ({ kind: 'branches', current: 'main', branches: [] })) } })) + afterEach(async () => { cleanup() + vi.unstubAllGlobals() await changeUiLocale('zh-CN') }) describe('WorkspaceFilesPanel', () => { + it('keeps directory navigation and compact actions bounded independently', () => { + const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8') + expect(css.match(/\.workspace-files__actions\s*\{([^}]*)\}/)?.[1]).toContain('flex-wrap: nowrap') + expect(css.match(/\.workspace-files__breadcrumb-parts\s*\{([^}]*)\}/)?.[1]).toContain('overflow-x: auto') + expect(css.match(/\.workspace-files__breadcrumb-parts button\s*\{([^}]*)\}/)?.[1]).toContain('text-overflow: ellipsis') + const menu = css.match(/\.workspace-files__menu\s*\{([^}]*)\}/)?.[1] + expect(menu).toContain('position: fixed') + expect(menu).toContain('max-width: calc(100vw - 16px)') + expect(menu).toContain('max-height: calc(100vh - 16px)') + const more = css.match(/\.workspace-files__more\s*\{([^}]*)\}/)?.[1] + expect(more).toContain('position: absolute') + expect(more).toContain('top: 50%') + expect(css).toMatch(/\.workspace-files__menu button\s*\{[^}]*justify-content: flex-start/) + expect(css.match(/^\.workspace-git__commit\s*\{([^}]*)\}/m)?.[1]).toContain('grid-template-columns: minmax(0, 1fr)') + expect(css).toMatch(/\.workspace-git__toolbar\s*\{[^}]*flex-wrap: wrap;[^}]*gap: var\(--space-2\)/) + 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/) + }) + + it.each([ + ['D:\\project', 'd:\\project\\target', 'target/notes.txt'], + ['/project', '/project', 'notes.txt'], + ['/', '/target', 'target/notes.txt'] + ])('moves into the native directory selection inside %s', async (rootPath, directory, destination) => { + const selectWorkspace = vi.fn(async () => directory) + const manage = vi.fn(async () => ({ kind: 'done' })) + vi.stubGlobal('goodbuddy', { projects: { list: vi.fn(async () => [{ id: 'project', rootPath }]) }, settings: { selectWorkspace }, workspace: { manage } }) + render( ({ path: '', entries: [{ name: 'notes.txt', path: 'source/notes.txt', type: 'file' as const }], truncated: false }))} + onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />) + fireEvent.click(await screen.findByRole('button', { name: 'notes.txt 的更多操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '移动' })) + const dialog = screen.getByRole('dialog', { name: '移动' }) + expect(within(dialog).queryByRole('textbox')).not.toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: '取消' })).toHaveClass('secondary-button') + expect(within(dialog).getByRole('button', { name: '移动' })).toBeDisabled() + fireEvent.click(within(dialog).getByRole('button', { name: '目标目录 选择目录' })) + await within(dialog).findByText(directory) + expect(selectWorkspace).toHaveBeenCalledOnce() + fireEvent.click(within(dialog).getByRole('button', { name: '移动' })) + await waitFor(() => expect(manage).toHaveBeenCalledWith('project', { kind: 'move', path: 'source/notes.txt', destination })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + it('keeps native picker cancellation and outside-workspace choices from moving files', async () => { + const selectWorkspace = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce('D:\\project-other').mockResolvedValueOnce('D:\\project\\..\\outside') + const manage = vi.fn() + vi.stubGlobal('goodbuddy', { projects: { list: vi.fn(async () => [{ id: 'project', rootPath: 'D:\\project' }]) }, settings: { selectWorkspace }, workspace: { manage } }) + render( ({ path: '', entries: [{ name: 'notes.txt', path: 'notes.txt', type: 'file' as const }], truncated: false }))} + onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />) + fireEvent.click(await screen.findByRole('button', { name: 'notes.txt 的更多操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '移动' })) + const choose = screen.getByRole('button', { name: '目标目录 选择目录' }) + fireEvent.click(choose) + await waitFor(() => expect(choose).toBeEnabled()) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + for (let attempt = 0; attempt < 2; attempt++) { + fireEvent.click(choose) + expect(await screen.findByRole('alert')).toHaveTextContent('暂不支持移动到此工作区之外') + await waitFor(() => expect(choose).toBeEnabled()) + expect(screen.getByRole('button', { name: '移动' })).toBeDisabled() + } + expect(manage).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('browses remote move destinations on the project host and maps the selected directory', async () => { + const browseDirectories = vi.fn(async (_host: string, path: string) => ({ path, parentPath: '/remote', entries: path === '/remote/project' ? [{ name: 'target', path: '/remote/project/target' }] : [] })) + const cancelDirectoryBrowse = vi.fn(async () => undefined) + const selectWorkspace = vi.fn() + const manage = vi.fn(async () => ({ kind: 'done' })) + vi.stubGlobal('goodbuddy', { projects: { list: vi.fn(async () => [{ id: 'project', executionSpace: { kind: 'ssh', hostId: 'host', remoteRootPath: '/remote/project' } }]) }, settings: { selectWorkspace }, sshHosts: { browseDirectories, cancelDirectoryBrowse }, workspace: { manage } }) + render( ({ path: '', entries: [{ name: 'notes.txt', path: 'notes.txt', type: 'file' as const }], truncated: false }))} + onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />) + fireEvent.click(await screen.findByRole('button', { name: 'notes.txt 的更多操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '移动' })) + fireEvent.click(screen.getByRole('button', { name: '目标目录 选择目录' })) + fireEvent.click(await screen.findByRole('button', { name: 'target' })) + await waitFor(() => expect(screen.getByRole('button', { name: '选择此目录' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: '选择此目录' })) + fireEvent.click(screen.getByRole('button', { name: '移动' })) + await waitFor(() => expect(manage).toHaveBeenCalledWith('project', { kind: 'move', path: 'notes.txt', destination: 'target/notes.txt' })) + expect(browseDirectories.mock.calls).toEqual([['host', '/remote/project'], ['host', '/remote/project/target']]) + expect(selectWorkspace).not.toHaveBeenCalled() + }) + + it('uses shared Git controls and opens compact commit rows into file details', async () => { + const manage = vi.fn(async (_project: string, action: { kind: string }) => action.kind === 'history' + ? { kind: 'history', commits: [{ oid: 'a'.repeat(40), subject: 'Initial commit', author: 'Author', time: '2026-09-09T10:00:00Z', refs: 'HEAD -> main' }], hasMore: false } + : action.kind === 'commitFiles' ? { kind: 'commitFiles', files: [{ path: 'notes.txt', status: 'A' }] } + : { kind: 'branches', current: 'main', branches: [] }) + vi.stubGlobal('goodbuddy', { workspace: { manage } }) + render( ({ path: '', entries: [], truncated: false }))} onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />) + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) + expect(await screen.findByRole('button', { name: 'main' })).toHaveClass('model-button') + expect(screen.getByRole('button', { name: 'Fetch' })).toHaveClass('secondary-button') + expect(screen.getByRole('button', { name: 'Fetch' })).toHaveAttribute('title', 'Fetch') + const toolbar = screen.getByRole('button', { name: 'main' }).closest('.workspace-git__toolbar')! + expect(within(toolbar as HTMLElement).getByRole('button', { name: '列表' })).toHaveClass('segmented-control__option') + const history = screen.getByRole('button', { name: '提交历史' }) + expect(history).toHaveClass('workspace-files__changed-row') + expect(history).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(history) + const commit = (await screen.findByText('Initial commit')).closest('button')! + expect(commit).toHaveClass('workspace-files__changed-row', 'workspace-git__commit') + fireEvent.click(commit) + expect(await screen.findByRole('button', { name: /notes\.txt/ })).toHaveClass('workspace-files__changed-row') + }) + + 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 })) + const cancelDirectoryBrowse = vi.fn(async () => undefined) + const manage = vi.fn() + vi.stubGlobal('goodbuddy', { projects: { list: vi.fn(async () => [{ id: 'project', executionSpace: { kind: 'ssh', hostId: 'host', remoteRootPath: '/project' } }]) }, sshHosts: { browseDirectories, cancelDirectoryBrowse }, workspace: { manage } }) + render( ({ path: '', entries: [{ name: 'notes.txt', path: 'notes.txt', type: 'file' as const }], truncated: false }))} + onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />) + fireEvent.click(await screen.findByRole('button', { name: 'notes.txt 的更多操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '移动' })) + fireEvent.click(screen.getByRole('button', { name: '目标目录 选择目录' })) + await waitFor(() => expect(browseDirectories).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(cancelDirectoryBrowse).toHaveBeenCalledOnce() + await act(async () => resolveBrowse({ path: '/project', entries: [] })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(manage).not.toHaveBeenCalled() + }) + + it('portals and clamps the row menu, supports keyboard navigation and restores focus', async () => { + const { container } = render( ({ path: '', entries: [{ name: 'notes.txt', path: 'notes.txt', type: 'file' as const }], truncated: false }))} + onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />) + const trigger = await screen.findByRole('button', { name: 'notes.txt 的更多操作' }) + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ right: window.innerWidth + 50, bottom: window.innerHeight + 50 } as DOMRect) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + const menu = screen.getByRole('menu', { name: 'notes.txt' }) + expect(container).not.toContainElement(menu) + expect(menu.parentElement).toBe(document.body) + expect(parseFloat(menu.style.left)).toBeLessThanOrEqual(window.innerWidth - 8) + expect(parseFloat(menu.style.top)).toBeLessThanOrEqual(window.innerHeight - 8) + expect(within(menu).getByRole('menuitem', { name: '重命名' })).toHaveFocus() + fireEvent.keyDown(menu, { key: 'End' }) + expect(within(menu).getByRole('menuitem', { name: '删除' })).toHaveFocus() + fireEvent.keyDown(menu, { key: 'Home' }) + fireEvent.keyDown(menu, { key: 'ArrowDown' }) + expect(within(menu).getByRole('menuitem', { name: '移动' })).toHaveFocus() + fireEvent.keyDown(menu, { key: 'Escape' }) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) it.each([['??', 'U'], [' M', 'M'], ['A ', 'A'], [' D', 'D'], ['R ', 'R'], ['C ', 'C'], ['UU', 'U'], ['AM', 'AM'], [' T', 'T']])('maps %s to %s without calling every new file added', (status, letter) => { expect(gitStatusLetter(status)).toBe(letter) }) @@ -57,7 +221,7 @@ describe('WorkspaceFilesPanel', () => { /> ) - expect(await screen.findByText('当前工作区')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '当前工作区' })).toHaveAttribute('aria-current', 'location') fireEvent.click(await screen.findByRole('button', { name: 'docs' })) fireEvent.click( await screen.findByRole('button', { name: 'guide.md' }) @@ -66,17 +230,11 @@ describe('WorkspaceFilesPanel', () => { expect(onListDirectory).toHaveBeenCalledWith('') expect(onListDirectory).toHaveBeenCalledWith('docs') expect(onOpenFile).toHaveBeenCalledWith('docs/guide.md') - fireEvent.click( - screen.getByRole('button', { - name: '使用默认应用打开文件 guide.md' - }) - ) + fireEvent.click(screen.getByLabelText('guide.md 的更多操作')) + fireEvent.click(within(screen.getByRole('menu', { name: 'guide.md' })).getByRole('menuitem', { name: '使用默认应用打开' })) expect(onOpenEntry).toHaveBeenCalledWith('docs/guide.md', 'file') - fireEvent.click( - screen.getByRole('button', { - name: '在系统资源管理器中打开文件夹 docs' - }) - ) + fireEvent.click(screen.getByLabelText('docs 的更多操作')) + fireEvent.click(within(screen.getByRole('menu', { name: 'docs' })).getByRole('menuitem', { name: '使用默认应用打开' })) expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory') expect(screen.getAllByText('M')).not.toHaveLength(0) }) @@ -138,7 +296,7 @@ describe('WorkspaceFilesPanel', () => { })) render( - { /> ) - await screen.findByText('当前工作区') + await screen.findByText('工作区为空。') expect(onListDirectory).toHaveBeenCalledOnce() await changeUiLocale('en-US') expect( - await screen.findByText('Current workspace') + await screen.findByRole('button', { name: 'Current workspace' }) ).toBeInTheDocument() - expect(screen.getByText('Uncommitted changes')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Git Workspace' })) expect(screen.getByText('M')).toBeInTheDocument() expect(onListDirectory).toHaveBeenCalledOnce() }) - it('refreshes expanded directories and drops collapsed caches without remounting', async () => { + it('refreshes expanded directories and reloads collapsed directories when reopened', async () => { let version = 0 const onListDirectory = vi.fn(async (path: string) => ({ path, @@ -188,15 +346,17 @@ describe('WorkspaceFilesPanel', () => { it('opens a deleted file diff rather than reading the missing file', async () => { const onLoadDiff = vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '-deleted content', stagedPatch: '-staged content', truncated: true })) const onOpenFile = vi.fn() - render( ({ path: '', entries: [], truncated: false }))} onLoadDiff={onLoadDiff} onOpenFile={onOpenFile} />) + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) fireEvent.click(screen.getByRole('button', { name: /deleted.txt/ })) await screen.findByText('-deleted content') expect(screen.getByText('-staged content')).toBeInTheDocument() expect(screen.getByText('差异内容已截断。')).toBeInTheDocument() expect(onLoadDiff).toHaveBeenCalledWith('deleted.txt') expect(onOpenFile).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: '查看当前文件' })).not.toBeInTheDocument() }) it('reloads the selected diff on refresh and ignores an older pending response', async () => { @@ -206,12 +366,13 @@ describe('WorkspaceFilesPanel', () => { .mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) .mockResolvedValue(value) const props = { - projectId: 'project', changedFiles: [{ path: 'seed.txt', status: ' M' }], + isRepository: true, projectId: 'project', changedFiles: [{ path: 'seed.txt', status: ' M' }], onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })), onLoadDiff, onOpenFile: vi.fn() } const view = render() await waitFor(() => expect(props.onListDirectory).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) fireEvent.click(screen.getByRole('button', { name: /seed.txt/ })) view.rerender() await screen.findByText('+fresh') @@ -225,16 +386,125 @@ describe('WorkspaceFilesPanel', () => { rootPath: '/project', available: true, files: [], status: '', patch: '-last deleted', stagedPatch: '', truncated: false })) - render( ({ path: `deleted-${index}.txt`, status: ' D' }))} onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))} onLoadDiff={onLoadDiff} onOpenFile={vi.fn()} />) + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) expect(screen.queryByRole('button', { name: /deleted-50.txt/ })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '加载更多变更(剩余 1 个)' })) fireEvent.click(screen.getByRole('button', { name: /deleted-50.txt/ })) await screen.findByText('-last deleted') expect(onLoadDiff).toHaveBeenCalledWith('deleted-50.txt') }) + + it('preserves mounted rows, expansion, selection, view and scroll when returning from a diff', async () => { + 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 }] })) + const onOpenFile = vi.fn() + const props = { isRepository: true, projectId: 'project', changedFiles: [{ path: 'docs/guide.md', status: ' M' }], onListDirectory, + onLoadDiff: vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '+updated', truncated: false })), onOpenFile } + const { container, rerender } = render(
) + expect(screen.getByRole('button', { name: '文件' })).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(await screen.findByRole('button', { name: 'docs' })) + const fileRow = (await screen.findByText('guide.md')).closest('button')! + fireEvent.click(fileRow) + expect(fileRow).toHaveAttribute('aria-current', 'true') + rerender(
) + // Simulate the parent hiding its existing section without unmounting the panel. + rerender(
) + expect(screen.getByText('guide.md').closest('button')).toBe(fileRow) + expect(fileRow).toHaveAttribute('aria-current', 'true') + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) + const changedRow = screen.getByRole('button', { name: /docs\/guide\.md/ }) + const treeRow = container.querySelector('.workspace-files__row[title="docs/guide.md"]') + const scroller = container.firstElementChild as HTMLElement + scroller.scrollTop = 340 + scroller.scrollLeft = 12 + fireEvent.click(changedRow) + await screen.findByText('+updated') + expect(treeRow).toBeInTheDocument() + expect(changedRow).not.toBeVisible() + expect(scroller.scrollTop).toBe(0) + expect(screen.getByRole('button', { name: '返回未提交更改' })).toHaveFocus() + scroller.scrollTop = 90 + fireEvent.click(screen.getByRole('button', { name: '返回未提交更改' })) + expect(changedRow).toHaveFocus() + expect(changedRow).toHaveAttribute('aria-current', 'true') + expect(scroller.scrollTop).toBe(340) + expect(scroller.scrollLeft).toBe(12) + expect(screen.getByRole('button', { name: 'Git 工作区' })).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(screen.getByRole('button', { name: '文件' })) + expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('guide.md').closest('button')).toBe(treeRow) + }) + + it('retains root and expanded listings on refresh failure and retries each directory locally', async () => { + let fail = false + const onListDirectory = vi.fn(async (path: string) => { + if (fail) throw new Error(`Cannot read ${path || 'root'}`) + return { path, truncated: false, entries: path + ? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file' as const }] + : [{ name: 'docs', path: 'docs', type: 'directory' as const }] } + }) + const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile: vi.fn() } + const { rerender } = render() + fireEvent.click(await screen.findByRole('button', { name: 'docs' })) + const row = await screen.findByRole('button', { name: 'guide.md' }) + fail = true + rerender() + await screen.findByText('Cannot read root') + expect(screen.getByText('Cannot read docs')).toBeInTheDocument() + expect(row).toBeVisible() + fail = false + const calls = onListDirectory.mock.calls.length + fireEvent.click(screen.getByText('Cannot read docs').querySelector('button')!) + await waitFor(() => expect(screen.queryByText('Cannot read docs')).not.toBeInTheDocument()) + expect(onListDirectory).toHaveBeenCalledTimes(calls + 1) + expect(onListDirectory).toHaveBeenLastCalledWith('docs') + fireEvent.click(screen.getByText('Cannot read root').querySelector('button')!) + await waitFor(() => expect(screen.queryByText('Cannot read root')).not.toBeInTheDocument()) + expect(onListDirectory).toHaveBeenLastCalledWith('') + }) + + it('shows Git errors and empty states only in changes view', async () => { + const props = { projectId: 'project', changedFiles: [], onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })), onLoadDiff: vi.fn(), onOpenFile: vi.fn() } + const { rerender } = render() + await screen.findByText('工作区为空。') + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Git 工作区' })).not.toBeInTheDocument() + rerender() + expect(screen.queryByRole('button', { name: 'Git 工作区' })).not.toBeInTheDocument() + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) + expect(screen.getByText(/没有未提交的更改/)).toBeVisible() + rerender() + expect(screen.getByRole('button', { name: 'Git 工作区' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('status')).toHaveTextContent('Git offline') + }) + + it('views the current file, refreshes a diff and copies its relative path with recoverable errors', async () => { + const writeText = vi.fn().mockRejectedValueOnce(new Error('Clipboard busy')).mockResolvedValue(undefined) + vi.stubGlobal('goodbuddy', { ...window.goodbuddy, clipboard: { writeText } }) + const onOpenFile = vi.fn() + const onLoadDiff = vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '+content', truncated: false })) + const onListDirectory = vi.fn(async () => ({ path: '', entries: [], truncated: false })) + render() + await waitFor(() => expect(onListDirectory).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) + fireEvent.click(screen.getByRole('button', { name: /docs\/a\.md/ })) + await screen.findByText('+content') + fireEvent.click(screen.getByRole('button', { name: '查看当前文件' })) + expect(onOpenFile).toHaveBeenCalledWith('docs/a.md') + fireEvent.click(screen.getByRole('button', { name: '刷新' })) + await waitFor(() => expect(onLoadDiff).toHaveBeenCalledTimes(2)) + fireEvent.click(screen.getByRole('button', { name: '复制相对路径' })) + expect(await screen.findByRole('alert')).toHaveTextContent('复制路径失败。 Clipboard busy') + fireEvent.click(screen.getByRole('button', { name: '复制相对路径' })) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(writeText).toHaveBeenLastCalledWith('docs/a.md') + }) }) diff --git a/src/renderer/src/WorkspaceFilesPanel.tsx b/src/renderer/src/WorkspaceFilesPanel.tsx index bc15b9ec..0d5f5aac 100644 --- a/src/renderer/src/WorkspaceFilesPanel.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.tsx @@ -2,20 +2,35 @@ import { ChevronDown, ChevronRight, ChevronLeft, - FileSearch, + MoreHorizontal, + RefreshCw, + FilePlus, + FolderPlus, + Copy, + Edit3, + ExternalLink, + FolderInput, + Info, + Trash2, FileText, Folder, + FolderRoot, FolderOpen } from 'lucide-react' import { useCallback, useEffect, useEffectEvent, + useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' +import { SegmentedControl } from './WorkspacePrimitives' +import { WorkspaceActionDialog } from './WorkspaceActionDialog' +import { WorkspaceGitTools } from './WorkspaceGitTools' import type { WorkspaceChangedFile, WorkspaceChanges, @@ -25,7 +40,11 @@ import type { type WorkspaceFilesPanelProps = { projectId?: string + rootPath?: string + onRefresh?: () => Promise changedFiles: WorkspaceChangedFile[] + gitError?: string + isRepository?: boolean refreshToken?: unknown onLoadDiff: (path: string) => Promise onListDirectory: (path: string) => Promise @@ -42,9 +61,25 @@ export function gitStatusLetter(status: string): string { return status.trim().replaceAll('.', '').split('').filter((value, index, values) => values.indexOf(value) === index).join('') } +function changedTree(entries: { path: string; row: React.JSX.Element }[], prefix = ''): React.JSX.Element { + const groups = new Map() + const files = entries.filter((entry) => !entry.path.slice(prefix.length).includes('/')) + for (const entry of entries) { + const rest = entry.path.slice(prefix.length) + if (!rest.includes('/')) continue + const directory = rest.split('/')[0]! + groups.set(directory, [...(groups.get(directory) ?? []), entry]) + } + return <>{[...groups].map(([name, children]) =>
{name}
{changedTree(children, `${prefix}${name}/`)}
)}{files.map((entry) => entry.row)} +} + export function WorkspaceFilesPanel({ projectId, + rootPath, + onRefresh, changedFiles, + gitError, + isRepository, refreshToken, onLoadDiff, onListDirectory, @@ -70,22 +105,99 @@ export function WorkspaceFilesPanel({ }>({ value: new Set() }) const [errorState, setErrorState] = useState<{ projectId?: string - value?: string - }>({}) + value: Record + }>({ value: {} }) + const [actionError, setActionError] = useState('') + const [menu, setMenu] = useState<{ entry: WorkspaceDirectoryEntry; trigger: HTMLButtonElement }>() + const menuRef = useRef(null) + const closeMenu = (restoreFocus = false): void => { + if (restoreFocus) menu?.trigger.focus({ preventScroll: true }) + setMenu(undefined) + } + useLayoutEffect(() => { + const surface = menuRef.current + if (!menu || !surface) return + const position = (): void => { + const anchor = menu.trigger.getBoundingClientRect() + const bounds = surface.getBoundingClientRect() + surface.style.left = `${Math.max(8, Math.min(anchor.right - bounds.width, window.innerWidth - bounds.width - 8))}px` + surface.style.top = `${Math.max(8, Math.min(anchor.bottom + 4, window.innerHeight - bounds.height - 8))}px` + } + position() + surface.querySelector('button')?.focus({ preventScroll: true }) + const dismiss = (event: Event): void => { + if (event.target instanceof Node && !surface.contains(event.target) && !menu.trigger.contains(event.target)) setMenu(undefined) + } + document.addEventListener('pointerdown', dismiss) + document.addEventListener('focusin', dismiss) + window.addEventListener('resize', position) + window.addEventListener('scroll', position, true) + return () => { + document.removeEventListener('pointerdown', dismiss) + document.removeEventListener('focusin', dismiss) + window.removeEventListener('resize', position) + window.removeEventListener('scroll', position, true) + } + }, [menu]) + const [view, setView] = useState<'files' | 'changes'>('files') + const [browsedPath, setBrowsedPath] = useState('') + const [createTarget, setCreateTarget] = useState('') + const [dialog, setDialog] = useState<{ kind: 'rename' | 'move' | 'delete' | 'properties' | 'createFile' | 'createDirectory'; path: string }>() + const [changeView, setChangeView] = useState<'list' | 'tree'>('list') + const [refreshing, setRefreshing] = useState(false) + const repositoryKnown = useRef<{ projectId?: string; available: boolean }>({ available: false }) + useEffect(() => { if (!gitError && isRepository !== undefined) repositoryKnown.current = { projectId, available: isRepository } }, [gitError, isRepository, projectId]) + const showGit = isRepository === true || (Boolean(gitError) && repositoryKnown.current.projectId === projectId && repositoryKnown.current.available) + const activeView = showGit ? view : 'files' + const [selected, setSelected] = useState<{ projectId?: string; path: string }>() + const panelRef = useRef(null) + const backRef = useRef(null) + const returnContext = useRef<{ scrollTop: number; scrollLeft: number; trigger: HTMLButtonElement } | undefined>(undefined) const requestGeneration = useRef(0) const inFlightPaths = useRef(new Set()) - const [diff, setDiff] = useState<{ path: string; value?: WorkspaceChanges; error?: string }>() + const [diffState, setDiff] = useState<{ projectId?: string; path: string; value?: WorkspaceChanges; error?: string }>() + const diff = diffState?.projectId === projectId ? diffState : undefined const diffRequest = useRef(0) const [visibleChangeCount, setVisibleChangeCount] = useState(50) const openDiff = (path: string): void => { const request = ++diffRequest.current - setDiff({ path }) + setDiff({ projectId, path }) void onLoadDiff(path).then((value) => { - if (request === diffRequest.current) setDiff({ path, value, error: value.error }) + if (request === diffRequest.current) setDiff({ projectId, path, value, error: value.error }) }).catch((reason: unknown) => { - if (request === diffRequest.current) setDiff({ path, error: reason instanceof Error ? reason.message : t('files.errors.read') }) + if (request === diffRequest.current) setDiff({ projectId, path, error: reason instanceof Error ? reason.message : t('files.errors.read') }) }) } + const showingDiff = Boolean(diff) + useLayoutEffect(() => { + const scroller = panelRef.current?.closest('.assistant-sidebar__body') + if (showingDiff) { + if (scroller) { + scroller.scrollTop = 0 + scroller.scrollLeft = 0 + } + backRef.current?.focus({ preventScroll: true }) + } else if (returnContext.current) { + const context = returnContext.current + if (context.trigger.isConnected) { + context.trigger.focus({ preventScroll: true }) + if (scroller) { + scroller.scrollTop = context.scrollTop + scroller.scrollLeft = context.scrollLeft + } + } + returnContext.current = undefined + } + }, [showingDiff]) + + const copyPath = async (path: string): Promise => { + setActionError('') + try { + await window.goodbuddy.clipboard.writeText(path) + } catch (reason) { + setActionError(reason instanceof Error ? `${t('sidebar.workspace.copyError')} ${reason.message}` : t('sidebar.workspace.copyError')) + } + } const loadDirectory = useCallback( async (path: string, generation: number): Promise => { @@ -112,16 +224,18 @@ export function WorkspaceFilesPanel({ [path]: listing } })) - setErrorState({ projectId }) + setErrorState((current) => ({ projectId, value: { + ...(current.projectId === projectId ? current.value : {}), [path]: undefined + } })) } catch (reason) { if (requestGeneration.current === generation) { - setErrorState({ + setErrorState((current) => ({ projectId, - value: + value: { ...(current.projectId === projectId ? current.value : {}), [path]: reason instanceof Error ? reason.message - : tRef.current('files.errors.read') - }) + : tRef.current('files.errors.read') } + })) } } finally { if (requestGeneration.current === generation) { @@ -141,7 +255,6 @@ export function WorkspaceFilesPanel({ const refreshDirectories = useEffectEvent((generation: number) => { const paths = expandedState.projectId === projectId ? [...expandedState.value] : [] - setListingState({ projectId, value: {} }) setLoadingState({ projectId, value: new Set() }) void loadDirectory('', generation) for (const path of paths) void loadDirectory(path, generation) @@ -185,10 +298,18 @@ export function WorkspaceFilesPanel({ loadingState.projectId === projectId ? loadingState.value : emptyPaths - const error = - errorState.projectId === projectId ? errorState.value : undefined + const errors = errorState.projectId === projectId ? errorState.value : {} + const renderDirectoryError = (path: string): React.JSX.Element | null => errors[path] ? ( +

+ {errors[path]} + +

+ ) : null const toggleDirectory = (path: string): void => { + setCreateTarget(path) const expanding = !expandedPaths.has(path) setExpandedState((current) => { const next = new Set( @@ -201,26 +322,52 @@ export function WorkspaceFilesPanel({ } return { projectId, value: next } }) - if (expanding && !listings[path]) { + if (expanding) { void loadDirectory(path, requestGeneration.current) } } const openEntry = (entry: WorkspaceDirectoryEntry): void => { - setErrorState({ projectId }) + setActionError('') void onOpenEntry?.(entry.path, entry.type).catch((reason: unknown) => { - setErrorState({ - projectId, - value: + setActionError( reason instanceof Error ? reason.message : entry.type === 'directory' ? t('files.errors.openFolder') : t('files.errors.openFile') - }) + ) }) } + const refresh = async (): Promise => { + setRefreshing(true) + try { + await onRefresh?.() + await loadDirectory('', requestGeneration.current) + if (browsedPath) await loadDirectory(browsedPath, requestGeneration.current) + for (const path of expandedPaths) await loadDirectory(path, requestGeneration.current) + } catch (reason) { setActionError(String(reason)) } + finally { setRefreshing(false) } + } + const browse = (path: string): void => { + setBrowsedPath(path); setCreateTarget(path) + void loadDirectory(path, requestGeneration.current) + } + const entryMenu = (entry: WorkspaceDirectoryEntry): React.JSX.Element => ( + + ) + const renderEntry = ( entry: WorkspaceDirectoryEntry ): React.JSX.Element => { @@ -245,17 +392,7 @@ export function WorkspaceFilesPanel({ {expanded ? : } {entry.name} - {onOpenEntry && } + {entryMenu(entry)}
{expanded && (
@@ -267,6 +404,7 @@ export function WorkspaceFilesPanel({ {t('files.reading')}

)} + {renderDirectoryError(entry.path)} {listing?.truncated && (

{t('files.directoryTruncated')} @@ -281,7 +419,12 @@ export function WorkspaceFilesPanel({

- {onOpenEntry && } + {entryMenu(entry)}
) } @@ -317,7 +450,7 @@ export function WorkspaceFilesPanel({ ) } - const root = listings[''] + const root = listings[browsedPath] const renderPatch = (patch: string): React.JSX.Element => (
       {patch.split('\n').map((line, index) => (
@@ -330,15 +463,22 @@ export function WorkspaceFilesPanel({
       ))}
     
) - if (diff) { - return
+ return ( +
+ {diff &&
- + setActionError('') + }}>{t('files.backToChanges')} {diff.path}
+
+ {!changedByPath.get(diff.path)?.status.includes('D') && } + + +
{diff.error ?

{diff.error}

: !diff.value ?

{t('files.reading')}

: <> {diff.value.stagedPatch && <>

{t('files.staged')}

{renderPatch(diff.value.stagedPatch)}} @@ -346,30 +486,56 @@ export function WorkspaceFilesPanel({ {!diff.value.patch && !diff.value.stagedPatch &&

{t('files.noDiff')}

} {diff.value.truncated &&

{t('files.diffTruncated')}

} } -
- } - return ( -
- {changedFiles.length > 0 && ( -
- {t('files.changedTitle')} - {changedFiles.slice(0, visibleChangeCount).map((file) => { - return ( +
} +