qwen-code/scripts/prepare.js
Shaojin Wen 582fb49603
feat(web-shell): git status chip, visual working-tree diff, and sidebar git status (#7054)
* feat(web-shell): git status chip, visual working-tree diff, and sidebar git status

Bring working-tree Git awareness to the Web Shell (browser daemon session UI):

- Toolbar branch chip becomes a live status indicator: dirty (staged/unstaged/
  untracked), ahead/behind upstream, stash count, detached HEAD, in-progress
  operation (merge/rebase/cherry-pick/revert/bisect), and conflict count, each
  with a non-color cue.
- Read-only "Changes" dialog: working-tree-vs-HEAD file list with per-file,
  line-level, per-side syntax-highlighted diffs; opens via /diff or a dirty
  chip; untracked files expand as fully-added and deleted files still diff.
- Per-workspace git status in the sidebar: a compact icon-only chip per trusted
  workspace (status dot + hover tooltip); click opens that workspace's dialog.

All git access goes through the daemon REST API with per-workspace trust
gating; new SDK status fields are optional and additive (v2).

* fix(web-shell): themed tooltips and git-chip review follow-ups

Tooltips now render on the themed popover surface (bg-popover /
text-popover-foreground / border + fill-popover arrow) instead of the
inverted bg-foreground default, so they read dark-on-dark rather than a
bright box on the dark theme. Fixing the shared primitive corrects the
git branch tooltip in the composer toolbar and sidebar, plus every other
tooltip, at once.

Also addressing review feedback on the git integration:
- Replace the hand-drawn detached/conflict/stash SVG icons with
  lucide-react (CircleDot / TriangleAlert / Layers) per the web-shell
  icon convention.
- Gate the tooltip "Working tree clean" message on an enriched status
  (computedAt) so a branch-only status no longer asserts clean.
- Include the file path in the diff dialog row aria-label so screen
  readers can distinguish files.
- Reset the toolbar git chip on workspace switch so it never shows the
  previous repo's branch/counts while the new fetch resolves.
- Log a sidebar git poll failure only on the success->failure transition
  to avoid spamming a long-lived tab.
- Correct the SDK doc for DaemonWorkspaceGitDiffFile.added/removed
  (0, not undefined, for binary files).

* fix(web-shell): address git-integration review suggestions

Follow-ups from the /review pass on the git integration:

- GitBranchIndicator: include the short SHA in the detached-HEAD tooltip
  title, and add the "Working tree clean" status to the aria-label (gated
  on an enriched status, matching the tooltip) so the two never drift.
- WorkspaceSection: keep the last known git status on a transient poll
  failure instead of blanking the chip for a whole interval.
- App: surface a toast for `/diff` when no workspace is available instead
  of silently consuming the composer input.
- Tests: cover the diff dialog's list-load and per-file load error paths,
  and detectGitOperation's revert/bisect branches.
- Design doc: align the getGitWorkingTreeStatus spec text with the
  decision (transient states return status with `operation`; null is
  reserved for non-repo / git failure).

* fix(web-shell): focus-visible ring for git chip button; align doc poll interval

- Add a :focus-visible outline to .gitBranchChipButton so keyboard users
  get a visible focus indicator (the chip resets UA button chrome).
- Design doc: align the active-workspace poll-interval references at 30s
  to match the implementation.

* fix(web-shell): surface capped diffs, catch row-build failures, cover degradation paths

Address the remaining review findings on the git integration:

- Truncation is no longer silent: fetchGitDiffHunksForFile now returns
  { hunks, truncated } — the parser records files that actually lost
  lines to MAX_LINES_PER_FILE (tracked path), and the untracked
  synthesis reports its byte/line caps. The route forwards an additive
  `truncated` flag on the hunks response (absent when not truncated, so
  older clients and daemons are unaffected), and the Changes dialog
  renders a "Diff truncated" note under the visible window.
- DiffHunks catches an unexpected buildRows rejection (e.g. malformed
  hunk lines) and shows the per-file error instead of leaving an
  unhandled rejection and a silently empty diff area.
- New tests: untracked and tracked truncation at the core caps, the
  route's truncated passthrough (and its absence when clean), the
  branch-only degradation when the working-tree summary throws, the
  malformed-hunks error path, and the Shiki success path (a fake
  tokenizer proving add rows pull new-side tokens and del rows pull
  old-side tokens, not the plain-text fallback).

* fix(web-shell): drop dialog backdrop-blur that froze the page on open

The dialog and alert-dialog overlays applied `backdrop-blur-xs`, which
forces the browser to rasterize and blur the entire content behind the
overlay when a dialog opens. With a long transcript behind it, that
main-thread paint+blur froze the whole page — e.g. clicking the git
branch chip to open the Changes dialog. Keep the bg-black/10 scrim for
separation and drop the blur.

* fix(core): guard synthesizeUntrackedHunk against non-regular files

synthesizeUntrackedHunk opened an untracked path before checking its
type, so an untracked FIFO (listed by `ls-files --others`) would block
on open() forever waiting on a writer — hanging the daemon's event loop
and leaving the Web Shell Changes dialog stuck on a permanent loading
state. lstat-gate on regular files before opening, matching the existing
guard in countUntrackedLines. Adds a FIFO regression test.

* fix(web-shell,core): rename expansion, no-newline marker, chip measurement

Round-5 review Criticals:

- core: key renamed diff entries by the real (post-rename) path and carry
  the old path for display, so renamed rows can be expanded — the synthetic
  `old => new` key was sent to git as a nonexistent literal path. The diff
  dialog renders the rename as `old → new`.
- core: preserve Git's `\ No newline at end of file` marker through the hunk
  parser so a trailing-newline-only edit isn't shown as identical
  removed/added lines (the viewer already renders it as a meta row).
- web-shell: the toolbar's hidden git-chip measurement replica now renders
  the full chip content via the extracted GitBranchChipContent, so the
  expanded width includes the status indicators and the compact/expanded
  toggle no longer oscillates near the responsive threshold.

* fix(build): generate git-commit info even when prepare build is skipped

The review tooling runs `npm ci` with QWEN_SKIP_PREPARE=1 (to skip the
heavy prepare build) and then builds only the changed workspaces. Because
`prepare` exited before generating the gitignored git-commit.ts, a
per-workspace build of packages/cli failed at the unchanged systemInfo.ts
on the missing `../generated/git-commit.js` module. Generate the git-commit
info in the skip path too — it is cheap and never fails hard — so a later
per-workspace build or typecheck finds the module. The non-skip path still
generates it via `npm run build`.

* fix(web-shell,cli): address round-6 review suggestions

- cli: carry the pre-rename path (oldPath) through DiffRenderRow and show
  renamed files as `old → new` in both the Ink and plain-text renderers.
  The rename-keying fix updated the daemon and web-shell dialog but not the
  CLI `/diff` renderer, which silently dropped the old path.
- web-shell: key DiffFileRow by workspace + path so switching workspace
  remounts the row instead of reusing another workspace's hunks/open state
  for a path both workspaces share.
- web-shell: show a loading placeholder in DiffHunks while rows are (re)built
  (e.g. after a theme switch) instead of an empty, jumpily-resized box.
- web-shell: cover the /diff local intercept in App.test.tsx (opens the
  Changes dialog and is not forwarded to the agent).

* fix(web-shell,cli,core): address round-7 review suggestions

- cli: sanitize the rendered filename (and pre-rename oldPath) in the Ink
  DiffStatsDisplay via sanitizeFilenameForDisplay, matching the plain-text
  renderer so a crafted path can't inject into the interactive view.
- cli: apply the read headers before awaiting the per-file diff fetch (as
  handleDiffList does) so error responses also carry no-store/nosniff.
- cli + web-shell: strip Unicode bidi embedding/isolate controls
  (U+202A-202E, U+2066-2069) in the filename/control-char sanitizers so a
  crafted filename can't visually spoof its extension.
- core: guard countStashEntries with an lstat type check before readFile, so
  a symlink-to-FIFO at logs/refs/stash can't block the event loop (the same
  hazard already guarded in the untracked-file readers).
