diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml new file mode 100644 index 00000000..432d5718 --- /dev/null +++ b/.github/workflows/ui-review-screenshots.yml @@ -0,0 +1,550 @@ +name: UI review screenshots + +on: + workflow_dispatch: + push: + branches: ["codex/dialog-keyboard-shortcuts"] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ui-review-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" + CARGO_TARGET_DIR: target/ui-review + UI_REVIEW_PR: "286" + UI_REVIEW_SCENARIO: "dialog" + UI_REVIEW_TARGET: "Confirmation dialog Enter/Esc hints and shortcut settings rows" + +jobs: + native-ui: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows UI review + runner: windows-2022 + platform: windows + - name: macOS UI review + runner: macos-26 + platform: macos + runs-on: ${{ matrix.runner }} + timeout-minutes: 75 + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + workspaces: ". -> target/ui-review" + + - name: Prepare Windows console runtime + if: matrix.platform == 'windows' + shell: pwsh + run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost + + - name: Build complete Pebrel application + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + + - name: Capture Windows application and changed UI + if: matrix.platform == 'windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $qa = Join-Path $env:RUNNER_TEMP 'ui-review' + $config = Join-Path $qa 'config' + New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $app = Join-Path $PWD 'target/ui-review/release/pebrel.exe' + $stdout = Join-Path $qa 'pebrel.stdout.log' + $stderr = Join-Path $qa 'pebrel.stderr.log' + $p = Start-Process $app -ArgumentList '--working-directory', $PWD -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Start-Sleep -Seconds 7 + + function Capture([string]$name) { + $shot = Join-Path $qa $name + for ($i = 0; $i -lt 8; $i++) { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot + if (Test-Path $shot) { return } + Start-Sleep -Seconds 1 + } + throw "Could not capture Pebrel window: $name" + } + + Capture '00-full-app.png' + + switch ($env:UI_REVIEW_SCENARIO) { + 'settings' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Start-Sleep -Seconds 1 + Capture '01-appearance-control-sizing.png' + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '90,246' + Capture '02-terminal-control-sizing.png' + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '90,76' + Capture '03-workspace-toolbar-sizing.png' + } + 'shortcuts' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Start-Sleep -Seconds 1 + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '90,360' + Capture '01-key-bindings-platform-labels.png' + } + 'dialog' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Start-Sleep -Seconds 1 + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '120,705' + Capture '01-confirm-dialog-shortcuts.png' + } + 'sidebar' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Start-Sleep -Seconds 1 + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '90,323' + Capture '01-interaction-panel-resize.png' + } + 'ssh-copy' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-ssh-settings-entry.png' + } + 'ssh-ports' { + Capture '01-ssh-ports-regression.png' + } + default { + Capture '01-platform-regression.png' + } + } + + @( + "scenario=$env:UI_REVIEW_SCENARIO" + "target=$env:UI_REVIEW_TARGET" + "platform=windows" + "pid=$($p.Id)" + ) | Set-Content -Path (Join-Path $qa 'review-target.txt') -Encoding utf8 + + if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Kill + } + + - name: Capture SSH copy menu on Windows + if: matrix.platform == 'windows' && env.UI_REVIEW_SCENARIO == 'ssh-copy' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $qa = Join-Path $env:RUNNER_TEMP 'ui-review' + $probe = Join-Path $qa 'ssh-copy-probe' + $config = Join-Path $probe 'config' + New-Item -ItemType Directory -Force -Path $probe, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $env:PEBREL_SSH_COPY_QA_DIR = $probe + $stdout = Join-Path $probe 'probe.stdout.log' + $stderr = Join-Path $probe 'probe.stderr.log' + $args = @('test', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture') + $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + + $ready = Join-Path $probe 'menu-ready.json' + $deadline = (Get-Date).AddMinutes(20) + while (-not (Test-Path $ready)) { + if ($cargo.HasExited) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw 'SSH screenshot probe exited before opening its native window.' + } + if ((Get-Date) -gt $deadline) { throw 'Timed out waiting for SSH screenshot probe.' } + Start-Sleep -Milliseconds 500 + } + + $probePid = (Get-Content $ready -Raw | ConvertFrom-Json).pid + ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '02-ssh-context-menu.png') + ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294' + Start-Sleep -Milliseconds 700 + ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '03-ssh-copy-editor.png') + New-Item -ItemType File -Force -Path (Join-Path $probe 'capture-complete') | Out-Null + $cargo.WaitForExit() + if ($cargo.ExitCode -ne 0) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw "SSH screenshot probe failed with exit code $($cargo.ExitCode)." + } + + - name: Capture macOS application and changed UI + if: matrix.platform == 'macos' + shell: bash + run: | + set -euo pipefail + qa="$RUNNER_TEMP/ui-review" + config="$qa/config" + mkdir -p "$qa" "$config" + cat >"$config/pebrel_settings.txt" <<'EOF' + language=en-US + theme=Nord + opacity=1 + blur=off + restore_session=false + resume_ai=false + auto_check_updates=off + EOF + + export PEBREL_CONFIG_DIR="$config" + app="$PWD/target/ui-review/release/pebrel" + "$app" --working-directory "$PWD" >"$qa/pebrel.stdout.log" 2>"$qa/pebrel.stderr.log" & + pid=$! + + cleanup() { + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + for _ in $(seq 1 20); do + if ! kill -0 "$pid" >/dev/null 2>&1; then + cat "$qa/pebrel.stderr.log" >&2 || true + exit 1 + fi + sleep 0.5 + done + + screencapture -x "$qa/00-full-app.png" + + focus_and_keys() { + local keys="$1" + PID="$pid" KEYS="$keys" osascript <<'APPLESCRIPT' || true + set targetPid to (system attribute "PID") as integer + set requestedKeys to system attribute "KEYS" + tell application "System Events" + set appProc to first application process whose unix id is targetPid + set frontmost of appProc to true + delay 0.4 + if requestedKeys is "settings" then + keystroke "," using command down + else if requestedKeys is "menu" then + click menu bar item 2 of menu bar 1 of appProc + else if requestedKeys is "quit" then + keystroke "q" using command down + end if + end tell + APPLESCRIPT + } + + click_offset() { + local x="$1" + local y="$2" + PID="$pid" X="$x" Y="$y" osascript <<'APPLESCRIPT' || true + set targetPid to (system attribute "PID") as integer + set clickX to (system attribute "X") as integer + set clickY to (system attribute "Y") as integer + tell application "System Events" + set appProc to first application process whose unix id is targetPid + set frontmost of appProc to true + tell window 1 of appProc + set {wx, wy} to position + end tell + click at {wx + clickX, wy + clickY} + end tell + APPLESCRIPT + sleep 1 + } + + click_bottom() { + local x="$1" + local offset="$2" + PID="$pid" X="$x" OFFSET="$offset" osascript <<'APPLESCRIPT' || true + set targetPid to (system attribute "PID") as integer + set clickX to (system attribute "X") as integer + set bottomOffset to (system attribute "OFFSET") as integer + tell application "System Events" + set appProc to first application process whose unix id is targetPid + set frontmost of appProc to true + tell window 1 of appProc + set {wx, wy} to position + set {ww, wh} to size + end tell + click at {wx + clickX, wy + wh - bottomOffset} + end tell + APPLESCRIPT + sleep 1 + } + + case "$UI_REVIEW_SCENARIO" in + settings) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-appearance-control-sizing.png" + click_offset 90 249 + screencapture -x "$qa/02-terminal-control-sizing.png" + click_offset 90 81 + screencapture -x "$qa/03-workspace-toolbar-sizing.png" + ;; + shortcuts) + focus_and_keys settings + sleep 2 + click_offset 90 363 + screencapture -x "$qa/01-key-bindings-platform-labels.png" + ;; + dialog) + focus_and_keys settings + sleep 2 + click_bottom 120 24 + screencapture -x "$qa/01-confirm-dialog-shortcuts.png" + ;; + sidebar) + focus_and_keys settings + sleep 2 + click_offset 90 326 + screencapture -x "$qa/01-interaction-panel-resize.png" + ;; + menu) + focus_and_keys menu + sleep 1 + screencapture -x "$qa/01-native-menu.png" + ;; + portable) + cleanup + trap - EXIT + bundle="$RUNNER_TEMP/Pebrel UI Review.app" + mkdir -p "$bundle/Contents/MacOS" + cp "$app" "$bundle/Contents/MacOS/pebrel" + chmod +x "$bundle/Contents/MacOS/pebrel" + cat >"$bundle/Contents/Info.plist" <<'PLIST' + + + + CFBundleExecutablepebrel + CFBundleIdentifierio.github.kuddev.pebrel.ui-review + CFBundleNamePebrel UI Review + CFBundlePackageTypeAPPL + + PLIST + unset PEBREL_CONFIG_DIR NEBULA_CONFIG_DIR PEBREL_CONFIG_FILE NEBULA_CONFIG_FILE PEBREL_GPUI_CONFIG NEBULA_GPUI_CONFIG + export HOME="$qa/home" + mkdir -p "$HOME" + open -n "$bundle" + sleep 4 + screencapture -x "$qa/01-portable-startup-dialog.png" + pkill -f "$bundle/Contents/MacOS/pebrel" || true + ;; + ssh-copy) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-ssh-settings-entry.png" + ;; + ssh-ports) + screencapture -x "$qa/01-ssh-ports-regression.png" + ;; + notification) + screencapture -x "$qa/01-foreground-notification-regression.png" + ;; + *) + screencapture -x "$qa/01-platform-regression.png" + ;; + esac + + { + echo "scenario=$UI_REVIEW_SCENARIO" + echo "target=$UI_REVIEW_TARGET" + echo "platform=macos" + sw_vers + } >"$qa/review-target.txt" + + - name: Upload reviewer screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: ui-review-${{ matrix.platform }} + path: | + ${{ runner.temp }}/ui-review/*.png + ${{ runner.temp }}/ui-review/*.txt + ${{ runner.temp }}/ui-review/*.log + ${{ runner.temp }}/ui-review/ssh-copy-probe/*.json + ${{ runner.temp }}/ui-review/ssh-copy-probe/*.log + if-no-files-found: error + retention-days: 14 + compression-level: 0 + + - name: Add screenshot pointers to job summary + if: always() + shell: bash + run: | + { + echo "## Native UI review" + echo + echo "- Platform: ${{ matrix.platform }}" + echo "- Scenario: $UI_REVIEW_SCENARIO" + echo "- Changed UI target: $UI_REVIEW_TARGET" + echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." + } >>"$GITHUB_STEP_SUMMARY" + + + publish-evidence: + name: Publish UI evidence + if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' + needs: native-ui + runs-on: ubuntu-22.04 + concurrency: + group: ui-review-evidence-publish + cancel-in-progress: false + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true + + - uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: ui-review-windows + path: ${{ runner.temp }}/ui-review-evidence/windows + + - uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: ui-review-macos + path: ${{ runner.temp }}/ui-review-evidence/macos + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Polish screenshots for PR presentation + shell: bash + run: | + set -euo pipefail + python3 -m pip install --disable-pip-version-check --quiet "Pillow==11.3.0" + python3 - <<'PY' + import os + from pathlib import Path + from PIL import Image, ImageDraw, ImageFilter, ImageStat + + source = Path(os.environ["RUNNER_TEMP"]) / "ui-review-evidence" + output = Path(os.environ["RUNNER_TEMP"]) / "ui-review-polished" + scale = 2 + radius = 24 * scale + padding = 36 * scale + shadow_pad = 18 * scale + + for platform in ("windows", "macos"): + src_dir = source / platform + dst_dir = output / platform + dst_dir.mkdir(parents=True, exist_ok=True) + if not src_dir.exists(): + continue + + for path in sorted(src_dir.glob("*.png")): + image = Image.open(path).convert("RGB") + image = image.resize( + (image.width * scale, image.height * scale), + Image.Resampling.LANCZOS, + ) + image = image.filter(ImageFilter.UnsharpMask(radius=0.7, percent=115, threshold=2)) + + stat = ImageStat.Stat(image.resize((1, 1))) + luminance = sum(stat.mean) / 3 + background = (243, 245, 248) if luminance > 120 else (18, 20, 24) + border = (205, 209, 216) if luminance > 120 else (65, 69, 78) + + frame_w = image.width + padding * 2 + frame_h = image.height + padding * 2 + canvas = Image.new("RGBA", (frame_w + shadow_pad * 2, frame_h + shadow_pad * 2), background + (255,)) + + shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + x0 = shadow_pad + padding + y0 = shadow_pad + padding + x1 = x0 + image.width + y1 = y0 + image.height + shadow_draw.rounded_rectangle( + (x0 + 4 * scale, y0 + 7 * scale, x1 + 4 * scale, y1 + 7 * scale), + radius=radius, + fill=(0, 0, 0, 92), + ) + shadow = shadow.filter(ImageFilter.GaussianBlur(12 * scale)) + canvas.alpha_composite(shadow) + + mask = Image.new("L", image.size, 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, image.width - 1, image.height - 1), + radius=radius, + fill=255, + ) + framed = Image.new("RGBA", image.size, (0, 0, 0, 0)) + framed.paste(image, (0, 0), mask) + canvas.alpha_composite(framed, (x0, y0)) + + draw = ImageDraw.Draw(canvas) + draw.rounded_rectangle( + (x0, y0, x1 - 1, y1 - 1), + radius=radius, + outline=border + (255,), + width=2 * scale, + ) + + out = dst_dir / f"{path.stem}-review.png" + canvas.convert("RGB").save(out, "PNG", optimize=True) + PY + + - name: Publish screenshots outside the PR diff + shell: bash + run: | + set -euo pipefail + source_dir="$RUNNER_TEMP/ui-review-evidence" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin ui-review-evidence || true + if git show-ref --verify --quiet refs/remotes/origin/ui-review-evidence; then + git switch -C ui-review-evidence origin/ui-review-evidence + else + git switch --orphan ui-review-evidence + git rm -rf . >/dev/null 2>&1 || true + fi + + polished_dir="$RUNNER_TEMP/ui-review-polished" + root="ui-review/pr-$UI_REVIEW_PR" + rm -rf "$root" + mkdir -p "$root/windows" "$root/macos" "$root/raw/windows" "$root/raw/macos" + find "$polished_dir/windows" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/windows/" \; 2>/dev/null || true + find "$polished_dir/macos" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/macos/" \; 2>/dev/null || true + find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/windows/" \; 2>/dev/null || true + find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/macos/" \; 2>/dev/null || true + printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" + printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt" + + git add "$root" + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "docs(ui): publish PR #$UI_REVIEW_PR screenshots" + git push origin HEAD:ui-review-evidence diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index b7442e0f..07080146 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -83,6 +83,7 @@ pub(crate) const GROUPS: &[(&str, &str, usize)] = &[ /// 只读展示行(无法在图形页编辑,TOML/settings 行仍可覆盖其中的表驱动键)。 pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[ + ("确认对话框:确定 / 取消", "Confirmation dialogs: confirm / cancel", "Enter / Esc"), ("切换到第 N 个标签页", "Select tab N", "Alt+1..9 / Ctrl+1..9"), ("启动 Profile N", "Launch Profile N", "Ctrl+Shift+1..9"), ("贴入 AI 修复建议", "Paste AI fix suggestion", "Ctrl+."), diff --git a/nebula_app/src/gpui_shell/prelude.rs b/nebula_app/src/gpui_shell/prelude.rs index ab50c27b..b4be2bdc 100644 --- a/nebula_app/src/gpui_shell/prelude.rs +++ b/nebula_app/src/gpui_shell/prelude.rs @@ -142,7 +142,7 @@ pub fn confirm_dialog( Button::new("confirm-cancel") .debug_selector(|| "confirm-dialog-cancel".to_owned()) .flex_1() - .label(cancel_text.clone()) + .label(format!("{cancel_text} ⎋")) .on_click(|_, window, cx| { window.dispatch_action(Box::new(CancelDialog), cx); }), @@ -151,7 +151,7 @@ pub fn confirm_dialog( Button::new("confirm-ok") .debug_selector(|| "confirm-dialog-ok".to_owned()) .flex_1() - .label(ok_text.clone()) + .label(format!("{ok_text} ↵")) .with_variant(ok_variant) .on_click(|_, window, cx| { window.dispatch_action(Box::new(ConfirmDialog), cx);