Merge pull request #8179 from deepseek-harness/fix/forge-narrow-toolbar-comments
fix(forge): fold the changes toolbar and keep comments in their own column
This commit is contained in:
commit
3e299a491d
27 files changed
+872
-171
No files matched your search
@@ -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<PanelLaunch & { readonly view: Locator }> {
|
||||
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([])
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 Line truncated
|
||||
|
||||
- **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.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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 并明确允许再次提交。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
|
||||
@@ -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 <Menu open={visible && open} portal dense align="start" listClassName={css.menu} onClose={() => { setOpen(false) }}
|
||||
return <Menu open={visible && open} portal dense align="start" className={css.anchor} listClassName={css.menu} onClose={() => { setOpen(false) }}
|
||||
anchor={<button type="button" className={css.trigger} aria-haspopup="menu" aria-expanded={visible && open} disabled={props.files.length === 0}
|
||||
onClick={() => { setOpen(value => !value) }}>{props.t('diff.allFiles', { count: props.files.length })}<IconChevronDownOutlineRegular size={12} /></button>}>
|
||||
onClick={() => { setOpen(value => !value) }}>
|
||||
<span className={css.triggerLabel}>{props.t('diff.allFiles', { count: props.files.length })}</span>
|
||||
<IconChevronDownOutlineRegular size={12} className={css.triggerChevron} />
|
||||
</button>}>
|
||||
<ChangedFiles {...props} menu jump={(path) => { setOpen(false); props.jump(path) }} />
|
||||
</Menu>
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 && <span className={css.headerNumber}>{t('change.number', { number })}</span>}
|
||||
<span className={css.headerEnd}>
|
||||
{commitMenu}
|
||||
<Button size="sm" variant="outline" className={css.reviewSubmit} disabled={reviewDisabled} onClick={onReview}>
|
||||
<Button size="sm" variant="outline" className={css.headerAction} disabled={reviewDisabled} onClick={onReview}>
|
||||
{t('review.submit')}{reviewCount > 0 ? ` (${reviewCount})` : ''}
|
||||
</Button>
|
||||
</span>
|
||||
@@ -381,7 +383,7 @@ function LocalHeader({ state, scope, base, missing, visible, diffBases, choose,
|
||||
</>}
|
||||
{(scope === 'branch' || creatable) && <span className={css.headerEnd}>
|
||||
{scope === 'branch' && commitMenu}
|
||||
{creatable && <Button size="sm" variant="outline" onClick={createChangeRequest}>{t('changes.local.create')}</Button>}
|
||||
{creatable && <Button size="sm" variant="outline" className={css.headerAction} onClick={createChangeRequest}>{t('changes.local.create')}</Button>}
|
||||
</span>}
|
||||
</div>
|
||||
)
|
||||
@@ -444,7 +446,7 @@ function OrphanReviews({ path, actions, t }: PropsLocale<typeof NS> & {
|
||||
return <section className={css.reviewFile} data-forge-review-file={path}>
|
||||
<h3>{path}</h3>
|
||||
{state.threads.map(thread => <ReviewThread key={thread.key} thread={thread}
|
||||
actions={actions} locked={state.locked} outdated split={false} t={t} />)}
|
||||
actions={actions} locked={state.locked} outdated t={t} />)}
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -673,6 +675,13 @@ export function ChangesTab({
|
||||
const isExpanded = (file: ForgeDiffFile): boolean => chosen[file.path] ?? expandedByDefault(file)
|
||||
const sections = useRef(new Map<string, HTMLElement>())
|
||||
const scrollport = useRef<HTMLDivElement | null>(null)
|
||||
const toolbarRow = useRef<HTMLDivElement>(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({
|
||||
<div className={css.toolbar} data-tree={treeShown ? treePosition : 'hidden'} style={toolbarStyle}>
|
||||
{/* Above the file tree, the lens spans the tree's width; elsewhere a menu in the toolbar takes its place. */}
|
||||
{treeShown && <div className={css.lensCell}><LensControl {...lensProps} /></div>}
|
||||
<div className={css.toolbarMain}>
|
||||
<div ref={toolbarRow} className={css.toolbarMain}>
|
||||
{tab.visible && source.kind === 'local' && <ScopeMenu scope={scope} choose={(next) => { actions.scoped(tab.id, next) }} t={t} />}
|
||||
{tab.visible && <ChangedFilesMenu {...navigator} visible />}
|
||||
{tab.visible && <span className={css.filesSlot}><ChangedFilesMenu {...navigator} visible /></span>}
|
||||
{tab.visible && allFiles.length > 0 && (
|
||||
<span className={clsx(css.lensMenu, css.reviewRow)}><LensMenu {...lensProps} visible /></span>
|
||||
<span className={css.lensMenu}><LensMenu {...lensProps} visible /></span>
|
||||
)}
|
||||
{allFiles.length > 0 && <span className={css.reviewRow}>
|
||||
<HideTestsSwitch hidden={hideTests} toggle={() => { actions.toggledTests(tab.id) }} t={t} />
|
||||
{allFiles.length > 0 && <span className={css.foldTests}>
|
||||
<HideTestsSwitch hidden={hideTests} toggle={toggleTests} t={t} />
|
||||
</span>}
|
||||
<span className={css.spacer} />
|
||||
<span className={css.spacer} data-fold-spacer="" />
|
||||
{/* A truncated read keeps the list, whose footer says so, even when it kept no thread. */}
|
||||
{review !== null && (review.threads.length > 0 || review.truncated) && (
|
||||
<button ref={threadsToggle} type="button" className={css.threadsToggle} aria-pressed={threadList.open} data-forge-threads-toggle=""
|
||||
@@ -1147,30 +1160,31 @@ export function ChangesTab({
|
||||
{t('thread.list')}<span className={css.threadsTotal}>{review.threads.length}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className={clsx(css.progressSlot, css.reviewRow)}><ViewedProgress viewed={viewedCount} total={files.length} t={t} /></span>
|
||||
{files.length > 0 && <span className={css.totals}><Counts file={{ additions, deletions, binary: false }} t={t} /></span>}
|
||||
<span className={css.progressSlot}><ViewedProgress viewed={viewedCount} total={files.length} t={t} /></span>
|
||||
{files.length > 0 && (
|
||||
<span className={clsx(css.totals, css.foldViews)}><Counts file={{ additions, deletions, binary: false }} t={t} /></span>
|
||||
)}
|
||||
<span className={css.tools}>
|
||||
<Tooltip label={t('diff.refresh')} side="bottom">
|
||||
<button type="button" className={css.tool} aria-label={t('diff.refresh')} onClick={refresh}>
|
||||
<button type="button" className={clsx(css.tool, css.foldRefresh)} aria-label={t('diff.refresh')} onClick={refresh}>
|
||||
<RefreshGlyph busy={rereading} size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={expansionLabel} side="bottom">
|
||||
<button type="button" className={css.tool} data-expanded={anyExpanded ? '' : undefined} aria-label={expansionLabel}
|
||||
disabled={files.length === 0}
|
||||
onClick={() => { setChosen(Object.fromEntries(files.map(file => [file.path, !anyExpanded]))) }}>
|
||||
<button type="button" className={clsx(css.tool, css.foldViews)} data-expanded={anyExpanded ? '' : undefined} aria-label={expansionLabel}
|
||||
disabled={files.length === 0} onClick={toggleExpansion}>
|
||||
<IconChevronsUpDownOutlineRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t(split ? 'diff.unified' : 'diff.split')} side="bottom">
|
||||
<button type="button" className={css.tool} aria-pressed={split} aria-label={t('diff.splitAria')}
|
||||
onClick={() => { actions.toggledSplit(tab.id) }}>
|
||||
<button type="button" className={clsx(css.tool, css.foldViews)} aria-pressed={split} aria-label={t('diff.splitAria')}
|
||||
onClick={toggleSplit}>
|
||||
<IconCompareSplitOutlineRegular className={css.compareIcon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t(wrap ? 'diff.nowrap' : 'diff.wrap')} side="bottom">
|
||||
<button type="button" className={css.tool} aria-pressed={wrap} aria-label={t('diff.wrapAria')}
|
||||
onClick={() => { actions.toggledWrap(tab.id) }}>
|
||||
<button type="button" className={clsx(css.tool, css.foldViews)} aria-pressed={wrap} aria-label={t('diff.wrapAria')}
|
||||
onClick={toggleWrap}>
|
||||
{wrap ? <IconNowrapFillRegular /> : <IconWrapFillRegular />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -1183,8 +1197,11 @@ export function ChangesTab({
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<ViewOptionsMenu visible={tab.visible} options={{
|
||||
anyExpanded, hasFiles: files.length > 0, toggleExpansion, split, toggleSplit, wrap, toggleWrap,
|
||||
hideTests: allFiles.length > 0 ? hideTests : undefined, toggleTests, refresh,
|
||||
}} t={t} />
|
||||
</span>
|
||||
{allFiles.length > 0 && <span className={css.rowBreak} />}
|
||||
</div>
|
||||
</div>
|
||||
{data !== null && failed !== null && (
|
||||
|
||||
@@ -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<ReviewSelection | null>(null)
|
||||
// Mirrors `selection` for the release of a hover-plus drag, whose listener outlives the render that chose the range.
|
||||
const chosen = useRef<ReviewSelection | null>(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<readonly ReviewSelection[]>([])
|
||||
const root = useRef<HTMLDivElement>(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<HTMLTextAreaElement>(`[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 <span className={css.number}>{line ?? ''}</span>
|
||||
}
|
||||
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 <span className={clsx(css.number, reviewCss.gutter)} data-review-side={side}
|
||||
onPointerEnter={(event) => { if (dragging.current === 'gutter' && origin.current?.side === side && event.buttons === 1) choose(side, line, true) }}>
|
||||
<button type="button" className={reviewCss.line}
|
||||
aria-label={t('review.selectLine', { line: `${side}${line}` })} aria-pressed={selected(side, line)}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || editing) return
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
setNativeSide(null)
|
||||
@@ -282,16 +321,16 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme
|
||||
setNativeSide(null)
|
||||
choose(side, line, event.shiftKey)
|
||||
}}>{line}</button>
|
||||
{plus && (
|
||||
{plus !== null && (
|
||||
<Tooltip label={t('review.addComment')} portal>
|
||||
<button type="button" className={reviewCss.add} aria-label={t('review.addComment')}
|
||||
onPointerDown={(event) => { event.preventDefault() }} onClick={() => { setEditing(true) }}>
|
||||
onPointerDown={(event) => { event.preventDefault() }} onClick={() => { compose(plus) }}>
|
||||
<IconPlusOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Keyboard and assistive technology reach the same editor through the line button and the labeled plus above. */}
|
||||
{hover && !plus && !editing && (
|
||||
{hover && plus === null && (
|
||||
<span className={reviewCss.hoverAdd} aria-hidden="true" data-review-add={`${side}${line}`}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
@@ -310,44 +349,59 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme
|
||||
}
|
||||
const cell = (value: SplitCell | null, spans: SideSpans, side: 'L' | 'R'): ReactNode => value === null
|
||||
? <span className={clsx(css.cell, css.empty)} data-diff-side={side} />
|
||||
: <span className={clsx(css.cell, css[value.kind], selected(side, value.no) && (editing || nativeSide === null) && reviewCss.selected)}
|
||||
: <span className={clsx(css.cell, css[value.kind], marked(side, value.no) && reviewCss.selected)}
|
||||
data-diff-side={side}>
|
||||
{number(side, value.no, true)}<Code text={value.text} spans={spans?.get(value.no)} old={side === 'L' ? value.no : null} next={side === 'R' ? value.no : null} />
|
||||
</span>
|
||||
const rows: ReactNode[] = []
|
||||
let inserted = false
|
||||
const editor = review === undefined || selection === null || !editing ? null : <ReviewComposer key="comment" selection={selection} visible={visible} split={columns}
|
||||
actions={review} close={() => {
|
||||
const native = window.getSelection()
|
||||
if (root.current?.contains(native?.anchorNode ?? null) === true) native?.removeAllRanges()
|
||||
setSelection(null)
|
||||
setEditing(false)
|
||||
setNativeSide(null)
|
||||
origin.current = null
|
||||
}} t={t} />
|
||||
const remaining = new Set(comments.threads)
|
||||
const appendComments = (old: number | null, next: number | null): void => {
|
||||
if (review === undefined) return
|
||||
for (const thread of remaining) {
|
||||
const comment = thread.selection.comment
|
||||
if ((comment.side === 'L' ? old : next) !== comment.endLine || !reviewMatches(thread.selection, review.source, file)) continue
|
||||
rows.push(<ReviewThread key={`record-${thread.key}`} thread={thread} actions={review} locked={comments.locked} outdated={false} split={columns} t={t} />)
|
||||
remaining.delete(thread)
|
||||
}
|
||||
}
|
||||
// A host thread follows the row of its last line, or the collapsed unchanged lines that hold it.
|
||||
const unplaced = new Set(threads === undefined ? [] : threads.lines.map(item => ({ ...item, actions: threads.actions })))
|
||||
const threadCard = (item: LineThread & { readonly actions: ThreadActions }, columns: boolean): ReactNode =>
|
||||
<ThreadCard key={`thread-${item.thread.id}`} thread={item.thread} place={item.place} actions={item.actions} split={columns} t={t} />
|
||||
const appendThreads = (old: number | null, next: number | null): void => {
|
||||
for (const item of unplaced) {
|
||||
if ((item.place.side === 'L' ? old : next) !== item.place.endLine) continue
|
||||
rows.push(threadCard(item, columns))
|
||||
unplaced.delete(item)
|
||||
}
|
||||
}
|
||||
const endsAt = (old: number | null, next: number | null): boolean => selection !== null
|
||||
&& (selection.comment.side === 'L' ? old : next) === selection.comment.endLine
|
||||
const rows: ReactNode[] = []
|
||||
const unplacedEditors = new Set(editors)
|
||||
const remaining = new Set(comments.threads)
|
||||
// A host thread follows the row of its last line, or the collapsed unchanged lines that hold it.
|
||||
const unplaced = new Set(threads === undefined ? [] : threads.lines.map(item => ({ ...item, actions: threads.actions })))
|
||||
const threadCard = (item: LineThread & { readonly actions: ThreadActions }): ReactNode =>
|
||||
<ThreadCard key={`thread-${item.thread.id}`} thread={item.thread} place={item.place} actions={item.actions} t={t} />
|
||||
// The editors, the saved comments, then the host threads whose ranges end on this side's line.
|
||||
const commentsAt = (side: 'L' | 'R', line: number | null): ReactNode[] => {
|
||||
const nodes: ReactNode[] = []
|
||||
if (line === null) return nodes
|
||||
if (review !== undefined) {
|
||||
for (const item of unplacedEditors) {
|
||||
if (item.comment.side !== side || item.comment.endLine !== line) continue
|
||||
nodes.push(editorOf(item, review))
|
||||
unplacedEditors.delete(item)
|
||||
}
|
||||
for (const thread of remaining) {
|
||||
const comment = thread.selection.comment
|
||||
if (comment.side !== side || comment.endLine !== line || !reviewMatches(thread.selection, review.source, file)) continue
|
||||
nodes.push(<ReviewThread key={`record-${thread.key}`} thread={thread} actions={review} locked={comments.locked} outdated={false} t={t} />)
|
||||
remaining.delete(thread)
|
||||
}
|
||||
}
|
||||
for (const item of unplaced) {
|
||||
if (item.place.side !== side || item.place.endLine !== line) continue
|
||||
nodes.push(threadCard(item))
|
||||
unplaced.delete(item)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
const editorOf = (item: ReviewSelection, actions: ReviewActions): ReactNode =>
|
||||
<ReviewComposer key={`comment-${rangeKey(item)}`} selection={item} visible={visible} actions={actions}
|
||||
close={() => { setEditors(list => list.filter(open => !sameRange(open.comment, item.comment))) }} t={t} />
|
||||
// Side by side, a row's comments sit in a row of two cells under its columns, each continuing its line's background.
|
||||
const notes = (key: string, pair: SplitRow): void => {
|
||||
const left = commentsAt('L', pair.left?.no ?? null)
|
||||
const right = commentsAt('R', pair.right?.no ?? null)
|
||||
if (left.length === 0 && right.length === 0) return
|
||||
const side = (value: SplitCell | null, nodes: ReactNode[]): ReactNode => (
|
||||
<div className={clsx(css.noteCell, css[value?.kind ?? 'empty'])}>
|
||||
<span className={css.number} />
|
||||
<div className={css.noteStack} data-forge-comments="">{nodes}</div>
|
||||
</div>
|
||||
)
|
||||
rows.push(<div key={key} className={css.notes} data-forge-notes="">{side(pair.left, left)}{side(pair.right, right)}</div>)
|
||||
}
|
||||
for (const [at, segment] of prepared.entries()) {
|
||||
if (segment.kind === 'gap') {
|
||||
rows.push(<button key={`gap-${segment.index}`} type="button" className={css.gap} disabled={text === 'loading'}
|
||||
@@ -357,7 +411,7 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme
|
||||
</button>)
|
||||
for (const item of unplaced) {
|
||||
if (gapOf(file, item.place.side, item.place.endLine) !== segment.index) continue
|
||||
rows.push(threadCard(item, columns))
|
||||
rows.push(threadCard(item))
|
||||
unplaced.delete(item)
|
||||
}
|
||||
} else if (columns) {
|
||||
@@ -366,36 +420,29 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme
|
||||
onPointerEnter={(event) => { extendAdd(event.buttons, pair.left?.no ?? null, pair.right?.no ?? null) }}>
|
||||
{cell(pair.left, segment.old, 'L')}{cell(pair.right, segment.new, 'R')}
|
||||
</div>)
|
||||
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(<div key={`${at}-${index}`} className={clsx(css.line, css[row.kind], (selected('L', row.old) || selected('R', row.new)) && (editing || nativeSide === null) && reviewCss.selected)} data-diff-line={row.kind}
|
||||
const labeled = !selecting && endsAt(row.old, row.new)
|
||||
rows.push(<div key={`${at}-${index}`} className={clsx(css.line, css[row.kind], (marked('L', row.old) || marked('R', row.new)) && reviewCss.selected)} data-diff-line={row.kind}
|
||||
onPointerEnter={(event) => { 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)}<span className={css.sign}>{SIGN[row.kind]}</span>
|
||||
<Code text={row.text} old={row.old} next={row.new} spans={row.kind === 'del' ? segment.old?.get(row.old) : segment.new?.get(row.new)} />
|
||||
</div>)
|
||||
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(<ReviewThread key={`record-${thread.key}`} thread={thread} actions={review} locked={comments.locked} outdated split={false} t={t} />)
|
||||
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(<ReviewThread key={`record-${thread.key}`} thread={thread} actions={review} locked={comments.locked} outdated t={t} />)
|
||||
}
|
||||
}
|
||||
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 <div ref={root} className={css.code} style={style} data-single-side={singleSide ?? undefined} data-split={columns ? '' : undefined} data-wrap={wrap ? '' : undefined}
|
||||
data-review={review === undefined ? undefined : ''}
|
||||
data-native-side={nativeSide ?? undefined}
|
||||
@@ -404,12 +451,11 @@ function Lines({ file, text, open, handlers, split, wrap, review, visible, comme
|
||||
const side = codeSideAt(event.currentTarget, event.target)
|
||||
if (side === undefined) return
|
||||
if (!event.shiftKey || nativeSide !== side) window.getSelection()?.removeAllRanges()
|
||||
if (!editing) dragging.current = 'text'
|
||||
dragging.current = 'text'
|
||||
// The chosen column must allow selection before the browser starts its native text range.
|
||||
flushSync(() => {
|
||||
setNativeSide(side)
|
||||
if (editing) return
|
||||
setSelection(null)
|
||||
select(null)
|
||||
setSelecting(true)
|
||||
})
|
||||
}}>{rows}</div>
|
||||
@@ -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 => <ReviewThread key={thread.key} thread={thread}
|
||||
actions={review} locked={comments.locked} outdated={!reviewMatches(thread.selection, review.source, file)} split={false} t={t} />)
|
||||
actions={review} locked={comments.locked} outdated={!reviewMatches(thread.selection, review.source, file)} t={t} />)
|
||||
const lineThreads = threads?.lines.map(item => <ThreadCard key={item.thread.id} thread={item.thread} place={item.place}
|
||||
actions={threads.actions} t={t} />)
|
||||
if (file.binary) return <><p className={css.fileNote}>{t('diff.binary')}</p>{records}{lineThreads}</>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<typeof NS> & {
|
||||
export function ReviewComposer({ selection, actions, close, visible = true, t }: PropsLocale<typeof NS> & {
|
||||
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 (
|
||||
<section className={css.root} data-side={selection.comment.side} data-split={split ? '' : undefined} aria-label={t('review.range', { range })} data-forge-comment="">
|
||||
<section className={css.root} aria-label={t('review.range', { range })}
|
||||
data-forge-comment={`${selection.comment.side}${selection.comment.startLine}-${selection.comment.endLine}`}>
|
||||
<div className={css.title}>{t('review.range', { range })}</div>
|
||||
<textarea ref={input} className={css.input} rows={3} defaultValue={selection.comment.body} disabled={busy}
|
||||
aria-label={t('review.comment')} placeholder={t('review.comment')}
|
||||
|
||||
@@ -146,3 +146,37 @@
|
||||
corner-shape: round;
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.progressForm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* A narrow toolbar shows this form in place of the label and bar. */
|
||||
.progressForm[data-forge-progress='compact'] {
|
||||
display: none;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* The ring starts at twelve o’clock and fills clockwise with the viewed share. */
|
||||
.ring {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ringTrack,
|
||||
.ringFill {
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.ringTrack {
|
||||
stroke: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.ringFill {
|
||||
stroke: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
@@ -159,8 +159,12 @@ export function HideTestsSwitch({ hidden, toggle, t }: PropsLocale<typeof NS> &
|
||||
)
|
||||
}
|
||||
|
||||
/** Radius of the compact progress ring, inside its 14px box with a 2px stroke. */
|
||||
const RING_RADIUS = 5.5
|
||||
|
||||
/**
|
||||
* Render how many of the shown files the reader marked viewed, as a count and a bar.
|
||||
* Render how many of the shown files the reader marked viewed: a label with a bar, and a ring with the bare count that a
|
||||
* narrow toolbar shows instead (`data-forge-progress` names each form for the toolbar's styles).
|
||||
* @param props - the viewed and shown file counts, and copy.
|
||||
* @returns the progress, or nothing without shown files.
|
||||
*/
|
||||
@@ -169,13 +173,25 @@ export function ViewedProgress({ viewed, total, t }: PropsLocale<typeof NS> & {
|
||||
readonly total: number
|
||||
}): ReactNode {
|
||||
if (total === 0) return null
|
||||
const label = t('viewed.progress', { viewed: String(viewed), total: String(total) })
|
||||
const counts = { viewed: String(viewed), total: String(total) }
|
||||
const label = t('viewed.progress', counts)
|
||||
const circumference = 2 * Math.PI * RING_RADIUS
|
||||
return (
|
||||
<span className={css.progress} role="progressbar" aria-label={t('viewed.progressAria')} aria-valuemin={0} aria-valuemax={total}
|
||||
aria-valuenow={viewed} aria-valuetext={label} data-forge-viewed-progress={`${viewed}/${total}`}>
|
||||
<span className={css.progressLabel}>{label}</span>
|
||||
<span className={css.bar} aria-hidden="true">
|
||||
<span className={css.fill} style={{ width: `${(viewed / total) * 100}%` }} />
|
||||
<span className={css.progressForm} data-forge-progress="full">
|
||||
<span className={css.progressLabel}>{label}</span>
|
||||
<span className={css.bar} aria-hidden="true">
|
||||
<span className={css.fill} style={{ width: `${(viewed / total) * 100}%` }} />
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.progressForm} data-forge-progress="compact">
|
||||
<svg className={css.ring} viewBox="0 0 14 14" aria-hidden="true">
|
||||
<circle className={css.ringTrack} cx="7" cy="7" r={RING_RADIUS} />
|
||||
<circle className={css.ringFill} cx="7" cy="7" r={RING_RADIUS}
|
||||
strokeDasharray={`${(viewed / total) * circumference} ${circumference}`} />
|
||||
</svg>
|
||||
{t('viewed.count', counts)}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -29,12 +29,11 @@ export function useFileReview(actions: ReviewActions | undefined, path: string):
|
||||
* @param props - record, edit availability, source actions, and localized copy.
|
||||
* @returns one compact inline comment card.
|
||||
*/
|
||||
export function ReviewThread({ thread, actions, locked, outdated, split, t }: PropsLocale<typeof NS> & {
|
||||
export function ReviewThread({ thread, actions, locked, outdated, t }: PropsLocale<typeof NS> & {
|
||||
readonly thread: Thread
|
||||
readonly actions: ReviewActions
|
||||
readonly locked: boolean
|
||||
readonly outdated: boolean
|
||||
readonly split: boolean
|
||||
}): ReactNode {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [body, setBody] = useState(thread.selection.comment.body)
|
||||
@@ -60,8 +59,7 @@ export function ReviewThread({ thread, actions, locked, outdated, split, t }: Pr
|
||||
setMoving(false)
|
||||
}
|
||||
}
|
||||
return <section className={css.thread} data-forge-review-thread={thread.kind} data-side={comment.side}
|
||||
data-split={split ? '' : undefined} aria-label={t('review.range', { range })}>
|
||||
return <section className={css.thread} data-forge-review-thread={thread.kind} data-side={comment.side} aria-label={t('review.range', { range })}>
|
||||
<div className={css.threadHeader}>
|
||||
<span className={css.author}>{t('review.yourComment')}</span>
|
||||
<span className={css.range}>{range}</span>
|
||||
|
||||
@@ -24,9 +24,12 @@
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.card[data-split][data-side="R"],
|
||||
.folded[data-split][data-side="R"] {
|
||||
margin-inline-start: clamp(10px, calc(100% - 740px), calc(50% + 10px));
|
||||
/* Side by side, a card fills its side's cell under the code instead of the row's width. */
|
||||
[data-forge-comments] > .card,
|
||||
[data-forge-comments] > .folded {
|
||||
width: auto;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card[data-resolved] {
|
||||
@@ -316,9 +319,7 @@
|
||||
a container name per file, and the nearest inline-size container is the changes view. */
|
||||
@container (max-width: 479px) {
|
||||
.card,
|
||||
.folded,
|
||||
.card[data-split][data-side="R"],
|
||||
.folded[data-split][data-side="R"] {
|
||||
.folded {
|
||||
width: calc(100% - 12px);
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
@@ -288,15 +288,13 @@ function Resolution({ thread, writes, t }: PropsLocale<typeof NS> & {
|
||||
/**
|
||||
* Render one thread: its place, every comment, its reply field, and its actions. A resolved thread folds into one row
|
||||
* until the reader unfolds it or jumps to it.
|
||||
* @param props - the thread, where it shows, the shared actions, the layout, and copy.
|
||||
* @param props - the thread, where it shows, the shared actions, and copy.
|
||||
* @returns the thread card or its folded row.
|
||||
*/
|
||||
export function ThreadCard({ thread, place, actions, split = false, t }: PropsLocale<typeof NS> & {
|
||||
export function ThreadCard({ thread, place, actions, t }: PropsLocale<typeof NS> & {
|
||||
readonly thread: ForgeReviewThread
|
||||
readonly place: ThreadPlace
|
||||
readonly actions: ThreadActions
|
||||
/** Whether the card sits under the side of a side-by-side row that its lines number. */
|
||||
readonly split?: boolean
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false)
|
||||
// The jump the reader folded the thread after, which no longer unfolds it.
|
||||
@@ -325,14 +323,14 @@ export function ThreadCard({ thread, place, actions, split = false, t }: PropsLo
|
||||
const url = first?.url ?? null
|
||||
const writes = actions.writes
|
||||
if (!unfolded) {
|
||||
return <div className={css.folded} data-forge-thread={thread.id} data-side={side} data-split={split ? '' : undefined}>
|
||||
return <div className={css.folded} data-forge-thread={thread.id} data-side={side}>
|
||||
<IconCheckOutlineRegular size={14} />
|
||||
<span className={css.foldedText}>{t('thread.resolvedRow', { author: first?.author?.login ?? t('thread.deletedAuthor') })}</span>
|
||||
<button type="button" className={css.link} aria-expanded={false} onClick={() => { setOpen(true) }}>{t('thread.expand')}</button>
|
||||
</div>
|
||||
}
|
||||
const label = original !== null ? t('thread.originally', { range: anchorRange(thread.side, original) }) : range ?? t('thread.fileLevel')
|
||||
return <section className={css.card} data-forge-thread={thread.id} data-side={side} data-split={split ? '' : undefined}
|
||||
return <section className={css.card} data-forge-thread={thread.id} data-side={side}
|
||||
data-resolved={thread.resolved ? '' : undefined} aria-label={label} tabIndex={-1}>
|
||||
<div className={css.header}>
|
||||
<span className={css.place}>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** The changes toolbar's More menu, which holds the view controls a narrow toolbar folds out of its row. */
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconEllipsisOutlineRegular, Menu, Tooltip, type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { NS } from './locales.ts'
|
||||
import { foldOf } from './toolbar-fold.ts'
|
||||
import css from './ChangesTab.module.css'
|
||||
|
||||
/** Toolbar state and actions the menu repeats. */
|
||||
export interface ViewOptions {
|
||||
/** Whether any shown file is expanded, so the expansion row collapses them all. */
|
||||
readonly anyExpanded: boolean
|
||||
/** Whether any file shows, so the expansion row has files to change. */
|
||||
readonly hasFiles: boolean
|
||||
readonly toggleExpansion: () => void
|
||||
readonly split: boolean
|
||||
readonly toggleSplit: () => void
|
||||
readonly wrap: boolean
|
||||
readonly toggleWrap: () => void
|
||||
/** Whether test files hide, or undefined when the toolbar offers no test switch. */
|
||||
readonly hideTests: boolean | undefined
|
||||
readonly toggleTests: () => void
|
||||
readonly refresh: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the More button and its menu, which lists the controls of the toolbar's fold level when it opens (see
|
||||
* `observeToolbarFold`); the toolbar's styles hide the button while nothing folds.
|
||||
* @param props - the toolbar state and actions, whether the owning tab is visible, and copy.
|
||||
* @returns the button and its portalled menu.
|
||||
*/
|
||||
export function ViewOptionsMenu({ options, visible, t }: PropsLocale<typeof NS> & {
|
||||
readonly options: ViewOptions
|
||||
readonly visible: boolean
|
||||
}): ReactNode {
|
||||
const [fold, setFold] = useState<number | null>(null)
|
||||
const open = visible && fold !== null
|
||||
const entries: MenuEntry[] = []
|
||||
if (fold !== null) {
|
||||
entries.push(
|
||||
{ id: 'expansion', label: t(options.anyExpanded ? 'diff.collapseAll' : 'diff.expandAll'), disabled: !options.hasFiles },
|
||||
{ id: 'split', label: t('diff.splitAria') },
|
||||
{ id: 'wrap', label: t('diff.wrapAria') },
|
||||
)
|
||||
if (fold >= 2 && options.hideTests !== undefined) entries.push({ type: 'separator', id: 'tests-separator' }, { id: 'tests', label: t('tests.hide') })
|
||||
if (fold >= 3) entries.push({ type: 'separator', id: 'refresh-separator' }, { id: 'refresh', label: t('diff.refresh') })
|
||||
}
|
||||
const selected = [options.split && 'split', options.wrap && 'wrap', options.hideTests === true && 'tests'].filter(item => item !== false)
|
||||
const run = (id: string): void => {
|
||||
switch (id) {
|
||||
case 'expansion': options.toggleExpansion(); break
|
||||
case 'split': options.toggleSplit(); break
|
||||
case 'wrap': options.toggleWrap(); break
|
||||
case 'tests': options.toggleTests(); break
|
||||
case 'refresh': options.refresh(); break
|
||||
/* v8 ignore next -- the menu offers only the rows above */
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Menu open={open} portal dense align="end" autoFocus items={entries} selectedIds={selected}
|
||||
onClose={() => { setFold(null) }}
|
||||
onSelect={(id) => {
|
||||
setFold(null)
|
||||
run(id)
|
||||
}}
|
||||
anchor={(
|
||||
<Tooltip label={t('diff.moreOptions')} side="bottom">
|
||||
<button type="button" className={clsx(css.tool, css.more)} aria-label={t('diff.moreOptions')}
|
||||
aria-haspopup="menu" aria-expanded={open} data-forge-view-options=""
|
||||
onClick={(event) => { setFold(fold === null ? foldOf(event.currentTarget) : null) }}>
|
||||
<IconEllipsisOutlineRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)} />
|
||||
)
|
||||
}
|
||||
@@ -193,6 +193,7 @@ export const zh = {
|
||||
'viewed.changed': '有新改动',
|
||||
'viewed.progress': '已审阅 {viewed} / {total}',
|
||||
'viewed.progressAria': '已审阅的文件',
|
||||
'viewed.count': '{viewed}/{total}',
|
||||
'viewed.saveFailed': '无法保存已审阅状态',
|
||||
'commits.all': '全部提交',
|
||||
'commits.range': '{from}…{to}',
|
||||
@@ -216,6 +217,7 @@ export const zh = {
|
||||
'diff.wrap': '开启自动换行',
|
||||
'diff.nowrap': '关闭自动换行',
|
||||
'diff.wrapAria': '自动换行',
|
||||
'diff.moreOptions': '更多视图选项',
|
||||
'diff.unchanged': '{count} 行未变更',
|
||||
'diff.unchangedRest': '其余未变更的行',
|
||||
'diff.newFile': '新文件',
|
||||
@@ -568,6 +570,7 @@ export const en: Record<ForgePanelKey, string> = {
|
||||
'viewed.changed': 'Changed since viewed',
|
||||
'viewed.progress': '{viewed} / {total} viewed',
|
||||
'viewed.progressAria': 'Viewed files',
|
||||
'viewed.count': '{viewed}/{total}',
|
||||
'viewed.saveFailed': 'Could not save the viewed state',
|
||||
'commits.all': 'All commits',
|
||||
'commits.range': '{from}…{to}',
|
||||
@@ -591,6 +594,7 @@ export const en: Record<ForgePanelKey, string> = {
|
||||
'diff.wrap': 'Wrap long lines',
|
||||
'diff.nowrap': 'Stop wrapping long lines',
|
||||
'diff.wrapAria': 'Wrap lines',
|
||||
'diff.moreOptions': 'More view options',
|
||||
'diff.unchanged': '{count} unchanged lines',
|
||||
'diff.unchangedRest': 'Remaining unchanged lines',
|
||||
'diff.newFile': 'New file',
|
||||
@@ -947,6 +951,7 @@ export const ja: Record<ForgePanelKey, string> = {
|
||||
'viewed.changed': '確認後に変更あり',
|
||||
'viewed.progress': '確認済み {viewed} / {total}',
|
||||
'viewed.progressAria': '確認済みのファイル',
|
||||
'viewed.count': '{viewed}/{total}',
|
||||
'viewed.saveFailed': '確認済みの状態を保存できませんでした',
|
||||
'commits.all': 'すべてのコミット',
|
||||
'commits.range': '{from}…{to}',
|
||||
@@ -970,6 +975,7 @@ export const ja: Record<ForgePanelKey, string> = {
|
||||
'diff.wrap': '長い行を折り返す',
|
||||
'diff.nowrap': '長い行を折り返さない',
|
||||
'diff.wrapAria': '行を折り返す',
|
||||
'diff.moreOptions': 'その他の表示オプション',
|
||||
'diff.unchanged': '未変更 {count} 行',
|
||||
'diff.unchangedRest': '残りの未変更行',
|
||||
'diff.newFile': '新規ファイル',
|
||||
@@ -1319,6 +1325,7 @@ export const es: Record<ForgePanelKey, string> = {
|
||||
'viewed.changed': 'Cambió tras revisarlo',
|
||||
'viewed.progress': '{viewed} / {total} revisados',
|
||||
'viewed.progressAria': 'Archivos revisados',
|
||||
'viewed.count': '{viewed}/{total}',
|
||||
'viewed.saveFailed': 'No se pudo guardar el estado de revisión',
|
||||
'commits.all': 'Todos los commits',
|
||||
'commits.range': '{from}…{to}',
|
||||
@@ -1342,6 +1349,7 @@ export const es: Record<ForgePanelKey, string> = {
|
||||
'diff.wrap': 'Ajustar líneas largas',
|
||||
'diff.nowrap': 'No ajustar líneas largas',
|
||||
'diff.wrapAria': 'Ajustar líneas',
|
||||
'diff.moreOptions': 'Más opciones de vista',
|
||||
'diff.unchanged': '{count} líneas sin cambios',
|
||||
'diff.unchangedRest': 'Líneas restantes sin cambios',
|
||||
'diff.newFile': 'Archivo nuevo',
|
||||
@@ -1691,6 +1699,7 @@ export const fr: Record<ForgePanelKey, string> = {
|
||||
'viewed.changed': 'Modifié depuis la revue',
|
||||
'viewed.progress': '{viewed} / {total} vus',
|
||||
'viewed.progressAria': 'Fichiers vus',
|
||||
'viewed.count': '{viewed}/{total}',
|
||||
'viewed.saveFailed': 'Impossible d’enregistrer l’état de revue',
|
||||
'commits.all': 'Tous les commits',
|
||||
'commits.range': '{from}…{to}',
|
||||
@@ -1714,6 +1723,7 @@ export const fr: Record<ForgePanelKey, string> = {
|
||||
'diff.wrap': 'Replier les lignes longues',
|
||||
'diff.nowrap': 'Ne pas replier les lignes longues',
|
||||
'diff.wrapAria': 'Activer le retour à la ligne',
|
||||
'diff.moreOptions': 'Plus d’options d’affichage',
|
||||
'diff.unchanged': '{count} lignes inchangées',
|
||||
'diff.unchangedRest': 'Lignes inchangées restantes',
|
||||
'diff.newFile': 'Nouveau fichier',
|
||||
@@ -2063,6 +2073,7 @@ export const de: Record<ForgePanelKey, string> = {
|
||||
'viewed.changed': 'Seit dem Ansehen geändert',
|
||||
'viewed.progress': '{viewed} / {total} angesehen',
|
||||
'viewed.progressAria': 'Angesehene Dateien',
|
||||
'viewed.count': '{viewed}/{total}',
|
||||
'viewed.saveFailed': 'Der Ansichtsstatus konnte nicht gespeichert werden',
|
||||
'commits.all': 'Alle Commits',
|
||||
'commits.range': '{from}…{to}',
|
||||
@@ -2086,6 +2097,7 @@ export const de: Record<ForgePanelKey, string> = {
|
||||
'diff.wrap': 'Lange Zeilen umbrechen',
|
||||
'diff.nowrap': 'Lange Zeilen nicht umbrechen',
|
||||
'diff.wrapAria': 'Zeilen umbrechen',
|
||||
'diff.moreOptions': 'Weitere Ansichtsoptionen',
|
||||
'diff.unchanged': '{count} unveränderte Zeilen',
|
||||
'diff.unchangedRest': 'Übrige unveränderte Zeilen',
|
||||
'diff.newFile': 'Neue Datei',
|
||||
@@ -2435,6 +2447,7 @@ export const ru: Record<ForgePanelKey, string> = {
|
||||
'viewed.changed': 'Изменён после просмотра',
|
||||
'viewed.progress': 'Просмотрено {viewed} из {total}',
|
||||
'viewed.progressAria': 'Просмотренные файлы',
|
||||
'viewed.count': '{viewed}/{total}',
|
||||
'viewed.saveFailed': 'Не удалось сохранить отметку о просмотре',
|
||||
'commits.all': 'Все коммиты',
|
||||
'commits.range': '{from}…{to}',
|
||||
@@ -2458,6 +2471,7 @@ export const ru: Record<ForgePanelKey, string> = {
|
||||
'diff.wrap': 'Переносить длинные строки',
|
||||
'diff.nowrap': 'Не переносить длинные строки',
|
||||
'diff.wrapAria': 'Переносить строки',
|
||||
'diff.moreOptions': 'Другие параметры просмотра',
|
||||
'diff.unchanged': 'Неизменённых строк: {count}',
|
||||
'diff.unchangedRest': 'Остальные неизменённые строки',
|
||||
'diff.newFile': 'Новый файл',
|
||||
@@ -2807,6 +2821,7 @@ export const pt: Record<ForgePanelKey, string> = {
|
||||
'viewed.changed': 'Alterado após a revisão',
|
||||
'viewed.progress': '{viewed} / {total} revisados',
|
||||
'viewed.progressAria': 'Arquivos revisados',
|
||||
'viewed.count': '{viewed}/{total}',
|
||||
'viewed.saveFailed': 'Não foi possível salvar o estado de revisão',
|
||||
'commits.all': 'Todos os commits',
|
||||
'commits.range': '{from}…{to}',
|
||||
@@ -2830,6 +2845,7 @@ export const pt: Record<ForgePanelKey, string> = {
|
||||
'diff.wrap': 'Quebrar linhas longas',
|
||||
'diff.nowrap': 'Não quebrar linhas longas',
|
||||
'diff.wrapAria': 'Quebrar linhas',
|
||||
'diff.moreOptions': 'Mais opções de exibição',
|
||||
'diff.unchanged': '{count} linhas sem alterações',
|
||||
'diff.unchangedRest': 'Demais linhas sem alterações',
|
||||
'diff.newFile': 'Novo arquivo',
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Width-driven folding of the changes toolbar into its More menu. */
|
||||
|
||||
/** Fold levels, from none to the deepest: level 1 folds the line totals, expansion, side by side, and wrapping; level 2
|
||||
* the test switch, with the progress shown as a ring; level 3 refresh. */
|
||||
export const DEEPEST_FOLD = 3
|
||||
|
||||
/**
|
||||
* Read the fold level a toolbar shows.
|
||||
* @param inside - an element inside the toolbar, such as its More button.
|
||||
* @returns the level, or the deepest level outside a measured toolbar.
|
||||
*/
|
||||
export function foldOf(inside: Element): number {
|
||||
const level = Number.parseInt(inside.closest('[data-fold]')?.getAttribute('data-fold') ?? '', 10)
|
||||
return Number.isInteger(level) ? level : DEEPEST_FOLD
|
||||
}
|
||||
|
||||
/**
|
||||
* Width the row's displayed items need on one line: each item but the spacer at its own width, and one column gap
|
||||
* between neighbours, the spacer included.
|
||||
* @param row - the toolbar row.
|
||||
* @param gap - its column gap in pixels.
|
||||
* @returns the width in pixels.
|
||||
*/
|
||||
function demand(row: HTMLElement, gap: number): number {
|
||||
let width = 0
|
||||
let items = 0
|
||||
for (const child of row.children) {
|
||||
const spacer = child.hasAttribute('data-fold-spacer')
|
||||
const own = spacer ? 0 : child.getBoundingClientRect().width
|
||||
if (!spacer && own === 0) continue
|
||||
width += own
|
||||
items += 1
|
||||
}
|
||||
return width + Math.max(0, items - 1) * gap
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a toolbar row on one line by setting `data-fold` to the shallowest level whose items fit, measuring each level's
|
||||
* demand synchronously; the row's styles hide each level's items and show the More button. Content, size, and font
|
||||
* changes measure again.
|
||||
* @param row - the toolbar row; its children are the items, and the one with `data-fold-spacer` takes the slack.
|
||||
* @returns Disconnect the observers and the font listener.
|
||||
*/
|
||||
export function observeToolbarFold(row: HTMLElement): () => void {
|
||||
const resize = new ResizeObserver(() => { measure() })
|
||||
const measure = (): void => {
|
||||
// Items React adds after the first measure are observed too; observing an observed element again does nothing.
|
||||
for (const child of row.children) resize.observe(child)
|
||||
const style = getComputedStyle(row)
|
||||
const available = row.getBoundingClientRect().width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
|
||||
const gap = parseFloat(style.columnGap)
|
||||
let level = 0
|
||||
for (; level < DEEPEST_FOLD; level += 1) {
|
||||
row.setAttribute('data-fold', String(level))
|
||||
if (demand(row, gap) <= available) break
|
||||
}
|
||||
row.setAttribute('data-fold', String(level))
|
||||
}
|
||||
resize.observe(row)
|
||||
const mutation = new MutationObserver(measure)
|
||||
mutation.observe(row, { subtree: true, childList: true, characterData: true })
|
||||
const fonts = document.fonts
|
||||
fonts.addEventListener('loadingdone', measure)
|
||||
measure()
|
||||
return () => {
|
||||
resize.disconnect()
|
||||
mutation.disconnect()
|
||||
fonts.removeEventListener('loadingdone', measure)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import './fonts-dom.client.ts'
|
||||
import type { PaneId, SidebarRightTabActions, SidebarRightTabInfo, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -1971,9 +1972,19 @@ describe('ChangesTab review threads', () => {
|
||||
return { ...bound, update: { key: 'github:github.com/octo/demo#7' as ChangeRequestKey, fetchedAt, snapshot: snapshot(7) } }
|
||||
}
|
||||
const card = (m: ReturnType<typeof mount>, id: string): HTMLElement | null => m.view.container.querySelector(`[data-forge-thread="${id}"]`)
|
||||
/** The element a thread places among the rows: its card, or side by side, the row of comment cells that holds it. */
|
||||
const placed = (m: ReturnType<typeof mount>, id: string): Element | null => {
|
||||
const found = card(m, id)
|
||||
return found?.closest('[data-forge-notes]') ?? found
|
||||
}
|
||||
/** Code of the row a thread follows, on the side its lines number. */
|
||||
const following = (m: ReturnType<typeof mount>, id: string, side: 'L' | 'R' = 'R'): string | null =>
|
||||
card(m, id)?.previousElementSibling?.querySelector(side === 'L' ? '[data-old-line]' : '[data-new-line]')?.textContent ?? null
|
||||
placed(m, id)?.previousElementSibling?.querySelector(side === 'L' ? '[data-old-line]' : '[data-new-line]')?.textContent ?? null
|
||||
/** The column of a side-by-side row that holds a thread: 0 for the left side, 1 for the right. */
|
||||
const column = (m: ReturnType<typeof mount>, id: string): number => {
|
||||
const found = card(m, id)!
|
||||
return Array.from(found.closest('[data-forge-notes]')!.children).findIndex(cell => cell.contains(found))
|
||||
}
|
||||
|
||||
it('writes to a thread at the head of its read and reads the threads again', async () => {
|
||||
const writable = line('on-row', 'src/app.ts', 'R', 4, { viewerCanResolve: true })
|
||||
@@ -1993,6 +2004,8 @@ describe('ChangesTab review threads', () => {
|
||||
const app = m.section('src/app.ts')
|
||||
expect(following(m, 'on-row')).toBe('const y = 4')
|
||||
expect(following(m, 'old-side', 'L')).toBe('const w = 12')
|
||||
// Side by side, each thread sits in the column of the side its lines number.
|
||||
expect([column(m, 'on-row'), column(m, 'old-side')]).toEqual([1, 0])
|
||||
expect(card(m, 'in-gap')?.previousElementSibling?.getAttribute('data-forge-diff-gap')).toBe('1')
|
||||
expect(card(m, 'resolved')?.textContent).toContain('Resolved thread · reviewer')
|
||||
// The count alone shows below 480px.
|
||||
|
||||
@@ -163,7 +163,10 @@ it('opens the editor from a hover plus in one press, extending the range on its
|
||||
expect(m.line('R5').getAttribute('aria-pressed')).toBe('false')
|
||||
fireEvent.pointerUp(window)
|
||||
expect(m.getByRole('region', { name: 'Comment on R3–4' })).toBeTruthy()
|
||||
expect(m.container.querySelector('[data-review-add]')).toBeNull()
|
||||
// The open editor leaves every hover plus for the next comment, and the range it froze keeps the highlight.
|
||||
expect(m.container.querySelectorAll('[data-review-add]').length).toBeGreaterThan(0)
|
||||
expect(m.line('R3').getAttribute('aria-pressed')).toBe('false')
|
||||
expect(m.code('R', 3).closest('[data-diff-side]')!.className).toContain('selected')
|
||||
fireEvent.click(m.getByRole('button', { name: 'Cancel' }))
|
||||
fireEvent.pointerDown(plus('L2'), { button: 0 })
|
||||
fireEvent.pointerCancel(window)
|
||||
@@ -278,3 +281,64 @@ it('keeps a text selection across a render that leaves the file unchanged', () =
|
||||
expect(m.code('R', 3).firstChild).toBe(node)
|
||||
expect(document.getSelection()?.toString()).toBe(selected)
|
||||
})
|
||||
|
||||
it('writes a left and a right comment at once, each under its own column of the row, and focuses an open range again', () => {
|
||||
const m = mount()
|
||||
const open = (name: string) => {
|
||||
fireEvent.click(m.line(name))
|
||||
fireEvent.click(m.getByRole('button', { name: 'Add comment' }))
|
||||
return m.getByRole('region', { name: `Comment on ${name}` })
|
||||
}
|
||||
const left = open('L3')
|
||||
const right = open('R3')
|
||||
const row = left.closest<HTMLElement>('[data-forge-notes]')!
|
||||
expect(right.closest('[data-forge-notes]')).toBe(row)
|
||||
const [leftCell, rightCell] = Array.from(row.children)
|
||||
expect(leftCell!.contains(left)).toBe(true)
|
||||
expect(rightCell!.contains(right)).toBe(true)
|
||||
// Each cell continues the background of its side's line.
|
||||
expect(leftCell!.className).toContain('del')
|
||||
expect(rightCell!.className).toContain('add')
|
||||
expect(m.line('L3').getAttribute('aria-pressed')).toBe('false')
|
||||
fireEvent.change(within(right).getByRole('textbox'), { target: { value: 'Draft' } })
|
||||
open('R3')
|
||||
expect(m.getAllByRole('region', { name: 'Comment on R3' })).toHaveLength(1)
|
||||
expect(document.activeElement).toBe(within(right).getByRole('textbox'))
|
||||
expect(within(right).getByRole<HTMLTextAreaElement>('textbox').value).toBe('Draft')
|
||||
fireEvent.click(within(left).getByRole('button', { name: 'Cancel' }))
|
||||
expect(m.queryByRole('region', { name: 'Comment on L3' })).toBeNull()
|
||||
expect(m.getByRole('region', { name: 'Comment on R3' })).toBe(right)
|
||||
})
|
||||
|
||||
it('places side-by-side records in their columns and keeps an editor whose line stops drawing after the lines', () => {
|
||||
const m = mount()
|
||||
act(() => {
|
||||
for (const side of ['L', 'R'] as const) m.records.recordAgent({ source, comment: { ...selectReviewLines(file, side, 3, 4)!, body: side } })
|
||||
})
|
||||
const threads = Array.from(m.container.querySelectorAll<HTMLElement>('[data-forge-review-thread]'))
|
||||
expect(threads.map(thread => thread.closest('[data-forge-notes]'))).toEqual([threads[0]!.closest('[data-forge-notes]'), threads[0]!.closest('[data-forge-notes]')])
|
||||
const cells = Array.from(threads[0]!.closest('[data-forge-notes]')!.children)
|
||||
expect(threads.map(thread => cells.findIndex(cell => cell.contains(thread)))).toEqual([0, 1])
|
||||
// The added blank line has no old line, so the left cell of its row is empty.
|
||||
fireEvent.click(m.line('R5'))
|
||||
fireEvent.click(m.getByRole('button', { name: 'Add comment' }))
|
||||
const editor = m.getByRole('region', { name: 'Comment on R5' })
|
||||
const [emptyCell] = Array.from(editor.closest('[data-forge-notes]')!.children)
|
||||
expect(emptyCell!.className).toContain('empty')
|
||||
expect(emptyCell!.querySelector('[data-forge-comments]')!.childElementCount).toBe(0)
|
||||
const shorter = { ...file.hunks[0]!, newLines: 3, lines: file.hunks[0]!.lines.slice(0, -1) }
|
||||
m.rerender(<DiffFile {...m.props} file={{ ...file, hunks: [shorter] }} />)
|
||||
const code = m.container.querySelector('[data-review]')!
|
||||
const rows = Array.from(code.children)
|
||||
const moved = m.getByRole('region', { name: 'Comment on R5' })
|
||||
expect(moved.parentElement).toBe(code)
|
||||
expect(rows.indexOf(moved)).toBeGreaterThan(rows.findLastIndex(row => row.hasAttribute('data-diff-pair')))
|
||||
})
|
||||
|
||||
it('labels the plus on the unified row that ends a right-side range', () => {
|
||||
const m = mount({ split: false })
|
||||
fireEvent.click(m.line('R4'))
|
||||
expect(m.getByRole('button', { name: 'Add comment' })).toBeTruthy()
|
||||
expect(m.container.querySelector('[data-review-add="R4"]')).toBeNull()
|
||||
expect(m.container.querySelector('[data-review-add="R3"]')).not.toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/** jsdom font-loading events for specs that mount the changes toolbar, which measures again when fonts load. */
|
||||
import { afterEach, beforeEach } from 'vitest'
|
||||
|
||||
const fonts = Object.getOwnPropertyDescriptor(document, 'fonts')
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() })
|
||||
})
|
||||
afterEach(() => {
|
||||
if (fonts === undefined) Reflect.deleteProperty(document, 'fonts')
|
||||
else Object.defineProperty(document, 'fonts', fonts)
|
||||
})
|
||||
@@ -115,6 +115,11 @@ describe('HideTestsSwitch and ViewedProgress', () => {
|
||||
const bar = view.getByRole('progressbar', { name: en['viewed.progressAria'] })
|
||||
expect([bar.getAttribute('aria-valuenow'), bar.getAttribute('aria-valuemax'), bar.getAttribute('aria-valuetext')]).toEqual(['2', '4', '2 / 4 viewed'])
|
||||
expect(bar.querySelector<HTMLElement>('[style]')?.style.width).toBe('50%')
|
||||
// The compact form a narrow toolbar shows: a ring filled to the same share, and the bare count.
|
||||
const compact = bar.querySelector<HTMLElement>('[data-forge-progress="compact"]')!
|
||||
expect(compact.textContent).toBe('2/4')
|
||||
const [length, gap] = compact.querySelectorAll('circle')[1]!.getAttribute('stroke-dasharray')!.split(' ').map(Number)
|
||||
expect(length! / gap!).toBeCloseTo(0.5)
|
||||
view.rerender(<ViewedProgress viewed={0} total={0} t={t} />)
|
||||
expect(view.queryByRole('progressbar')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -246,9 +246,9 @@ describe('ThreadCard', () => {
|
||||
reviewComment({ author: { login: 'ci', avatarUrl: null, bot: true }, pending: true }),
|
||||
reviewComment({ author: null }),
|
||||
] })
|
||||
const view = render(<ThreadCard thread={thread} place={{ kind: 'line', side: 'R', startLine: 3, endLine: 3 }} actions={actions()} split t={t} />)
|
||||
const view = render(<ThreadCard thread={thread} place={{ kind: 'line', side: 'R', startLine: 3, endLine: 3 }} actions={actions()} t={t} />)
|
||||
const card = view.container.querySelector('[data-forge-thread]')!
|
||||
expect(card.getAttribute('data-split')).toBe('')
|
||||
expect(card.getAttribute('data-side')).toBe('R')
|
||||
expect(card.querySelector('strong')?.textContent).toBe('bold')
|
||||
expect(card.textContent).not.toContain('<b>')
|
||||
expect(card.textContent).not.toContain('hidden')
|
||||
|
||||
@@ -311,7 +311,7 @@ describe('inline records', () => {
|
||||
const thread = records.fileComments('a.ts', selection.source).threads[0]!
|
||||
const addToInput = vi.fn().mockReturnValue(false)
|
||||
const actions = { records, source: selection.source, addToInput }
|
||||
const view = render(<ReviewThread thread={thread} actions={actions} locked={false} outdated={false} split t={t} />)
|
||||
const view = render(<ReviewThread thread={thread} actions={actions} locked={false} outdated={false} t={t} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Add to input' }))
|
||||
expect(view.getByRole('alert')).toHaveProperty('textContent', en['review.inputFailed'])
|
||||
fireEvent.click(view.getByRole('button', { name: 'Edit' }))
|
||||
@@ -329,7 +329,7 @@ describe('inline records', () => {
|
||||
const records = drafted(selection)
|
||||
const thread = records.fileComments('a.ts', selection.source).threads[0]!
|
||||
const view = render(<ReviewThread thread={thread} actions={{ source: selection.source, addToInput: () => true, records }}
|
||||
locked={false} outdated split={false} t={t} />)
|
||||
locked={false} outdated t={t} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Edit' }))
|
||||
records.close()
|
||||
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Not saved' } })
|
||||
@@ -344,7 +344,7 @@ describe('inline records', () => {
|
||||
const thread = records.fileComments('a.ts', selection.source).threads[0]!
|
||||
const queue = vi.fn<NonNullable<ReviewActions['queue']>>().mockResolvedValueOnce('GitHub refused')
|
||||
const view = render(<ReviewThread thread={thread} actions={{ source: selection.source, addToInput: () => true, records, queue }}
|
||||
locked={false} outdated={false} split={false} t={t} />)
|
||||
locked={false} outdated={false} t={t} />)
|
||||
expect(view.getByText(en['review.browserDraft'])).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: 'Add to review' }))
|
||||
expect(view.getByRole('button', { name: en['review.publishing'] })).toHaveProperty('disabled', true)
|
||||
@@ -367,22 +367,22 @@ describe('inline records', () => {
|
||||
const thread = records.fileComments('a.ts', selection.source).threads[0]!
|
||||
const queue = vi.fn<NonNullable<ReviewActions['queue']>>()
|
||||
const outdated = render(<ReviewThread thread={thread} actions={{ source: selection.source, addToInput: () => true, records, queue }}
|
||||
locked={false} outdated split={false} t={t} />)
|
||||
locked={false} outdated t={t} />)
|
||||
expect(outdated.queryByRole('button', { name: 'Add to review' })).toBeNull()
|
||||
outdated.unmount()
|
||||
const current = render(<ReviewThread thread={thread} actions={{ source: selection.source, addToInput: () => true, records, queue }}
|
||||
locked={false} outdated={false} split={false} t={t} />)
|
||||
locked={false} outdated={false} t={t} />)
|
||||
fireEvent.click(current.getByRole('button', { name: 'Edit' }))
|
||||
expect(current.queryByRole('button', { name: 'Add to review' })).toBeNull()
|
||||
current.unmount()
|
||||
const without = render(<ReviewThread thread={thread} actions={{ source: selection.source, addToInput: () => true, records }}
|
||||
locked={false} outdated={false} split={false} t={t} />)
|
||||
locked={false} outdated={false} t={t} />)
|
||||
expect(without.queryByRole('button', { name: 'Add to review' })).toBeNull()
|
||||
})
|
||||
it('opens a published receipt and omits editing on immutable records', () => {
|
||||
const openLink = vi.fn()
|
||||
const view = render(<ReviewThread thread={{ key: 1, kind: 'published', selection, url: 'https://github.com/review' }}
|
||||
actions={{ source: selection.source, addToInput: () => true, openLink }} locked={false} outdated={false} split={false} t={t} />)
|
||||
actions={{ source: selection.source, addToInput: () => true, openLink }} locked={false} outdated={false} t={t} />)
|
||||
fireEvent.click(view.getByRole('button', { name: en['review.recheck'] }))
|
||||
expect(openLink).toHaveBeenCalledWith('https://github.com/review')
|
||||
expect(view.queryByRole('button', { name: 'Edit' })).toBeNull()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The changes toolbar's fold measurement: the shallowest level that fits, and every change that measures again. */
|
||||
import './fonts-dom.client.ts'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { DEEPEST_FOLD, foldOf, observeToolbarFold } from '../src/client/toolbar-fold.ts'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** A row whose items take the widths of the level it shows: line totals and view toggles fold first, then the bar, then refresh. */
|
||||
function fixture() {
|
||||
const row = document.createElement('div')
|
||||
row.style.padding = '0 8px'
|
||||
row.style.columnGap = '4px'
|
||||
const level = (): number => Number(row.getAttribute('data-fold'))
|
||||
const item = (width: () => number, spacer = false): HTMLElement => {
|
||||
const element = document.createElement('span')
|
||||
if (spacer) element.setAttribute('data-fold-spacer', '')
|
||||
element.getBoundingClientRect = () => new DOMRect(0, 0, width(), 28)
|
||||
row.append(element)
|
||||
return element
|
||||
}
|
||||
const sizes = { row: 460, files: 100 }
|
||||
const files = item(() => sizes.files)
|
||||
item(() => 999, true)
|
||||
item(() => level() < 2 ? 150 : 50)
|
||||
item(() => level() === 0 ? 60 : 0)
|
||||
item(() => (level() < 3 ? 28 : 0) + (level() === 0 ? 84 : 30))
|
||||
row.getBoundingClientRect = () => new DOMRect(0, 0, sizes.row, 38)
|
||||
document.body.append(row)
|
||||
const observe = vi.fn()
|
||||
const disconnect = vi.fn()
|
||||
let resized: () => void = () => {}
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: () => void) { resized = callback }
|
||||
observe = observe
|
||||
disconnect = disconnect
|
||||
})
|
||||
const dispose = observeToolbarFold(row)
|
||||
onTestFinished(dispose)
|
||||
return { row, files, sizes, observe, disconnect, dispose, level, resize: (width: number) => { sizes.row = width; resized() } }
|
||||
}
|
||||
|
||||
it('shows the shallowest level whose items fit and the deepest when none does', () => {
|
||||
const { level, resize, observe } = fixture()
|
||||
expect(level()).toBe(0)
|
||||
// Row, then its five items.
|
||||
expect(observe).toHaveBeenCalledTimes(6)
|
||||
for (const [width, expected] of [[454, 0], [453, 1], [336, 1], [335, 2], [236, 2], [235, 3], [100, DEEPEST_FOLD], [460, 0]] as const) {
|
||||
resize(width)
|
||||
expect([width, level()]).toEqual([width, expected])
|
||||
}
|
||||
})
|
||||
|
||||
it('measures again when text changes, items arrive, or fonts load, and stops after disposal', async () => {
|
||||
const { row, files, sizes, level, observe, disconnect, dispose } = fixture()
|
||||
sizes.files = 140
|
||||
files.textContent = 'All 1,204 files'
|
||||
await Promise.resolve()
|
||||
expect(level()).toBe(1)
|
||||
const added = document.createElement('span')
|
||||
added.getBoundingClientRect = () => new DOMRect(0, 0, 150, 28)
|
||||
row.append(added)
|
||||
await Promise.resolve()
|
||||
expect(level()).toBe(2)
|
||||
expect(observe).toHaveBeenCalledWith(added)
|
||||
sizes.files = 100
|
||||
document.fonts.dispatchEvent(new Event('loadingdone'))
|
||||
expect(level()).toBe(2)
|
||||
added.remove()
|
||||
await Promise.resolve()
|
||||
expect(level()).toBe(0)
|
||||
dispose()
|
||||
expect(disconnect).toHaveBeenCalled()
|
||||
sizes.files = 400
|
||||
document.fonts.dispatchEvent(new Event('loadingdone'))
|
||||
files.textContent = 'All files'
|
||||
await Promise.resolve()
|
||||
expect(level()).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the level of the toolbar around an element, and the deepest level outside one', () => {
|
||||
const row = document.createElement('div')
|
||||
const inside = document.createElement('button')
|
||||
row.append(inside)
|
||||
expect(foldOf(inside)).toBe(DEEPEST_FOLD)
|
||||
row.setAttribute('data-fold', '1')
|
||||
expect(foldOf(inside)).toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The changes toolbar's More menu: the rows of each fold level, their marks, and their actions. */
|
||||
import { cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import { ViewOptionsMenu, type ViewOptions } from '../src/client/ViewOptionsMenu.tsx'
|
||||
|
||||
const t = makeTranslate(en)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function mount(overrides: Partial<ViewOptions> = {}, visible = true, fold?: string) {
|
||||
const options: ViewOptions = {
|
||||
anyExpanded: false, hasFiles: true, toggleExpansion: vi.fn(), split: true, toggleSplit: vi.fn(), wrap: false, toggleWrap: vi.fn(),
|
||||
hideTests: true, toggleTests: vi.fn(), refresh: vi.fn(), ...overrides,
|
||||
}
|
||||
const view = render(<div data-fold={fold}><ViewOptionsMenu options={options} visible={visible} t={t} /></div>)
|
||||
const trigger = within(view.container).getByRole('button', { name: en['diff.moreOptions'] })
|
||||
const rows = (): string[] => view.queryAllByRole('menuitem').map(row => row.textContent ?? '')
|
||||
return { view, options, trigger, rows }
|
||||
}
|
||||
|
||||
it('lists every folded control and marks the active toggles outside a measured toolbar', () => {
|
||||
const m = mount()
|
||||
fireEvent.click(m.trigger)
|
||||
expect(m.trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(m.rows()).toEqual([en['diff.expandAll'], en['diff.splitAria'], en['diff.wrapAria'], en['tests.hide'], en['diff.refresh']])
|
||||
const marked = m.view.getAllByRole('menuitem').map(row => row.querySelector('svg') !== null)
|
||||
expect(marked).toEqual([false, true, false, true, false])
|
||||
fireEvent.click(m.trigger)
|
||||
expect(m.rows()).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1', [en['diff.collapseAll'], en['diff.splitAria'], en['diff.wrapAria']]],
|
||||
['2', [en['diff.collapseAll'], en['diff.splitAria'], en['diff.wrapAria'], en['tests.hide']]],
|
||||
['3', [en['diff.collapseAll'], en['diff.splitAria'], en['diff.wrapAria'], en['tests.hide'], en['diff.refresh']]],
|
||||
])('lists the controls folded at level %s', (level, expected) => {
|
||||
const m = mount({ anyExpanded: true }, true, level)
|
||||
fireEvent.click(m.trigger)
|
||||
expect(m.rows()).toEqual(expected)
|
||||
})
|
||||
|
||||
it('runs the chosen control and closes, omits the test switch without one, and stays closed in a hidden tab', () => {
|
||||
const m = mount({ hideTests: undefined })
|
||||
const choose = (name: string): void => {
|
||||
fireEvent.click(m.trigger)
|
||||
fireEvent.click(m.view.getByRole('menuitem', { name }))
|
||||
expect(m.rows()).toEqual([])
|
||||
}
|
||||
choose(en['diff.expandAll'])
|
||||
choose(en['diff.splitAria'])
|
||||
choose(en['diff.wrapAria'])
|
||||
choose(en['diff.refresh'])
|
||||
const actions = [m.options.toggleExpansion, m.options.toggleSplit, m.options.toggleWrap, m.options.refresh]
|
||||
expect(actions.map(action => vi.mocked(action).mock.calls.length))
|
||||
.toEqual([1, 1, 1, 1])
|
||||
fireEvent.click(m.trigger)
|
||||
expect(m.rows()).not.toContain(en['tests.hide'])
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(m.rows()).toEqual([])
|
||||
const hidden = mount({ hasFiles: false }, false)
|
||||
fireEvent.click(hidden.trigger)
|
||||
expect(hidden.trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('toggles test files from the menu', () => {
|
||||
const m = mount()
|
||||
fireEvent.click(m.trigger)
|
||||
fireEvent.click(m.view.getByRole('menuitem', { name: en['tests.hide'] }))
|
||||
expect(m.options.toggleTests).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
Reference in new issue
Block a user