- core: cover fetchGitDiffHunksForFile's transient-state guard with a test
  (the sibling helpers already had one).

* fix(web-shell,cli,core): address round-8 review suggestions

- core: pass --no-optional-locks to the ls-files call in
  fetchGitDiffHunksForFile, matching the other runGit calls so it doesn't
  contend for an optional index-refresh lock alongside concurrent git
  add/commit.
- cli: add a route test asserting a rename's oldPath survives serialization
  end-to-end (keyed by the new path, old path carried alongside).
- web-shell: add a GitDiffDialog test for the hiddenCount>0 "N more files
  not shown" note (every payload previously used hiddenCount: 0).
- web-shell: drop the nonexistent primaryLabel prop from the WorkspaceSection
  test (it is not a WorkspaceSectionProps member).
- docs: correct the plan doc — large-diff virtual scrolling was explicitly
  descoped (core caps + per-file lazy loading), not implemented in Phase 2.

* fix(cli,web-shell): address round-9 review findings

- cli: propagate the pre-rename oldPath through DiffDialog's
  perFileToUnified and render renamed files as `old → new` in the
  interactive diff viewer (the rename-keying fix had updated the daemon,
  the web-shell dialog, and the /diff stats, but not this viewer).
- cli: cover DiffStatsDisplay's rename (`old → new`) rendering and the
  sanitizeFilenameForDisplay path for hostile filenames carrying control
  characters.
