diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 60642f1499..e1aa34c2fa 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -11,7 +11,7 @@ import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { initialShortcutConfig, type DesktopShortcutInput, type ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' -import { chromium, type Browser, type Page } from 'playwright' +import { chromium, type Browser, type Locator, type Page } from 'playwright' import { expect, it, onTestFailed, onTestFinished } from 'vitest' import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, readPersistedEvents, watchConsole, @@ -1453,7 +1453,9 @@ it.skipIf(MODE === 'record')('opens a comment from the hover plus in one press w expect(await file.getByRole('textbox').count()).toBe(0) await page.mouse.up() await file.getByRole('region', { name: '评论 R6–8', exact: true }).waitFor() - expect(await plus('R6').count()).toBe(0) + // The open editor leaves the hover plus for the next comment. + await code(3).hover() + expect(await plus('R3').isVisible()).toBe(true) await file.getByRole('button', { name: '取消', exact: true }).click() await file.getByRole('textbox').waitFor({ state: 'detached' }) @@ -1993,10 +1995,14 @@ it.skipIf(MODE === 'record')('overlays the review threads of a change request an const panel = view.locator('[data-forge-diff-file="src/panel.ts"]') const onLine = panel.locator('[data-forge-thread="on-line"]') await onLine.getByText('two', { exact: true }).waitFor({ timeout: 20_000 }) - // The thread follows the row of its last line, under the new side. - expect(await onLine.evaluate(card => card.previousElementSibling?.querySelector('[data-new-line]')?.textContent)).toBe('export const n2 = \'two\'') - expect(await panel.locator('[data-forge-thread="old-side"]').evaluate(card => card.previousElementSibling?.querySelector('[data-old-line]')?.textContent)) - .toBe('export const n15 = 15') + // The thread follows the row of its last line, in the column of its side: under the new side and the old side. + const placement = (card: Element, number: string) => { + const row = card.closest('[data-forge-notes]') + const column = Array.from(row?.children ?? []).findIndex(cell => cell.contains(card)) + return { line: row?.previousElementSibling?.querySelector(number)?.textContent, column } + } + expect(await onLine.evaluate(placement, '[data-new-line]')).toEqual({ line: 'export const n2 = \'two\'', column: 1 }) + expect(await panel.locator('[data-forge-thread="old-side"]').evaluate(placement, '[data-old-line]')).toEqual({ line: 'export const n15 = 15', column: 0 }) await panel.locator('[data-forge-thread="resolved"]').getByText('已解决的讨论 · lin', { exact: true }).waitFor() await panel.locator('[data-forge-outdated-threads]').getByText('1 条讨论已过期:所在代码已被后续提交修改', { exact: true }).waitFor() await view.locator('[data-forge-diff-file="docs/panel.md"] [data-forge-thread="whole-file"]').getByText('文件级讨论', { exact: true }).waitFor() @@ -2167,3 +2173,112 @@ it.skipIf(MODE === 'record')('keeps a narrow file header\'s viewed mark and menu await expect.poll(() => file.getAttribute('data-viewed')).toBe('viewed') expect(tripwire.pageErrors).toEqual([]) }) + +/** + * Launch the panel on a branch whose README.md replaces its first line and adds eight more, so a side-by-side view + * pairs a deletion with an addition, then open the Session's local changes full screen. + * @param failureShot - screenshot name for a failure. + * @returns the launch and the local changes view. + */ +async function openLocalChanges(failureShot: string): Promise { + const launch = await launchPanel(failureShot) + const { scaffold, page, project } = launch + 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'), `changed\n${Array.from({ length: 8 }, (_, line) => `line ${line + 2}\n`).join('')}`) + await writeFile(join(project, 'notes.md'), 'note\n') + 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: /查看本会话的变更/ }).click() + const view = page.locator('[data-forge-changes="local"]') + await view.locator('[data-forge-diff-file="README.md"]').waitFor() + await page.getByRole('button', { name: '全屏', exact: true }).click() + return { ...launch, view } +} + +it.skipIf(MODE === 'record')('keeps the changes toolbar and header on one line, folding view controls into the More menu as they narrow', async () => { + const { page, view, tripwire } = await openLocalChanges('web-e2e-forge-toolbar-fold') + const toolbar = view.locator('[data-fold]') + const more = toolbar.getByRole('button', { name: '更多视图选项', exact: true }) + const create = view.getByRole('button', { name: '创建变更请求', exact: true }) + const layout = () => toolbar.evaluate((row) => { + const header = row.closest('[data-forge-changes]')!.firstElementChild! + return { fold: row.getAttribute('data-fold'), overflow: row.scrollWidth - row.clientWidth, height: row.getBoundingClientRect().height, + header: header.getBoundingClientRect().height } + }) + // The button keeps a fixed height when its label wraps, so the label's own height tells one line from two. + const labelHeight = () => create.evaluate((button) => { + const range = document.createRange() + range.selectNodeContents(button) + return Math.round(range.getBoundingClientRect().height) + }) + const lineHeight = await labelHeight() + const seen: string[] = [] + for (const width of [1680, 700, 560, 390, 340]) { + await page.setViewportSize({ width, height: 800 }) + await expect.poll(async () => (await layout()).overflow).toBeLessThanOrEqual(0) + const { fold, height, header } = await layout() + // One 28px control row inside the toolbar's padding, and the header keeps its single-line height. + expect([width, height]).toEqual([width, 38]) + expect([width, header]).toEqual([width, 44]) + expect([width, await labelHeight()]).toEqual([width, lineHeight]) + seen.push(`${width}:${fold}`) + } + expect(seen[0]).toBe('1680:0') + expect(seen.at(-1)).toBe('340:3') + expect(await more.isVisible()).toBe(true) + + await page.setViewportSize({ width: 390, height: 800 }) + await expect.poll(() => toolbar.getAttribute('data-fold')).toBe('2') + // Folded controls leave the row; the progress turns into the ring and the bare count. + expect(await toolbar.getByRole('button', { name: '左右对比', exact: true }).isVisible()).toBe(false) + expect(await toolbar.getByRole('switch', { name: '隐藏测试文件', exact: true }).isVisible()).toBe(false) + expect(await toolbar.locator('[data-forge-progress="compact"]').innerText()).toBe('0/2') + await more.click() + const menu = page.getByRole('menu') + expect(await menu.getByRole('menuitem').allTextContents()).toEqual(['全部折叠', '左右对比', '自动换行', '隐藏测试文件']) + const code = view.locator('[data-forge-diff-file="README.md"] [data-review]') + expect(await code.getAttribute('data-split')).toBe('') + await menu.getByRole('menuitem', { name: '左右对比', exact: true }).click() + await expect.poll(() => code.getAttribute('data-split')).toBe(null) + expect(await page.getByRole('menu').count()).toBe(0) + await page.setViewportSize({ width: 1680, height: 800 }) + await expect.poll(() => toolbar.getAttribute('data-fold')).toBe('0') + expect(await more.isVisible()).toBe(false) + expect(await toolbar.getByRole('button', { name: '左右对比', exact: true }).getAttribute('aria-pressed')).toBe('false') + expect(tripwire.pageErrors).toEqual([]) +}) + +it.skipIf(MODE === 'record')('writes a left and a right comment at once, each inside its own column of the side-by-side row', async () => { + const { page, view, tripwire } = await openLocalChanges('web-e2e-forge-side-comments') + const readme = view.locator('[data-forge-diff-file="README.md"]') + const open = async (name: string) => { + await readme.getByRole('button', { name: `选择第 ${name} 行`, exact: true }).click() + await readme.getByRole('button', { name: '添加评论', exact: true }).click() + return readme.getByRole('region', { name: `评论 ${name}`, exact: true }) + } + const left = await open('L1') + const right = await open('R1') + await right.getByRole('textbox').fill('右侧的评论') + for (const width of [1680, 1000, 780]) { + await page.setViewportSize({ width, height: 900 }) + const boxes = await readme.evaluate((section) => { + const pair = section.querySelector('[data-diff-pair]')! + const [l, r] = Array.from(pair.children, cell => cell.getBoundingClientRect()) + const [a, b] = Array.from(section.querySelectorAll('[data-forge-comment]'), form => form.getBoundingClientRect()) + return { l: [l!.left, l!.right], r: [r!.left, r!.right], a: [a!.left, a!.right, a!.top], b: [b!.left, b!.right, b!.top] } + }) + // Each comment stays inside its side's column, and both share one row. + expect(boxes.a[0]!).toBeGreaterThan(boxes.l[0]!) + expect(boxes.a[1]!).toBeLessThan(boxes.l[1]!) + expect(boxes.b[0]!).toBeGreaterThan(boxes.r[0]!) + expect(boxes.b[1]!).toBeLessThan(boxes.r[1]!) + expect(boxes.a[2]).toBe(boxes.b[2]) + } + await left.getByRole('button', { name: '取消', exact: true }).click() + expect(await left.count()).toBe(0) + expect(await right.getByRole('textbox').inputValue()).toBe('右侧的评论') + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 3affdf7c58..4c012a0972 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 78e5f4f2b3faeb7a - zh: 6d0e381fc385d6c7 + en: dfa8a37cfe5da8ce + zh: 5583699567f6c053 /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 0feae5f8c5ef559f zh: 5334adff35e3bebd diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 8c24bf2687..e7348b8173 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -38,7 +38,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **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 its head branch end filled in the color of 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. Cancelled checks have a separate count; a collapsed card names cancellations when no listed check failed. 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, whose count ends in `+` when the provider counted only some 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** — whenever the `branch` scope changes files, independently of bound change requests, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, which is always the default base below, 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 when the code host repository is recognized and no open change request (including drafts) has the checked-out branch as its head, 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. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. 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. Each file is a separate card, with a tinted header that stays at the top while its lines scroll. 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. When the header is narrow, the path, the badges, and Changed since viewed shorten, so the line counts, Viewed, and the menu stay whole. 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. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. 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 whose output exceeds the provider's `maxOutputBytes`, 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. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. 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. The toolbar and the header each keep one line: as the toolbar narrows, the line totals, expanding all files, two columns, and wrapping move into a More menu first, then the test file switch, with the viewed progress shown as a ring and a count, and then refresh, while header buttons keep their labels on one line and the menus and title shorten. 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. Each file is a separate card, with a tinted header that stays at the top while its lines scroll. 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. When the header is narrow, the path, the badges, and Changed since viewed shorten, so the line counts, Viewed, and the menu stay whole. 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. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. 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 whose output exceeds the provider's `maxOutputBytes`, 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. - **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. - **Review threads** — A change request view overlays the review threads its code host read at the shown head. A thread on lines follows the row of its last line, on the side it was written on, or the collapsed unchanged lines that hold it; it shows the place it was written, each comment's avatar, author, time, and Markdown with raw HTML left out, and tags for the change request's author, bots, and comments still pending in the signed-in user's review. A resolved thread folds into one row that unfolds on request. Threads on a whole file open the file, and threads later commits made outdated gather after its lines in a section that shows the end of the hunk they were written on and offers View file at that commit, which reads the file at the commit they were written on with their lines marked. Chosen commits show only new-side threads whose lines number the last chosen commit. The file tree and each file header count unresolved threads. Each click on the count in a header goes to the file's next unresolved thread in the order the file shows them, threads the chosen commits do not hold last, and from the last back to the first; the Threads toolbar button opens the thread list, which groups the threads by file in file tree order and orders each file's threads the same way, shows the Unresolved, Resolved, or All threads, Unresolved first, and opens each thread at its place, choosing all commits for one the chosen commits do not hold. The list docks beside the files in a view at least 1,040px wide, floats over them in a narrower one and closes before it opens a thread there, and takes their whole width below 480px. The list names a truncated read. A thread offers Add to input, which quotes the whole thread, View on GitHub, and the writes the signed-in user may make. Without a pending review, its reply field replies at once, and its menu starts a pending review with the reply; with one, the field adds the reply to it, as GitHub adds every reply of the user to the pending review. Resolve thread and Unresolve thread change its state, and the menu of the user's own comment edits or deletes it after asking, saying when deleting the only comment deletes the thread. A resolved thread stays open while the user writes a reply or an edit in it. A write the code host refuses keeps the text and says why; a write whose outcome is unknown reads the threads again and asks the user to check them before trying again. Every write reads the threads again, since resolving a thread does not change a GitHub pull request's update time. Threads are read again at each status while the view shows, and from the code host after Refresh even while the panel keeps an earlier read; a failed read keeps the last threads and offers Retry, and a provider that does not read reviews shows none. @@ -49,7 +49,7 @@ The diff follows the file tree’s depth-first order: directories before files, `gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, `maxCachedDiffBytes`, `maxCachedReviewBytes`, `reviewRefreshMs`, `maxCommits`, `testPaths`, and `interfacePaths` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). -All files opens a searchable directory tree with the selected file scrolled to its middle. 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 cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or 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. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the 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 that shows the file name, side and lines, and comment, and on hover the file path; multiple quotes are sent together only when the user sends that composer. Once the review threads are read and the user may review, the GitHub split button starts the user's pending review on GitHub with the comment, or adds the comment to it; the comment then shows as a thread tagged Pending, which only the user sees until the review is submitted. Its menu publishes a single comment immediately, which GitHub refuses while the user has a pending review, so the menu is then absent. Agent references and published comments remain beside their code. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. With a pending review, it submits that review, keeping a submission whose outcome is unknown blocked until the user checks GitHub, as a direct submission is, and Discard review deletes the review and its comments after a confirmation; without one, it publishes the summary as one review. On the user's own pull request, which GitHub lets its author only comment on, Approve and Request changes are unavailable. Added and deleted files show only their populated line-number column. The summary, decision, and records of Agent references and published comments 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 them, and reopening starts an empty summary. The pending review, published GitHub comments, and references already added to the main composer are unaffected. Comment drafts an earlier version kept in this browser stay beside their code tagged Browser draft and can be edited, deleted, or added to the pending review; they keep Submit review disabled while a pending review exists, and without one Submit review publishes them with the summary. 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 with the selected file scrolled to its middle. 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 cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or 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. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. Several editors can stay open at once, and choosing the range of an open one focuses it again. In two columns, each comment sits under its own side of the row, so a left and a right comment share that row side by side, each continuing its line’s background. 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 that shows the file name, side and lines, and comment, and on hover the file path; multiple quotes are sent together only when the user sends that composer. Once the review threads are read and the user may review, the GitHub split button starts the user's pending review on GitHub with the comment, or adds the comment to it; the comment then shows as a thread tagged Pending, which only the user sees until the review is submitted. Its menu publishes a single comment immediately, which GitHub refuses while the user has a pending review, so the menu is then absent. Agent references and published comments remain beside their code. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. With a pending review, it submits that review, keeping a submission whose outcome is unknown blocked until the user checks GitHub, as a direct submission is, and Discard review deletes the review and its comments after a confirmation; without one, it publishes the summary as one review. On the user's own pull request, which GitHub lets its author only comment on, Approve and Request changes are unavailable. Added and deleted files show only their populated line-number column. The summary, decision, and records of Agent references and published comments 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 them, and reopening starts an empty summary. The pending review, published GitHub comments, and references already added to the main composer are unaffected. Comment drafts an earlier version kept in this browser stay beside their code tagged Browser draft and can be edited, deleted, or added to the pending review; they keep Submit review disabled while a pending review exists, and without one Submit review publishes them with the summary. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. ----- diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 6960bca568..46df4b943a 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -38,7 +38,7 @@ kind: "package-reference" - **会话列表**——Session 在面板为它创建的 worktree 中工作时,会话行的时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求的状态,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,head 分支端点填充为检查进行中、已通过或失败对应的颜色;已合并与已关闭的保留各自的状态颜色。会话行的悬浮卡片写出 worktree 分支及其起点,再列出最多三个变更请求的编号、标题、状态与检查,并说明还有多少个。重启后,下次轮询之前显示重启前已知的状态。 - **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。已取消的检查单独计数;列出的检查没有失败项时,折叠卡片显示取消数量。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数(提供方只统计了部分讨论时数字后带 `+`)、合并状态,已合并或已关闭的显示结束时间;最后是按所选界面语言格式化的变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。 - **本地变更卡片**——`branch` 范围有文件变更时始终显示,不受已关联变更请求影响。变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`,始终是下文的默认基准)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库、且没有以当前检出分支为源分支的打开中变更请求(含草稿)时,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 -- **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。每个文件是一张独立的卡片,带浅色底的标题在滚动时停在顶部。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。标题变窄时,路径、标记和“有新改动”会缩短,行数、“已审阅”和文件菜单始终完整显示。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 +- **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。工具栏和标题栏各自保持一行:工具栏变窄时,先把变更行数、全部展开、左右对比与自动换行收进“更多视图选项”菜单,再收起“隐藏测试文件”开关,已审阅进度改为圆环加计数,最后收起刷新;标题栏的按钮文字不换行,菜单和标题先缩短。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。每个文件是一张独立的卡片,带浅色底的标题在滚动时停在顶部。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。标题变窄时,路径、标记和“有新改动”会缩短,行数、“已审阅”和文件菜单始终完整显示。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 - **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 - **评审讨论**——变更请求视图会叠加代码托管平台在所显示 head 处读到的评审讨论。行级讨论显示在它最后一行所在的行之后,位于写下时所在的那一侧;若该行折叠在未变更的行中,则显示在折叠行之后。讨论标明写下时的位置,每条评论显示头像、作者、时间和 Markdown 正文(不显示原始 HTML),并用标签标出变更请求作者、机器人账号,以及当前登录用户尚未提交的评审中的评论。已解决的讨论折叠成一行,可以展开。针对整个文件的讨论显示在文件开头;被后续提交改到而过期的讨论集中在文件的行之后,显示写下时那段 hunk 的末尾,并提供“查看当时的文件”,读取写下讨论时那个提交上的文件并标出相应的行。选择了部分提交时,只显示行号对应最后一个所选提交的新版本侧讨论。文件树和每个文件标题都显示未解决讨论数。每点击一次文件标题上的计数,就按文件中的显示顺序跳到该文件的下一条未解决讨论(所选提交中没有的讨论排在最后),到最后一条后回到第一条;工具栏的“讨论”按钮打开讨论列表。列表按文件树顺序分组,组内讨论的顺序与文件中相同,可显示未解决、已解决或全部讨论,默认显示未解决的讨论;点击讨论会跳到它的位置,若所选提交中没有该讨论,会先切换到全部提交。视图宽度不小于 1,040px 时列表停靠在文件旁;更窄时浮在文件之上,并在跳到讨论前先关闭;窄于 480px 时占满文件区的宽度。读取被截断时,列表会注明。讨论上提供“添加到输入框”(把整个讨论作为引用插入)、“在 GitHub 中查看”,以及当前登录用户可以执行的写操作。没有待提交评审时,回复框立即回复,其下拉菜单可以带着这条回复开始待提交评审;有待提交评审时,回复框把回复加入其中,因为 GitHub 会把用户的每条回复都加入待提交评审。“解决讨论”和“取消解决”切换讨论状态;用户自己评论的菜单可以编辑或删除该评论,删除前会先确认,删除讨论中唯一的评论会一并删除讨论,确认时会说明这一点。用户正在讨论中写回复或编辑评论时,即使讨论已被解决,也保持展开。代码托管平台拒绝写操作时保留输入的文字并说明原因;结果不明时会重新读取讨论,并请用户核对后再重试。每次写操作后都会重新读取讨论,因为解决讨论不会改变 GitHub pull request 的更新时间。视图显示期间,每收到一次状态就重新读取讨论;点击刷新后,即使面板还保留着之前的读取结果,也会从代码托管平台重新读取;读取失败时保留上次的讨论并提供重试,提供方不支持读取讨论时不显示讨论。 @@ -49,7 +49,7 @@ diff 按文件树的深度优先顺序排列:同层目录在前、文件在后 `gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles`、`maxCachedDiffBytes`、`maxCachedReviewBytes`、`reviewRefreshMs`、`maxCommits`、`testPaths` 与 `interfacePaths` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 -“全部文件”打开可搜索的目录树,并把选中文件滚动到中间;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,引用显示文件名、侧与行号以及评论,悬停时显示文件路径;多条引用只会在用户发送主输入框时一起发送。读到评审讨论且用户可以评审时,GitHub 分段按钮会用这条评论在 GitHub 上开始用户的待提交评审,或把评论加入其中;评论随后显示为带“待提交”标签的讨论,提交评审之前只有用户自己能看到。下拉菜单可以立即发布单条评论;用户有待提交评审时 GitHub 会拒绝单条评论,因此这时没有该菜单。Agent 引用和已发布的评论保留在对应代码旁。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。有待提交评审时提交该评审,结果不明的提交与直接提交一样,在用户到 GitHub 核对之前阻止再次提交;“丢弃评审”在确认后删除该评审及其中的评论;没有待提交评审时,把总结作为一次评审发布。在用户自己的 PR 上,GitHub 只允许作者评论,因此“批准”和“请求更改”不可用。新建和删除文件只显示有内容的一侧行号。总结、评审决定,以及 Agent 引用与已发布评论的记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空它们,重新打开从空总结开始。待提交评审、已发布到 GitHub 的评论与已加入主输入框的引用不受影响。早期版本保存在此浏览器中的评论草稿保留在对应代码旁,带“浏览器草稿”标签,可以编辑、删除或加入待提交评审;存在待提交评审时,它们会让“提交评审”保持不可用;没有待提交评审时,“提交评审”把它们连同总结一起发布。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 +“全部文件”打开可搜索的目录树,并把选中文件滚动到中间;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。可以同时打开多个评论框,再次选中已打开评论框的范围会回到该评论框。两栏时每条评论位于该行自己一侧的下方,左右两侧的评论并排处在同一行,各自延续所在行的底色。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,引用显示文件名、侧与行号以及评论,悬停时显示文件路径;多条引用只会在用户发送主输入框时一起发送。读到评审讨论且用户可以评审时,GitHub 分段按钮会用这条评论在 GitHub 上开始用户的待提交评审,或把评论加入其中;评论随后显示为带“待提交”标签的讨论,提交评审之前只有用户自己能看到。下拉菜单可以立即发布单条评论;用户有待提交评审时 GitHub 会拒绝单条评论,因此这时没有该菜单。Agent 引用和已发布的评论保留在对应代码旁。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。有待提交评审时提交该评审,结果不明的提交与直接提交一样,在用户到 GitHub 核对之前阻止再次提交;“丢弃评审”在确认后删除该评审及其中的评论;没有待提交评审时,把总结作为一次评审发布。在用户自己的 PR 上,GitHub 只允许作者评论,因此“批准”和“请求更改”不可用。新建和删除文件只显示有内容的一侧行号。总结、评审决定,以及 Agent 引用与已发布评论的记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空它们,重新打开从空总结开始。待提交评审、已发布到 GitHub 的评论与已加入主输入框的引用不受影响。早期版本保存在此浏览器中的评论草稿保留在对应代码旁,带“浏览器草稿”标签,可以编辑、删除或加入待提交评审;存在待提交评审时,它们会让“提交评审”保持不可用;没有待提交评审时,“提交评审”把它们连同总结一起发布。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 ----- diff --git a/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css b/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css index 2006c2b546..db93e6decd 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css @@ -14,6 +14,11 @@ .menu { width: min(360px, calc(100vw - 24px)); } .trigger { display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 8px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-primary); font: inherit; white-space: nowrap; cursor: pointer; } .trigger:disabled { color: var(--dsw-alias-label-tertiary); cursor: default; } +/* The label ends in an ellipsis once its toolbar lets the entry shrink. */ +.anchor { min-width: 0; max-width: 100%; } +.trigger { min-width: 0; max-width: 100%; } +.triggerLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.triggerChevron { flex: none; } @media (hover: hover) { .row:hover, .trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } } @media (pointer: coarse) { .row { min-height: 44px; } .search input { font-size: 16px; } } diff --git a/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx b/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx index eb24d62b51..73edf2f7aa 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx @@ -133,9 +133,12 @@ export function ChangedFiles(props: ChangedFilesProps): ReactNode { */ export function ChangedFilesMenu({ visible, ...props }: ChangedFilesProps & { readonly visible: boolean }): ReactNode { const [open, setOpen] = useState(false) - return { setOpen(false) }} + return { setOpen(false) }} anchor={}> + onClick={() => { setOpen(value => !value) }}> + {props.t('diff.allFiles', { count: props.files.length })} + + }> { setOpen(false); props.jump(path) }} /> } diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index 9183d560e4..8469bce141 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -125,10 +125,10 @@ border-inline-end: 0; } +/* One line of controls, never wrapped; the fold rules after `.tool` move controls into the More menu as it narrows. */ .toolbarMain { display: flex; flex: 1; - flex-wrap: wrap; align-items: center; gap: 0 4px; box-sizing: border-box; @@ -155,30 +155,12 @@ flex: 1; } -.progressSlot { +.progressSlot, +.foldTests, +.filesSlot { display: inline-flex; -} - -/* Breaks the toolbar's review controls onto a second row where one row is too narrow for them. */ -.rowBreak { - display: none; -} - -@container forge-changes (max-width: 640px) { - .reviewRow { - order: 2; - } - - .rowBreak { - display: block; - flex-basis: 100%; - order: 1; - height: 4px; - } - - .progressSlot { - margin-left: auto; - } + flex: none; + min-width: 0; } .select { @@ -293,6 +275,36 @@ transform: rotate(90deg); } +/* `observeToolbarFold` sets `data-fold` on the toolbar row to the shallowest level whose items fit on one line: level 1 + folds the line totals, expansion, side by side, and wrapping into the More menu; level 2 the test switch, with the + progress shown as a ring; level 3 refresh. */ +.more { + display: none; +} + +.toolbarMain:is([data-fold='1'], [data-fold='2'], [data-fold='3']) .foldViews, +.toolbarMain:is([data-fold='2'], [data-fold='3']) .foldTests, +.toolbarMain[data-fold='3'] .foldRefresh { + display: none; +} + +/* With every control folded, the file list entry shortens its label before the row overflows. */ +.toolbarMain[data-fold='3'] .filesSlot { + flex-shrink: 1; +} + +.toolbarMain:is([data-fold='1'], [data-fold='2'], [data-fold='3']) .more { + display: inline-flex; +} + +.toolbarMain:is([data-fold='2'], [data-fold='3']) .progressSlot [data-forge-progress='full'] { + display: none; +} + +.toolbarMain:is([data-fold='2'], [data-fold='3']) .progressSlot [data-forge-progress='compact'] { + display: inline-flex; +} + /* A menu row with a path that shortens and its counts. */ .menuRow { display: flex; @@ -679,6 +691,36 @@ background: var(--forge-diff-empty); } +/* Comments under a side-by-side row: one cell per column, each continuing its line's gutter and background, with the + comments aligned to the code text. Either side holds its own comments, so both sides can show one at once. */ +.notes { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + white-space: normal; +} + +.noteCell { + display: grid; + grid-template-columns: var(--forge-number-width, 3.5em) minmax(0, 1fr); + min-width: 0; +} + +.noteCell + .noteCell { + border-left: 0.5px solid var(--dsw-alias-border-l3); +} + +.noteStack { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 8px 12px 10px calc(1.4em + 6px); +} + +.noteStack:empty { + padding: 0; +} + /* Collapsed unchanged lines; the row expands them. */ /* Collapsed unchanged lines, a collapsed large file, and a file whose lines cannot be drawn share one strip; only the first two open on a click. */ @@ -860,7 +902,8 @@ .code[data-split][data-native-side="L"] [data-diff-side="R"] *, .code[data-split][data-native-side="R"] [data-diff-side="L"], .code[data-split][data-native-side="R"] [data-diff-side="L"] * { user-select: none; } -.reviewSubmit { flex: none; white-space: nowrap; } +/* A header button keeps its label on one line; the menus and the title before it shorten instead. */ +.headerAction { flex: none; white-space: nowrap; } .code[data-single-side] .line { grid-template-columns: var(--forge-number-width) 1.4em minmax(0, 1fr); } .code[data-single-side]:not([data-wrap]) .line { grid-template-columns: var(--forge-number-width) 1.4em max-content; } diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index babc227996..2d7f052749 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -55,6 +55,8 @@ import { drawnLines, matchesHunks, textLines } from './diff-rows.ts' import { ChangeRequestGlyph } from './glyphs.tsx' import type { ForgePanelKey, NS } from './locales.ts' import { RefreshGlyph } from './RefreshGlyph.tsx' +import { ViewOptionsMenu } from './ViewOptionsMenu.tsx' +import { observeToolbarFold } from './toolbar-fold.ts' import type { FileThreads, ThreadActions, ThreadFocus, ThreadWrite } from './ReviewThreads.tsx' import { inFileOrder, type ThreadFilter } from './review-threads.ts' import { usePlacedThreads, useThreadRead, type PlacedThread } from './thread-state.ts' @@ -330,7 +332,7 @@ function ChangeHeader({ url, entry, openLink, commitMenu, reviewCount, reviewDis {number !== null && {t('change.number', { number })}} {commitMenu} - @@ -381,7 +383,7 @@ function LocalHeader({ state, scope, base, missing, visible, diffBases, choose, } {(scope === 'branch' || creatable) && {scope === 'branch' && commitMenu} - {creatable && } + {creatable && } } ) @@ -444,7 +446,7 @@ function OrphanReviews({ path, actions, t }: PropsLocale & { return

{path}

{state.threads.map(thread => )} + actions={actions} locked={state.locked} outdated t={t} />)}
} @@ -673,6 +675,13 @@ export function ChangesTab({ const isExpanded = (file: ForgeDiffFile): boolean => chosen[file.path] ?? expandedByDefault(file) const sections = useRef(new Map()) const scrollport = useRef(null) + const toolbarRow = useRef(null) + useLayoutEffect(() => { + const row = toolbarRow.current + /* v8 ignore next -- the toolbar row renders in every state of the view */ + if (row === null) return + return observeToolbarFold(row) + }, []) const reach = useMemo(() => new SectionReach(), []) const attachScrollport = useCallback((element: HTMLDivElement | null) => { scrollport.current = element @@ -1094,6 +1103,10 @@ export function ChangesTab({ // The toolbar collapses every file while any is expanded, as the files on screen show, and expands every file otherwise. const anyExpanded = files.some(isExpanded) const expansionLabel = t(anyExpanded ? 'diff.collapseAll' : 'diff.expandAll') + const toggleExpansion = (): void => { setChosen(Object.fromEntries(files.map(file => [file.path, !anyExpanded]))) } + const toggleSplit = (): void => { actions.toggledSplit(tab.id) } + const toggleWrap = (): void => { actions.toggledWrap(tab.id) } + const toggleTests = (): void => { actions.toggledTests(tab.id) } const treeAction = t(treePosition === 'left' ? 'diff.moveFileTreeRight' : treePosition === 'right' ? 'diff.hideFileTree' : 'diff.showFileTreeLeft') let additions = 0 let deletions = 0 @@ -1129,16 +1142,16 @@ export function ChangesTab({
{/* Above the file tree, the lens spans the tree's width; elsewhere a menu in the toolbar takes its place. */} {treeShown &&
} -
+
{tab.visible && source.kind === 'local' && { actions.scoped(tab.id, next) }} t={t} />} - {tab.visible && } + {tab.visible && } {tab.visible && allFiles.length > 0 && ( - + )} - {allFiles.length > 0 && -
{data !== null && failed !== null && ( diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index e07bb2f722..05ea2fd3cc 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -161,6 +161,21 @@ function Code({ text, spans, old, next }: { } /** Whether a side-by-side row repeats an unchanged line or shows a change. */ +/** Whether two comments cover the same lines of the same side. */ +function sameRange(a: ReviewSelection['comment'], b: ReviewSelection['comment']): boolean { + return a.side === b.side && a.startLine === b.startLine && a.endLine === b.endLine +} + +/** Whether a selection covers one line of a side. */ +function covers(item: ReviewSelection, side: 'L' | 'R', line: number): boolean { + return item.comment.side === side && line >= item.comment.startLine && line <= item.comment.endLine +} + +/** The editor key of a range, such as `R3-4`. */ +function rangeKey(item: ReviewSelection): string { + return `${item.comment.side}${item.comment.startLine}-${item.comment.endLine}` +} + function pairKind(pair: SplitRow): 'context' | 'change' { return pair.left !== null && pair.left.kind === 'context' ? 'context' : 'change' } @@ -176,33 +191,53 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme const style: CSSProperties & { '--forge-number-width': string } = { '--forge-number-width': `calc(${Math.max(2, String(maximum).length)}ch + 12px)` } const highlighter = useCodeHighlighter(languageForPath(file.path)) const [selection, setSelection] = useState(null) + // Mirrors `selection` for the release of a hover-plus drag, whose listener outlives the render that chose the range. + const chosen = useRef(null) + const select = (next: ReviewSelection | null): void => { + chosen.current = next + setSelection(next) + } + // Comments being written, in the order they opened; each keeps the range it opened with while new ranges are chosen. + const [editors, setEditors] = useState([]) const root = useRef(null) const origin = useRef<{ side: 'L' | 'R'; line: number } | null>(null) // `add` is a gutter drag that began on a row's hover plus and opens the editor when the pointer is released. const dragging = useRef<'gutter' | 'add' | 'text' | null>(null) const [selecting, setSelecting] = useState(false) - const [editing, setEditing] = useState(false) const [nativeSide, setNativeSide] = useState<'L' | 'R' | null>(null) const nativeSelection = useCallback((): void => { - if (editing || nativeSide === null || root.current === null || review === undefined) return + if (nativeSide === null || root.current === null || review === undefined) return const selected = readCodeSelection(root.current, nativeSide) if (selected === undefined) { + chosen.current = null setSelection(null) setNativeSide(null) return } const comment = selectReviewLines(file, selected.side, selected.from, selected.to) - setSelection(previous => comment === undefined ? null - : previous?.comment.side === comment.side && previous.comment.startLine === comment.startLine - && previous.comment.endLine === comment.endLine - ? previous : { source: review.source, comment }) - }, [editing, nativeSide, review, file]) + const previous = chosen.current + const next = comment === undefined ? null + : previous !== null && sameRange(previous.comment, comment) ? previous : { source: review.source, comment } + chosen.current = next + setSelection(next) + }, [nativeSide, review, file]) useEffect(() => { - if (nativeSide === null || editing) return + if (nativeSide === null) return const changed = (): void => { if (dragging.current === null) nativeSelection() } document.addEventListener('selectionchange', changed) return () => { document.removeEventListener('selectionchange', changed) } - }, [nativeSide, editing, nativeSelection]) + }, [nativeSide, nativeSelection]) + /** Open an editor for a range, or focus the one already open for it, and clear the selection for the next range. */ + const compose = (target: ReviewSelection): void => { + const open = root.current?.querySelector(`[data-forge-comment="${rangeKey(target)}"] textarea`) ?? null + if (open !== null) open.focus() + else setEditors(list => [...list, target]) + const native = window.getSelection() + if (root.current?.contains(native?.anchorNode ?? null) === true) native?.removeAllRanges() + select(null) + setNativeSide(null) + origin.current = null + } useEffect(() => { if (nativeSide === null) return const copy = (event: ClipboardEvent): void => { @@ -220,10 +255,10 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme if (!selecting) return const finish = (event: PointerEvent): void => { if (dragging.current === 'text') nativeSelection() - const compose = dragging.current === 'add' && event.type === 'pointerup' + const release = dragging.current === 'add' && event.type === 'pointerup' ? chosen.current : null dragging.current = null setSelecting(false) - if (compose) setEditing(true) + if (release !== null) compose(release) } window.addEventListener('pointerup', finish) window.addEventListener('pointercancel', finish) @@ -237,12 +272,13 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme : { ...segment, old: sideSpans(segment.rows, 'old', highlighter), new: sideSpans(segment.rows, 'new', highlighter) }), [file, lines, open, highlighter]) // Returns whether the range was accepted; a range across hunks or outside them keeps the previous selection. const choose = (side: 'L' | 'R', line: number, extend: boolean): boolean => { - if (review === undefined || editing) return false + /* v8 ignore next -- the line buttons and hover pluses that choose a range draw only with review actions */ + if (review === undefined) return false const from = extend && origin.current?.side === side ? origin.current.line : line const comment = selectReviewLines(file, side, from, line) if (comment === undefined) return false if (!extend || origin.current?.side !== side) origin.current = { side, line } - setSelection({ source: review.source, comment }) + select({ source: review.source, comment }) return true } // A drag that began on a hover plus extends through every row with a line on its starting side, wherever the pointer crosses that row. @@ -252,8 +288,10 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme const line = from.side === 'L' ? old : next if (line !== null) choose(from.side, line, true) } - const selected = (side: 'L' | 'R', line: number | null): boolean => line !== null && selection !== null - && selection.comment.side === side && line >= selection.comment.startLine && line <= selection.comment.endLine + const selected = (side: 'L' | 'R', line: number | null): boolean => line !== null && selection !== null && covers(selection, side, line) + // The browser paints a native text range itself; a gutter range and every open editor's range take the highlight. + const marked = (side: 'L' | 'R', line: number | null): boolean => line !== null + && ((selected(side, line) && nativeSide === null) || editors.some(item => covers(item, side, line))) // `hover` adds the mouse shortcut: a plus shown while a hovering pointer is over this side of the row. const number = (side: 'L' | 'R', line: number | null, hover: boolean): ReactNode => { if (line === null || review === undefined || !file.hunks.some((hunk) => { @@ -262,13 +300,14 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme })) { return {line ?? ''} } - const plus = !selecting && !editing && selection?.comment.side === side && selection.comment.endLine === line + const plus = !selecting && selection !== null && selection.comment.side === side && selection.comment.endLine === line + ? selection : null return { if (dragging.current === 'gutter' && origin.current?.side === side && event.buttons === 1) choose(side, line, true) }}> - {plus && ( + {plus !== null && ( )} {/* Keyboard and assistive technology reach the same editor through the line button and the labeled plus above. */} - {hover && !plus && !editing && ( + {hover && plus === null && (
) - if (!inserted && endsAt(pair.left?.no ?? null, pair.right?.no ?? null)) { - rows.push(editor) - inserted = true - } - appendComments(pair.left?.no ?? null, pair.right?.no ?? null) - appendThreads(pair.left?.no ?? null, pair.right?.no ?? null) + notes(`notes-${at}-${index}`, pair) } } else { for (const [index, row] of segment.rows.entries()) { // Both numbers place their plus in the sign column, so a row showing the labeled plus offers no hover plus. - const labeled = !selecting && !editing && endsAt(row.old, row.new) - rows.push(
{ extendAdd(event.buttons, row.old, row.new) }}> {singleSide !== 'R' && number('L', row.old, row.kind === 'del' && !labeled)}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del' && !labeled)}{SIGN[row.kind]}
) - if (!inserted && endsAt(row.old, row.new)) { - rows.push(editor) - inserted = true - } - appendComments(row.old, row.new) - appendThreads(row.old, row.new) + rows.push(...commentsAt('L', row.old), ...commentsAt('R', row.new)) } } } - if (review !== undefined) for (const thread of remaining) { - rows.push() + if (review !== undefined) { + // An editor whose last line no longer draws, after a refresh or a narrower choice of commits, follows the lines. + for (const item of unplacedEditors) rows.push(editorOf(item, review)) + for (const thread of remaining) { + rows.push() + } } - for (const item of unplaced) rows.push(threadCard(item, false)) - if (!inserted) rows.push(editor) + for (const item of unplaced) rows.push(threadCard(item)) return
{ setNativeSide(side) - if (editing) return - setSelection(null) + select(null) setSelecting(true) }) }}>{rows}
@@ -425,7 +471,7 @@ function Content(props: DiffFileProps): ReactNode { const { file, large, revealed, handlers, review, threads, t } = props const comments = useFileReview(review, file.path) const records = review === undefined ? null : comments.threads.map(thread => ) + actions={review} locked={comments.locked} outdated={!reviewMatches(thread.selection, review.source, file)} t={t} />) const lineThreads = threads?.lines.map(item => ) if (file.binary) return <>

{t('diff.binary')}

{records}{lineThreads} diff --git a/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css b/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css index 5ce683c482..d79ac0b0e2 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css @@ -2,7 +2,8 @@ .root, .thread { box-sizing: border-box; width: calc(100% - 20px); max-width: 680px; margin: 10px; padding: 12px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: var(--dsw-font-xs-13); font-family: Inter, var(--dsw-font-family); white-space: normal; } .root { background: var(--dsw-alias-bg-layer-2); border-radius: var(--dsw-radius-lg); } .root:focus-within { border-color: var(--dsw-alias-border-l4); } -.root[data-split][data-side="R"], .thread[data-split][data-side="R"] { margin-inline-start: clamp(10px, calc(100% - 700px), calc(50% + 10px)); } +/* Side by side, a comment fills its side's cell under the code instead of the row's width. */ +[data-forge-comments] > .root, [data-forge-comments] > .thread { width: auto; max-width: none; margin: 0; } .title { margin-bottom: 12px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; } /* Fixed-size textarea layout stays local during typing; native resizing can still change its outer height. */ .input { display: block; box-sizing: border-box; width: 100%; height: 88px; min-height: 88px; contain: strict; padding: 10px 12px; resize: vertical; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md); outline: none; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); font: inherit; } diff --git a/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx b/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx index 82e482c35f..694dc51bea 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx @@ -62,9 +62,8 @@ export function SplitAction({ disabled, label, run, menuLabel, menu, other }: { * @param props - frozen selection, actions, cancellation, and localized copy. * @returns the inline comment form. */ -export function ReviewComposer({ selection, actions, close, visible = true, split = false, t }: PropsLocale & { +export function ReviewComposer({ selection, actions, close, visible = true, t }: PropsLocale & { readonly visible?: boolean - readonly split?: boolean readonly selection: ReviewSelection readonly actions: ReviewActions readonly close: () => void @@ -97,7 +96,8 @@ export function ReviewComposer({ selection, actions, close, visible = true, spli } } return ( -
+
{t('review.range', { range })}