fix(forge): keep narrow file header actions inside the card

The card's overflow clip cut off the viewed mark and the file menu when a
narrow header also held a long path, the changed tag, and a rename badge;
before the cards, the list could still scroll sideways to them.

The icon, path, changed tag, and badge now share one box that shrinks to
any width, so the counts, the viewed mark, and the menu after it stay
whole. The path and badge shorten in proportion to their widths, the
changed tag is capped at 30% of the box with an ellipsis, and the path
keeps its full-path tooltip.

A 390px e2e renames a long file, changes it after it is marked viewed,
and checks that the mark and menu stay inside the card, the mark still
toggles, and hovering the name shows both paths.
This commit is contained in:
Yichen Jiang committed 2026-11-07 15:46:36 +08:00
1 parent e2237f26b9
commit 63e00f87ec
6 files changed
+85 -11

No files matched your search

+48
View File
@@ -1844,3 +1844,51 @@ it.skipIf(MODE === 'record')('keeps local viewed marks across reloads, clears a
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
})
it.skipIf(MODE === 'record')('keeps a narrow file header\'s viewed mark and menu inside its card and its name under the pointer', async () => {
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-narrow-header', 'normal', { branch: 'feat' })
const from = 'src/original-review-module-with-a-long-name.ts'
const to = 'src/renamed-review-module-with-an-even-longer-name.ts'
const body = Array.from({ length: 20 }, (_, index) => `export const value${index} = ${index}\n`).join('')
await mkdir(join(project, 'src'))
await writeFile(join(project, from), body)
await git(project, 'add', from)
await git(project, 'commit', '--quiet', '-m', 'Add the module')
// The base holds the old path, so the branch shows a rename.
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
await git(project, 'mv', from, to)
await writeFile(join(project, to), `${body}export const extra = 1\n`)
await git(project, 'commit', '--quiet', '-am', 'Rename the module')
const { settled } = await sendPrompt(page, scaffold)
await settled
await page.getByRole('button', { name: '打开右侧边栏', exact: true }).click()
await page.locator('[data-sidebar-right-guide]').getByRole('button', { name: /^代码审查和变更/ }).click()
await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click()
await page.locator('[data-forge-local]').getByRole('button', { name: /^查看本会话的变更/ }).click({ timeout: 20_000 })
const local = page.locator('[data-forge-changes="local"]')
const name = 'renamed-review-module-with-an-even-longer-name.ts'
const file = local.locator(`[data-forge-diff-file="${to}"]`)
const viewed = file.getByRole('checkbox', { name: `将 ${name} 标记为已审阅`, exact: true })
await viewed.check({ timeout: 20_000 })
await writeFile(join(project, to), `${body}export const extra = 2\n`)
await local.getByRole('button', { name: '刷新变更', exact: true }).click()
await expect.poll(() => file.getAttribute('data-viewed'), { timeout: 20_000 }).toBe('changed')
await file.getByText('由 original-review-module-with-a-long-name.ts 重命名', { exact: true }).waitFor()
// The changed tag, the rename badge, and the long path compete for one narrow header with the counts and actions.
await page.setViewportSize({ width: 390, height: 780 })
const label = file.locator('[data-forge-file-label]')
const [card, mark, menu, labelBox] = await Promise.all([
file.boundingBox(), viewed.boundingBox(), file.getByRole('button', { name: `${name} 的更多操作`, exact: true }).boundingBox(), label.boundingBox(),
])
if (card === null || mark === null || menu === null || labelBox === null) throw new Error('The header controls must be rendered')
expect(mark.x + mark.width).toBeLessThanOrEqual(card.x + card.width)
expect(menu.x + menu.width).toBeLessThanOrEqual(card.x + card.width)
expect(labelBox.width).toBeGreaterThan(40)
await label.hover()
await page.getByRole('tooltip').filter({ hasText: `${from} → ${to}` }).waitFor()
await viewed.check()
await expect.poll(() => file.getAttribute('data-viewed')).toBe('viewed')
expect(tripwire.pageErrors).toEqual([])
})
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-client-ui-forge/use-this-package:
en: 8d017c771840d6de
zh: 0705332c947f825a
en: c7e1b7d87ccccd2e
zh: 42d864b918a6175c
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
en: 7495fe1e0d0ee643
zh: f820a5790ae6e2a7
@@ -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. 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. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Each file is a separate card, with a tinted header that stays at the top while its lines scroll. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. When the header is narrow, the path, the badges, and Changed since viewed shorten, so the line counts, Viewed, and the menu stay whole. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff whose output exceeds the provider's `maxOutputBytes`, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host.
- **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits.
@@ -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 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。
@@ -446,10 +446,23 @@
border-bottom: 0;
}
/* The file's own information gives way inside this box, so the counts, the viewed mark, and the menu after it stay
whole at any width. */
.fileTitle {
display: flex;
flex: 1 1 0;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.fileIcon {
flex: none;
}
/* The path shares the shortage with the badge in proportion to their widths, so the longer path keeps the larger part
and stays under the pointer for its full-path tooltip. */
.filePath {
display: flex;
flex: 0 1 auto;
@@ -469,8 +482,16 @@
transform: rotate(-90deg);
}
/* Capped rather than shrunk, so a long path never truncates the tag; a longer translation ends in an ellipsis. */
.changedTag {
flex: none;
max-width: 30%;
}
.changedText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* The viewed mark: a native checkbox with its label as one bordered chip, peer to the file menu button. */
@@ -526,7 +547,8 @@
}
.badge {
flex: none;
flex: 0 1 auto;
min-width: 0;
max-width: 50%;
overflow: hidden;
padding: 0 6px;
@@ -458,13 +458,17 @@ export function DiffFile(props: DiffFileProps): ReactNode {
>
<IconChevronDownOutlineRegular size={14} className={css.chevron} />
</button>
<FileTypeIcon path={file.path} size={16} className={css.fileIcon} />
<span className={css.filePath}>
<FileLabel path={file.path} tooltip={tooltip} />
<span className={css.fileTitle}>
<FileTypeIcon path={file.path} size={16} className={css.fileIcon} />
<span className={css.filePath}>
<FileLabel path={file.path} tooltip={tooltip} />
</span>
{viewed === 'changed' && (
<Tag tone="warning" className={css.changedTag}><span className={css.changedText}>{t('viewed.changed')}</span></Tag>
)}
<span className={css.fileSpacer} data-forge-path-spacer="" />
{badge !== '' && <span className={css.badge}>{badge}</span>}
</span>
{viewed === 'changed' && <Tag tone="warning" className={css.changedTag}>{t('viewed.changed')}</Tag>}
<span className={css.fileSpacer} data-forge-path-spacer="" />
{badge !== '' && <span className={css.badge}>{badge}</span>}
{!file.binary && <Counts file={file} t={t} />}
<label className={css.viewed} title={t('viewed.hint')}>
<input type="checkbox" checked={viewed === 'viewed'} aria-label={t('viewed.aria', { name })}