- web-shell: guard the GitBranchIndicator test afterEach against
  double-unmounting an already-unmounted root (the localization tests
  assert on getTranslator without calling render()).

* fix(core,cli,web-shell): rename-aware single-file diff (old→new)

fetchGitDiffHunksForFile pathspec-limited the diff to the new path, which
defeats git's rename detection — a renamed file was reported as fully
added (every line +) instead of its actual edit. Thread an optional
pre-rename path through the single-file endpoint (core → route → SDK →
dialog) and diff old→new with -M when it is present, so expanding a
renamed file shows its real content change.

* fix(cli): address round-10 review suggestions

- DiffDialog: split the path-width budget between old and new paths for a
  rename (reserving the " → " separator) so the combined width stays within
  maxPathChars instead of overflowing the row layout.
- textUtils: extend MULTILINE_CONTROL_CHARS_REGEX with the Unicode bidi
  ranges (matching FILENAME_CONTROL_CHARS_REGEX) and add a test that
  sanitizeFilenameForDisplay strips bidi embedding/isolate controls.
- workspace-git-diff route: add a test that ?oldPath= is parsed and
  forwarded to fetchGitDiffHunksForFile.

* test(sdk),docs: cover diff client methods; align design doc

- sdk: add DaemonClient unit tests for workspaceGitDiff() and
  workspaceGitDiffFile(path, oldPath?) — URL construction (incl. urlEncode
  on path/oldPath, with and without oldPath, plus the workspace-qualified
  route) and response deserialization, mirroring the existing workspaceGit()
  test.
- docs: add the oldPath? param to the workspaceGitDiffFile API spec; record
  that the diff client methods now have unit tests (correcting the claim
  that workspaceGit() had none); attribute the bundle-limit bump to
  packages/sdk-typescript/scripts/build.js; clarify ahead/behind are relative
  to upstream (0, and ↑N/↓N not shown, without one).

* fix(web-shell,core): address round-11 review suggestions

- GitBranchIndicator: count conflicted entries as dirty — a merge where every
  changed file is conflicted (staged=unstaged=untracked=0) is still
  uncommitted, so the expanded chip's dirty dot / data-dirty now reflect it.
- core: split the status branch line at the last "..." (the branch/upstream
  separator) so a dotted branch name isn't truncated at the first "...".
- GitDiffDialog: guard DiffFileRow's in-flight fetch against unmount via a
  cancelled ref, matching DiffHunks / GitDiffDialog.
- tests: forward oldPath when expanding a renamed file in the web-shell
  dialog; bidi-strip coverage for the web-shell sanitizeControlChars;
  untrusted-guard coverage on the single-file diff route; conflicted-only
  dirty; branch-line "..." split.

* fix(web-shell,cli): address round-12 review suggestions

- DiffDialog: only render the rename "old → new" when there's room for both
  sides (≥19 cols, so each gets ≥8); otherwise fall back to the new path
  alone, so a narrow terminal no longer overflows the row (the Math.max(8,…)
  floor could exceed maxPathChars).
- GitBranchIndicator test: guard afterEach container.remove() for non-render
  tests run in isolation, and make the compact-mode ↑-suppression assertion
  non-vacuous by giving the fixture an ahead count.
- App: compute the active workspace once (useMemo) and share it between the
  git-status effect and the Changes-dialog entry point, so the chip and the
  dialog can't drift onto different repos.

* fix(core,docs): address round-13 review suggestions

