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 ( +
} +