diff --git a/apps/web/tests/expected/forge-panel/branch-details.expected.md b/apps/web/tests/expected/forge-panel/branch-details.expected.md new file mode 100644 index 0000000000..8c348d423a --- /dev/null +++ b/apps/web/tests/expected/forge-panel/branch-details.expected.md @@ -0,0 +1 @@ +- 'tooltip "Current branch: feature/a-long-current-branch-name-for-mobile-review"' diff --git a/apps/web/tests/expected/forge-panel/changes-view.expected.md b/apps/web/tests/expected/forge-panel/changes-view.expected.md index 1a6db5161e..b6c825a833 100644 --- a/apps/web/tests/expected/forge-panel/changes-view.expected.md +++ b/apps/web/tests/expected/forge-panel/changes-view.expected.md @@ -8,65 +8,64 @@ - button "全部折叠" - button "左右对比" [pressed] - button "自动换行" [pressed] -- button "收起 panel.ts" [expanded] -- 'button "复制路径: src/panel.ts"': src/panel.ts -- text: +2 −2 -- button "panel.ts 的更多操作" -- button "选择第 L1 行": "1" -- text: export const n1 = 1 -- button "选择第 R1 行": "1" -- text: export const n1 = 1 -- button "选择第 L2 行": "2" -- text: export const n2 = 2 -- button "选择第 R2 行": "2" -- text: export const n2 = 'two' -- button "选择第 L3 行": "3" -- text: export const n3 = 3 -- button "选择第 R3 行": "3" -- text: export const n3 = 3 -- button "选择第 L4 行": "4" -- text: export const n4 = 4 -- button "选择第 R4 行": "4" -- text: export const n4 = 4 -- button "选择第 L5 行": "5" -- text: export const n5 = 5 -- button "选择第 R5 行": "5" -- text: export const n5 = 5 -- button "6 行未变更" -- button "选择第 L12 行": "12" -- text: export const n12 = 12 -- button "选择第 R12 行": "12" -- text: export const n12 = 12 -- button "选择第 L13 行": "13" -- text: export const n13 = 13 -- button "选择第 R13 行": "13" -- text: export const n13 = 13 -- button "选择第 L14 行": "14" -- text: export const n14 = 14 -- button "选择第 R14 行": "14" -- text: export const n14 = 14 -- button "选择第 L15 行": "15" -- text: export const n15 = 15 -- button "选择第 R15 行": "15" -- text: export const n15 = 'fifteen' -- button "选择第 L16 行": "16" -- text: export const n16 = 16 -- button "选择第 R16 行": "16" -- text: export const n16 = 16 -- button "选择第 L17 行": "17" -- text: export const n17 = 17 -- button "选择第 R17 行": "17" -- text: export const n17 = 17 -- button "选择第 L18 行": "18" -- text: export const n18 = 18 -- button "选择第 R18 行": "18" -- text: export const n18 = 18 -- button "其余未变更的行" -- button "收起 panel.md" [expanded] -- 'button "复制路径: docs/panel.md"': docs/panel.md -- text: 新文件 +2 −0 -- button "panel.md 的更多操作" -- button "选择第 R1 行": "1" -- text: "+ # Panel" -- button "选择第 R2 行": "2" -- text: + Shows the change requests of a Session. +- region "全部 2 个文件": + - button "收起 panel.ts" [expanded] + - text: src/panel.ts +2 −2 + - button "panel.ts 的更多操作" + - button "选择第 L1 行": "1" + - text: export const n1 = 1 + - button "选择第 R1 行": "1" + - text: export const n1 = 1 + - button "选择第 L2 行": "2" + - text: export const n2 = 2 + - button "选择第 R2 行": "2" + - text: export const n2 = 'two' + - button "选择第 L3 行": "3" + - text: export const n3 = 3 + - button "选择第 R3 行": "3" + - text: export const n3 = 3 + - button "选择第 L4 行": "4" + - text: export const n4 = 4 + - button "选择第 R4 行": "4" + - text: export const n4 = 4 + - button "选择第 L5 行": "5" + - text: export const n5 = 5 + - button "选择第 R5 行": "5" + - text: export const n5 = 5 + - button "6 行未变更" + - button "选择第 L12 行": "12" + - text: export const n12 = 12 + - button "选择第 R12 行": "12" + - text: export const n12 = 12 + - button "选择第 L13 行": "13" + - text: export const n13 = 13 + - button "选择第 R13 行": "13" + - text: export const n13 = 13 + - button "选择第 L14 行": "14" + - text: export const n14 = 14 + - button "选择第 R14 行": "14" + - text: export const n14 = 14 + - button "选择第 L15 行": "15" + - text: export const n15 = 15 + - button "选择第 R15 行": "15" + - text: export const n15 = 'fifteen' + - button "选择第 L16 行": "16" + - text: export const n16 = 16 + - button "选择第 R16 行": "16" + - text: export const n16 = 16 + - button "选择第 L17 行": "17" + - text: export const n17 = 17 + - button "选择第 R17 行": "17" + - text: export const n17 = 17 + - button "选择第 L18 行": "18" + - text: export const n18 = 18 + - button "选择第 R18 行": "18" + - text: export const n18 = 18 + - button "其余未变更的行" + - button "收起 panel.md" [expanded] + - text: docs/panel.md 新文件 +2 −0 + - button "panel.md 的更多操作" + - button "选择第 R1 行": "1" + - text: "+ # Panel" + - button "选择第 R2 行": "2" + - text: + Shows the change requests of a Session. diff --git a/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md b/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md index f1231bbb8f..b1c29e9f64 100644 --- a/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md +++ b/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md @@ -9,68 +9,67 @@ - button "左右对比" [pressed] - button "自动换行" [pressed] - button "文件树" [expanded] [pressed] -- button "收起 panel.ts" [expanded] -- 'button "复制路径: src/panel.ts"': src/panel.ts -- text: +2 −2 -- button "panel.ts 的更多操作" -- button "选择第 L1 行": "1" -- text: export const n1 = 1 -- button "选择第 R1 行": "1" -- text: export const n1 = 1 -- button "选择第 L2 行": "2" -- text: export const n2 = 2 -- button "选择第 R2 行": "2" -- text: export const n2 = 'two' -- button "选择第 L3 行": "3" -- text: export const n3 = 3 -- button "选择第 R3 行": "3" -- text: export const n3 = 3 -- button "选择第 L4 行": "4" -- text: export const n4 = 4 -- button "选择第 R4 行": "4" -- text: export const n4 = 4 -- button "选择第 L5 行": "5" -- text: export const n5 = 5 -- button "选择第 R5 行": "5" -- text: export const n5 = 5 -- button "6 行未变更" -- button "选择第 L12 行": "12" -- text: export const n12 = 12 -- button "选择第 R12 行": "12" -- text: export const n12 = 12 -- button "选择第 L13 行": "13" -- text: export const n13 = 13 -- button "选择第 R13 行": "13" -- text: export const n13 = 13 -- button "选择第 L14 行": "14" -- text: export const n14 = 14 -- button "选择第 R14 行": "14" -- text: export const n14 = 14 -- button "选择第 L15 行": "15" -- text: export const n15 = 15 -- button "选择第 R15 行": "15" -- text: export const n15 = 'fifteen' -- button "选择第 L16 行": "16" -- text: export const n16 = 16 -- button "选择第 R16 行": "16" -- text: export const n16 = 16 -- button "选择第 L17 行": "17" -- text: export const n17 = 17 -- button "选择第 R17 行": "17" -- text: export const n17 = 17 -- button "选择第 L18 行": "18" -- text: export const n18 = 18 -- button "选择第 R18 行": "18" -- text: export const n18 = 18 -- button "其余未变更的行" -- button "收起 panel.md" [expanded] -- 'button "复制路径: docs/panel.md"': docs/panel.md -- text: 新文件 +2 −0 -- button "panel.md 的更多操作" -- button "选择第 R1 行": "1" -- text: "+ # Panel" -- button "选择第 R2 行": "2" -- text: + Shows the change requests of a Session. +- region "全部 2 个文件": + - button "收起 panel.ts" [expanded] + - text: src/panel.ts +2 −2 + - button "panel.ts 的更多操作" + - button "选择第 L1 行": "1" + - text: export const n1 = 1 + - button "选择第 R1 行": "1" + - text: export const n1 = 1 + - button "选择第 L2 行": "2" + - text: export const n2 = 2 + - button "选择第 R2 行": "2" + - text: export const n2 = 'two' + - button "选择第 L3 行": "3" + - text: export const n3 = 3 + - button "选择第 R3 行": "3" + - text: export const n3 = 3 + - button "选择第 L4 行": "4" + - text: export const n4 = 4 + - button "选择第 R4 行": "4" + - text: export const n4 = 4 + - button "选择第 L5 行": "5" + - text: export const n5 = 5 + - button "选择第 R5 行": "5" + - text: export const n5 = 5 + - button "6 行未变更" + - button "选择第 L12 行": "12" + - text: export const n12 = 12 + - button "选择第 R12 行": "12" + - text: export const n12 = 12 + - button "选择第 L13 行": "13" + - text: export const n13 = 13 + - button "选择第 R13 行": "13" + - text: export const n13 = 13 + - button "选择第 L14 行": "14" + - text: export const n14 = 14 + - button "选择第 R14 行": "14" + - text: export const n14 = 14 + - button "选择第 L15 行": "15" + - text: export const n15 = 15 + - button "选择第 R15 行": "15" + - text: export const n15 = 'fifteen' + - button "选择第 L16 行": "16" + - text: export const n16 = 16 + - button "选择第 R16 行": "16" + - text: export const n16 = 16 + - button "选择第 L17 行": "17" + - text: export const n17 = 17 + - button "选择第 R17 行": "17" + - text: export const n17 = 17 + - button "选择第 L18 行": "18" + - text: export const n18 = 18 + - button "选择第 R18 行": "18" + - text: export const n18 = 18 + - button "其余未变更的行" + - button "收起 panel.md" [expanded] + - text: docs/panel.md 新文件 +2 −0 + - button "panel.md 的更多操作" + - button "选择第 R1 行": "1" + - text: "+ # Panel" + - button "选择第 R2 行": "2" + - text: + Shows the change requests of a Session. - separator "调整文件树宽度" - navigation "全部 2 个文件": - textbox "筛选文件…": src diff --git a/apps/web/tests/expected/forge-panel/local-card.expected.md b/apps/web/tests/expected/forge-panel/local-card.expected.md new file mode 100644 index 0000000000..24058934d3 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/local-card.expected.md @@ -0,0 +1,4 @@ +- button "Local changes" +- text: HEAD → origin/main +- 'button "View the changes of this Session: 60 files, 60 lines added, 0 lines deleted"': +60 −0 60 files +- button "Create change request" diff --git a/apps/web/tests/expected/forge-panel/worktree-option.expected.md b/apps/web/tests/expected/forge-panel/worktree-option.expected.md new file mode 100644 index 0000000000..c9a85b8723 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/worktree-option.expected.md @@ -0,0 +1,3 @@ +- button "当前分支:main": main +- checkbox "新建工作树" +- text: 新建工作树 diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 739c2d6ebd..a49d8ac475 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -217,9 +217,12 @@ interface PanelLaunch { * Launch the bundle against a GitHub checkout of `main` and `release` and open the New Session screen of that workspace. * @param failureShot - name of the screenshot saved when the test fails. * @param scenario - normal conversation, a restoration follow-up, or a numbered PR lookup. + * @param options - desktop transport, initial checkout branch, and touch input for the browser. * @returns the scaffold, page, and checkout. */ -async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal', desktop?: 'darwin' | 'win32'): Promise { +async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal', + options: { desktop?: 'darwin' | 'win32'; branch?: string; hasTouch?: boolean } = {}): Promise { + const { desktop } = options const resources: { scaffold?: WebScaffold; browser?: Browser } = {} const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-')) onTestFinished(async () => { @@ -275,6 +278,7 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git') await git(project, 'branch', 'release') await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only') + if (options.branch !== undefined) await git(project, 'checkout', '--quiet', '-b', options.branch) if (scenario === 'reference') { // The model's confined command must own the CLI double's writable call log. const modelBin = join(project, '.fixture-bin') @@ -285,7 +289,8 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' const browser = await chromium.launch() resources.browser = browser - const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai' }) + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai', + hasTouch: options.hasTouch ?? false }) if (desktop !== undefined) { // Only Electron's keyboard transport is substituted; the product registry and menus remain real. await page.addInitScript(({ device, config }) => { @@ -338,7 +343,7 @@ async function requestReleaseWorktree(failureShot: string, followUp = false): Pr const { page } = launched const option = page.locator('[data-forge-worktree]') await option.waitFor({ timeout: 20_000 }) - await option.getByRole('checkbox', { name: '工作树', exact: true }).check() + await option.getByRole('checkbox', { name: '新建工作树', exact: true }).check() await option.getByRole('button', { name: '基于 main', exact: true }).click() await page.getByRole('menuitem', { name: 'release', exact: true }).click() await option.getByRole('button', { name: '基于 release', exact: true }).waitFor() @@ -636,9 +641,9 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor() const readme = local.locator('[data-forge-diff-file="README.md"]') await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) - await readme.getByRole('button', { name: '复制路径: README.md', exact: true }).click() - await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('README.md') await page.evaluate(() => navigator.clipboard.writeText('unchanged')) + await readme.locator('[data-forge-file-label]').click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged') await readme.locator('[data-forge-path-spacer]').click() await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged') await readme.getByRole('button', { name: '6 行未变更', exact: true }).click() @@ -815,14 +820,15 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click() await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 }) await panelFile.getByRole('button', { name: '2 行未变更', exact: true }).waitFor() - await panelFile.getByRole('button', { name: '复制路径: src/panel.ts', exact: true }).click() + await panelFile.getByRole('button', { name: 'panel.ts 的更多操作', exact: true }).click() + await page.getByRole('menuitem', { name: '复制路径', exact: true }).click() await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('src/panel.ts') const docsFile = view.locator('[data-forge-diff-file="docs/panel.md"]') await docsFile.getByRole('button', { name: '收起 panel.md', exact: true }).click() await page.setViewportSize({ width: 1280, height: 500 }) const scrollport = view.locator('[data-forge-changes-scroll]') await scrollport.evaluate((element) => { element.scrollTop = 100; element.dispatchEvent(new Event('scroll')) }) - await panelFile.getByRole('button', { name: '复制路径: src/panel.ts', exact: true }).click() + await panelFile.locator('[data-forge-file-label]').click() const position = await scrollport.evaluate(element => element.scrollTop) expect(position).toBeGreaterThan(0) const originalFile = await panelFile.elementHandle() @@ -870,8 +876,300 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an }) +it.skipIf(MODE === 'record')('retains a wrapped code line and tree width across tab and Session switches', async () => { + const { page, scaffold, root, tripwire } = await launchPanel('web-e2e-forge-reading-position') + const paths = ['src/before-0.ts', 'src/before-1.ts', 'src/before-2.ts', 'src/server.ts', 'src/after.ts'] + const patch = paths.map(path => [ + `diff --git a/${path} b/${path}`, 'index 1111111..2222222 100644', `--- a/${path}`, `+++ b/${path}`, + '@@ -1,650 +1,650 @@', '-const oldValue = true;', '+const newValue = true;', + ...Array.from({ length: 649 }, (_, index) => ` const value_${index + 2} = "${'x'.repeat(24 + (index + 2) % 70)}";`), '', + ].join('\n')).join('\n') + await writeFile(join(root, 'pr-7.diff'), patch) + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('Session must stay loaded') + scaffold.ctx.forgeSession.track(session, PULL) + // Keep the file tree and the Session sidebar visible together on every platform. + await page.setViewportSize({ width: 2400, height: 700 }) + await page.locator('[data-forge-header]').click() + await page.locator(`[data-forge-change="${PULL}"]`).getByRole('button', { name: /查看 #7 的变更/ }).click() + const view = page.locator('[data-forge-changes="change"]') + const scroll = view.locator('[data-forge-changes-scroll]') + const file = view.locator('[data-forge-diff-file="src/server.ts"]') + const code = file.locator('[data-review-code][data-new-line="300"]') + const separator = view.getByRole('separator', { name: '调整文件树宽度', exact: true }) + const beforeFile = view.locator('[data-forge-diff-file="src/before-0.ts"]') + // ResizeObserver runs between rendering frames, including when an ancestor becomes hidden. + const layout = () => page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + const reading = () => file.evaluate((element) => { + const viewport = element.closest('[data-forge-changes-scroll]')! + const top = Math.max(viewport.getBoundingClientRect().top, element.firstElementChild!.getBoundingClientRect().bottom) + const line = [...element.querySelectorAll('[data-review-code][data-new-line]')] + .find(row => row.getBoundingClientRect().bottom > top + 1)! + return { path: element.getAttribute('data-forge-diff-file'), line: line.dataset.newLine, + offset: line.getBoundingClientRect().top - top, scrollTop: viewport.scrollTop } + }) + await code.waitFor() + for (const width of [240, 320]) { + if (width === 320) { + for (let step = 0; step < 4; step += 1) await separator.press('ArrowLeft') + await beforeFile.getByRole('button', { name: '收起 before-0.ts', exact: true }).click() + } + await layout() + await code.evaluate((element) => { + const viewport = element.closest('[data-forge-changes-scroll]')! + const header = element.closest('[data-forge-diff-file]')!.firstElementChild! + const top = viewport.getBoundingClientRect().top + header.getBoundingClientRect().height + viewport.scrollTop += element.getBoundingClientRect().top - top + 7 + }) + await layout() + const position = await reading() + expect(position.line).toBe('300') + expect(position.offset).toBe(-7) + for (const destination of ['tab', 'session', 'tab', 'session']) { + if (destination === 'tab') await page.getByRole('tab').filter({ hasText: '变更请求' }).click() + else await page.getByRole('button', { name: '新建会话', exact: true }).last().click() + await expect.poll(() => view.isVisible()).toBe(false) + await layout() + expect(await scroll.evaluate(element => element.clientWidth)).toBe(0) + if (destination === 'tab') await page.getByRole('tab').filter({ hasText: '#7 的变更' }).click() + else await page.locator(`[data-row-key="session:${sessionId}"]`).click() + await view.waitFor() + await layout() + expect(await separator.getAttribute('aria-valuenow')).toBe(String(width)) + expect(await reading()).toEqual(position) + expect(await beforeFile.getAttribute('data-expanded')).toBe(width === 240 ? '' : null) + } + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while preserving vertical scrolling and comment editing', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-file-keys') + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + for (const path of ['a.txt', 'b.txt', 'c.txt']) { + await writeFile(join(project, path), Array.from({ length: path === 'c.txt' ? 1 : 80 }, (_, line) => `line ${line + 1}\n`).join('')) + } + const { settled } = await sendPrompt(page, scaffold) + await settled + await page.locator('[data-forge-header]').click() + await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() + await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:3 个文件/ }).click() + const view = page.locator('[data-forge-changes="local"]') + const scroll = view.getByRole('region', { name: '全部 3 个文件', exact: true }) + await scroll.waitFor() + await page.getByRole('button', { name: '全屏', exact: true }).click() + await page.setViewportSize({ width: 1280, height: 500 }) + const tree = view.getByRole('navigation') + const a = view.locator('[data-forge-diff-file="a.txt"]') + const b = view.locator('[data-forge-diff-file="b.txt"]') + const c = view.locator('[data-forge-diff-file="c.txt"]') + await b.getByRole('button', { name: '收起 b.txt', exact: true }).click() + await scroll.evaluate((element) => { element.scrollTop = 0 }) + await scroll.focus() + await page.keyboard.press('ArrowLeft') + expect(await scroll.evaluate(element => element.scrollTop)).toBe(0) + expect(await scroll.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + await page.keyboard.press('ArrowDown') + await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + await scroll.evaluate((element) => { element.scrollTop = 0 }) + await page.keyboard.press('ArrowRight') + await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await b.getAttribute('data-expanded')).toBe('') + expect(await scroll.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press('ArrowRight') + await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true') + const lastPosition = await scroll.evaluate(element => element.scrollTop) + const lastBox = await c.boundingBox(), viewport = await scroll.boundingBox() + if (lastBox === null || viewport === null) throw new Error('The last file and scroll viewport must be rendered') + expect(lastBox.y).toBeGreaterThan(viewport.y) + expect(lastBox.y).toBeLessThan(viewport.y + viewport.height) + await page.keyboard.press('ArrowRight') + expect(await scroll.evaluate(element => element.scrollTop)).toBe(lastPosition) + await page.keyboard.press('ArrowLeft') + await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true') + await page.keyboard.press('ArrowLeft') + await expect.poll(() => tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true') + await b.evaluate((element) => { + const viewport = element.closest('[data-forge-changes-scroll]')! + viewport.scrollTop += element.getBoundingClientRect().top - viewport.getBoundingClientRect().top + 100 + }) + await page.keyboard.press('ArrowRight') + await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true') + await tree.getByRole('button', { name: 'a.txt', exact: true }).click() + await a.getByRole('button', { name: '选择第 R1 行', exact: true }).click() + await a.getByRole('button', { name: '添加评论', exact: true }).click() + const input = a.getByRole('textbox', { name: '留下评论…', exact: true }) + await input.fill('keep editing') + await input.press('ArrowLeft') + expect(await input.evaluate(element => (element as HTMLTextAreaElement).selectionStart)).toBe(11) + await input.press('ArrowRight') + expect(await input.inputValue()).toBe('keep editing') + expect(await tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await input.evaluate(element => document.activeElement === element)).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('keeps localized status columns separate and long file lists inside one vertical scroller', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-layout') + await page.locator('[data-forge-current-branch]').waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'worktree-option.expected.md'), + await captureStableAria(page, '[data-forge-worktree]', scaffold.workspaceCwd), MODE) + await page.getByRole('button', { name: '收起侧边栏', exact: true }).click() + await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(() => page.locator('[data-forge-worktree]') + .evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(390) + await page.setViewportSize({ width: 1680, height: 1000 }) + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await mkdir(join(project, 'src')) + await Promise.all(Array.from({ length: 60 }, (_, index) => writeFile(join(project, 'src', `file-${index}.ts`), `export const value = ${index}\n`))) + const { settled } = await sendPrompt(page, scaffold) + const session = scaffold.ctx.sessions.get(await settled) + if (session === undefined) throw new Error('The Session must stay loaded') + scaffold.ctx.forgeSession.track(session, PULL) + await page.locator('[data-forge-header]').click() + const panel = page.locator('[data-forge-panel]') + await panel.getByRole('button', { name: '刷新状态', exact: true }).click() + const local = panel.locator('[data-forge-local]') + await local.locator('button[aria-expanded]').click() + await expect.poll(() => local.locator('li').count(), { timeout: 20_000 }).toBe(60) + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 700 }) + const outerScrollers = await panel.evaluate((element) => { + const scrollers: string[] = [] + for (let ancestor = element.parentElement; ancestor !== null; ancestor = ancestor.parentElement) { + if (/auto|scroll/.test(getComputedStyle(ancestor).overflowY) && ancestor.scrollHeight > ancestor.clientHeight + 1) { + scrollers.push(ancestor.className) + } + } + return scrollers + }) + expect(outerScrollers).toEqual([]) + expect(await panel.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true) + await local.locator('li').last().scrollIntoViewIfNeeded() + expect(await local.locator('li').last().isVisible()).toBe(true) + } + await local.locator('button[aria-expanded]').click() + const change = panel.locator(`[data-forge-change="${PULL}"]`) + for (const [locale, labels] of [ + ['zh', ['检查', '评审', '合并', '变更']], + ['en', ['Checks', 'Review', 'Merge', 'Changes']], + ['de', ['Prüfungen', 'Review', 'Zusammenführen', 'Änderungen']], + ] as const) { + await scaffold.ctx.settings.update('locale', { preference: locale }) + await change.getByText(labels[0], { exact: true }).waitFor() + if (locale === 'en') { + await compareOrRefreshGolden(join(EXPECTED, 'local-card.expected.md'), + await captureStableAria(page, '[data-forge-local]', scaffold.workspaceCwd), MODE) + } + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 700 }) + await expect.poll(() => change.evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(width) + await change.getByText(labels[3], { exact: true }).hover() + for (const label of labels) { + const geometry = await change.getByText(label, { exact: true }).evaluate((element) => { + const value = element.nextElementSibling + if (value === null) throw new Error('Status label must have a value') + const range = document.createRange() + range.selectNodeContents(element) + const name = range.getBoundingClientRect() + const content = value.getBoundingClientRect() + const row = element.parentElement! + const icon = element.previousElementSibling! + const rowRect = row.getBoundingClientRect() + return { + gap: content.left - name.right, + iconInset: icon.getBoundingClientRect().left - rowRect.left, + rowInset: rowRect.left - row.closest('[data-forge-change]')!.getBoundingClientRect().left, + labelWidth: element.getBoundingClientRect().width, + valueOffset: content.left - rowRect.left, + right: content.right, + width: document.documentElement.clientWidth, + } + }) + expect(geometry.gap).toBeGreaterThanOrEqual(8) + expect(geometry.iconInset).toBe(8) + expect(geometry.rowInset).toBeGreaterThanOrEqual(6) + expect(geometry.rowInset).toBeLessThanOrEqual(7) + if (locale === 'zh') { + expect(geometry.labelWidth).toBe(32) + expect(geometry.valueOffset).toBe(76) + } + expect(geometry.right).toBeLessThanOrEqual(geometry.width) + } + } + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('keeps long branch details accessible by touch and keyboard across the hero row breakpoints', async () => { + const branch = 'feature/a-long-current-branch-name-for-mobile-review' + const { page, scaffold, tripwire } = await launchPanel('web-e2e-forge-branch-details', 'normal', { branch, hasTouch: true }) + const option = page.locator('[data-forge-worktree]') + const chip = option.locator('[data-forge-current-branch]') + await chip.waitFor() + await page.getByRole('button', { name: '收起侧边栏', exact: true }).click() + for (const [locale, label] of [['zh', `当前分支:${branch}`], ['en', `Current branch: ${branch}`], ['de', `Aktueller Branch: ${branch}`]] as const) { + await scaffold.ctx.settings.update('locale', { preference: locale }) + await option.getByRole('button', { name: label, exact: true }).waitFor() + for (const width of [390, 600, 601, 700, 1280]) { + await page.setViewportSize({ width, height: 844 }) + await chip.scrollIntoViewIfNeeded() + await expect.poll(() => option.evaluate((element) => { + const row = element.closest('[class*="heroWorkspaceRow"]')! + const ancestors: Element[] = [] + for (let node: Element | null = row; node !== null; node = node.parentElement) ancestors.push(node) + return { + fits: element.getBoundingClientRect().right <= innerWidth, + overflow: ancestors.filter(node => node.scrollWidth > node.clientWidth + 1).map(node => node.className), + } + })).toEqual({ fits: true, overflow: [] }) + } + } + await scaffold.ctx.settings.update('locale', { preference: 'en' }) + await option.getByRole('button', { name: `Current branch: ${branch}`, exact: true }).waitFor() + await page.setViewportSize({ width: 390, height: 844 }) + expect(await chip.locator('span').evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + const tooltip = page.getByRole('tooltip', { exact: true, name: `Current branch: ${branch}` }) + await chip.tap() + await tooltip.waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'branch-details.expected.md'), await tooltip.ariaSnapshot(), MODE) + await chip.tap() + await tooltip.waitFor({ state: 'hidden' }) + await chip.tap() + await tooltip.waitFor() + await page.locator('[class*="heroWorkspaceRow"]').tap({ position: { x: 1, y: 1 } }) + await tooltip.waitFor({ state: 'hidden' }) + await chip.focus() + await page.keyboard.press('Tab') + await page.keyboard.press('Shift+Tab') + await tooltip.waitFor() + await page.keyboard.press('Escape') + await tooltip.waitFor({ state: 'hidden' }) + await page.keyboard.press('Enter') + await tooltip.waitFor() + await page.keyboard.press('Escape') + await tooltip.waitFor({ state: 'hidden' }) + expect(await option.getByRole('checkbox').isChecked()).toBe(false) + expect(await page.getByRole('menu').count()).toBe(0) + await scaffold.ctx.settings.update('locale', { preference: 'zh' }) + await option.getByRole('checkbox', { name: '新建工作树', exact: true }).waitFor() + const { settled } = await sendPrompt(page, scaffold) + await settled + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff menus when the %s desktop shortcut deactivates a retained tab', async (platform) => { - const { page, scaffold, project, tripwire } = await launchPanel(`web-e2e-forge-menu-${platform}`, 'normal', platform) + const { page, scaffold, project, tripwire } = await launchPanel(`web-e2e-forge-menu-${platform}`, 'normal', { desktop: platform }) await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') await writeFile(join(project, 'README.md'), 'review this change\n') @@ -881,7 +1179,7 @@ it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff men await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:1 个文件/ }).click() const view = page.locator('[data-forge-changes="local"]') - await view.getByRole('button', { name: '复制路径: README.md', exact: true }).waitFor() + await view.locator('[data-forge-diff-file="README.md"]').waitFor() for (const name of ['分支', '全部 1 个文件', 'README.md 的更多操作']) { await view.getByRole('button', { name, exact: true }).click() await page.getByRole('menu').waitFor() diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 1126d1afde..4c540b83ad 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: f60bc73de0aecb2b - zh: 92ab0b46d13bd0c8 + en: 4d8f639509bd13f7 + zh: 8a1576790ac37018 /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 013e84157a09da7e - zh: 99b4f276385d5e16 + zh: cd9f1232acfe4783 /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 3116c2157b..cba891ba08 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -29,18 +29,20 @@ Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it com Panel text follows the application language: Chinese, English, Japanese, Spanish, French, German, Russian, or Portuguese. -- **Worktree option** — on the New Session screen, Worktree sits at the right end of the row with the workspace and mode pickers. Checking it shows the base branch, the current branch by default; the menu lists recent local branches. The option is disabled, with the reason in its tooltip, outside a Git checkout, in a read-only Session, and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message hands it to the Session at once, and it waits there while the branch `dsh--<4 random hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the message's turn starts once the worktree exists. +- **Worktree option** — on the New Session screen, New worktree sits beside the current branch at the right end of the row with the workspace and mode pickers. Both stay hidden outside a Git checkout. The branch is read-only until New worktree is checked, when it becomes the base branch selector; the menu lists recent local branches. If permissions block an existing request, its selected base stays visible without a selector and the request can still be withdrawn. Hovering, focusing, or tapping the read-only branch or base shows its full description. The option is disabled, with the reason in its tooltip, in a read-only Session and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message hands it to the Session at once, and it waits there while the branch `dsh--<4 random hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the message's turn starts once the worktree exists. - **Creation card** — while the worktree is created, a card above the composer shows the new branch and its two steps, creating the branch and checking out files; More details adds the branch and base. Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting, and the card shows the reason of a failure: Retry tries again with a new branch name, and Continue without a worktree withdraws the request so the message goes on in the current checkout. - **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and a message sent meanwhile waits without starting its turn until the worktree is restored or left; the card then says that the message goes on afterwards. Restore worktree, also offered as Restore in the tab, checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; while it runs, the card follows its two steps, preparing the worktree and checking out files, and the tab's Restore turns into a spinner. Continue in the project moves the Session to the source checkout. Meanwhile the tab's first row shows a warning glyph. - **Header button** — appears when the Session has bound change requests or its directory is inside a Git checkout, and shows or hides the tab. A red dot marks an open change request with failed checks, requested changes, or conflicts. - **Session list** — after a Session row's time, a branch glyph shows while the Session works in the worktree the panel created for it, and a glyph shows the state of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. The glyph of an open change request keeps the row's subdued color with a dot for running, passed, or failed checks; merged and closed ones keep their state colors. The row's hover card names the worktree branch and what it started from, then up to three change requests with their number, title, state, and checks, and how many more there are. A restart shows the states known before it until the next poll. - **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, after a folder glyph for the project directory or a branch glyph for a worktree, and the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory; its tooltip adds the path with the Host home shortened to `~`, its button copies the full path, and for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one that is not a draft with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. The card's header also offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches, then one row per fact: for an open change request the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, and the merge state, or when a merged or closed one ended; and the changed lines, formatted in the selected UI language, whose row opens the changes view. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and pins the displayed head when opened. Confirm merge uses that head; a polled head change disables confirmation until the menu is reopened after review. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again; its icon turns until the inspection and the sign-in check end. A banner shows the `gh auth login` command when sign-in is missing. - **Local changes card** — while the `branch` scope changes files and no open change request has the checked-out branch as its head, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered once the code host repository is recognized, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent. -- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. A local view shows Branch by default: the working tree and untracked files against the fork point, which is the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Clicking the file name or directory in either diff source copies its repository-relative path and reports success or failure in a toast. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. +- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point, which is the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. + +Click the diff content to focus it. Left and Right move to the previous and next file in diff order and expand it; navigation stops at the first and last file. Up and Down keep their normal scrolling behavior. After manual scrolling, file navigation starts from the file at the top of the viewport. Text editing, non-collapsed text selections, modified keys, menus, and file-tree controls keep their own keyboard behavior. `gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, and `maxCachedDiffBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). -All files opens a searchable directory tree. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button hides or restores it without clearing the search, folds, or width. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. +All files opens a searchable directory tree. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button hides or restores it. The search, folds, and width survive hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. ----- diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index edfa13f9d1..4a8a4763c6 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -29,18 +29,20 @@ kind: "package-reference" 面板文案跟随应用语言,支持中文、英文、日语、西班牙语、法语、德语、俄语和葡萄牙语。 -- **工作树选项**——在新会话页面上,“工作树”位于工作区与模式选择器那一行的右端。勾选后显示基准分支,默认为当前分支;菜单列出最近的本地分支。不在 Git 检出中、Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh--<4 位随机十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后这条消息的这一轮才开始。 +- **工作树选项**——在新会话页面上,“新建工作树”与当前分支一起位于工作区与模式选择器那一行的右端。不在 Git 检出中时,两者均隐藏。分支默认为只读展示;勾选“新建工作树”后,它变为基准分支选择器,菜单列出最近的本地分支。权限阻止已有请求时,仍只读显示已选基准,并允许取消请求。悬停、聚焦或轻点只读分支或基准会显示完整说明。Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh--<4 位随机十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后这条消息的这一轮才开始。 - **创建卡片**——创建 worktree 期间,输入框上方的卡片显示新分支以及“创建分支”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止这次尝试。尝试被停止或失败后,消息继续等待,失败时卡片显示原因:“重试”换一个新分支名再试;“不使用工作树”撤回请求,消息在当前检出中继续发送。 - **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;此期间发送的消息会等待、不开始这一轮,直到该 worktree 被恢复或离开,卡片也会说明消息之后会接着发送。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支;恢复期间卡片显示“准备工作树”“检出文件”两个步骤,标签页中的“恢复”变为加载状态。“在项目目录继续”把 Session 移回源检出。此期间标签页顶部一行显示警示图标。 - **标题栏按钮**——Session 有已绑定的变更请求、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的变更请求存在检查失败、被要求修改或冲突时,按钮带红点。 - **会话列表**——Session 在面板为它创建的 worktree 中工作时,会话行的时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求的状态,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,并用圆点标出检查进行中、已通过或失败;已合并与已关闭的保留各自的状态颜色。会话行的悬浮卡片写出 worktree 分支及其起点,再列出最多三个变更请求的编号、标题、状态与检查,并说明还有多少个。重启后,下次轮询之前显示重启前已知的状态。 - **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数、合并状态,已合并或已关闭的显示结束时间;最后是按所选界面语言格式化的变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。 -- **变更卡片**——`branch` 范围有文件变更、且没有以当前检出分支为源分支的打开中变更请求时,变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库后,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 -- **变更视图**——展开的变更请求卡片上的“变更”一行与变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地变更默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更;分叉点是 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。两种 diff 来源中,点击文件名或目录都会复制仓库相对路径,并通过 toast 报告成功或失败。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 +- **本地变更卡片**——`branch` 范围有文件变更、且没有以当前检出分支为源分支的打开中变更请求时,变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库后,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 +- **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更;分叉点是 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 + +点击 diff 内容区可将焦点移入。左右方向键按 diff 顺序跳到上一个、下一个文件并展开它;到达首尾时停留。上下方向键保留原有滚动行为。手动滚动后,文件跳转从视口顶部的文件继续。文本编辑、非折叠文本选区、组合键、菜单与文件树控件保留各自的键盘行为。 `gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles` 与 `maxCachedDiffBytes` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 -“全部文件”打开可搜索的目录树;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮可以关闭或打开它,并保留搜索、折叠状态和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”才打开纯文本评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 +“全部文件”打开可搜索的目录树;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮可以关闭或打开它。隐藏文件树或切换标签页与 Session 时保留搜索、折叠状态和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”才打开纯文本评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 ----- @@ -52,7 +54,7 @@ kind: "package-reference" `ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。 -检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`rev-parse --path-format=absolute --git-dir`、`symbolic-ref --short HEAD`、`rev-parse --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=`。之后,检查会为变更卡片统计 `branch` 范围的规模:找出分叉点,对它运行 `diff --numstat -z -M`,并列出未跟踪文件。每个命令都在 Session 的只读沙箱策略下运行,禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。 +检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`rev-parse --path-format=absolute --git-dir`、`symbolic-ref --short HEAD`、`rev-parse --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=`。之后,检查会为本地变更卡片统计 `branch` 范围的规模:找出分叉点,对它运行 `diff --numstat -z -M`,并列出未跟踪文件。每个命令都在 Session 的只读沙箱策略下运行,禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。 worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。该投影还从 Session 头部与每个 `working-directory/change` 记录 Session 的目录;它的客户端视图随 Session 摘要下发,在该目录就是所创建的 worktree 或位于其下时为该 worktree,否则为 null。一轮开始或 worktree 已创建之后,`requestWorktree` 以 `forgePanel/session-started` 拒绝;没有 `worktrees` 服务时以 `forgePanel/worktrees-unavailable` 拒绝;分支名为空时以 `forgePanel/invalid-branch` 拒绝;`cancelWorktree` 只以第一种拒绝,也不需要 `worktrees` 服务;两者都会忘掉失败的尝试。消息进入有未完成请求的 Session 的收件箱时,一个 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Session 所属 Agent 的维护任务,因此 Agent 会暂存唤醒,等这次暂留结束后才开始这条消息的这一轮。暂留使用新分支名进行一次尝试,在面板状态中发布正在进行的阶段,设置 `held`,`waiting` 为 `creation`,并记录带源仓库根目录的 `created`。尝试失败(其分支、基准、失败步骤与原因以 `failure` 留在面板状态中)或经 `stopCreation` 停止后,暂留继续等待:`retryWorktree` 再做一次尝试,没有消息等待或已有尝试进行时以 `forgePanel/not-waiting` 拒绝;撤回请求则让消息在当前检出中继续。Agent 空闲且 Session 在所创建 worktree 中工作时,进入的每条消息都会启动一次暂留,检查该 worktree 是否缺失;缺失期间暂留以 `waiting` 为 `restoration` 等待,`restoreWorktree` 或 `continueInProject` 在暂留内运行,之后放行消息;检查无法给出结果时立即放行。Agent 的取消与 Remote 的停止都会结束暂留;Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留。worktree 服务把工作目录通知排进队列,由第一步认领。一个 `agent/pre-step` 监听器在请求仍未完成的 Session 的第 1 步创建 worktree,覆盖没有被暂留的消息;它把通知移入本步提议的消息,失败时这一轮结束、请求保留。没有消息等待时,`restoreWorktree` 与 `continueInProject` 以各自的维护任务运行;`restoreWorktree` 以 `restoration` 发布正在进行的步骤,并在结束恢复的同一状态中清除 `missing`。套件把它的 `worktrees` 服务隔离,因此同时启用 Git Worktrees 时会有两个互不影响的服务。`track` 以 `forgePanel/unrecognized-url` 拒绝没有提供方识别的 URL。`markReady` 与 `merge` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;`merge` 带上卡片显示的 head 提交,head 已变化时以 `forgePanel/stale-head` 拒绝,其他拒绝都是带主机原因的 `forgePanel/action-failed`。 @@ -79,7 +81,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi | [`src/local-diff.ts`](src/local-diff.ts) | 分叉点、本地范围、新版本文本与 `branch` 范围摘要 | | [`src/client/mount.ts`](src/client/mount.ts) | Remote 挂载、slot 条目与操作结果映射 | | [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | 可复用的变更请求卡片 | -| [`src/client/LocalChangesCard.tsx`](src/client/LocalChangesCard.tsx) | 变更卡片 | +| [`src/client/LocalChangesCard.tsx`](src/client/LocalChangesCard.tsx) | 本地变更卡片 | | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | 变更视图 | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | 变更视图中的一个文件 | | [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | 变更文件的标签:文件名在前,目录在后 | diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index 290570befb..739513374c 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -248,6 +248,10 @@ overflow: auto; } +.files:focus { + outline: none; +} + /* The actions of a failed read, side by side. */ .actions { display: flex; @@ -321,13 +325,6 @@ display: flex; flex: 0 1 auto; min-width: 0; - padding: 0; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - font: inherit; - text-align: left; } .fileSpacer { @@ -631,7 +628,6 @@ .localView:focus-visible, .localToggle:focus-visible, .fileToggle:focus-visible, -.filePath:focus-visible, .localFile:focus-visible, .select:focus-visible, .gap:focus-visible { diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 3617545ba8..906ff4863d 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -7,7 +7,7 @@ * scope summary changes; a read keeps the previous changes on screen until it settles. */ -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react' +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from 'react' import clsx from 'clsx' import { Button, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconCompareSplitOutlineRegular, IconInfoOutlineRegular, @@ -424,9 +424,13 @@ export function ChangesTab({ const [revealed, setRevealed] = useState>(new Set()) const initial = useMemo(() => initiallyOpen(data === null ? [] : data.files), [data]) const sections = useRef(new Map()) + const scrollport = useRef(null) + // A short final file cannot align with the viewport top; retain its jump until the reader scrolls away. + const jumpPosition = useRef<{ readonly data: ChangesData; readonly top: number } | null>(null) useLayoutEffect(() => { if (!tab.visible || wanted === null || data === null) return sections.current.get(wanted)?.scrollIntoView({ block: 'start' }) + if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop } setWanted(null) }, [tab.visible, wanted, data]) const refresh = (): void => { setAttempt(value => value + 1) } @@ -496,6 +500,40 @@ export function ChangesTab({ .filter(path => !data?.files.some(file => file.path === path)) const orphanRecords = absentPaths.map(path => ) + const files = data === null ? [] : data.files + const jump = (path: string): void => { + setTreeState(value => ({ ...value, selected: path })) + setChosen(value => ({ ...value, [path]: true })) + setWanted(path) + } + const navigateFiles = (event: KeyboardEvent): void => { + if (!tab.visible || event.defaultPrevented || event.nativeEvent.isComposing + || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey + || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return + const viewport = event.currentTarget + const origin = event.target + const selection = window.getSelection() + if (!(origin instanceof HTMLElement) || !viewport.contains(origin) + || origin.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [data-forge-comment]') !== null + || (selection !== null && !selection.isCollapsed)) return + const remembered = jumpPosition.current + let index = remembered !== null && remembered.data === data && remembered.top === viewport.scrollTop + ? files.findIndex(file => file.path === treeState.selected) : -1 + if (index < 0) { + const top = viewport.getBoundingClientRect().top + index = Math.max(0, files.findLastIndex((file) => { + const section = sections.current.get(file.path) + return section !== undefined && section.getBoundingClientRect().top <= top + 1 + })) + } + event.preventDefault() + event.stopPropagation() + const next = files[index + (event.key === 'ArrowRight' ? 1 : -1)] + if (next === undefined) return + viewport.focus({ preventScroll: true }) + jump(next.path) + } + let body: ReactNode if (data === null) { if (state === null && snapshot?.disconnected) { @@ -526,7 +564,11 @@ export function ChangesTab({ body =
{orphanRecords}
{t('diff.empty')}
} else { body = ( -
+
{ + if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null + }}> {data.files.map((file) => { const path = file.path return ( @@ -560,7 +602,6 @@ export function ChangesTab({ badge={badgeOf(file, data, t)} tooltip={pathTooltipOf(file, t)} actions={fileActions(data, file)} - onCopyPath={() => { copyPath(path) }} sectionRef={(element) => { if (element === null) sections.current.delete(path) else sections.current.set(path, element) @@ -573,16 +614,11 @@ export function ChangesTab({
) } - const files = data === null ? [] : data.files const navigator: ChangedFilesProps = { files, state: treeState, t, filter: (query) => { setTreeState(value => ({ ...value, query })) }, toggle: (path) => { setTreeState(value => ({ ...value, collapsed: { ...value.collapsed, [path]: value.collapsed[path] !== true } })) }, - jump: (path) => { - setTreeState(value => ({ ...value, selected: path })) - setChosen(value => ({ ...value, [path]: true })) - setWanted(path) - }, + jump, } // The toolbar collapses every file while any is expanded, as the files on screen show, and expands every file otherwise. const anyExpanded = files.some(file => chosen[file.path] ?? initial.has(file.path)) diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index b627bcd3a2..a3c8d9a2ba 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -72,8 +72,6 @@ export interface DiffFileProps { readonly tooltip: string /** Rows of the file's menu. */ readonly actions: readonly DiffFileAction[] - /** Copy the repository-relative path from the file header. */ - readonly onCopyPath: () => void /** Receives the section element, which scrolling to the file targets. */ readonly sectionRef: (element: HTMLElement | null) => void /** Forge panel copy. */ @@ -405,7 +403,7 @@ function FileMenu({ name, actions, t }: { * @returns the file section. */ export function DiffFile(props: DiffFileProps): ReactNode { - const { file, expanded, onToggle, badge, tooltip, actions, onCopyPath, sectionRef, t } = props + const { file, expanded, onToggle, badge, tooltip, actions, sectionRef, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) return (
@@ -420,9 +418,9 @@ export function DiffFile(props: DiffFileProps): ReactNode { - + {badge !== '' && {badge}} {!file.binary && } diff --git a/packages/experimental/client-ui-forge/src/client/FileLabel.module.css b/packages/experimental/client-ui-forge/src/client/FileLabel.module.css index dcee927f92..add13b56ed 100644 --- a/packages/experimental/client-ui-forge/src/client/FileLabel.module.css +++ b/packages/experimental/client-ui-forge/src/client/FileLabel.module.css @@ -1,5 +1,7 @@ /* A changed file's name, then its directory in smaller subdued type. */ .label { + /* Keep the hidden accessible path inside the file's own scroll container. */ + position: relative; display: flex; min-width: 0; } diff --git a/packages/experimental/client-ui-forge/src/client/FileTreePane.tsx b/packages/experimental/client-ui-forge/src/client/FileTreePane.tsx index 925091dce1..9de6f00338 100644 --- a/packages/experimental/client-ui-forge/src/client/FileTreePane.tsx +++ b/packages/experimental/client-ui-forge/src/client/FileTreePane.tsx @@ -23,7 +23,12 @@ export function FileTreePane({ open, id, ...props }: ChangedFilesProps & { reado useLayoutEffect(() => { const parent = pane.current?.parentElement if (parent === undefined || parent === null) return - const measure = (): void => { setMaximum(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, parent.clientWidth - DIFF_MIN_WIDTH))) } + const measure = (): void => { + const available = parent.clientWidth + // A retained tab has no measurable width while its session or pane is hidden. + if (available === 0) return + setMaximum(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, available - DIFF_MIN_WIDTH))) + } measure() const observer = new ResizeObserver(measure) observer.observe(parent) diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css index ddebe94967..19d60ee8fe 100644 --- a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css @@ -388,16 +388,18 @@ /* Expanded facts, one row each: an icon under the glyph, the label, the value, and a chevron on a row that opens something. */ .rows { - display: flex; - flex-direction: column; - gap: 2px; + display: grid; + /* Edge tracks include each row's padding; labels retain their Chinese width and grow for longer translations. */ + grid-template-columns: 24px minmax(32px, max-content) minmax(0, 1fr) 22px; + gap: 2px 10px; padding: 4px 6px 6px; border-top: 0.5px solid var(--dsw-alias-border-l2); } .row { display: grid; - grid-template-columns: 16px 32px minmax(0, 1fr) 12px; + grid-column: 1 / -1; + grid-template-columns: subgrid; align-items: center; column-gap: 10px; box-sizing: border-box; @@ -449,6 +451,7 @@ align-items: center; gap: 6px; min-width: 0; + overflow-wrap: anywhere; } .rowValue[data-tone='error'] { @@ -469,7 +472,9 @@ /* The checks menu anchors to the whole row. */ .checksAnchor { - display: block; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; } .checksList { diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css b/packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css index 6844385290..53cf3e1714 100644 --- a/packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css +++ b/packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css @@ -9,7 +9,8 @@ } .toggle, -.base { +.base, +.current { display: inline-flex; align-items: center; gap: 6px; @@ -45,7 +46,18 @@ padding-right: 4px; } +.current { + box-sizing: content-box; + max-width: 200px; + font-family: inherit; +} + +.current svg { + flex: none; +} + .base:focus-visible, +.current:focus-visible, .toggle:has(.checkbox:focus-visible) { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; @@ -105,3 +117,16 @@ color: var(--dsw-alias-label-tertiary); font-size: 12px; } + +@media (max-width: 600px) { + .root { + flex-direction: column; + align-items: flex-end; + gap: 0; + } + + .base, + .current { + max-width: 120px; + } +} diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx b/packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx index 1abde16c94..a6053b6a5e 100644 --- a/packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx +++ b/packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx @@ -2,11 +2,11 @@ import { useState, type ReactNode } from 'react' import clsx from 'clsx' -import { IconChevronDownOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBranchOutlineRegular, IconChevronDownOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ForgeBranches, ForgePanelState } from '../types.ts' +import type { ForgeBranches, ForgePanelState, ForgeWorktreeBlocker } from '../types.ts' import type { NS } from './locales.ts' import type { PanelSnapshot } from './source.ts' import css from './WorktreeToggle.module.css' @@ -95,22 +95,28 @@ function BaseMenu({ base, held, branches, request, t }: { } /** What the option shows for a panel state, or undefined while it stays hidden. */ -function optionOf(state: ForgePanelState | null): { readonly usable: boolean; readonly current: string | null } | undefined { +function optionOf(state: ForgePanelState | null): { + readonly blocker: Exclude | null + readonly current: string | null + readonly commit: string | null +} | undefined { if (state === null || !state.worktree.editable) return undefined + const blocker = state.worktree.blocker + if (blocker === 'not-git') return undefined const checkout = state.checkout - if (checkout.state === 'none') return { usable: false, current: null } if (checkout.state !== 'ready') return undefined - return { usable: state.worktree.blocker === null, current: checkout.checkout.branch } + return { blocker, current: checkout.checkout.branch, commit: checkout.checkout.commit } } /** * Render the worktree option while the Session can still start in a worktree: - * the base branch menu once requested, then the checkbox. When the checkout - * cannot host a worktree, the tooltip gives the reason and the checkbox only - * withdraws an open request. Both stay fixed while the worktree is being - * created. + * the current branch, or the base branch menu once requested, then the checkbox. + * A blocked request keeps its selected base visible without a menu; the checkbox + * only withdraws it. Branch details support pointer, touch, and keyboard access. + * The request stays fixed while the worktree is being created. * @param props - panel snapshot hook, request actions, and copy. - * @returns the option, or nothing once a turn started, the request settled, or no worktree service is mounted. + * @returns the option, or nothing outside a known Git checkout, once a turn started, + * the request settled, or no worktree service is mounted. */ export function WorktreeToggle({ usePanel, request, cancel, branches, t }: WorktreeToggleProps): ReactNode { const state = usePanel(snapshot => snapshot.state) @@ -119,18 +125,30 @@ export function WorktreeToggle({ usePanel, request, cancel, branches, t }: Workt if (state === null || option === undefined) return null const held = state.worktree.creation !== null const requested = pending ?? state.worktree.request !== null - const disabled = held || pending !== null || (!option.usable && !requested) - const blocker = state.worktree.blocker ?? 'not-git' + const usable = option.blocker === null + const disabled = held || pending !== null || (!usable && !requested) + const current = option.current ?? (option.commit === null ? t('checkout.detached') : t('worktree.detachedTip', { commit: option.commit })) + const base = state.worktree.request === null ? option.current : state.worktree.request.from + const branchText = requested ? base === null ? t('worktree.baseHead') : t('worktree.base', { branch: base }) : current + const branchTip = requested || option.current === null ? branchText : t('worktree.currentBranchTip', { branch: option.current }) const change = (checked: boolean): void => { setPending(checked) void (checked ? request(option.current) : cancel()).then(() => { setPending(null) }) } return ( - {requested && option.usable && ( + {requested && usable ? ( + ) : ( + + + )} - +