- core: add a rebase-apply detection test (git am / an interrupted
  `rebase --apply` creates rebase-apply, which detectGitOperation also maps
  to 'rebase'); previously only rebase-merge was exercised.
- docs: correct section 5 to describe the actual diff-dialog mechanism
  (diffWorkspaceCwd state, not the stale activePanel design).

* test(core): cover stray no-newline marker before any hunk header

parseGitDiff's pre-hunk guard already skips a "\ No newline at end of
file" marker that appears before any @@ header, so a malformed/truncated
diff can't throw on a null currentHunk and lose subsequent files' hunks;
add a regression test pinning that behavior.

* fix(web-shell): unstick per-file diff loading and skip non-path git poll

- DiffFileRow: reset the cancelled-fetch flag on mount so StrictMode's
  mount/unmount/mount replay no longer leaves it latched at true, which
  dropped the fetched hunks and froze the row on "Loading changes…" despite
  a 200 response.
- WorkspaceSection: skip the git status poll when the workspace cwd is not an
  absolute path. A synthetic fallback workspace carries a display name there,
  which the cwd-qualified route rejects with a 400.

* fix(web-shell,cli): address review suggestions on the git diff surface

- GitDiffDialog: highlight each diff side independently so a small side
  keeps syntax highlighting even when the other side exceeds the size cap
  (the old guard dropped both as soon as either was too large).
- ChatEditor: complete the .gitBranchChipButton reset (font/color/padding/
  margin) so the clickable dirty-tree chip matches the read-only output chip
  instead of picking up UA button styling.
- DiffDialog: cover the interactive rename display (old to new on a wide
  terminal), mirroring the rename tests DiffStatsDisplay and GitDiffDialog
  already have.

* test(web-shell,cli): cover git chip clean/reload/traversal paths, fix doc

- GitDiffDialog: add the missing expect(header).not.toBeNull() guard to the
  three expand-file tests that lacked it, matching the others in the block.
- GitBranchIndicator: cover the known-clean aria-label branch (computedAt set
  and every change counter zero).
- WorkspaceSection: verify a reloadToken change re-fetches git status instead
  of waiting for the next 60s poll.
- workspace-git-diff route: verify a traversal oldPath is forwarded to core
  and surfaced as available:false rather than escaping the workspace.
- Design doc: /diff is handled via setDiffWorkspaceCwd, not setActivePanel.

* fix(core): allow literal `..foo` paths in diff normalization

- toRepoRelativePath: reject only a real climb-out (`..` or `../…`), not a
  literal `..foo` filename at the repo root, which the bare startsWith('..')
  over-rejected, leaving the diff viewer unable to render such a file.
- parseGitDiff: cover the truncatedPaths output set directly (it was only
  exercised indirectly through fetchGitDiffHunksForFile).

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-07-18 10:06:07 +00:00

54 lines
1.6 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
// Release workflow jobs set this when they run explicit build/bundle steps after
// npm ci. Workflows that rely on prepare-during-install should leave it unset.
const skipPrepare = ['1', 'true'].includes(
(process.env.QWEN_SKIP_PREPARE ?? '').toLowerCase(),
);
if (skipPrepare) {
// The heavy build/bundle/husky are skipped, but git-commit.ts (gitignored,
// imported by e.g. cli's systemInfo) is still required to build or typecheck
// the packages that import it. Generate it here so a later per-workspace
// build/typecheck — such as the review tooling's — doesn't fail on the
// missing module. The non-skip path generates it via `npm run build`.
run('npm', ['run', 'generate']);
console.log(
'Skipping prepare build/bundle/husky because QWEN_SKIP_PREPARE is set.',
);
process.exit(0);
}
run('husky');
run('npm', ['run', 'build']);
run('npm', ['run', 'bundle']);
function run(command, args = []) {
const result = spawnSync(command, args, {
shell: process.platform === 'win32',
stdio: 'inherit',
});
const label = args.length ? `${command} ${args.join(' ')}` : command;
if (result.error) {
console.error(`prepare: ${label} failed: ${result.error.message}`);
process.exit(1);
}
if (result.signal) {
console.error(`prepare: ${label} killed by signal ${result.signal}`);
process.exit(1);
}
if (result.status !== 0) {
console.error(`prepare: ${label} exited with status ${result.status}`);
process.exit(result.status ?? 1);
}
}