Merge pull request #7457 from deepseek-harness/worktree/forge-ui-polish

feat(forge): clarify worktree controls and add keyboard file navigation
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-28 15:20:53 +08:00
commit b2fff8a197
24 files changed
+791 -241

No files matched your search

@@ -0,0 +1 @@
- 'tooltip "Current branch: feature/a-long-current-branch-name-for-mobile-review"'
@@ -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.
@@ -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
@@ -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"
@@ -0,0 +1,3 @@
- button "当前分支:main": main
- checkbox "新建工作树"
- text: 新建工作树
+307 -9
View File
@@ -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<PanelLaunch> {
async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal',
options: { desktop?: 'darwin' | 'win32'; branch?: string; hasTouch?: boolean } = {}): Promise<PanelLaunch> {
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<void>((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<HTMLElement>('[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()
@@ -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
@@ -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-<first 8 hexadecimal digits of the Session id>-<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-<first 8 hexadecimal digits of the Session id>-<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.
-----
@@ -29,18 +29,20 @@ kind: "package-reference"
面板文案跟随应用语言,支持中文、英文、日语、西班牙语、法语、德语、俄语和葡萄牙语。
- **工作树选项**——在新会话页面上,“工作树”位于工作区与模式选择器那一行的右端。勾选后显示基准分支,默认为当前分支;菜单列出最近的本地分支。不在 Git 检出中、Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh-<Session id 前 8 位十六进制数>-<4 位随机十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后这条消息的这一轮才开始。
- **工作树选项**——在新会话页面上,“新建工作树”与当前分支一起位于工作区与模式选择器那一行的右端。不在 Git 检出中时,两者均隐藏。分支默认为只读展示;勾选“新建工作树”后,它变为基准分支选择器,菜单列出最近的本地分支。权限阻止已有请求时,仍只读显示已选基准,并允许取消请求。悬停、聚焦或轻点只读分支或基准会显示完整说明。Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh-<Session id 前 8 位十六进制数>-<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=<maxBranches>`。之后,检查会为变更卡片统计 `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=<maxBranches>`。之后,检查会为本地变更卡片统计 `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) | 变更文件的标签:文件名在前,目录在后 |
@@ -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 {
@@ -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<ReadonlySet<string>>(new Set())
const initial = useMemo(() => initiallyOpen(data === null ? [] : data.files), [data])
const sections = useRef(new Map<string, HTMLElement>())
const scrollport = useRef<HTMLDivElement>(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 => <OrphanReviews key={path} path={path} actions={recordActions} t={t} />)
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<HTMLDivElement>): 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 = <div className={css.files}>{orphanRecords}<div className={css.centered}>{t('diff.empty')}</div></div>
} else {
body = (
<div className={css.files} data-forge-changes-scroll="">
<div ref={scrollport} className={css.files} data-forge-changes-scroll="" role="region"
aria-label={t('diff.allFiles', { count: files.length })} aria-keyshortcuts="ArrowLeft ArrowRight" tabIndex={0}
onKeyDown={navigateFiles} onScroll={(event) => {
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({
</div>
)
}
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))
@@ -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 (
<section ref={sectionRef} className={css.file} data-forge-diff-file={file.path} data-expanded={expanded ? '' : undefined}>
@@ -420,9 +418,9 @@ export function DiffFile(props: DiffFileProps): ReactNode {
<IconChevronDownOutlineRegular size={14} className={css.chevron} />
</button>
<FileTypeIcon path={file.path} size={16} className={css.fileIcon} />
<button type="button" className={css.filePath} aria-label={`${t('diff.copyPath')}: ${file.path}`} onClick={onCopyPath}>
<span className={css.filePath}>
<FileLabel path={file.path} tooltip={tooltip} />
</button>
</span>
<span className={css.fileSpacer} data-forge-path-spacer="" />
{badge !== '' && <span className={css.badge}>{badge}</span>}
{!file.binary && <Counts file={file} t={t} />}
@@ -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;
}
@@ -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)
@@ -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 {
@@ -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;
}
}
@@ -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<ForgeWorktreeBlocker, 'not-git'> | 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 (
<span className={css.root} data-forge-worktree="">
{requested && option.usable && (
{requested && usable ? (
<BaseMenu base={state.worktree.request?.from ?? option.current} held={held} branches={branches} request={request} t={t} />
) : (
<Tooltip label={branchTip} side="bottom" portal openOnClick>
<button type="button" className={css.current} aria-label={branchTip}
data-forge-current-branch={requested ? undefined : ''} data-forge-base-branch={requested ? '' : undefined}>
<IconBranchOutlineRegular size={14} />
<span className={css.baseLabel}>{branchText}</span>
</button>
</Tooltip>
)}
<Tooltip label={option.usable ? t('worktree.hint') : t(`worktree.blocked.${blocker}`)} side="bottom" delayMs={500}>
<Tooltip label={option.blocker === null ? t('worktree.hint') : t(`worktree.blocked.${option.blocker}`)} side="bottom" delayMs={500}>
<label className={css.toggle} data-disabled={disabled ? '' : undefined}>
<input
type="checkbox"
@@ -79,13 +79,14 @@ export function parseChangesAddress(address: string): ChangesTarget | undefined
}
/**
* Name a changes view: 变更 for the local changes, #N 的变更 for a change request with a number.
* Name a changes view: 本地变更 for the local changes, #N 的变更 for a change request with a number.
* @param source - what the view shows.
* @param t - forge panel copy.
* @returns the title in the current language.
*/
export function changesTitle(source: ChangesSource, t: TranslateNS<typeof NS>): string {
const number = source.kind === 'change' ? numberOf({ ref: null, url: source.url }) : null
if (source.kind === 'local') return t('changes.local.title')
const number = numberOf({ ref: null, url: source.url })
return number === null ? t('diff.tab') : t('diff.tabNumbered', { number })
}
@@ -61,7 +61,7 @@ export const zh = {
'changes.title': '本会话的变更请求 · {count}',
'changes.empty': '本会话还没有变更请求',
'changes.emptyHint': '本会话关联的变更请求(创建、查看、编辑或检出的,以及所在分支或推送分支上的)会显示在这里',
'changes.local.title': '变更',
'changes.local.title': '本地变更',
'changes.local.refs': '{head} → {base}',
'changes.local.files': '{count} 个文件',
'changes.local.viewAria': '查看本会话的变更:{count} 个文件,新增 {added} 行,删除 {deleted} 行',
@@ -188,15 +188,16 @@ export const zh = {
'duration.seconds': '{seconds} 秒',
'duration.minutes': '{minutes} 分 {seconds} 秒',
'duration.hours': '{hours} 小时 {minutes} 分',
'worktree.label': '工作树',
'worktree.label': '新建工作树',
'worktree.hint': '在独立的工作树中运行本会话:发送后基于所选分支新建分支,修改和提交不影响项目目录',
'worktree.blocked.not-git': '工作区不是 Git 仓库,无法使用工作树',
'worktree.blocked.outside-writable-root': '这个工作区的 Git 数据不在可写目录中(例如工作区本身就是工作树),暂不支持使用工作树',
'worktree.blocked.read-only': '只读模式下无法使用工作树',
'worktree.base': '基于 {branch}',
'worktree.baseHead': '基于当前 HEAD',
'worktree.baseMenu': '选择基准分支',
'worktree.currentBranch': '当前分支',
'worktree.currentBranchTip': '当前分支:{branch}',
'worktree.detachedTip': '未在分支上 · {commit}',
'creation.title': '正在创建工作树',
'creation.failed': '创建工作树失败',
'creation.prepare': '创建分支',
@@ -310,7 +311,7 @@ export const en: Record<ForgePanelKey, string> = {
'changes.title': 'Change requests of this Session · {count}',
'changes.empty': 'This Session has no change requests yet',
'changes.emptyHint': 'Change requests linked to this Session (created, viewed, edited, or checked out, or open on its current or pushed branches) appear here',
'changes.local.title': 'Changes',
'changes.local.title': 'Local changes',
'changes.local.refs': '{head} → {base}',
'changes.local.files': '{count} files',
'changes.local.viewAria': 'View the changes of this Session: {count} files, {added} lines added, {deleted} lines deleted',
@@ -437,15 +438,16 @@ export const en: Record<ForgePanelKey, string> = {
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'duration.hours': '{hours}h {minutes}m',
'worktree.label': 'Worktree',
'worktree.label': 'New worktree',
'worktree.hint': 'Run this Session in its own worktree: sending creates a new branch from the chosen one, so changes and commits leave the project directory alone',
'worktree.blocked.not-git': 'This workspace is not a Git repository, so it cannot use a worktree',
'worktree.blocked.outside-writable-root': 'The Git data of this workspace is outside the writable directories, as when the workspace is itself a worktree, so it cannot use a worktree yet',
'worktree.blocked.read-only': 'A read-only Session cannot use a worktree',
'worktree.base': 'From {branch}',
'worktree.baseHead': 'From the current HEAD',
'worktree.baseMenu': 'Base branch',
'worktree.currentBranch': 'Current',
'worktree.currentBranchTip': 'Current branch: {branch}',
'worktree.detachedTip': 'Not on a branch · {commit}',
'creation.title': 'Creating the worktree',
'creation.failed': 'The worktree could not be created',
'creation.prepare': 'Create the branch',
@@ -566,7 +568,7 @@ export const ja: Record<ForgePanelKey, string> = {
'changes.title': 'このセッションの変更リクエスト · {count}',
'changes.empty': 'このセッションにはまだ変更リクエストがありません',
'changes.emptyHint': 'このセッションで作成、表示、編集、チェックアウトした変更リクエストや、現在のブランチまたはプッシュしたブランチで開いている変更リクエストがここに表示されます',
'changes.local.title': '変更',
'changes.local.title': 'ローカルの変更',
'changes.local.refs': '{head} → {base}',
'changes.local.files': '{count} ファイル',
'changes.local.viewAria': 'このセッションの変更を表示:{count} ファイル、{added} 行追加、{deleted} 行削除',
@@ -690,15 +692,16 @@ export const ja: Record<ForgePanelKey, string> = {
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分 {seconds}秒',
'duration.hours': '{hours}時間 {minutes}分',
'worktree.label': 'ワークツリー',
'worktree.label': '新しいワークツリー',
'worktree.hint': 'このセッションを専用のワークツリーで実行します。送信時に選択したブランチから新しいブランチを作成するため、変更やコミットはプロジェクトディレクトリに影響しません',
'worktree.blocked.not-git': 'このワークスペースは Git リポジトリではないため、ワークツリーを使用できません',
'worktree.blocked.outside-writable-root': 'このワークスペースの Git データが書き込み可能なディレクトリ外にあります。ワークスペース自体がワークツリーである場合などは、まだワークツリーを使用できません',
'worktree.blocked.read-only': '読み取り専用のセッションではワークツリーを使用できません',
'worktree.base': '基点 {branch}',
'worktree.baseHead': '現在の HEAD を基点にする',
'worktree.baseMenu': '基準ブランチ',
'worktree.currentBranch': '現在',
'worktree.currentBranchTip': '現在のブランチ:{branch}',
'worktree.detachedTip': 'ブランチ上にありません · {commit}',
'creation.title': 'ワークツリーを作成中',
'creation.failed': 'ワークツリーを作成できませんでした',
'creation.prepare': 'ブランチを作成',
@@ -812,7 +815,7 @@ export const es: Record<ForgePanelKey, string> = {
'changes.title': 'Solicitudes de cambio de esta sesión · {count}',
'changes.empty': 'Esta sesión aún no tiene solicitudes de cambio',
'changes.emptyHint': 'Aquí aparecen las solicitudes vinculadas a esta sesión: creadas, consultadas, editadas o extraídas, o abiertas en sus ramas actuales o enviadas',
'changes.local.title': 'Cambios',
'changes.local.title': 'Cambios locales',
'changes.local.refs': '{head} → {base}',
'changes.local.files': '{count} archivos',
'changes.local.viewAria': 'Ver cambios de esta sesión: {count} archivos, {added} líneas añadidas y {deleted} eliminadas',
@@ -936,15 +939,16 @@ export const es: Record<ForgePanelKey, string> = {
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes} min {seconds} s',
'duration.hours': '{hours} h {minutes} min',
'worktree.label': 'Árbol de trabajo',
'worktree.label': 'Nuevo árbol de trabajo',
'worktree.hint': 'Ejecuta esta sesión en su propio árbol de trabajo: al enviar se crea una rama desde la elegida, sin que los cambios ni los commits afecten al directorio del proyecto',
'worktree.blocked.not-git': 'Este espacio de trabajo no es un repositorio Git y no puede usar un árbol de trabajo',
'worktree.blocked.outside-writable-root': 'Los datos Git están fuera de los directorios con permiso de escritura, como cuando el espacio ya es un árbol de trabajo; aún no se puede usar otro árbol',
'worktree.blocked.read-only': 'Una sesión de solo lectura no puede usar un árbol de trabajo',
'worktree.base': 'Desde {branch}',
'worktree.baseHead': 'Desde el HEAD actual',
'worktree.baseMenu': 'Rama base',
'worktree.currentBranch': 'Actual',
'worktree.currentBranchTip': 'Rama actual: {branch}',
'worktree.detachedTip': 'Sin rama activa · {commit}',
'creation.title': 'Creando el árbol de trabajo',
'creation.failed': 'No se pudo crear el árbol de trabajo',
'creation.prepare': 'Crear la rama',
@@ -1058,7 +1062,7 @@ export const fr: Record<ForgePanelKey, string> = {
'changes.title': 'Demandes de modification de cette session · {count}',
'changes.empty': 'Cette session n’a pas encore de demande de modification',
'changes.emptyHint': 'Les demandes liées à cette session apparaissent ici : créées, consultées, modifiées ou extraites, ou ouvertes sur ses branches actuelles ou envoyées',
'changes.local.title': 'Modifications',
'changes.local.title': 'Modifications locales',
'changes.local.refs': '{head} → {base}',
'changes.local.files': '{count} fichiers',
'changes.local.viewAria': 'Voir les modifications de cette session : {count} fichiers, {added} lignes ajoutées et {deleted} supprimées',
@@ -1182,15 +1186,16 @@ export const fr: Record<ForgePanelKey, string> = {
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes} min {seconds} s',
'duration.hours': '{hours} h {minutes} min',
'worktree.label': 'Arbre de travail',
'worktree.label': 'Nouvel arbre de travail',
'worktree.hint': 'Exécutez cette session dans son propre arbre de travail : l’envoi crée une branche depuis celle choisie, sans que les modifications ou les commits affectent le dossier du projet',
'worktree.blocked.not-git': 'Cet espace de travail n’est pas un dépôt Git et ne peut pas utiliser d’arbre de travail',
'worktree.blocked.outside-writable-root': 'Les données Git sont hors des dossiers accessibles en écriture, notamment lorsque l’espace est déjà un arbre de travail ; cette fonction n’est pas encore disponible',
'worktree.blocked.read-only': 'Une session en lecture seule ne peut pas utiliser d’arbre de travail',
'worktree.base': 'Depuis {branch}',
'worktree.baseHead': 'Depuis le HEAD actuel',
'worktree.baseMenu': 'Branche de base',
'worktree.currentBranch': 'Actuelle',
'worktree.currentBranchTip': 'Branche actuelle : {branch}',
'worktree.detachedTip': 'Aucune branche active · {commit}',
'creation.title': 'Création de l’arbre de travail',
'creation.failed': 'Impossible de créer l’arbre de travail',
'creation.prepare': 'Créer la branche',
@@ -1304,7 +1309,7 @@ export const de: Record<ForgePanelKey, string> = {
'changes.title': 'Änderungsanfragen dieser Sitzung · {count}',
'changes.empty': 'Diese Sitzung hat noch keine Änderungsanfragen',
'changes.emptyHint': 'Hier erscheinen verknüpfte Änderungsanfragen: in dieser Sitzung erstellt, angesehen, bearbeitet oder ausgecheckt oder auf ihren aktuellen oder gepushten Branches geöffnet',
'changes.local.title': 'Änderungen',
'changes.local.title': 'Lokale Änderungen',
'changes.local.refs': '{head} → {base}',
'changes.local.files': '{count} Dateien',
'changes.local.viewAria': 'Änderungen dieser Sitzung anzeigen: {count} Dateien, {added} Zeilen hinzugefügt, {deleted} Zeilen gelöscht',
@@ -1428,15 +1433,16 @@ export const de: Record<ForgePanelKey, string> = {
'duration.seconds': '{seconds} Sek.',
'duration.minutes': '{minutes} Min. {seconds} Sek.',
'duration.hours': '{hours} Std. {minutes} Min.',
'worktree.label': 'Arbeitsbaum',
'worktree.label': 'Neuer Arbeitsbaum',
'worktree.hint': 'Diese Sitzung in einem eigenen Arbeitsbaum ausführen: Beim Senden wird aus dem gewählten Branch ein neuer erstellt; Änderungen und Commits lassen das Projektverzeichnis unverändert',
'worktree.blocked.not-git': 'Dieser Arbeitsbereich ist kein Git-Repository und kann keinen Arbeitsbaum verwenden',
'worktree.blocked.outside-writable-root': 'Die Git-Daten liegen außerhalb der beschreibbaren Verzeichnisse, etwa wenn der Arbeitsbereich selbst ein Arbeitsbaum ist; Arbeitsbäume werden hier noch nicht unterstützt',
'worktree.blocked.read-only': 'Eine schreibgeschützte Sitzung kann keinen Arbeitsbaum verwenden',
'worktree.base': 'Aus {branch}',
'worktree.baseHead': 'Aus dem aktuellen HEAD',
'worktree.baseMenu': 'Basis-Branch',
'worktree.currentBranch': 'Aktuell',
'worktree.currentBranchTip': 'Aktueller Branch: {branch}',
'worktree.detachedTip': 'Auf keinem Branch · {commit}',
'creation.title': 'Arbeitsbaum wird erstellt',
'creation.failed': 'Arbeitsbaum konnte nicht erstellt werden',
'creation.prepare': 'Branch erstellen',
@@ -1550,7 +1556,7 @@ export const ru: Record<ForgePanelKey, string> = {
'changes.title': 'Запросы на изменение этого сеанса · {count}',
'changes.empty': 'В этом сеансе пока нет запросов на изменение',
'changes.emptyHint': 'Здесь отображаются связанные с сеансом запросы: созданные, просмотренные, изменённые или извлечённые, а также открытые для текущей или отправленной ветки',
'changes.local.title': 'Изменения',
'changes.local.title': 'Локальные изменения',
'changes.local.refs': '{head} → {base}',
'changes.local.files': 'Файлов: {count}',
'changes.local.viewAria': 'Просмотреть изменения сеанса: файлов — {count}, добавлено строк — {added}, удалено — {deleted}',
@@ -1674,15 +1680,16 @@ export const ru: Record<ForgePanelKey, string> = {
'duration.seconds': '{seconds} с',
'duration.minutes': '{minutes} мин {seconds} с',
'duration.hours': '{hours} ч {minutes} мин',
'worktree.label': 'Рабочее дерево',
'worktree.label': 'Новое рабочее дерево',
'worktree.hint': 'Запустить сеанс в отдельном рабочем дереве: при отправке будет создана ветка на основе выбранной, а изменения и коммиты не затронут каталог проекта',
'worktree.blocked.not-git': 'Рабочая область не является репозиторием Git, поэтому рабочее дерево недоступно',
'worktree.blocked.outside-writable-root': 'Данные Git находятся вне доступных для записи каталогов, например когда рабочая область сама является рабочим деревом; создание рабочего дерева пока недоступно',
'worktree.blocked.read-only': 'Сеанс только для чтения не может использовать рабочее дерево',
'worktree.base': 'На основе {branch}',
'worktree.baseHead': 'На основе текущего HEAD',
'worktree.baseMenu': 'Базовая ветка',
'worktree.currentBranch': 'Текущая',
'worktree.currentBranchTip': 'Текущая ветка: {branch}',
'worktree.detachedTip': 'Вне ветки · {commit}',
'creation.title': 'Создание рабочего дерева',
'creation.failed': 'Не удалось создать рабочее дерево',
'creation.prepare': 'Создать ветку',
@@ -1796,7 +1803,7 @@ export const pt: Record<ForgePanelKey, string> = {
'changes.title': 'Solicitações de alteração desta sessão · {count}',
'changes.empty': 'Esta sessão ainda não tem solicitações de alteração',
'changes.emptyHint': 'Aqui aparecem solicitações vinculadas à sessão: criadas, visualizadas, editadas ou usadas em checkout, ou abertas nas branches atuais ou enviadas',
'changes.local.title': 'Alterações',
'changes.local.title': 'Alterações locais',
'changes.local.refs': '{head} → {base}',
'changes.local.files': '{count} arquivos',
'changes.local.viewAria': 'Ver alterações desta sessão: {count} arquivos, {added} linhas adicionadas e {deleted} removidas',
@@ -1920,15 +1927,16 @@ export const pt: Record<ForgePanelKey, string> = {
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes} min {seconds} s',
'duration.hours': '{hours} h {minutes} min',
'worktree.label': 'Árvore de trabalho',
'worktree.label': 'Nova árvore de trabalho',
'worktree.hint': 'Execute esta sessão em sua própria árvore de trabalho: o envio cria uma branch a partir da escolhida, sem que alterações e commits afetem o diretório do projeto',
'worktree.blocked.not-git': 'Este espaço de trabalho não é um repositório Git e não pode usar uma árvore de trabalho',
'worktree.blocked.outside-writable-root': 'Os dados Git estão fora dos diretórios graváveis, como quando o espaço já é uma árvore de trabalho; ainda não é possível usar outra árvore',
'worktree.blocked.read-only': 'Uma sessão somente leitura não pode usar uma árvore de trabalho',
'worktree.base': 'A partir de {branch}',
'worktree.baseHead': 'A partir do HEAD atual',
'worktree.baseMenu': 'Branch base',
'worktree.currentBranch': 'Atual',
'worktree.currentBranchTip': 'Branch atual: {branch}',
'worktree.detachedTip': 'Fora de uma branch · {commit}',
'creation.title': 'Criando a árvore de trabalho',
'creation.failed': 'Não foi possível criar a árvore de trabalho',
'creation.prepare': 'Criar a branch',
@@ -37,7 +37,7 @@ describe('changes addresses', () => {
it('name the local view and a change request view by its number when the URL ends in one', () => {
const t = makeTranslate(zh)
expect(changesTitle({ kind: 'local' }, t)).toBe('变更')
expect(changesTitle({ kind: 'local' }, t)).toBe('本地变更')
expect(changesTitle({ kind: 'change', url: URL }, t)).toBe('#7 的变更')
expect(changesTitle({ kind: 'change', url: 'https://example.com/review' }, t)).toBe('变更')
})
@@ -244,6 +244,116 @@ function rowTexts(section: HTMLElement): string[] {
return [...section.querySelectorAll(`.${css.text}`)].map(element => element.textContent ?? '')
}
/** Section geometry with a short final page whose scroll position is clamped by the viewport. */
function fileViewport(m: ReturnType<typeof mount>) {
const nodes = [...m.view.container.querySelectorAll<HTMLElement>('[data-forge-diff-file]')]
const viewport = m.view.getByRole('region', { name: `All ${nodes.length} files` })
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 600, 300))
nodes.forEach((node, index) => {
vi.spyOn(node, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, index * 200 - viewport.scrollTop, 600, 200))
})
scrolled.mockImplementation(function (this: HTMLElement) {
viewport.scrollTop = Math.min(nodes.indexOf(this) * 200, nodes.length * 200 - 300)
})
return { viewport, nodes }
}
describe('ChangesTab keyboard file navigation', () => {
it.each([{ kind: 'local' }, { kind: 'change', url: URL7 }] as const)('moves between $kind files, expands the destination and keeps focus in the diff', async (source) => {
const m = mount({ source })
await settle()
const { viewport, nodes } = fileViewport(m)
const destination = nodes[2]!
fireEvent.click(within(destination).getByRole('button', { name: /^Collapse / }))
viewport.scrollTop = 250
fireEvent.scroll(viewport)
expect(fireEvent.keyDown(viewport, { key: 'ArrowRight' })).toBe(false)
expect(scrolled.mock.contexts.at(-1)).toBe(destination)
expect(destination.hasAttribute('data-expanded')).toBe(true)
expect(document.activeElement).toBe(viewport)
const tree = within(m.view.getByRole('navigation'))
expect(tree.getByRole('button', { name: destination.dataset.forgeDiffFile! }).getAttribute('aria-current')).toBe('true')
fireEvent.scroll(viewport)
fireEvent.keyDown(viewport, { key: 'ArrowLeft' })
expect(scrolled.mock.contexts.at(-1)).toBe(nodes[1])
expect(m.faces.localDiff.mock.calls.length + m.faces.changeRequestDiff.mock.calls.length).toBe(1)
})
it('stops at the ends, remembers a clamped last file, and follows manual scrolling', async () => {
const m = mount()
await settle()
const { viewport, nodes } = fileViewport(m)
fireEvent.keyDown(viewport, { key: 'ArrowLeft' })
expect(scrolled).not.toHaveBeenCalled()
for (let index = 1; index < nodes.length; index += 1) {
fireEvent.keyDown(viewport, { key: 'ArrowRight' })
fireEvent.scroll(viewport)
expect(scrolled.mock.contexts.at(-1)).toBe(nodes[index])
}
const count = scrolled.mock.calls.length
fireEvent.keyDown(viewport, { key: 'ArrowRight' })
expect(scrolled).toHaveBeenCalledTimes(count)
fireEvent.keyDown(viewport, { key: 'ArrowLeft' })
expect(scrolled.mock.contexts.at(-1)).toBe(nodes.at(-2))
viewport.scrollTop = 225
fireEvent.scroll(viewport)
fireEvent.keyDown(viewport, { key: 'ArrowRight' })
expect(scrolled.mock.contexts.at(-1)).toBe(nodes[2])
})
it('leaves vertical arrows, modifiers, composition and handled keys to their owners', async () => {
const m = mount()
await settle()
const { viewport } = fileViewport(m)
for (const input of [
{ key: 'ArrowDown' }, { key: 'ArrowUp' }, { key: 'ArrowRight', shiftKey: true },
{ key: 'ArrowRight', ctrlKey: true }, { key: 'ArrowRight', metaKey: true },
{ key: 'ArrowRight', altKey: true }, { key: 'ArrowRight', isComposing: true },
]) expect(fireEvent.keyDown(viewport, input)).toBe(true)
const handled = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })
handled.preventDefault()
fireEvent(viewport, handled)
act(() => { m.visibility.set(false) })
fireEvent.keyDown(viewport, { key: 'ArrowRight' })
expect(scrolled).not.toHaveBeenCalled()
})
it('preserves comment editing, native selections, tree controls and portalled menus', async () => {
const m = mount()
await settle()
const { viewport } = fileViewport(m)
const section = within(m.section('src/app.ts'))
fireEvent.click(section.getByRole('button', { name: 'Select line R4' }))
fireEvent.click(section.getByRole('button', { name: 'Add comment' }))
const input = section.getByRole('textbox')
fireEvent.change(input, { target: { value: 'Keep editing' } })
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.keyDown(section.getByRole('button', { name: 'Add to input' }), { key: 'ArrowLeft' })
expect(input).toHaveProperty('value', 'Keep editing')
expect(scrolled).not.toHaveBeenCalled()
fireEvent.keyDown(m.view.getByRole('separator'), { key: 'ArrowRight' })
fireEvent.keyDown(within(m.view.getByRole('navigation')).getByRole('textbox'), { key: 'ArrowLeft' })
fireEvent.click(section.getByRole('button', { name: 'More actions for app.ts' }))
const menuItem = m.view.getByRole('menuitem', { name: 'Copy path' })
fireEvent.keyDown(menuItem, { key: 'ArrowRight' })
fireEvent.keyDown(menuItem, { key: 'Escape' })
fireEvent.keyDown(m.section('src/app.ts').querySelector('svg')!, { key: 'ArrowRight' })
expect(scrolled).not.toHaveBeenCalled()
const selection = window.getSelection()!
const range = document.createRange()
range.selectNodeContents(m.section('src/new.ts').querySelector('[data-review-code][data-new-line="1"]')!)
selection.removeAllRanges()
selection.addRange(range)
try {
expect(selection.toString()).toBe('export const added = 1')
fireEvent.keyDown(viewport, { key: 'ArrowRight' })
expect(scrolled).not.toHaveBeenCalled()
} finally {
selection.removeAllRanges()
}
})
})
describe('ChangesTab local reads', () => {
it('waits for the panel state, then reads the branch scope and draws its files', async () => {
const m = mount({ state: null })
@@ -677,16 +787,16 @@ describe('ChangesTab actions', () => {
return m.view.getAllByRole('menuitem').map(item => item.textContent ?? '')
}
it.each(['local', 'change'] as const)('copies the full relative path from a %s diff header without folding the file', async (kind) => {
it.each(['local', 'change'] as const)('keeps the clipboard unchanged when a %s diff file label is clicked', async (kind) => {
const m = mount({ source: kind === 'local' ? { kind } : { kind, url: URL7 } })
await settle()
fireEvent.click(within(m.section('src/app.ts')).getByRole('button', { name: `${en['diff.copyPath']}: src/app.ts` }))
expect(m.faces.copyPath).toHaveBeenCalledWith('src/app.ts')
fireEvent.click(m.section('src/app.ts').querySelector('[data-forge-file-label]')!)
expect(m.faces.copyPath).not.toHaveBeenCalled()
expect(m.section('src/app.ts').hasAttribute('data-expanded')).toBe(true)
expect(m.tabActions.openResource).not.toHaveBeenCalled()
fireEvent.click(within(m.section('docs/gone.txt')).getByRole('button', { name: 'Collapse gone.txt' }))
fireEvent.click(within(m.section('docs/gone.txt')).getByRole('button', { name: `${en['diff.copyPath']}: docs/gone.txt` }))
expect(m.faces.copyPath).toHaveBeenLastCalledWith('docs/gone.txt')
fireEvent.click(m.section('docs/gone.txt').querySelector('[data-forge-file-label]')!)
expect(m.faces.copyPath).not.toHaveBeenCalled()
expect(m.section('docs/gone.txt').hasAttribute('data-expanded')).toBe(false)
})
@@ -695,9 +805,11 @@ describe('ChangesTab actions', () => {
...LOCAL, files: [file('src/index.ts'), file('tests/index.ts')],
})) } })
await settle()
fireEvent.click(m.view.getByRole('button', { name: 'Copy path: src/index.ts' }))
menu(m, 'src/index.ts')
fireEvent.click(m.view.getByRole('menuitem', { name: en['diff.copyPath'] }))
expect(m.faces.copyPath).toHaveBeenLastCalledWith('src/index.ts')
fireEvent.click(m.view.getByRole('button', { name: 'Copy path: tests/index.ts' }))
menu(m, 'tests/index.ts')
fireEvent.click(m.view.getByRole('menuitem', { name: en['diff.copyPath'] }))
expect(m.faces.copyPath).toHaveBeenLastCalledWith('tests/index.ts')
})
@@ -22,7 +22,7 @@ function mount(overrides: Partial<DiffFileProps> = {}) {
const review: ReviewActions = { source, addToInput, records, onQuoted: (item) => { records.recordAgent(item) } }
const props: DiffFileProps = { file, visible: true, expanded: true, onToggle: vi.fn(), large: false, revealed: false, onReveal: vi.fn(),
text: undefined, open: new Set(), onExpand: vi.fn(), split: true, wrap: true, badge: '', tooltip: 'a.txt', actions: [],
onCopyPath: vi.fn(), sectionRef: vi.fn(), review, t, ...overrides }
sectionRef: vi.fn(), review, t, ...overrides }
const view = render(<DiffFile {...props} />)
const line = (name: string) => view.getByRole('button', { name: `Select line ${name}` })
const code = (side: 'L' | 'R', no: number): HTMLElement => {
@@ -121,7 +121,12 @@ it('bounds pointer and keyboard resizing, retains hidden width, and stops cancel
expect(width()).toBe(350)
}
view.rerender(<FileTreePane {...props} open={false} />)
containerWidth = 0
act(() => { measure([], new NativeObserver(() => {})) })
expect(width()).toBe(350)
containerWidth = 1000
view.rerender(<FileTreePane {...props} open />)
act(() => { measure([], new NativeObserver(() => {})) })
expect(width()).toBe(350)
containerWidth = 600
act(() => { measure([], new NativeObserver(() => {})) })
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import menuCss from '@deepseek-ai/dsh-client-ui-primitives/src/Menu.module.css'
import { en } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
import type { PanelSnapshot } from '../src/client/source.ts'
import { WorktreeToggle } from '../src/client/WorktreeToggle.tsx'
import type { ForgeBranches, ForgeCheckoutState, ForgePanelState, ForgePanelWorktree, ForgeWorktreeBlocker } from '../src/types.ts'
@@ -32,7 +32,7 @@ function deferred<T>() {
return { promise, resolve }
}
function option(state: ForgePanelState | null) {
function option(state: ForgePanelState | null, translate = t) {
const panel = createSnapshotStore<PanelSnapshot>({ state, disconnected: false })
const request = vi.fn(async (_from: string | null) => true)
const cancel = vi.fn(async () => true)
@@ -43,7 +43,7 @@ function option(state: ForgePanelState | null) {
request={request}
cancel={cancel}
branches={branches}
t={t}
t={translate}
/>,
)
const checkbox = (): HTMLInputElement => {
@@ -83,6 +83,7 @@ describe('WorktreeToggle visibility', () => {
['once the Session can no longer change its worktree', ready()],
['while the first inspection runs', editable({}, { state: 'pending' })],
['when the inspection failed', editable({}, { state: 'failed', message: 'git status failed' })],
['outside a Git checkout', editable({ blocker: 'not-git' }, { state: 'none' })],
])('renders nothing %s', (_label, state) => {
expect(option(state).view.container.innerHTML).toBe('')
})
@@ -97,15 +98,6 @@ describe('WorktreeToggle visibility', () => {
})
describe('WorktreeToggle checkbox', () => {
it('disables the option outside a Git checkout and says why', () => {
const b = option(editable({ blocker: 'not-git' }, { state: 'none' }))
expect(b.checkbox().disabled).toBe(true)
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(true)
expect(b.base()).toBeNull()
expect(hint(b)).toBe(en['worktree.blocked.not-git'])
})
it.each<[ForgeWorktreeBlocker, string]>([
['read-only', en['worktree.blocked.read-only']],
['outside-writable-root', en['worktree.blocked.outside-writable-root']],
@@ -118,20 +110,54 @@ describe('WorktreeToggle checkbox', () => {
expect(hint(b)).toBe(reason)
})
it('keeps an open request of a blocked checkout checked, without its base menu, until it is withdrawn', async () => {
const b = option(editable({ blocker: 'read-only', request: { from: 'main' } }))
it.each(['read-only', 'outside-writable-root'] as const)('keeps the selected base visible after %s blocks a request until it is withdrawn', async (blocker) => {
const b = option(editable({ request: { from: 'release' } }))
b.publish(editable({ blocker, request: { from: 'release' } }))
expect(b.checkbox().checked).toBe(true)
expect(b.checkbox().disabled).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
expect(b.base()).toBeNull()
expect(hint(b)).toBe(en['worktree.blocked.read-only'])
expect(b.view.getByRole('button', { name: 'From release' }).textContent).toBe('From release')
expect(hint(b)).toBe(en[`worktree.blocked.${blocker}`])
vi.useRealTimers()
await act(async () => { fireEvent.click(b.checkbox()) })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.request).not.toHaveBeenCalled()
b.publish(editable({ blocker: 'read-only' }))
b.publish(editable({ blocker }))
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().disabled).toBe(true)
expect(b.view.getByRole('button', { name: 'Current branch: main' })).toBeTruthy()
})
it('preserves a HEAD base while blocked and restores the selected branch menu when permission returns', () => {
const b = option(editable({ blocker: 'read-only', request: { from: null } }))
expect(b.view.getByRole('button', { name: en['worktree.baseHead'] })).toBeTruthy()
b.publish(editable({ blocker: 'read-only', request: { from: 'release' } }))
b.publish(editable({ request: { from: 'release' } }))
expect(b.base()?.textContent).toBe('From release')
expect(b.checkbox().checked).toBe(true)
})
it.each([
['en', en, 'Current branch: main', 'Not on a branch · 0a1b2c3'],
['zh', zh, '当前分支:main', '未在分支上 · 0a1b2c3'],
] as const)('names the branch and detached checkout in an informational tooltip (%s)', (_locale, copy, branchLabel, detachedLabel) => {
const b = option(editable(), makeTranslate(copy))
const chip = b.view.getByRole('button', { name: branchLabel })
fireEvent.click(chip)
expect(screen.getByRole('tooltip').textContent).toBe(branchLabel)
expect(chip.getAttribute('aria-describedby')).toBe(screen.getByRole('tooltip').id)
fireEvent.click(chip)
expect(screen.queryByRole('tooltip')).toBeNull()
b.publish(ready({ worktree: worktree({ editable: true }) }, { branch: null }))
const detached = b.view.getByRole('button', { name: detachedLabel })
fireEvent.click(detached)
expect(screen.getByRole('tooltip').textContent).toBe(detachedLabel)
fireEvent.click(detached)
b.publish(ready({ worktree: worktree({ editable: true }) }, { branch: null, commit: null }))
expect(b.view.getByRole('button', { name: copy['checkout.detached'] })).toBeTruthy()
expect(b.request).not.toHaveBeenCalled()
expect(b.branches).not.toHaveBeenCalled()
})
it('offers the option again once the blocker clears', () => {
@@ -142,13 +168,17 @@ describe('WorktreeToggle checkbox', () => {
expect(hint(b)).toBe(en['worktree.hint'])
})
it('offers the option unchecked inside a Git checkout', () => {
it('shows the current branch without requesting a worktree inside a Git checkout', () => {
const b = option(editable())
expect(b.checkbox().disabled).toBe(false)
expect(b.checkbox().checked).toBe(false)
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
expect(b.base()).toBeNull()
expect(hint(b)).toBe(en['worktree.hint'])
const current = b.view.getByText('main')
fireEvent.click(current)
expect(b.request).not.toHaveBeenCalled()
expect(b.branches).not.toHaveBeenCalled()
})
it('requests a worktree from the current branch and holds the checked state until the Host answers', async () => {
@@ -171,6 +201,7 @@ describe('WorktreeToggle checkbox', () => {
it('requests a worktree from HEAD on a detached checkout', async () => {
const b = option(ready({ worktree: worktree({ editable: true }) }, { branch: null }))
expect(b.view.getByText(`${en['checkout.detached']} · 0a1b2c3`)).toBeTruthy()
await act(async () => { fireEvent.click(b.checkbox()) })
expect(b.request).toHaveBeenCalledWith(null)
b.publish(ready({ worktree: worktree({ editable: true, request: { from: null } }) }, { branch: null }))
@@ -186,7 +217,7 @@ describe('WorktreeToggle checkbox', () => {
expect(b.base()).toBeNull()
})
it('withdraws an open request and hides the base branch until the Host answers', async () => {
it('withdraws an open request and shows the current branch until the Host answers', async () => {
const b = option(editable({ request: { from: 'release' } }))
expect(b.checkbox().checked).toBe(true)
expect(b.base()?.textContent).toBe('From release')