diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 309c24b..3415bd8 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -22,6 +22,15 @@ outputs: cache-hit: description: Whether the compiled target cache matched exactly value: ${{ steps.target.outputs.cache-hit }} + cache-matched-key: + description: The compiled target cache key actually restored by actions/cache + value: ${{ steps.target.outputs.cache-matched-key }} + legacy-fallback-invoked: + description: Whether the optional legacy cache fallback action was invoked + value: ${{ steps.cache-summary.outputs.legacy-fallback-invoked }} + legacy-fallback-outcome: + description: Outcome of the optional legacy cache fallback action + value: ${{ steps.cache-summary.outputs.legacy-fallback-outcome }} runs: using: composite @@ -96,6 +105,7 @@ runs: ${{ steps.identity.outputs.restore-key }} ${{ steps.identity.outputs.migration-key }} - name: Recover an existing combined cache during migration + id: legacy-fallback if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != '' uses: Swatinem/rust-cache@v2 with: @@ -104,6 +114,50 @@ runs: cache-bin: false cache-on-failure: true save-if: false + - name: Summarize compiled target cache + id: cache-summary + if: always() + shell: bash + env: + TARGET_CACHE_HIT: ${{ steps.target.outputs.cache-hit }} + TARGET_CACHE_MATCHED_KEY: ${{ steps.target.outputs.cache-matched-key }} + SAVE_KEY: ${{ steps.identity.outputs.key }} + LEGACY_CONFIGURED: ${{ inputs.legacy-key != '' && 'true' || 'false' }} + LEGACY_OUTCOME: ${{ steps.legacy-fallback.outcome }} + CACHE_WORKLOAD: ${{ inputs.key }} + RUNNER_OS_VALUE: ${{ runner.os }} + RUNNER_ARCH_VALUE: ${{ runner.arch }} + run: | + set -euo pipefail + + target_cache_hit="${TARGET_CACHE_HIT:-false}" + target_cache_matched_key="${TARGET_CACHE_MATCHED_KEY:-}" + save_key="${SAVE_KEY:-}" + legacy_configured="${LEGACY_CONFIGURED:-false}" + legacy_outcome="${LEGACY_OUTCOME:-skipped}" + legacy_invoked=false + if [[ "$legacy_configured" == 'true' && "$legacy_outcome" != 'skipped' && -n "$legacy_outcome" ]]; then + legacy_invoked=true + fi + + { + echo '### Rust compiled target cache' + echo + echo "- Workload: \`$CACHE_WORKLOAD\`" + echo "- Runner: \`$RUNNER_OS_VALUE / $RUNNER_ARCH_VALUE\`" + echo "- Target cache exact hit: \`$target_cache_hit\`" + echo "- Target cache matched key: \`$target_cache_matched_key\`" + echo "- Save key: \`$save_key\`" + echo "- Legacy fallback configured: \`$legacy_configured\`" + echo "- Legacy fallback invoked: \`$legacy_invoked\`" + echo "- Legacy fallback outcome: \`$legacy_outcome\`" + echo '- Legacy fallback status is separate and is not counted as target exact hit' + } >> "$GITHUB_STEP_SUMMARY" + + { + echo "legacy-fallback-invoked=$legacy_invoked" + echo "legacy-fallback-outcome=$legacy_outcome" + } >> "$GITHUB_OUTPUT" - name: Populate complete downloads before sharing the cache if: steps.downloads.outputs.cache-hit != 'true' shell: bash diff --git a/.github/workflows/architecture.yml b/.github/workflows/architecture.yml index b062120..5613d3c 100644 --- a/.github/workflows/architecture.yml +++ b/.github/workflows/architecture.yml @@ -36,6 +36,27 @@ jobs: scripts.tests.test_architecture_budgets scripts.tests.test_architecture_dependencies scripts.tests.test_architecture_governance + scripts.tests.test_prohibited_names + - name: Check prohibited product names in the new range + env: + PROHIBITED_NAMES_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} + PROHIBITED_NAMES_HEAD: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }} + run: | + set -euo pipefail + base="$PROHIBITED_NAMES_BASE" + head="$PROHIBITED_NAMES_HEAD" + # The first push has no before SHA. Derive its known parent when one + # exists; an unresolved/zero base must still fail closed in the checker. + if [[ -z "$base" || "$base" =~ ^0+$ ]]; then + base="$(git rev-parse "${head}^" 2>/dev/null || true)" + fi + if [[ -z "$base" || "$base" =~ ^0+$ || -z "$head" || "$head" =~ ^0+$ ]]; then + echo "::error::Cannot determine a non-zero base/head for prohibited-name range" >&2 + exit 1 + fi + python3 scripts/check_prohibited_names.py range \ + --base "$base" \ + --head "$head" - name: Check source size and dependency direction env: ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 40a4806..8205936 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -54,6 +54,17 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.13" + - name: Test CI contracts before matrix planning + run: | + python3 -m unittest \ + scripts.tests.test_ci_plan \ + scripts.tests.test_ci_native_tests \ + scripts.tests.test_stable_release \ + scripts.tests.test_ci_cache \ + scripts.tests.test_platform_cfg \ + scripts.tests.test_pr_size_workflow -v + - name: Check platform cfg budget before native jobs + run: python3 scripts/check_platform_cfg.py - name: Plan native validation id: matrix shell: bash diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 3ad7282..c209452 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -49,6 +49,20 @@ jobs: const size = counted <= 100 ? "S" : counted <= 500 ? "M" : counted <= LIMIT ? "L" : "XL"; const labels = new Set(pr.labels.map((l) => l.name)); const wanted = `size/${size}`; + const oversized = size === "XL" && !labels.has("size-exempt"); + core.info(`counted ${counted} lines (${ignored} ignored) in ${files.length} files -> ${wanted}`); + await core.summary + .addRaw(`PR size: **${size}**, ${counted} counted lines (${ignored} ignored), limit ${LIMIT}.`) + .write(); + if (oversized) { + core.setFailed(`${counted} changed source lines exceed the ${LIMIT}-line limit; split the pull request or add size-exempt.`); + } + // Fork tokens are read-only. The size decision above is still enforced; + // optional labels and comments must not turn a valid PR into a 403 failure. + if (pr.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.info("Fork PR: size is reported in the summary; no label/comment writes."); + return; + } for (const l of labels) { if (l.startsWith("size/") && l !== wanted) { await github.rest.issues.removeLabel({ ...context.repo, issue_number: pr.number, name: l }).catch(() => {}); @@ -58,8 +72,7 @@ jobs: await github.rest.issues.addLabels({ ...context.repo, issue_number: pr.number, labels: [wanted] }); } - core.info(`counted ${counted} lines (${ignored} ignored) in ${files.length} files -> ${wanted}`); - if (size !== "XL" || labels.has("size-exempt")) return; + if (!oversized) return; const marker = ""; const comments = await github.paginate(github.rest.issues.listComments, { @@ -78,4 +91,3 @@ jobs: ].join("\n"), }); } - core.setFailed(`${counted} changed source lines exceed the ${LIMIT}-line limit; split the pull request or add size-exempt.`); diff --git a/.gitignore b/.gitignore index 5b93fd4..f5160ec 100644 --- a/.gitignore +++ b/.gitignore @@ -159,6 +159,8 @@ scripts/drawer_repro.ps1 scripts/ghost_repro/ scripts/icon-build/ scripts/render_shell_icon.html +# The competitor-name policy is a maintained CI and hook contract, not a local probe. +!/scripts/check_prohibited_names.py # Only the maintained Markdown stress runner is public; its scratch variants stay local. /scripts/markdown-memory-* !/scripts/markdown-memory-stress.ps1 diff --git a/architecture/notes/nebula_app/input/2026-09-23-configurable-tab-rename.md b/architecture/notes/nebula_app/input/2026-09-23-configurable-tab-rename.md new file mode 100644 index 0000000..6378cfd --- /dev/null +++ b/architecture/notes/nebula_app/input/2026-09-23-configurable-tab-rename.md @@ -0,0 +1,68 @@ +# Configurable tab rename uses the shared keymap + +## Status + +Implemented for review. + +## Context + +Tab rename registered F2 directly in the GPUI workspace. The editable keymap did +not contain a rename action, so users could not release that key for a terminal +application. Adding a settings row alone would leave the original F2 binding active. + +## Evidence + +The workspace previously registered `f2` with `RenameActiveTab`, independently +of the shared defaults and settings action list. The keymap editor previously +added a replacement chord without disabling the action's original default. + +## Decision + +Add the shared `RenameTab` action with default F2 and expose its localized label +in the Tabs keymap group. Both shells use the existing `keybind=combo:action` +storage format, clear/reset operations and effective-binding calculation. + +Editing an action now uses one shared rebinding operation. It releases the +action's model-defined defaults and previous custom chords through `ReceiveChar`, +then assigns the replacement. Other explicit key owners are preserved. Raw +handwritten keybind entries retain their existing ordering and override semantics. + +The GPUI default comes from the shared table and adapts to the existing rename +action. Runtime overrides are registered at workspace and terminal scope. Menu +shortcut hints follow the effective binding instead of a hardcoded F2 label. + +## Rejected alternatives + +- Add only an editable row: changes the display while F2 still intercepts input. +- Detect a particular CLI and change its keys: terminal input ownership belongs + to the user's keymap and the application's negotiated keyboard protocol. +- Add another persistent rename-shortcut setting: duplicates the existing keymap. +- Remove the default for everyone: changes existing behavior without a user choice. + +## Consequences + +The default remains F2. Users can move it, clear it, or explicitly restore it; +changes apply without restarting the workspace. Keymap row edits move a binding +rather than leaving the model's previous chords as hidden aliases. Moving or +clearing the rename binding allows F2 to reach terminal applications. + +## Validation + +Model tests cover persistence round trips, default/custom binding release, +clear/reset and preservation of another action's explicit ownership. GPUI tests +exercise actual event dispatch in both tab layouts, menu binding lookup, the +rendered settings row and capture cancellation. A terminal-context probe verifies +F2 delivery with VT, Win32 and enhanced keyboard modes after remapping. + +The explicit legacy-shell build and independent translation contracts are also +checked. Virtual-window tests do not claim an authenticated CLI session or full +desktop visual acceptance. + +## Supersedes + +The workspace-only F2 default and UI row edits that retained old default chords. + +## Revisit when + +The keymap gains explicit multiple-shortcut editing or a unified ownership model +for additional platform-specific aliases. diff --git a/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md b/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md index 3b3c9d5..dfe22fd 100644 --- a/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md +++ b/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md @@ -60,6 +60,15 @@ The existing Windows diagnostic dispatch continues to use its separate workflow. ## Validation +The required `lint` job now runs the lightweight planner/native-workflow/ +stable-release and cache contract tests after `cargo fmt --all -- --check` and before +`ci_plan.py` writes either matrix. The count is 7 planner tests, 10 native +workflow tests, 17 stable-release tests and 8 cache contract tests. A final local run of the exact +command completed 42 tests (41 passed, one Windows-only case skipped) in 3.341 seconds on 2026-09-23; this is a local +measurement, not a general speed guarantee. The workflow contract test checks +that this step precedes planning and that both dynamic matrices depend on the +required lint job. + Planner tests cover draft and ready PRs, all full-validation caller events, malformed input and real command-line output. Workflow contract tests require the existing lint check to own planning, both matrices to depend on it, draft step diff --git a/architecture/notes/scripts/ci/2026-09-23-prohibited-name-range-check.md b/architecture/notes/scripts/ci/2026-09-23-prohibited-name-range-check.md new file mode 100644 index 0000000..126a155 --- /dev/null +++ b/architecture/notes/scripts/ci/2026-09-23-prohibited-name-range-check.md @@ -0,0 +1,49 @@ +# Prohibited-name range checks + +## Status + +Implemented for review. The existing local hook modes remain unchanged; the public CI entry point is `range --base BASE --head HEAD`. + +## Context + +The repository already used an ignored `scripts/check_prohibited_names.py` in staged, commit-message, and pre-push hooks. The policy must also run in the existing `architecture-contracts` check for pull requests, merge groups, and pushes to `main`, without rescanning old release history or making legitimate attribution and protocol compatibility impossible. + +## Evidence + +- `.github/workflows/architecture.yml` already runs on `pull_request`, `merge_group`, and `push` to `main`, with a full checkout. +- The local checker already owns the prohibited-name list and hook behavior; duplicating the list in workflow YAML would create a second policy source. +- Inline copyright/third-party attribution can contain a project name, while Cargo git/source references and protocol/external-format identifiers include key-reporting constants, multiplexer escape sequences, and theme-format variants. + +- macOS run 35950152526 rejected a malformed UTF-8 filename before the checker + could run. The fixture now writes a Git tree directly, preserving the same + invalid-path regression without depending on filesystem filename support. + +## Decision + +- Publish the existing checker as the single policy implementation and add a fail-closed range mode. +- Resolve both revisions, compute their `merge-base`, scan ordinary commits against their first parent and merge commits with a combined diff, and scan every commit message in the resulting range. Missing, zero, unreadable, or unrelated revisions return failure. Invalid UTF-8 in paths, patches or messages fails explicitly rather than substituting another path or text. +- Keep only the checker, its contract tests and the intentional negative comparison fixture as exact path exceptions. Strip only reviewed inline attribution, Cargo git/source references, and protocol compatibility identifiers before applying the existing patterns. +- Run the checker as a step inside `architecture-contracts` and run its unit tests with the other guardrail tests. No new required check or server-side ruleset change is introduced. + +## Rejected alternatives + +- Scanning the whole repository would fail on historical, already-reviewed attribution and compatibility names. +- Using only `git diff base..head` would miss a prohibited name added and removed by separate commits; range mode reuses the hook's per-commit added-line scan. Comparing every merge to its first parent would replay second-parent main changes, so merge commits use combined diff instead. +- Treating an unknown base as an empty range would make an unavailable GitHub event input look green; the workflow derives only a known first-push parent and otherwise fails. A normal PR that has diverged from main uses its merge-base. +- Adding a broad `docs/`, `src/`, or product-directory exemption would hide ordinary competitor comparisons, so exemptions remain exact and line-scoped. + +## Consequences + +New PR/merge-group/main-push text and commit subjects are checked by the existing required job. A legitimate inline attribution, real dependency source, or supported protocol identifier remains usable. Adding another legal/protocol/dependency spelling requires a narrow fixture and policy update rather than silently bypassing the scan. + +## Validation + +`scripts/tests/test_prohibited_names.py` covers ordinary comparison failure, inline attribution positive/negative paths, protocol identifiers, Cargo dependency references, GitHub CLI text, range text, range commit messages, diverged related bases, unrelated/missing bases, old-history exclusion, added-then-deleted text, and merge-resolution additions. The checker also covers malformed UTF-8 paths/text and normal Unicode/control-character paths; the malformed-path fixture constructs Git tree objects without checking them out, so it also runs on filesystems that reject those bytes. + +## Supersedes + +None. + +## Revisit when + +GitHub changes the event payload fields used for base/head selection, the repository adopts a different merge queue event, or a new legal/protocol/dependency contract requires a narrowly documented exception. diff --git a/architecture/notes/scripts/ci/2026-09-24-fork-size-check.md b/architecture/notes/scripts/ci/2026-09-24-fork-size-check.md new file mode 100644 index 0000000..056ebda --- /dev/null +++ b/architecture/notes/scripts/ci/2026-09-24-fork-size-check.md @@ -0,0 +1,58 @@ +# Keep the size decision independent of optional PR writes + +## Status + +Implemented for review. + +## Context + +Fork pull requests have a read-only token. The required size check counted files, +then tried to add a size label before reporting its decision. An ordinary small +contribution therefore failed with HTTP 403 instead of reporting its actual size. + +## Evidence + +PR 274 at `98fad00b01da9dd089581e4d4f66a251bfc1404d` changes 127 Rust source lines. +Run 35893377849 had PullRequests-read permission and failed at POST issue labels +while trying to add the ordinary `size/M` classification. Its native tests passed. + +## Decision + +Compute and publish the size result before optional writes. The existing 1500-line +limit, ignored documentation/assets and maintainer exemption semantics remain the +same. An oversized fork fails immediately. Forks report their result in the job +summary and do not attempt labels or comments with a read-only token. Same-repository +pull requests retain their existing labels and oversized-change guidance. + +Tests execute the actual inline workflow script with fake APIs, so there is one +implementation of the counting and fork boundary. Node is already available on +the supported hosted images; the tests require it and use no npm dependencies. + +## Rejected alternatives + +Broadening fork token access or executing fork code in a privileged PR event is +unnecessary. Suppressing the entire size check on forks would waive its invariant. +A separate privileged labeling service adds another trust boundary and is not +needed to validate source size. + +## Consequences + +Fork contributors receive a genuine size-check result without write access. +Automatic size labels remain available for same-repository contributions; +maintainers may apply the ordinary size label to a fork. Such a label is not a +budget exemption, and the source count is still recomputed by the required check. + +## Validation + +The workflow-script tests cover a small fork, oversized fork, exact limit, +existing maintainer exemption, deleted-fork metadata and same-repository guidance. +No test calls the real GitHub write APIs. Actionlint validates the workflow. + +## Supersedes + +None. + +## Revisit when + +A metadata-only labeling service is justified, or the source-size policy changes +through a separately reviewed rule update. diff --git a/architecture/notes/scripts/ci/2026-09-24-platform-preflight.md b/architecture/notes/scripts/ci/2026-09-24-platform-preflight.md new file mode 100644 index 0000000..971cc5b --- /dev/null +++ b/architecture/notes/scripts/ci/2026-09-24-platform-preflight.md @@ -0,0 +1,62 @@ +# Check the platform budget before allocating native runners + +## Status + +Implemented for review. + +## Context + +The platform-condition budget ran only during packaging. A Windows-only WSL +regression introduced one additional test-level condition, leaving the source +count at 485 against a budget of 484. All ten PR checks passed because none ran +that budget checker. Preview preparation then failed before any package build. + +## Evidence + +- Preview run 35882296442 failed in `Check platform cfg budget` on main commit + `1d26c2dc9860a6cfb61c14b630c12d36dbf95e43`. +- The added condition guarded the WSL truecolor regression, not a new production + platform branch. Five related WSL forwarding tests repeated the same Windows + compilation condition. +- Sharing that condition at the WSL test-module boundary preserves all five test + bodies and lowers the repository count to 481 without moving source outside + the scanner's declared roots or adding an exclusion. + +## Decision + +- Keep the WSL forwarding regressions together under one Windows test module in + their existing owning source file. Other identity and PATH tests retain their + existing boundaries. Production environment behavior is unchanged. +- Tighten the existing budget from 484 to the measured count of 481. +- Execute the same authoritative platform checker in required lint before matrix + planning. Keep the packaging check as an independent release precondition. +- Run the checker's contract tests with the existing lightweight CI contracts. + +## Rejected alternatives + +Raising the budget, deleting the new regression, excluding test files from the +scanner, or ignoring the packaging failure would hide the mismatch. None is +needed. This change also does not turn the historical text-count heuristic into +a claim that architectural coupling has been proven absent. + +## Consequences + +The five WSL tests share one platform boundary and remain Windows-only. Budget +failures are visible before native runner allocation. Legitimate future scanner +policy changes still need their own reproducer, tests and review. + +## Validation + +The platform checker reports 481 against 481. Validation also compares the test +function inventory, runs the Windows identity/WSL regression group, and checks +that lint executes the gate before planning without automatic budget updates or +failure suppression. + +## Supersedes + +None. + +## Revisit when + +The text-based platform counter is replaced with a reviewed semantic contract, +or native runner planning is moved to another shared preflight owner. diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json index 2c668ec..e43307f 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -73,6 +73,7 @@ "delete": "Löschen", "edit": "Bearbeiten", "rename": "Umbenennen", + "rename_tab": "Tab umbenennen", "copy": "Kopieren", "paste": "Einfügen", "cut": "Ausschneiden", diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 137445a..30aa94d 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -237,6 +237,7 @@ "delete": "Delete", "edit": "Edit", "rename": "Rename", + "rename_tab": "Rename tab", "copy": "Copy", "paste": "Paste", "cut": "Cut", diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json index ddd85cf..773b594 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -73,6 +73,7 @@ "delete": "Eliminar", "edit": "Editar", "rename": "Cambiar nombre", + "rename_tab": "Renombrar pestaña", "copy": "Copiar", "paste": "Pegar", "cut": "Cortar", diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json index c6f6e89..bbd5169 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -123,6 +123,7 @@ "delete": "Supprimer", "edit": "Modifier", "rename": "Renommer", + "rename_tab": "Renommer l’onglet", "copy": "Copier", "paste": "Coller", "cut": "Couper", diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json index 1de3815..08159b7 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -73,6 +73,7 @@ "delete": "Elimina", "edit": "Modifica", "rename": "Rinomina", + "rename_tab": "Rinomina scheda", "copy": "Copia", "paste": "Incolla", "cut": "Taglia", diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json index 1b83876..2ed02bf 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -73,6 +73,7 @@ "delete": "削除", "edit": "編集", "rename": "名前を変更", + "rename_tab": "タブの名前を変更", "copy": "コピー", "paste": "貼り付け", "cut": "切り取り", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 19a9fd6..b676c9e 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -73,6 +73,7 @@ "delete": "삭제", "edit": "편집", "rename": "이름 바꾸기", + "rename_tab": "탭 이름 바꾸기", "copy": "복사", "paste": "붙여넣기", "cut": "잘라내기", diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json index 645a6e3..6b7366e 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -73,6 +73,7 @@ "delete": "Excluir", "edit": "Editar", "rename": "Renomear", + "rename_tab": "Renomear aba", "copy": "Copiar", "paste": "Colar", "cut": "Recortar", diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json index e039409..fd75e5c 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -73,6 +73,7 @@ "delete": "Удалить", "edit": "Изменить", "rename": "Переименовать", + "rename_tab": "Переименовать вкладку", "copy": "Копировать", "paste": "Вставить", "cut": "Вырезать", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 607bb1b..babb11c 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -237,6 +237,7 @@ "delete": "删除", "edit": "编辑", "rename": "重命名", + "rename_tab": "重命名标签页", "copy": "复制", "paste": "粘贴", "cut": "剪切", diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index 23e8cda..7f220dd 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -97,6 +97,7 @@ "delete": "刪除", "edit": "編輯", "rename": "重新命名", + "rename_tab": "重新命名分頁", "copy": "複製", "paste": "貼上", "cut": "剪下", diff --git a/nebula_app/src/agent_env.rs b/nebula_app/src/agent_env.rs index 5714be4..a6788d8 100644 --- a/nebula_app/src/agent_env.rs +++ b/nebula_app/src/agent_env.rs @@ -304,77 +304,83 @@ mod tests { assert_eq!(entries.iter().filter(|entry| same_directory(entry, directory)).count(), 1); } + // WSL forwarding has one Windows compilation boundary for its whole contract. #[cfg(windows)] - #[test] - fn wslenv_entries_match_variables() { - // 透传表里的路径条目是字面量。变量改名而这里忘记跟着改,就会静默丢掉 - // WSL 侧的可达性——用一个断言把它变成编译期之后立刻可见的失败。 - let names: Vec<&str> = WSLENV_ENTRIES.iter().copied().map(variable_name).collect(); - assert!(names.contains(&CLI_ENV), "{CLI_ENV} missing from WSLENV passthrough: {names:?}"); - assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}"); - assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}"); - assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV)); - assert!(names.contains(&"COLORTERM")); - } + mod wsl { + use super::*; - #[cfg(windows)] - #[test] - fn wsl_receives_truecolor_without_replacing_an_explicit_forwarding_rule() { - let mut env = HashMap::from([ - ("COLORTERM".to_owned(), "truecolor".to_owned()), - ("WSLENV".to_owned(), "KEEP/p".to_owned()), - ]); - apply(&mut env, 8); - assert_eq!(env["COLORTERM"], "truecolor"); - assert!(env["WSLENV"].split(':').any(|entry| entry == "COLORTERM")); - assert!(env["WSLENV"].split(':').any(|entry| entry == "KEEP/p")); - env.insert("WSLENV".into(), "KEEP/p:COLORTERM/u".into()); - env.insert("COLORTERM".into(), "24bit".into()); - apply(&mut env, 8); - assert_eq!(env["COLORTERM"], "24bit"); - let entries: Vec<_> = - env["WSLENV"].split(':').filter(|entry| variable_name(entry) == "COLORTERM").collect(); - assert_eq!(entries, ["COLORTERM/u"]); - } + #[test] + fn wslenv_entries_match_variables() { + // 透传表里的路径条目是字面量。变量改名而这里忘记跟着改,就会静默丢掉 + // WSL 侧的可达性——用一个断言把它变成编译期之后立刻可见的失败。 + let names: Vec<&str> = WSLENV_ENTRIES.iter().copied().map(variable_name).collect(); + assert!( + names.contains(&CLI_ENV), + "{CLI_ENV} missing from WSLENV passthrough: {names:?}" + ); + assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}"); + assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}"); + assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV)); + assert!(names.contains(&"COLORTERM")); + } - #[cfg(windows)] - #[test] - fn wslenv_keeps_foreign_entries() { - let mut env = HashMap::new(); - // `shell_detect::wsl_cwd_report_env` 先写过 cwd 上报的透传条目。 - env.insert("WSLENV".to_owned(), "PROMPT_COMMAND".to_owned()); - apply(&mut env, 3); + #[test] + fn wsl_receives_truecolor_without_replacing_an_explicit_forwarding_rule() { + let mut env = HashMap::from([ + ("COLORTERM".to_owned(), "truecolor".to_owned()), + ("WSLENV".to_owned(), "KEEP/p".to_owned()), + ]); + apply(&mut env, 8); + assert_eq!(env["COLORTERM"], "truecolor"); + assert!(env["WSLENV"].split(':').any(|entry| entry == "COLORTERM")); + assert!(env["WSLENV"].split(':').any(|entry| entry == "KEEP/p")); + env.insert("WSLENV".into(), "KEEP/p:COLORTERM/u".into()); + env.insert("COLORTERM".into(), "24bit".into()); + apply(&mut env, 8); + assert_eq!(env["COLORTERM"], "24bit"); + let entries: Vec<_> = env["WSLENV"] + .split(':') + .filter(|entry| variable_name(entry) == "COLORTERM") + .collect(); + assert_eq!(entries, ["COLORTERM/u"]); + } - let wslenv = env.get("WSLENV").expect("WSLENV"); - let entries: Vec<&str> = wslenv.split(':').collect(); - assert!(entries.contains(&"PROMPT_COMMAND"), "cwd reporting must survive: {wslenv}"); - assert!(entries.contains(&PANE_ENV), "pane identity must cross into WSL: {wslenv}"); - // 路径必须带 `/p`,否则来宾拿到无法执行的 `D:\…` 字面量。 - assert!(entries.contains(&"NEBULA_CLI/p"), "cli path needs translation: {wslenv}"); - } + #[test] + fn wslenv_keeps_foreign_entries() { + let mut env = HashMap::new(); + // `shell_detect::wsl_cwd_report_env` 先写过 cwd 上报的透传条目。 + env.insert("WSLENV".to_owned(), "PROMPT_COMMAND".to_owned()); + apply(&mut env, 3); - #[cfg(windows)] - #[test] - fn wslenv_merge_is_idempotent() { - let mut env = HashMap::new(); - apply(&mut env, 4); - let once = env.get("WSLENV").cloned().expect("WSLENV"); - apply(&mut env, 4); - assert_eq!(env.get("WSLENV"), Some(&once)); - } + let wslenv = env.get("WSLENV").expect("WSLENV"); + let entries: Vec<&str> = wslenv.split(':').collect(); + assert!(entries.contains(&"PROMPT_COMMAND"), "cwd reporting must survive: {wslenv}"); + assert!(entries.contains(&PANE_ENV), "pane identity must cross into WSL: {wslenv}"); + // 路径必须带 `/p`,否则来宾拿到无法执行的 `D:\…` 字面量。 + assert!(entries.contains(&"NEBULA_CLI/p"), "cli path needs translation: {wslenv}"); + } - #[cfg(windows)] - #[test] - fn wslenv_respects_foreign_flags_for_same_variable() { - let mut env = HashMap::new(); - // 宿主已用 `/l`(列表)传同名变量:尊重它,不改成我们的 `/p`, - // 否则会破坏那个工具原有的转换语义。 - env.insert("WSLENV".to_owned(), "NEBULA_CLI/l".to_owned()); - apply(&mut env, 5); + #[test] + fn wslenv_merge_is_idempotent() { + let mut env = HashMap::new(); + apply(&mut env, 4); + let once = env.get("WSLENV").cloned().expect("WSLENV"); + apply(&mut env, 4); + assert_eq!(env.get("WSLENV"), Some(&once)); + } - let wslenv = env.get("WSLENV").expect("WSLENV"); - let entries: Vec<&str> = wslenv.split(':').collect(); - assert!(entries.contains(&"NEBULA_CLI/l")); - assert!(!entries.contains(&"NEBULA_CLI/p")); + #[test] + fn wslenv_respects_foreign_flags_for_same_variable() { + let mut env = HashMap::new(); + // 宿主已用 `/l`(列表)传同名变量:尊重它,不改成我们的 `/p`, + // 否则会破坏那个工具原有的转换语义。 + env.insert("WSLENV".to_owned(), "NEBULA_CLI/l".to_owned()); + apply(&mut env, 5); + + let wslenv = env.get("WSLENV").expect("WSLENV"); + let entries: Vec<&str> = wslenv.split(':').collect(); + assert!(entries.contains(&"NEBULA_CLI/l")); + assert!(!entries.contains(&"NEBULA_CLI/p")); + } } } diff --git a/nebula_app/src/config/bindings.rs b/nebula_app/src/config/bindings.rs index 2c9485d..f65c0f2 100644 --- a/nebula_app/src/config/bindings.rs +++ b/nebula_app/src/config/bindings.rs @@ -226,6 +226,9 @@ pub enum Action { /// Close the focused tab (or pane, when the tab is split). CloseTab, + /// Rename the active tab. + RenameTab, + /// Toggle a left/right split in the focused tab. SplitRight, diff --git a/nebula_app/src/config/bindings/defaults.rs b/nebula_app/src/config/bindings/defaults.rs index 7fc1e7d..85806e5 100644 --- a/nebula_app/src/config/bindings/defaults.rs +++ b/nebula_app/src/config/bindings/defaults.rs @@ -92,7 +92,6 @@ pub fn default_key_bindings() -> Vec { ArrowLeft, +BindingMode::APP_CURSOR, ~BindingMode::VI, ~BindingMode::SEARCH; Action::Esc("\x1bOD".into()); // Legacy keys handling which can't be automatically encoded. F1, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOP".into()); - F2, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOQ".into()); F3, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOR".into()); F4, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOS".into()); Tab, ModifiersState::SHIFT, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1b[Z".into()); @@ -201,6 +200,7 @@ pub fn nebula_key_bindings() -> Vec { KeyBinding; "t", ctrl_shift; Action::CreateNewTab; "w", ctrl_shift; Action::CloseTab; + F2; Action::RenameTab; Tab, ctrl; Action::SelectNextTab; Tab, ctrl_shift; Action::SelectPreviousTab; "e", ctrl_shift; Action::CreateNewWindow; diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs index 2382bbc..c209117 100644 --- a/nebula_app/src/display/context_menu.rs +++ b/nebula_app/src/display/context_menu.rs @@ -99,6 +99,10 @@ struct Row { danger: bool, } +fn row_shortcut<'a>(row: &Row, rename: &'a str) -> &'a str { + if matches!(row.action, ContextMenuAction::RenameTab(_)) { rename } else { row.hint } +} + #[derive(Debug, Clone)] struct MenuLayout { panel: (f32, f32, f32, f32), @@ -225,7 +229,7 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32 ContextMenuAction::RenameTab(index), ICON_EDIT, "重命名", - "F2", + "", cursor_y, false, )); @@ -520,10 +524,14 @@ pub(super) fn draw(display: &mut Display) { } // 右键菜单和命令面板共用同一颗键帽组件;快捷键不再是贴在右缘的一串 // 灰字,独立小键帽给出清晰的可按暗示,也让两类浮层形成同一套视觉语法。 + let rename = keymap::effective_combo(&crate::config::Action::RenameTab, &display.nebula_keymap) + .map(|(combo, _)| combo) + .unwrap_or_default(); for row in &layout.rows { - if !row.hint.is_empty() { + let hint = row_shortcut(row, &rename); + if !hint.is_empty() { let combo = keycap::layout_combo( - row.hint, + hint, row.rect.0 + row.rect.2 - s(10.0), row.rect.1 + row.rect.3 * 0.5, size.cell_width(), @@ -558,9 +566,10 @@ pub(super) fn draw(display: &mut Display) { row.label, &mut display.glyph_cache, ); - if !row.hint.is_empty() { + let hint = row_shortcut(row, &rename); + if !hint.is_empty() { let combo = keycap::layout_combo( - row.hint, + hint, row.rect.0 + row.rect.2 - s(10.0), row.rect.1 + row.rect.3 * 0.5, size.cell_width(), diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 80679e3..b7442e0 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -45,6 +45,7 @@ pub(crate) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[ // -- 标签页 -- (Action::CreateNewTab, "新建标签页", "New tab"), (Action::CloseTab, "关闭标签页 / 分屏", "Close tab / pane"), + (Action::RenameTab, "重命名标签页", "Rename tab"), (Action::SelectNextTab, "下一个标签页", "Next tab"), (Action::SelectPreviousTab, "上一个标签页", "Previous tab"), // -- 窗格 -- @@ -74,7 +75,7 @@ pub(crate) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[ /// `usize` 是本组行数;区间连续覆盖全部可编辑行(含第 0 行快速终端)。 pub(crate) const GROUPS: &[(&str, &str, usize)] = &[ ("全局", "Global", 6), - ("标签页", "Tabs", 4), + ("标签页", "Tabs", 5), ("窗格", "Panes", 7), ("侧栏面板", "Side panels", 2), ("终端", "Terminal", 9), @@ -87,6 +88,17 @@ pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[ ("贴入 AI 修复建议", "Paste AI fix suggestion", "Ctrl+."), ]; +pub(crate) fn action_label( + row: &(Action, &'static str, &'static str), + language: super::UiLanguage, +) -> &'static str { + if row.0 == Action::RenameTab { + language.text(crate::i18n::Message::CommonRenameTab) + } else { + language.pick(row.1, row.2) + } +} + #[cfg(test)] mod group_tests { use super::*; @@ -115,6 +127,13 @@ pub(crate) fn default_shortcuts() -> Vec<(String, Action)> { .collect() } +/// Editing one action moves its shortcut: old keys pass through to the terminal. +/// Keep explicit bindings for other actions so the editor can report conflicts. +pub(crate) fn rebind_action(raw: &mut Vec<(String, String)>, action: &Action, combo: String) { + clear_action(raw, action); + raw.push((combo, action_storage_name(action))); +} + /// Preserve the existing keybind format: ReceiveChar explicitly passes a key /// to the terminal. Clearing an action disables its defaults and its custom key. pub(crate) fn clear_action(raw: &mut Vec<(String, String)>, action: &Action) { @@ -574,6 +593,56 @@ pub(crate) fn gpui_mods_prefix(modifiers: &::gpui::Modifiers) -> String { #[cfg(test)] mod tests { + #[test] + fn rename_binding_moves_persists_clears_and_restores() { + use super::*; + let action = Action::RenameTab; + assert_eq!(effective_combo(&action, &[]), Some(("F2".into(), false))); + let mut raw = Vec::new(); + rebind_action(&mut raw, &action, "ctrl+alt+r".into()); + let saved = nebula_settings::apply_keybinds("theme=nord\n", &raw); + let mut restored = nebula_settings::keybind_pairs_from_text(&saved); + let bindings = build_bindings(&restored); + assert_eq!(effective_combo(&action, &bindings), Some(("Ctrl+Alt+R".into(), true))); + let (mods, trigger) = parse_combo("f2").unwrap(); + let f2 = bindings.iter().find(|b| b.mods == mods && b.trigger == trigger).unwrap(); + assert_eq!(f2.action, Action::ReceiveChar, "the old default must reach the CLI"); + clear_action(&mut restored, &action); + assert_eq!(effective_combo(&action, &build_bindings(&restored)), None); + reset_action(&mut restored, &action); + assert_eq!( + effective_combo(&action, &build_bindings(&restored)), + Some(("F2".into(), false)) + ); + } + + #[test] + fn rename_rebinding_preserves_explicit_ownership_of_the_old_key() { + use super::*; + let mut raw = vec![("f2".into(), "CreateNewTab".into())]; + rebind_action(&mut raw, &Action::RenameTab, "ctrl+alt+r".into()); + assert_eq!(raw[0], ("f2".into(), "CreateNewTab".into())); + assert!( + !raw.iter() + .any(|(key, action)| key.eq_ignore_ascii_case("f2") && action == "ReceiveChar") + ); + } + + #[test] + fn rename_is_editable_and_localized_in_every_language() { + use super::*; + let row = + EDITABLE_ACTIONS.iter().find(|(action, ..)| *action == Action::RenameTab).unwrap(); + for language in super::super::UiLanguage::ALL { + assert_eq!( + action_label(row, *language), + language.text(crate::i18n::Message::CommonRenameTab) + ); + assert!(!action_label(row, *language).is_empty()); + } + assert_eq!(action_label(row, super::super::UiLanguage::ZhCn), "重命名标签页"); + } + #[test] fn clearing_ctrl_k_survives_persistence_and_restore_is_explicit() { use super::*; diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index c227cee..dab06c2 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -5670,7 +5670,7 @@ impl Display { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .map(|(_, zh, en)| self.nebula_language.pick(zh, en).to_owned()) + .map(|row| keymap::action_label(row, self.nebula_language).to_owned()) .unwrap_or_default() } }; @@ -7202,8 +7202,7 @@ impl Display { } } - /// 捕获完成:`combo` 归属该行动作。同 combo 的旧自定义行被移除(键随 - /// 最后写入者),该动作旧的自定义行也移除(一动作一自定义键)。 + /// Apply the shared rebinding policy, then refresh the editor. pub fn keymap_assign(&mut self, row: usize, combo: String) { if row == keymap::QUICK_TERMINAL_ROW { self.nebula_keymap_capture = None; @@ -7215,14 +7214,15 @@ impl Display { self.pending_update.dirty = true; return; } - let action_row = row.saturating_sub(1); - let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(action_row) else { return }; - let name = keymap::action_storage_name(action); - self.nebula_keybinds.retain(|(c, a)| c != &combo && !a.eq_ignore_ascii_case(&name)); - self.nebula_keybinds.push((combo, name)); + let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; + keymap::rebind_action(&mut self.nebula_keybinds, action, combo); self.keymap_commit(); } + pub fn active_tab_index(&self) -> usize { + self.nebula_active_tab + } + /// Bare Backspace disables the action, preserving pass-through on reload. pub fn keymap_clear_custom(&mut self, row: usize) { if row == keymap::QUICK_TERMINAL_ROW { diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index a9e5142..8113dc9 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -7338,15 +7338,14 @@ pub(super) fn draw_text( } let i = flat; let ty = rect.1 + (kh - cell_h) / 2.0; - let (zh_label, en_label) = if i == keymap::QUICK_TERMINAL_ROW { + let label = if i == keymap::QUICK_TERMINAL_ROW { if view.quick_hotkey_error.is_some() { - ("快速终端(注册失败)", "Quick terminal (failed)") + language.pick("快速终端(注册失败)", "Quick terminal (failed)") } else { - ("快速终端", "Quick terminal") + language.pick("快速终端", "Quick terminal") } } else { - let (_, zh, en) = keymap::EDITABLE_ACTIONS[i - 1]; - (zh, en) + keymap::action_label(&keymap::EDITABLE_ACTIONS[i - 1], language) }; r.draw_chrome_text( size, @@ -7357,7 +7356,7 @@ pub(super) fn draw_text( } else { sk.ink }, - language.pick(zh_label, en_label), + label, gc, ); let hovered = view.hover == SettingsHit::KeymapRow(slot); diff --git a/nebula_app/src/gpui_shell/settings_pane/keymap.rs b/nebula_app/src/gpui_shell/settings_pane/keymap.rs index 084bf07..85d3ae1 100644 --- a/nebula_app/src/gpui_shell/settings_pane/keymap.rs +++ b/nebula_app/src/gpui_shell/settings_pane/keymap.rs @@ -101,7 +101,7 @@ impl SettingsPane { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .map(|(_, zh, en)| language.pick(zh, en).to_owned()) + .map(|row| keymap::action_label(row, language).to_owned()) .unwrap_or_default() } }; @@ -164,9 +164,7 @@ impl SettingsPane { return; } let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; - let name = keymap::action_storage_name(action); - self.keymap_binds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name)); - self.keymap_binds.push((combo, name)); + keymap::rebind_action(&mut self.keymap_binds, action, combo); self.persist_keybinds(cx); } @@ -271,7 +269,7 @@ impl SettingsPane { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .map(|(_, zh, en)| language.pick(zh, en).to_owned()) + .map(|row| keymap::action_label(row, language).to_owned()) .unwrap_or_default() .into() }; diff --git a/nebula_app/src/gpui_shell/settings_pane/tests.rs b/nebula_app/src/gpui_shell/settings_pane/tests.rs index bdbe0a4..d88ae43 100644 --- a/nebula_app/src/gpui_shell/settings_pane/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/tests.rs @@ -1,5 +1,49 @@ use super::*; +#[cfg(feature = "gpui-test-support")] +#[gpui::test] +fn rename_keymap_row_is_searchable_and_enters_capture(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(crate::gpui_shell::config::Settings::load(ThemeName::Nord)); + }); + let mut pane_out = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let pane = cx.new(|cx| SettingsPane::new(window, cx)); + pane.update(cx, |pane, cx| { + pane.keymap_binds.clear(); + pane.settings_search_input + .update(cx, |input, cx| input.replace_all("Rename tab", window, cx)); + }); + pane_out = Some(pane.clone()); + gpui_component::Root::new(pane, window, cx) + }); + let pane = pane_out.unwrap(); + cx.simulate_resize(gpui::size(px(1280.0), px(900.0))); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let row = crate::display::keymap::EDITABLE_ACTIONS + .iter() + .position(|(action, ..)| *action == crate::config::Action::RenameTab) + .unwrap() + + 1; + let bounds = cx + .debug_bounds(Box::leak(format!("settings-keymap-row-{row}").into_boxed_str())) + .expect("rename row is rendered"); + assert!(bounds.size.width > px(0.0) && bounds.size.height > px(0.0)); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + assert_eq!(pane.read_with(cx, |pane, _| pane.keymap_capture), Some(row)); + cx.simulate_event(gpui::KeyDownEvent { + keystroke: gpui::Keystroke::parse("escape").unwrap(), + is_held: false, + prefer_character_input: false, + }); + cx.run_until_parked(); + assert_eq!(pane.read_with(cx, |pane, _| pane.keymap_capture), None); +} + #[cfg(feature = "gpui-test-support")] #[gpui::test] fn settings_search_replaces_keymap_search_and_keeps_section_queries(cx: &mut gpui::TestAppContext) { diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 8dd3357..1ccd710 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -81,8 +81,6 @@ pub(super) fn default_workspace_bindings() -> Vec { // ctrl+shift+s 上下、ctrl+shift+enter 缩放、ctrl+alt+方向切聚焦。 KeyBinding::new("ctrl-shift-d", SplitRight, None), KeyBinding::new("ctrl-shift-s", SplitDown, None), - // F2 重命名活动标签(旧壳同键位);右键菜单的键帽读的就是这条。 - KeyBinding::new("f2", RenameActiveTab, None), KeyBinding::new("ctrl-shift-enter", ToggleZoom, None), KeyBinding::new("ctrl-alt-left", FocusPaneLeft, None), KeyBinding::new("ctrl-alt-right", FocusPaneRight, None), @@ -117,10 +115,12 @@ pub(super) fn default_workspace_bindings() -> Vec { KeyBinding::new("alt-enter", ToggleFullscreen, None), KeyBinding::new("ctrl-shift-o", OpenQuickJump, None), ]; - // Numeric shortcuts come from the same defaults shown by Settings and - // used by the legacy shell, including each platform's existing modifiers. + // Tab selection and rename use the same defaults displayed by Settings. bindings.extend(crate::display::keymap::default_shortcuts().into_iter().filter_map( |(combo, action)| { + if action == crate::config::Action::RenameTab { + return custom_workspace_binding(&combo, &action); + } let action = SelectTab::from_config(&action)?; Some(KeyBinding::new(&gpui_binding_combo(&combo), action, None)) }, @@ -223,6 +223,7 @@ fn workspace_binding_in_context( Action::CreateNewTab => Some(KeyBinding::new(&combo, NewTerminal, scope)), Action::CreateNewWindow => Some(KeyBinding::new(&combo, NewWindow, scope)), Action::CloseTab => Some(KeyBinding::new(&combo, CloseActiveTerminal, scope)), + Action::RenameTab => Some(KeyBinding::new(&combo, RenameActiveTab, scope)), Action::ToggleFilesPanel => Some(KeyBinding::new(&combo, ToggleFileTree, scope)), Action::ToggleGitPanel => Some(KeyBinding::new(&combo, ToggleGitPanel, scope)), Action::SplitRight => Some(KeyBinding::new(&combo, SplitRight, scope)), @@ -249,8 +250,8 @@ fn workspace_binding_in_context( )), Action::ToggleFullscreen => Some(KeyBinding::new(&combo, ToggleFullscreen, scope)), Action::OpenQuickJump => Some(KeyBinding::new(&combo, OpenQuickJump, scope)), - // `none` 禁用键:gpui 的 NoAction 绑定在最高优先级命中时吞掉按键, - // 与旧壳 keybind=combo:none 的语义一致。 + // Mask the app action. ReceiveChar entries from the editor allow + // the terminal input handler to encode the released key. Action::None | Action::ReceiveChar => Some(KeyBinding::new(&combo, gpui::NoAction, scope)), _ => None, } diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs index f31baef..3929aec 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -200,9 +200,78 @@ mod dispatch { assert_active(&workspace, 0, &mut cx); } + #[gpui::test] + fn rename_shortcut_remaps_clears_and_restores_in_both_tab_layouts(cx: &mut TestAppContext) { + use crate::config::Action; + use crate::display::keymap; + let (_directory, workspace, mut cx) = open_workspace(3, cx); + for position in + [nebula_settings::TabsPositionName::Sidebar, nebula_settings::TabsPositionName::Top] + { + workspace.update(&mut cx, |workspace, cx| { + workspace.tabs_position = position; + workspace.update_keybinds(Vec::new(), cx); + }); + press("ctrl-3", &mut cx); + press("f2", &mut cx); + assert_eq!( + workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)), + Some(2) + ); + cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx))); + cx.run_until_parked(); + + let mut raw = Vec::new(); + keymap::rebind_action(&mut raw, &Action::RenameTab, "ctrl+alt+r".into()); + let saved = nebula_settings::apply_keybinds("", &raw); + let mut raw = nebula_settings::keybind_pairs_from_text(&saved); + workspace.update(&mut cx, |w, cx| w.update_keybinds(raw.clone(), cx)); + press("f2", &mut cx); + assert!(workspace.read_with(&cx, |w, _| w.tab_rename.is_none())); + press("ctrl-alt-r", &mut cx); + assert_eq!( + workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)), + Some(2) + ); + cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx))); + cx.run_until_parked(); + cx.update(|window, _| { + let bindings = window.bindings_for_action(&RenameActiveTab); + assert!( + bindings + .iter() + .any(|b| b.match_keystrokes(&[Keystroke::parse("ctrl-alt-r").unwrap()]) + == Some(false)) + ); + assert!( + !bindings + .iter() + .any(|b| b.match_keystrokes(&[Keystroke::parse("f2").unwrap()]) + == Some(false)) + ); + }); + + keymap::clear_action(&mut raw, &Action::RenameTab); + workspace.update(&mut cx, |w, cx| w.update_keybinds(raw.clone(), cx)); + press("ctrl-alt-r", &mut cx); + press("f2", &mut cx); + assert!(workspace.read_with(&cx, |w, _| w.tab_rename.is_none())); + keymap::reset_action(&mut raw, &Action::RenameTab); + workspace.update(&mut cx, |w, cx| w.update_keybinds(raw, cx)); + press("f2", &mut cx); + assert_eq!( + workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)), + Some(2) + ); + cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx))); + cx.run_until_parked(); + } + } + struct TerminalKeyProbe { focus: FocusHandle, selected: Option, + renamed: usize, mode: TermMode, input: Vec, encoded: Vec, @@ -215,6 +284,7 @@ mod dispatch { .on_action(cx.listener(|this, action: &SelectTab, _, _| { this.selected = action.index_for(10); })) + .on_action(cx.listener(|this, _: &RenameActiveTab, _, _| this.renamed += 1)) .child( div() .size_full() @@ -247,6 +317,7 @@ mod dispatch { let probe = cx.new(|cx| TerminalKeyProbe { focus: cx.focus_handle(), selected: None, + renamed: 0, mode: TermMode::empty(), input: Vec::new(), encoded: Vec::new(), @@ -298,4 +369,67 @@ mod dispatch { assert!(!probe.encoded.is_empty()); }); } + + #[gpui::test] + fn remapped_rename_releases_f2_to_negotiated_terminal_input(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + init(cx); + let mut raw = Vec::new(); + crate::display::keymap::rebind_action( + &mut raw, + &crate::config::Action::RenameTab, + "ctrl+alt+r".into(), + ); + for (combo, name) in raw { + let action = crate::display::keymap::parse_action(&name).unwrap(); + for scope in [None, Some(crate::gpui_shell::terminal::KEY_CONTEXT)] { + cx.bind_keys([workspace_binding_in_context(&combo, &action, scope).unwrap()]); + } + } + }); + let mut probe_out = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let probe = cx.new(|cx| TerminalKeyProbe { + focus: cx.focus_handle(), + selected: None, + renamed: 0, + mode: TermMode::empty(), + input: Vec::new(), + encoded: Vec::new(), + }); + let focus = probe.read(cx).focus.clone(); + focus.focus(window, cx); + probe_out = Some(probe.clone()); + Root::new(probe, window, cx) + }); + let probe = probe_out.unwrap(); + for mode in [ + TermMode::empty(), + TermMode::WIN32_INPUT_MODE, + TermMode::DISAMBIGUATE_ESC_CODES, + TermMode::REPORT_ALL_KEYS_AS_ESC, + ] { + probe.update(cx, |p, _| { + p.mode = mode; + p.input.clear(); + p.encoded.clear(); + p.renamed = 0; + }); + press("ctrl-alt-r", cx); + probe.read_with(cx, |p, _| { + assert_eq!(p.renamed, 1); + assert!(p.input.is_empty()); + }); + press("f2", cx); + probe.read_with(cx, |p, _| { + assert_eq!(p.renamed, 1); + assert_eq!(p.input.last(), Some(&Keystroke::parse("f2").unwrap())); + assert!(!p.encoded.is_empty(), "F2 must reach the CLI"); + if mode.is_empty() { + assert_eq!(p.encoded, b"\x1bOQ"); + } + }); + } + } } diff --git a/nebula_app/src/input/action.rs b/nebula_app/src/input/action.rs index fdafc96..4935bef 100644 --- a/nebula_app/src/input/action.rs +++ b/nebula_app/src/input/action.rs @@ -357,6 +357,10 @@ impl Execute for Action { // and profiles all ride the shared tab-request/display plumbing, // which is platform-independent (the tab bar is self-drawn). Action::CloseTab => ctx.nebula_tab(crate::event::TabRequest::Close), + Action::RenameTab => { + let index = ctx.display().active_tab_index(); + ctx.nebula_tab(crate::event::TabRequest::BeginRename(index)); + }, Action::SplitRight => ctx.nebula_tab(crate::event::TabRequest::SplitToggle( crate::display::SplitDirection::LeftRight, )), diff --git a/scripts/check_prohibited_names.py b/scripts/check_prohibited_names.py new file mode 100644 index 0000000..a9366b5 --- /dev/null +++ b/scripts/check_prohibited_names.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""拒绝把竞品名称写入新增内容、提交说明或待推送提交。""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path +from typing import Iterable + + +PROHIBITED_PATTERNS = ( + re.compile(r"(? bytes: + return subprocess.check_output(["git", "-c", "i18n.logOutputEncoding=utf-8", *args], stderr=subprocess.DEVNULL) + + +def prohibited(text: str) -> bool: + return any(pattern.search(text) for pattern in PROHIBITED_PATTERNS) + + +def source_without_allowed_occurrences(path: str, text: str) -> str: + for pattern in LEGAL_ATTRIBUTION_PATTERNS: + text = pattern.sub("", text) + if Path(path).name in {"Cargo.toml", "Cargo.lock"} and any( + pattern.search(text) for pattern in DEPENDENCY_REFERENCE_PATTERNS + ): + for pattern in DEPENDENCY_REFERENCE_PATTERNS: + text = pattern.sub("", text) + for pattern in PROTOCOL_COMPATIBILITY_PATTERNS: + text = pattern.sub("", text) + return text + + +def prohibited_source_line(path: str, text: str) -> bool: + return prohibited(source_without_allowed_occurrences(path, text)) + + +def changed_paths(*revision_args: str) -> list[str]: + raw = git("diff", *revision_args, "--name-only", "-z", "--diff-filter=ACMR", "--", ".") + return [path.decode("utf-8") for path in raw.split(b"\0") if path] + + +def added_lines(path: str, *revision_args: str) -> Iterable[tuple[int, str]]: + patch = git("diff", *revision_args, "--no-ext-diff", "--unified=0", "--", path) + for line_no, raw_line in enumerate(patch.decode("utf-8").splitlines(), 1): + if raw_line.startswith("+") and not raw_line.startswith("+++"): + yield line_no, raw_line[1:] + + +def scan_added_lines(*revision_args: str) -> list[str]: + hits: list[str] = [] + for path in changed_paths(*revision_args): + normalized = path.replace("\\", "/") + if normalized in EXEMPT_PATHS: + continue + for line_no, line in added_lines(path, *revision_args): + if prohibited_source_line(normalized, line): + hits.append(f"{normalized}:{line_no}:{line}") + return hits + + +def added_patch_lines(patch: bytes, merge_parent_count: int | None) -> Iterable[tuple[int, str]]: + lines = patch.decode("utf-8").splitlines() + if merge_parent_count is None: + for line_no, raw_line in enumerate(lines, 1): + if raw_line.startswith("+") and not raw_line.startswith("+++"): + yield line_no, raw_line[1:] + return + + in_hunk = False + prefix = "+" * merge_parent_count + for line_no, raw_line in enumerate(lines, 1): + if raw_line.startswith("@@@"): + in_hunk = True + continue + # Combined diff headers (including `+++ `) occur before the hunk. + if in_hunk and raw_line.startswith(prefix): + yield line_no, raw_line[merge_parent_count:] + + +def scan_pending_commits(revision_range: str) -> list[str]: + hits: list[str] = [] + commits = git("rev-list", "--reverse", revision_range).decode("ascii").splitlines() + for commit in commits: + parents = git("rev-list", "--parents", "-n", "1", commit).decode("ascii").split() + parent = parents[1] if len(parents) > 1 else None + merge_parent_count = len(parents) - 1 + merge_commit = merge_parent_count > 1 + if merge_commit: + # Combined diff reports only content resolved against all parents; + # comparing to one parent would replay the other branch's changes. + paths = git("diff-tree", "--cc", "--no-commit-id", "--name-only", "-z", "-r", commit) + elif parent: + # Comparing against the first parent includes a merge resolution's + # actual additions for ordinary commits. + paths = git("diff", "--name-only", "-z", "--diff-filter=ACMR", parent, commit, "--", ".") + else: + paths = git("diff-tree", "--root", "--no-commit-id", "--name-only", "-z", "-r", commit) + for raw_path in paths.split(b"\0"): + if not raw_path: + continue + path = raw_path.decode("utf-8") + normalized = path.replace("\\", "/") + if normalized in EXEMPT_PATHS: + continue + if merge_commit: + patch = git("show", "--format=", "--cc", "--no-ext-diff", "--unified=0", commit, "--", path) + elif parent: + patch = git("diff", "--no-ext-diff", "--unified=0", parent, commit, "--", path) + else: + patch = git("show", "--format=", "--no-ext-diff", "--unified=0", commit, "--", path) + for line_no, line in added_patch_lines( + patch, merge_parent_count if merge_commit else None + ): + if prohibited_source_line(normalized, line): + hits.append(f"{commit}:{normalized}:{line_no}:{line}") + return hits + + +def scan_message(path: Path) -> list[str]: + return [ + f"{line_no}:{line}" + for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) + if prohibited(line) + ] + + +def resolved_commit(revision: str) -> str: + if not revision or re.fullmatch(r"0+", revision): + raise ValueError(f"missing or zero revision: {revision!r}") + try: + return git("rev-parse", "--verify", f"{revision}^{{commit}}").decode("ascii").strip() + except (subprocess.CalledProcessError, UnicodeDecodeError) as exc: + raise ValueError(f"cannot resolve revision {revision!r}") from exc + + +def checked_range(base: str, head: str) -> tuple[str, str]: + base_commit = resolved_commit(base) + head_commit = resolved_commit(head) + try: + common = git("merge-base", base_commit, head_commit).decode("ascii").strip() + except (subprocess.CalledProcessError, UnicodeDecodeError) as exc: + raise ValueError(f"base {base_commit} and head {head_commit} are unrelated") from exc + if not common: + raise ValueError(f"base {base_commit} and head {head_commit} have no common ancestor") + return common, head_commit + + +def scan_commit_messages(revision_range: str) -> list[str]: + hits: list[str] = [] + commits = git("rev-list", "--reverse", revision_range).decode("ascii").splitlines() + for commit in commits: + message = git("show", "-s", "--format=%B", commit).decode("utf-8") + for line_no, line in enumerate(message.splitlines(), 1): + if prohibited(line): + hits.append(f"commit-message:{commit}:{line_no}:{line}") + return hits + + +def scan_range(base: str, head: str) -> list[str]: + base_commit, head_commit = checked_range(base, head) + revision_range = f"{base_commit}..{head_commit}" + hits = scan_commit_messages(revision_range) + # Scan each commit's added lines so a name added and later deleted in the + # same range cannot disappear from the final two-commit diff. + hits.extend(scan_pending_commits(revision_range)) + return hits + + +def pending_push_range() -> str | None: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + upstream = result.stdout.decode("utf-8").strip() + return f"{upstream}..HEAD" if result.returncode == 0 and upstream else None + + +def report(title: str, hits: list[str]) -> int: + if not hits: + return 0 + print(f"ERROR: {title}\n" + "\n".join(hits), file=sys.stderr) + return 1 + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print( + "usage: check_prohibited_names.py staged|message|push [message-file] | " + "range --base BASE --head HEAD", + file=sys.stderr, + ) + return 2 + + mode = argv[1] + if mode == "staged": + return report("staged additions contain prohibited product names", scan_added_lines("--cached")) + if mode == "message": + if len(argv) != 3: + print("message mode requires the commit message path", file=sys.stderr) + return 2 + return report("commit message contains prohibited product names", scan_message(Path(argv[2]))) + if mode == "push": + revision_range = pending_push_range() + if revision_range is None: + print("No upstream branch; skipping pending-push name check") + return 0 + message_text = git("log", "--format=%H:%s%n%b", revision_range).decode("utf-8") + hits = [ + f"commit-message:{line_no}:{line}" + for line_no, line in enumerate(message_text.splitlines(), 1) + if prohibited(line) + ] + # 逐提交看新增行,避免“先加入、后删除”在最终 range diff 中被抵消。 + hits.extend(scan_pending_commits(revision_range)) + return report("commits pending push contain prohibited product names", hits) + if mode == "range": + if len(argv) != 6 or argv[2] != "--base" or argv[4] != "--head": + print("range mode requires --base BASE --head HEAD", file=sys.stderr) + return 2 + try: + hits = scan_range(argv[3], argv[5]) + except (OSError, ValueError, subprocess.CalledProcessError) as exc: + print(f"ERROR: cannot scan prohibited-name range: {exc}", file=sys.stderr) + return 1 + return report("range contains prohibited product names", hits) + + print(f"unknown mode: {mode}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv)) + except (OSError, UnicodeError, subprocess.CalledProcessError) as exc: + print(f"ERROR: cannot read naming-check input: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/platform_cfg_budget.txt b/scripts/platform_cfg_budget.txt index f52aaac..ff5670f 100644 --- a/scripts/platform_cfg_budget.txt +++ b/scripts/platform_cfg_budget.txt @@ -1 +1 @@ -484 +481 diff --git a/scripts/tests/fixtures/prohibited_names/github-cli-docs.sh b/scripts/tests/fixtures/prohibited_names/github-cli-docs.sh new file mode 100644 index 0000000..9649d8c --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/github-cli-docs.sh @@ -0,0 +1 @@ +printf '%s\n' 'gh api repos/example/project' diff --git a/scripts/tests/fixtures/prohibited_names/github-cli.sh b/scripts/tests/fixtures/prohibited_names/github-cli.sh new file mode 100644 index 0000000..44044a5 --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/github-cli.sh @@ -0,0 +1 @@ +gh api repos/Kuddev/pebrel diff --git a/scripts/tests/fixtures/prohibited_names/legal-notice.txt b/scripts/tests/fixtures/prohibited_names/legal-notice.txt new file mode 100644 index 0000000..e97ae1b --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/legal-notice.txt @@ -0,0 +1 @@ +Copyright 2020 The Alacritty Project diff --git a/scripts/tests/fixtures/prohibited_names/ordinary-comparison.md b/scripts/tests/fixtures/prohibited_names/ordinary-comparison.md new file mode 100644 index 0000000..38423df --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/ordinary-comparison.md @@ -0,0 +1 @@ +Pebrel is faster than Ghostty. diff --git a/scripts/tests/fixtures/prohibited_names/protocol.rs b/scripts/tests/fixtures/prohibited_names/protocol.rs new file mode 100644 index 0000000..fa7a1cd --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/protocol.rs @@ -0,0 +1,3 @@ +const KITTY_KEYBOARD_PROTOCOL: bool = true; +const TMUX_ESCAPE: &str = "\\x1bPtmux;"; +const FORMAT = ThemeFormat::Kitty; diff --git a/scripts/tests/test_ci_cache.py b/scripts/tests/test_ci_cache.py index f3e51bc..764ea3d 100644 --- a/scripts/tests/test_ci_cache.py +++ b/scripts/tests/test_ci_cache.py @@ -3,6 +3,8 @@ import os from pathlib import Path import re +import shutil +import subprocess import tempfile import textwrap import unittest @@ -13,6 +15,10 @@ ACTION = Path(__file__).resolve().parents[2] / ".github/actions/rust-cache/actio class CacheIdentityTests(unittest.TestCase): + @staticmethod + def action_text() -> str: + return ACTION.read_text(encoding="utf-8") + def identity(self, *, compiler="rustc 1.97.1", sdk="15.0", **changes): source = ACTION.read_text(encoding="utf-8") body = re.search(r"python - <<'PY'\n(.*?)\n PY", source, re.S) @@ -67,6 +73,97 @@ class CacheIdentityTests(unittest.TestCase): self.assertEqual(before["restore-key"], after["restore-key"]) self.assertEqual(before["key"], self.identity(CACHE_REVISION="one", PREVIEW_ID="third")["key"]) + def test_summary_exposes_real_target_cache_outputs(self): + source = self.action_text() + self.assertIn( + "value: ${{ steps.target.outputs.cache-matched-key }}", + source, + ) + self.assertIn( + "value: ${{ steps.cache-summary.outputs.legacy-fallback-invoked }}", + source, + ) + self.assertIn( + "value: ${{ steps.cache-summary.outputs.legacy-fallback-outcome }}", + source, + ) + self.assertIn( + "TARGET_CACHE_HIT: ${{ steps.target.outputs.cache-hit }}", + source, + ) + self.assertIn( + "TARGET_CACHE_MATCHED_KEY: ${{ steps.target.outputs.cache-matched-key }}", + source, + ) + self.assertIn("SAVE_KEY: ${{ steps.identity.outputs.key }}", source) + self.assertIn("CACHE_WORKLOAD: ${{ inputs.key }}", source) + self.assertIn("RUNNER_OS_VALUE: ${{ runner.os }}", source) + self.assertIn("RUNNER_ARCH_VALUE: ${{ runner.arch }}", source) + + summary = source.split("- name: Summarize compiled target cache", 1)[1] + summary = summary.split("\n - name:", 1)[0] + run_body = summary.split(" run: |", 1)[1] + self.assertNotIn("${{", run_body) + for field in ( + "Target cache exact hit", + "Target cache matched key", + "Save key", + "Legacy fallback invoked", + "Legacy fallback outcome", + ): + self.assertIn(field, run_body) + + def test_legacy_fallback_is_distinct_from_target_cache_hit(self): + source = self.action_text() + legacy = source.split("- name: Recover an existing combined cache during migration", 1)[1] + self.assertIn("id: legacy-fallback", legacy) + self.assertIn( + "if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != ''", + legacy, + ) + self.assertIn("LEGACY_OUTCOME: ${{ steps.legacy-fallback.outcome }}", source) + self.assertIn("legacy-fallback-invoked=", source) + self.assertIn("Legacy fallback status is separate and is not counted as target exact hit", source) + self.assertNotIn("LEGACY_KEY:", source) + self.assertNotIn("ACTIONS_RUNTIME_TOKEN", source) + self.assertNotIn("GITHUB_TOKEN", source) + + @unittest.skipUnless(os.name != "nt" and shutil.which("bash"), "POSIX summary shell") + def test_summary_executes_exact_partial_and_legacy_cache_cases(self): + source = self.action_text().split("- name: Summarize compiled target cache", 1)[1] + script = textwrap.dedent(source.split(" run: |\n", 1)[1].split("\n - name:", 1)[0]) + cases = ( + ("true", "current-key", "false", "skipped", "false"), + ("false", "older-compatible-key", "true", "skipped", "false"), + ("", "", "true", "success", "true"), + ("", "", "true", "failure", "true"), + ) + for hit, matched, configured, outcome, invoked in cases: + with self.subTest(hit=hit, matched=matched, outcome=outcome), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + marker = root / "must-not-be-created" + workload = f"$(touch {marker})" + environment = { + "PATH": os.environ["PATH"], + "GITHUB_STEP_SUMMARY": str(root / "summary"), + "GITHUB_OUTPUT": str(root / "outputs"), + "TARGET_CACHE_HIT": hit, "TARGET_CACHE_MATCHED_KEY": matched, + "SAVE_KEY": "new-save-key", "LEGACY_CONFIGURED": configured, + "LEGACY_OUTCOME": outcome, "CACHE_WORKLOAD": workload, + "RUNNER_OS_VALUE": "Linux", "RUNNER_ARCH_VALUE": "X64", + } + result = subprocess.run([shutil.which("bash"), "-c", script], env=environment, + text=True, capture_output=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + summary = (root / "summary").read_text() + outputs = (root / "outputs").read_text() + self.assertIn(f"Target cache exact hit: `{hit or 'false'}`", summary) + self.assertIn(f"Target cache matched key: `{matched or ''}`", summary) + self.assertIn(f"legacy-fallback-invoked={invoked}\n", outputs) + self.assertIn(f"legacy-fallback-outcome={outcome}\n", outputs) + self.assertIn(workload, summary) + self.assertFalse(marker.exists(), "summary values must never execute as shell code") + @unittest.skipUnless(os.name == "nt", "Windows cache migration") def test_windows_migration_preserves_compiler_and_workload_boundaries(self): before = self.identity() diff --git a/scripts/tests/test_ci_native_tests.py b/scripts/tests/test_ci_native_tests.py index 17d5912..0055e55 100644 --- a/scripts/tests/test_ci_native_tests.py +++ b/scripts/tests/test_ci_native_tests.py @@ -16,9 +16,33 @@ class NativeSuiteTests(unittest.TestCase): workflow = (root / ".github/workflows/linux-lua.yml").read_text() lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0] self.assertIn("name: lint", lint) + self.assertIn("Test CI contracts before matrix planning", lint) + for contract in ( + "scripts.tests.test_ci_plan", + "scripts.tests.test_ci_native_tests", + "scripts.tests.test_stable_release", + "scripts.tests.test_ci_cache", + "scripts.tests.test_platform_cfg", + "scripts.tests.test_pr_size_workflow", + ): + self.assertIn(contract, lint) self.assertIn("python scripts/ci_plan.py", lint) self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint) - self.assertLess(lint.index("cargo fmt"), lint.index("python scripts/ci_plan.py")) + self.assertLess( + lint.index("cargo fmt"), + lint.index("Test CI contracts before matrix planning"), + ) + self.assertLess( + lint.index("Test CI contracts before matrix planning"), + lint.index("python scripts/ci_plan.py"), + ) + self.assertLess( + lint.index("python3 scripts/check_platform_cfg.py"), + lint.index("python scripts/ci_plan.py"), + ) + platform_step = lint.split("Check platform cfg budget before native jobs", 1)[1].split(" - name:", 1)[0] + self.assertNotIn("continue-on-error", platform_step) + self.assertNotIn("--update", platform_step) for job, output in (("native-tests", "native_matrix"), ("macos-release-check", "release_matrix")): body = workflow.split(f"\n {job}:\n", 1)[1] diff --git a/scripts/tests/test_pr_size_workflow.py b/scripts/tests/test_pr_size_workflow.py new file mode 100644 index 0000000..afefd06 --- /dev/null +++ b/scripts/tests/test_pr_size_workflow.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +from pathlib import Path +import shutil +import subprocess +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +RUNNER = r''' +const fs = require('node:fs'); +const input = JSON.parse(fs.readFileSync(0, 'utf8')); +const writes = [], failures = [], summaries = []; +const listFiles = () => {}, listComments = () => {}; +const github = { + rest: { + pulls: {listFiles}, + issues: { + listComments, + addLabels: async (value) => writes.push(['add', value]), + removeLabel: async (value) => writes.push(['remove', value]), + createComment: async (value) => writes.push(['comment', value]), + }, + }, + paginate: async (method) => method === listFiles ? input.files : [], +}; +const summary = {addRaw(value) {summaries.push(value); return this;}, async write() {}}; +const core = {info() {}, setFailed(value) {failures.push(value);}, summary}; +const context = { + repo: {owner: 'base', repo: 'project'}, + payload: {pull_request: { + number: 1, labels: input.labels, + head: {repo: input.deleted ? null : {full_name: input.fork ? 'fork/project' : 'base/project'}}, + }}, +}; +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +new AsyncFunction('github', 'context', 'core', input.script)(github, context, core) + .then(() => process.stdout.write(JSON.stringify({writes, failures, summaries}))) + .catch((error) => {console.error(error); process.exit(1);}); +''' + + +class PrSizeWorkflowTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.node = shutil.which('node') + if cls.node is None: + raise RuntimeError('Node.js is required for the PR size workflow contract') + workflow = (ROOT / '.github/workflows/pr-size.yml').read_text(encoding='utf-8') + # Execute the actual inline action, without duplicating its policy. + cls.script = textwrap.dedent(workflow.split(' script: |\n', 1)[1]) + + def run_size(self, lines, *, fork=True, exempt=False, deleted=False): + data = { + 'script': self.script, 'fork': fork, 'deleted': deleted, + 'labels': [{'name': 'size-exempt'}] if exempt else [], + 'files': [ + {'filename': 'src/example.rs', 'additions': lines, 'deletions': 0}, + {'filename': 'Cargo.lock', 'additions': 9000, 'deletions': 500}, + {'filename': 'docs/guide.md', 'additions': 9000, 'deletions': 500}, + ], + } + result = subprocess.run( + [self.node, '-e', RUNNER], input=json.dumps(data), text=True, + encoding='utf-8', capture_output=True, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + + def test_small_fork_passes_without_write_permissions(self): + result = self.run_size(127) + self.assertEqual(result['failures'], []) + self.assertEqual(result['writes'], []) + self.assertIn('127 counted lines', result['summaries'][0]) + + def test_large_fork_is_rejected_before_optional_writes(self): + result = self.run_size(1501) + self.assertEqual(len(result['failures']), 1) + self.assertIn('1501', result['failures'][0]) + self.assertEqual(result['writes'], []) + + def test_exact_limit_and_existing_maintainer_exemption(self): + self.assertEqual(self.run_size(1500)['failures'], []) + self.assertEqual(self.run_size(1501, exempt=True)['failures'], []) + + def test_same_repository_keeps_labels_and_oversize_guidance(self): + small = self.run_size(127, fork=False) + self.assertEqual(small['failures'], []) + self.assertEqual(small['writes'][0][1]['labels'], ['size/M']) + large = self.run_size(1501, fork=False) + self.assertEqual(len(large['failures']), 1) + self.assertEqual(large['writes'][0][1]['labels'], ['size/XL']) + self.assertEqual(large['writes'][1][0], 'comment') + + def test_deleted_fork_can_still_be_counted_without_writes(self): + result = self.run_size(127, deleted=True) + self.assertEqual(result['failures'], []) + self.assertEqual(result['writes'], []) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_prohibited_names.py b/scripts/tests/test_prohibited_names.py new file mode 100644 index 0000000..fae08ae --- /dev/null +++ b/scripts/tests/test_prohibited_names.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from scripts import check_prohibited_names + + +ROOT = Path(__file__).resolve().parents[2] +CHECKER = ROOT / "scripts" / "check_prohibited_names.py" +FIXTURES = ROOT / "scripts" / "tests" / "fixtures" / "prohibited_names" + + +def run_git(repository: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repository, check=True, stdout=subprocess.DEVNULL) + + +class ProhibitedNamesTests(unittest.TestCase): + def check_staged(self, repository: Path): + return subprocess.run( + [sys.executable, str(CHECKER), "staged"], cwd=repository, + text=True, encoding="utf-8", capture_output=True, + env={**os.environ, "PYTHONUTF8": "1"}, + ) + + def test_invalid_utf8_path_cannot_turn_into_an_empty_diff(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + run_git(repository, "config", "user.name", "CI test") + run_git(repository, "config", "user.email", "ci@example.invalid") + # Git trees can contain these bytes even when the host filesystem + # cannot. Never require a checkout or create an invalid OS filename. + def write_object(kind, contents): + return subprocess.check_output( + ["git", "hash-object", "-w", "-t", kind, "--stdin"], + input=contents, cwd=repository, + ).decode("ascii").strip() + + empty_tree = write_object("tree", b"") + base = subprocess.check_output( + ["git", "commit-tree", empty_tree], input=b"baseline\n", cwd=repository, + ).decode("ascii").strip() + blob = write_object("blob", b"Ghostty\n") + tree = write_object("tree", b"100644 bad-\xff.rs\0" + bytes.fromhex(blob)) + head = subprocess.check_output( + ["git", "commit-tree", tree, "-p", base], + input=b"invalid path fixture\n", cwd=repository, + ).decode("ascii").strip() + result = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", base, "--head", head], + cwd=repository, text=True, encoding="utf-8", capture_output=True, + env={**os.environ, "PYTHONUTF8": "1"}, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("utf-8", result.stderr.lower()) + + def test_invalid_utf8_text_and_message_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + path = repository / "source.rs" + path.write_bytes(b"safe \xff\n") + run_git(repository, "add", "source.rs") + staged = self.check_staged(repository) + message = subprocess.run( + [sys.executable, str(CHECKER), "message", str(path)], cwd=repository, + text=True, encoding="utf-8", capture_output=True, + env={**os.environ, "PYTHONUTF8": "1"}, + ) + for result in (staged, message): + self.assertNotEqual(result.returncode, 0) + self.assertIn("utf-8", result.stderr.lower()) + + def test_valid_unicode_and_control_character_paths_are_scanned(self) -> None: + names = ["路径.rs"] + if os.name != "nt": + names.append("control\tline\n.rs") + for name in names: + with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + path = repository / name + path.write_text("safe\n", encoding="utf-8") + run_git(repository, "add", "--", name) + self.assertEqual(self.check_staged(repository).returncode, 0) + path.write_text("Ghostty\n", encoding="utf-8") + run_git(repository, "add", "--", name) + result = self.check_staged(repository) + self.assertEqual(result.returncode, 1) + self.assertIn("Ghostty", result.stderr) + + def test_names_do_not_match_inside_unrelated_words(self) -> None: + self.assertFalse(check_prohibited_names.prohibited("spotty network; tty72; netcatty_adapter")) + self.assertTrue(check_prohibited_names.prohibited("Use Otty or TTY-7")) + + def test_ordinary_competitor_comparison_is_rejected(self) -> None: + text = (FIXTURES / "ordinary-comparison.md").read_text(encoding="utf-8") + self.assertTrue(check_prohibited_names.prohibited_source_line("comparison.md", text)) + + def test_inline_legal_attribution_is_allowed_but_comparison_is_not(self) -> None: + text = (FIXTURES / "legal-notice.txt").read_text(encoding="utf-8") + self.assertFalse(check_prohibited_names.prohibited_source_line("source.rs", text)) + self.assertTrue( + check_prohibited_names.prohibited_source_line("README.md", "Compare with Alacritty.") + ) + self.assertTrue( + check_prohibited_names.prohibited_source_line( + "source.rs", "Copyright (c) Alacritty contributors; compare with Ghostty." + ) + ) + + def test_protocol_identifiers_are_allowed(self) -> None: + text = (FIXTURES / "protocol.rs").read_text(encoding="utf-8") + self.assertFalse(check_prohibited_names.prohibited_source_line("src/protocol.rs", text)) + + def test_github_cli_text_is_not_a_competitor_name(self) -> None: + command = (FIXTURES / "github-cli.sh").read_text(encoding="utf-8") + documentation = (FIXTURES / "github-cli-docs.sh").read_text(encoding="utf-8") + self.assertFalse(check_prohibited_names.prohibited_source_line("check.sh", command)) + self.assertFalse(check_prohibited_names.prohibited_source_line("check.sh", documentation)) + + def test_real_cargo_git_dependency_is_allowed_but_readme_reference_is_not(self) -> None: + dependency = 'gpui = { git = "https://github.com/ghostty/ghostty", rev = "deadbeef" }' + self.assertFalse(check_prohibited_names.prohibited_source_line("Cargo.toml", dependency)) + self.assertTrue(check_prohibited_names.prohibited_source_line("README.md", dependency)) + + def test_staged_hook_configuration_is_not_exempt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + (repository / ".pre-commit-config.yaml").write_text( + "# Compare with Ghostty.\nrepos: []\n", encoding="utf-8" + ) + run_git(repository, "add", ".pre-commit-config.yaml") + result = subprocess.run( + [sys.executable, str(CHECKER), "staged"], cwd=repository, + text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn(".pre-commit-config.yaml", result.stderr) + + def test_theme_format_message_does_not_exempt_an_appended_comparison(self) -> None: + message = ( + "unsupported theme format; use Pebrel JSON, Windows Terminal JSON, " + "Kitty, Ghostty, WezTerm or Alacritty" + ) + self.assertFalse(check_prohibited_names.prohibited_source_line("src/theme.rs", message)) + self.assertTrue(check_prohibited_names.prohibited_source_line( + "src/theme.rs", message + "; copied from Ghostty" + )) + + def test_range_scans_added_text_and_new_commit_messages(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + run_git(repository, "config", "user.name", "CI test") + run_git(repository, "config", "user.email", "ci@example.invalid") + (repository / "README.md").write_text("safe\n", encoding="utf-8") + run_git(repository, "add", "README.md") + run_git(repository, "commit", "-qm", "baseline") + base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + + (repository / "comparison.md").write_text("Compare with Ghostty.\n", encoding="utf-8") + run_git(repository, "add", "comparison.md") + run_git(repository, "commit", "-qm", "ordinary comparison") + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + result = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", base, "--head", head], + cwd=repository, + text=True, + capture_output=True, + env={**os.environ, "PYTHONUTF8": "1"}, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Ghostty", result.stderr) + + def test_range_scans_a_commit_message_even_when_text_is_legal(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + run_git(repository, "config", "user.name", "CI test") + run_git(repository, "config", "user.email", "ci@example.invalid") + (repository / "README.md").write_text("safe\n", encoding="utf-8") + run_git(repository, "add", "README.md") + run_git(repository, "commit", "-qm", "baseline") + base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + + (repository / "README.md").write_text("still safe\n", encoding="utf-8") + run_git(repository, "add", "README.md") + run_git(repository, "commit", "-qm", "Compare with Ghostty") + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + result = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", base, "--head", head], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("commit-message", result.stderr) + + def test_range_uses_merge_base_and_keeps_added_then_deleted_text(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + run_git(repository, "config", "user.name", "CI test") + run_git(repository, "config", "user.email", "ci@example.invalid") + (repository / "README.md").write_text("Historical Ghostty attribution\n", encoding="utf-8") + run_git(repository, "add", "README.md") + run_git(repository, "commit", "-qm", "baseline") + base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + + run_git(repository, "branch", "main") + run_git(repository, "switch", "main") + (repository / "README.md").write_text("safe on main\n", encoding="utf-8") + run_git(repository, "add", "README.md") + run_git(repository, "commit", "-qm", "advance main") + + run_git(repository, "switch", "-c", "feature", base) + (repository / "README.md").write_text("safe on feature\n", encoding="utf-8") + run_git(repository, "add", "README.md") + run_git(repository, "commit", "-qm", "advance feature") + clean_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + clean = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", "main", "--head", clean_head], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertEqual(clean.returncode, 0, clean.stderr) + + (repository / "temporary.md").write_text("Ghostty\n", encoding="utf-8") + run_git(repository, "add", "temporary.md") + run_git(repository, "commit", "-qm", "temporary comparison") + (repository / "temporary.md").unlink() + run_git(repository, "add", "-u") + run_git(repository, "commit", "-qm", "remove temporary comparison") + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + result = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", base, "--head", head], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("temporary.md", result.stderr) + + unrelated_tree = subprocess.check_output(["git", "write-tree"], cwd=repository, text=True).strip() + unrelated = subprocess.check_output( + ["git", "commit-tree", unrelated_tree, "-m", "unrelated"], + cwd=repository, + text=True, + ).strip() + unrelated_result = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", unrelated, "--head", clean_head], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertNotEqual(unrelated_result.returncode, 0) + self.assertIn("cannot scan prohibited-name range", unrelated_result.stderr) + + def test_range_scans_merge_resolution_without_replaying_second_parent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + run_git(repository, "config", "user.name", "CI test") + run_git(repository, "config", "user.email", "ci@example.invalid") + (repository / "shared.md").write_text("base\n", encoding="utf-8") + run_git(repository, "add", "shared.md") + run_git(repository, "commit", "-qm", "baseline") + run_git(repository, "branch", "main") + + run_git(repository, "switch", "-c", "feature") + (repository / "shared.md").write_text("feature\n", encoding="utf-8") + run_git(repository, "add", "shared.md") + run_git(repository, "commit", "-qm", "feature change") + feature_parent = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + run_git(repository, "switch", "main") + (repository / "shared.md").write_text("main Ghostty\n", encoding="utf-8") + run_git(repository, "add", "shared.md") + run_git(repository, "commit", "-qm", "main change") + main_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + run_git(repository, "switch", "feature") + merge = subprocess.run( + ["git", "merge", "--no-ff", "main", "-m", "merge main"], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertNotEqual(merge.returncode, 0, merge.stdout + merge.stderr) + (repository / "shared.md").write_text("resolved safe\n", encoding="utf-8") + run_git(repository, "add", "shared.md") + run_git(repository, "commit", "-qm", "resolve safe merge") + safe_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + + safe_result = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", main_head, "--head", safe_head], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertEqual(safe_result.returncode, 0, safe_result.stderr) + + run_git(repository, "switch", "-c", "feature-bad", feature_parent) + merge_bad = subprocess.run( + ["git", "merge", "--no-ff", "main", "-m", "merge main with bad resolution"], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertNotEqual(merge_bad.returncode, 0, merge_bad.stdout + merge_bad.stderr) + (repository / "shared.md").write_text("resolved Ghostty\n", encoding="utf-8") + run_git(repository, "add", "shared.md") + run_git(repository, "commit", "-qm", "resolve bad merge") + bad_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip() + bad_result = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", main_head, "--head", bad_head], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertNotEqual(bad_result.returncode, 0) + self.assertIn("shared.md", bad_result.stderr) + + def test_range_fails_closed_for_an_unknown_base(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + run_git(repository, "init", "-q") + result = subprocess.run( + [sys.executable, str(CHECKER), "range", "--base", "missing", "--head", "HEAD"], + cwd=repository, + text=True, + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("cannot scan prohibited-name range", result.stderr) + + +if __name__ == "__main__": + unittest.main()