Merge branch 'main' into feat/ssh-host-name-in-launcher
This commit is contained in:
46 files changed
+1824
-144
No files matched your search
@@ -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:-<none>}"
|
||||
save_key="${SAVE_KEY:-<none>}"
|
||||
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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<!-- pr-size-guard -->";
|
||||
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.`);
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"rename": "Umbenennen",
|
||||
"rename_tab": "Tab umbenennen",
|
||||
"copy": "Kopieren",
|
||||
"paste": "Einfügen",
|
||||
"cut": "Ausschneiden",
|
||||
@@ -104,6 +105,7 @@
|
||||
"new_window": "Neues Fenster",
|
||||
"close_tab": "Tab schließen",
|
||||
"duplicate_tab": "Tab duplizieren",
|
||||
"copy_working_directory": "Arbeitsverzeichnis kopieren",
|
||||
"terminal": "Terminal",
|
||||
"connect": "Verbinden",
|
||||
"disconnect": "Trennen",
|
||||
@@ -173,5 +175,15 @@
|
||||
"copied_lines": "{lines} Zeilen in die Zwischenablage kopiert",
|
||||
"paste_lines": "{lines} Zeilen einfügen?",
|
||||
"sent_to": "An {target} gesendet"
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "KI-Sitzung verzweigen",
|
||||
"move_to_new_window": "In neues Fenster verschieben",
|
||||
"export_as_workspace": "Als Arbeitsbereich exportieren…",
|
||||
"split_left_right": "Links und rechts teilen",
|
||||
"split_top_bottom": "Oben und unten teilen",
|
||||
"move_left": "Nach links verschieben",
|
||||
"move_right": "Nach rechts verschieben",
|
||||
"tab_color": "Tab-Farbe"
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"rename": "Rename",
|
||||
"rename_tab": "Rename tab",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"cut": "Cut",
|
||||
@@ -903,5 +904,15 @@
|
||||
"files.path_placeholder": "Absolute or relative directory path",
|
||||
"files.path_choose": "Choose a directory",
|
||||
"files.path_open": "Open directory",
|
||||
"files.path_failed": "Could not open this directory: {error}"
|
||||
"files.path_failed": "Could not open this directory: {error}",
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "Fork AI session",
|
||||
"move_to_new_window": "Move to new window",
|
||||
"export_as_workspace": "Export as workspace…",
|
||||
"split_left_right": "Split left and right",
|
||||
"split_top_bottom": "Split top and bottom",
|
||||
"move_left": "Move left",
|
||||
"move_right": "Move right",
|
||||
"tab_color": "Tab color"
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"rename": "Cambiar nombre",
|
||||
"rename_tab": "Renombrar pestaña",
|
||||
"copy": "Copiar",
|
||||
"paste": "Pegar",
|
||||
"cut": "Cortar",
|
||||
@@ -104,6 +105,7 @@
|
||||
"new_window": "Nueva ventana",
|
||||
"close_tab": "Cerrar pestaña",
|
||||
"duplicate_tab": "Duplicar pestaña",
|
||||
"copy_working_directory": "Copiar directorio de trabajo",
|
||||
"terminal": "Terminal",
|
||||
"connect": "Conectar",
|
||||
"disconnect": "Desconectar",
|
||||
@@ -173,5 +175,15 @@
|
||||
"copied_lines": "Se copiaron {lines} líneas al portapapeles",
|
||||
"paste_lines": "¿Pegar {lines} líneas?",
|
||||
"sent_to": "Enviado a {target}"
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "Bifurcar sesión de IA",
|
||||
"move_to_new_window": "Mover a una ventana nueva",
|
||||
"export_as_workspace": "Exportar como espacio de trabajo…",
|
||||
"split_left_right": "Dividir a izquierda y derecha",
|
||||
"split_top_bottom": "Dividir arriba y abajo",
|
||||
"move_left": "Mover a la izquierda",
|
||||
"move_right": "Mover a la derecha",
|
||||
"tab_color": "Color de la pestaña"
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"rename": "Renommer",
|
||||
"rename_tab": "Renommer l’onglet",
|
||||
"copy": "Copier",
|
||||
"paste": "Coller",
|
||||
"cut": "Couper",
|
||||
@@ -154,6 +155,7 @@
|
||||
"new_window": "Nouvelle fenêtre",
|
||||
"close_tab": "Fermer l’onglet",
|
||||
"duplicate_tab": "Dupliquer l’onglet",
|
||||
"copy_working_directory": "Copier le répertoire de travail",
|
||||
"terminal": "Terminal",
|
||||
"connect": "Connecter",
|
||||
"disconnect": "Déconnecter",
|
||||
@@ -244,5 +246,15 @@
|
||||
"http_status": "Le service a renvoyé HTTP {status}",
|
||||
"start_failed": "Impossible de démarrer le test du fournisseur : {error}"
|
||||
}
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "Dupliquer la session IA",
|
||||
"move_to_new_window": "Déplacer vers une nouvelle fenêtre",
|
||||
"export_as_workspace": "Exporter en espace de travail…",
|
||||
"split_left_right": "Diviser verticalement",
|
||||
"split_top_bottom": "Diviser horizontalement",
|
||||
"move_left": "Déplacer vers la gauche",
|
||||
"move_right": "Déplacer vers la droite",
|
||||
"tab_color": "Couleur de l’onglet"
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Elimina",
|
||||
"edit": "Modifica",
|
||||
"rename": "Rinomina",
|
||||
"rename_tab": "Rinomina scheda",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"cut": "Taglia",
|
||||
@@ -104,6 +105,7 @@
|
||||
"new_window": "Nuova finestra",
|
||||
"close_tab": "Chiudi scheda",
|
||||
"duplicate_tab": "Duplica scheda",
|
||||
"copy_working_directory": "Copia la directory di lavoro",
|
||||
"terminal": "Terminale",
|
||||
"connect": "Connetti",
|
||||
"disconnect": "Disconnetti",
|
||||
@@ -173,5 +175,15 @@
|
||||
"copied_lines": "{lines} righe copiate negli appunti",
|
||||
"paste_lines": "Incollare {lines} righe?",
|
||||
"sent_to": "Inviato a {target}"
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "Dirama sessione IA",
|
||||
"move_to_new_window": "Sposta in una nuova finestra",
|
||||
"export_as_workspace": "Esporta come area di lavoro…",
|
||||
"split_left_right": "Dividi a sinistra e a destra",
|
||||
"split_top_bottom": "Dividi in alto e in basso",
|
||||
"move_left": "Sposta a sinistra",
|
||||
"move_right": "Sposta a destra",
|
||||
"tab_color": "Colore della scheda"
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"rename": "名前を変更",
|
||||
"rename_tab": "タブの名前を変更",
|
||||
"copy": "コピー",
|
||||
"paste": "貼り付け",
|
||||
"cut": "切り取り",
|
||||
@@ -104,6 +105,7 @@
|
||||
"new_window": "新しいウィンドウ",
|
||||
"close_tab": "タブを閉じる",
|
||||
"duplicate_tab": "タブを複製",
|
||||
"copy_working_directory": "作業ディレクトリをコピー",
|
||||
"terminal": "ターミナル",
|
||||
"connect": "接続",
|
||||
"disconnect": "切断",
|
||||
@@ -173,5 +175,15 @@
|
||||
"copied_lines": "{lines} 行をクリップボードにコピーしました",
|
||||
"paste_lines": "{lines} 行を貼り付けますか?",
|
||||
"sent_to": "{target} に送信しました"
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "AI セッションを分岐",
|
||||
"move_to_new_window": "新しいウィンドウに移動",
|
||||
"export_as_workspace": "ワークスペースとしてエクスポート…",
|
||||
"split_left_right": "左右に分割",
|
||||
"split_top_bottom": "上下に分割",
|
||||
"move_left": "左に移動",
|
||||
"move_right": "右に移動",
|
||||
"tab_color": "タブの色"
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"rename": "이름 바꾸기",
|
||||
"rename_tab": "탭 이름 바꾸기",
|
||||
"copy": "복사",
|
||||
"paste": "붙여넣기",
|
||||
"cut": "잘라내기",
|
||||
@@ -104,6 +105,7 @@
|
||||
"new_window": "새 창",
|
||||
"close_tab": "탭 닫기",
|
||||
"duplicate_tab": "탭 복제",
|
||||
"copy_working_directory": "작업 디렉터리 복사",
|
||||
"terminal": "터미널",
|
||||
"connect": "연결",
|
||||
"disconnect": "연결 해제",
|
||||
@@ -173,5 +175,15 @@
|
||||
"copied_lines": "{lines}줄을 클립보드에 복사했습니다",
|
||||
"paste_lines": "{lines}줄을 붙여넣을까요?",
|
||||
"sent_to": "{target}(으)로 전송했습니다"
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "AI 세션 분기",
|
||||
"move_to_new_window": "새 창으로 이동",
|
||||
"export_as_workspace": "작업 공간으로 내보내기…",
|
||||
"split_left_right": "좌우로 분할",
|
||||
"split_top_bottom": "위아래로 분할",
|
||||
"move_left": "왼쪽으로 이동",
|
||||
"move_right": "오른쪽으로 이동",
|
||||
"tab_color": "탭 색상"
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Excluir",
|
||||
"edit": "Editar",
|
||||
"rename": "Renomear",
|
||||
"rename_tab": "Renomear aba",
|
||||
"copy": "Copiar",
|
||||
"paste": "Colar",
|
||||
"cut": "Recortar",
|
||||
@@ -104,6 +105,7 @@
|
||||
"new_window": "Nova janela",
|
||||
"close_tab": "Fechar aba",
|
||||
"duplicate_tab": "Duplicar aba",
|
||||
"copy_working_directory": "Copiar diretório de trabalho",
|
||||
"terminal": "Terminal",
|
||||
"connect": "Conectar",
|
||||
"disconnect": "Desconectar",
|
||||
@@ -173,5 +175,15 @@
|
||||
"copied_lines": "{lines} linhas copiadas para a área de transferência",
|
||||
"paste_lines": "Colar {lines} linhas?",
|
||||
"sent_to": "Enviado para {target}"
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "Bifurcar sessão de IA",
|
||||
"move_to_new_window": "Mover para nova janela",
|
||||
"export_as_workspace": "Exportar como espaço de trabalho…",
|
||||
"split_left_right": "Dividir lado a lado",
|
||||
"split_top_bottom": "Dividir em cima e embaixo",
|
||||
"move_left": "Mover para a esquerda",
|
||||
"move_right": "Mover para a direita",
|
||||
"tab_color": "Cor da aba"
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"rename": "Переименовать",
|
||||
"rename_tab": "Переименовать вкладку",
|
||||
"copy": "Копировать",
|
||||
"paste": "Вставить",
|
||||
"cut": "Вырезать",
|
||||
@@ -104,6 +105,7 @@
|
||||
"new_window": "Новое окно",
|
||||
"close_tab": "Закрыть вкладку",
|
||||
"duplicate_tab": "Дублировать вкладку",
|
||||
"copy_working_directory": "Копировать рабочий каталог",
|
||||
"terminal": "Терминал",
|
||||
"connect": "Подключиться",
|
||||
"disconnect": "Отключиться",
|
||||
@@ -173,5 +175,15 @@
|
||||
"copied_lines": "Скопировано строк в буфер обмена: {lines}",
|
||||
"paste_lines": "Вставить строки ({lines})?",
|
||||
"sent_to": "Отправлено в {target}"
|
||||
},
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "Создать ответвление сеанса ИИ",
|
||||
"move_to_new_window": "Переместить в новое окно",
|
||||
"export_as_workspace": "Экспортировать как рабочую область…",
|
||||
"split_left_right": "Разделить слева и справа",
|
||||
"split_top_bottom": "Разделить сверху и снизу",
|
||||
"move_left": "Переместить влево",
|
||||
"move_right": "Переместить вправо",
|
||||
"tab_color": "Цвет вкладки"
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"rename": "重命名",
|
||||
"rename_tab": "重命名标签页",
|
||||
"copy": "复制",
|
||||
"paste": "粘贴",
|
||||
"cut": "剪切",
|
||||
@@ -903,5 +904,15 @@
|
||||
"files.path_placeholder": "输入绝对路径或相对路径",
|
||||
"files.path_choose": "选择目录",
|
||||
"files.path_open": "打开目录",
|
||||
"files.path_failed": "无法打开这个目录:{error}"
|
||||
"files.path_failed": "无法打开这个目录:{error}",
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "分叉 AI 会话",
|
||||
"move_to_new_window": "移到新窗口",
|
||||
"export_as_workspace": "导出为工作区…",
|
||||
"split_left_right": "左右分屏",
|
||||
"split_top_bottom": "上下分屏",
|
||||
"move_left": "向左移动",
|
||||
"move_right": "向右移动",
|
||||
"tab_color": "标签颜色"
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@
|
||||
"delete": "刪除",
|
||||
"edit": "編輯",
|
||||
"rename": "重新命名",
|
||||
"rename_tab": "重新命名分頁",
|
||||
"copy": "複製",
|
||||
"paste": "貼上",
|
||||
"cut": "剪下",
|
||||
@@ -218,5 +219,15 @@
|
||||
"session.choose_conversation": "選擇要繼續的對話…",
|
||||
"session.exit_incomplete_title": "仍有 AI 工作階段無法識別,繼續關閉?",
|
||||
"session.exit_incomplete_body": "關閉前會儲存視窗配置及已知的對話 ID,並停止正在執行的命令。尚未取得 ID 的對話,重新開啟後可能需要手動選擇。",
|
||||
"session.exit_anyway": "儲存已知狀態並關閉"
|
||||
"session.exit_anyway": "儲存已知狀態並關閉",
|
||||
"tab_menu": {
|
||||
"fork_ai_session": "分叉 AI 工作階段",
|
||||
"move_to_new_window": "移到新視窗",
|
||||
"export_as_workspace": "匯出為工作區…",
|
||||
"split_left_right": "左右分割",
|
||||
"split_top_bottom": "上下分割",
|
||||
"move_left": "向左移動",
|
||||
"move_right": "向右移動",
|
||||
"tab_color": "分頁顏色"
|
||||
}
|
||||
}
|
||||
+71
-65
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ pub fn default_key_bindings() -> Vec<KeyBinding> {
|
||||
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> {
|
||||
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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -81,8 +81,6 @@ pub(super) fn default_workspace_bindings() -> Vec<KeyBinding> {
|
||||
// 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> {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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<usize>,
|
||||
renamed: usize,
|
||||
mode: TermMode,
|
||||
input: Vec<Keystroke>,
|
||||
encoded: Vec<u8>,
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,10 @@ use gpui::{
|
||||
};
|
||||
use gpui_component::menu::PopupMenuItem;
|
||||
|
||||
use crate::display::UiLanguage;
|
||||
use crate::display::color::Rgb;
|
||||
use crate::gpui_shell::prelude::*;
|
||||
use crate::i18n::Message;
|
||||
use crate::session::LaunchSession;
|
||||
use nebula_split::SplitDirection;
|
||||
|
||||
@@ -73,7 +75,7 @@ pub(super) fn copy_working_directory_item(
|
||||
let available = source.read(cx).working_directory().is_some();
|
||||
let source = source.downgrade();
|
||||
PopupMenuItem::new(
|
||||
super::workspace_ui_language().text(crate::i18n::Message::CommonCopyWorkingDirectory),
|
||||
crate::gpui_shell::config::ui_language(cx).text(Message::CommonCopyWorkingDirectory),
|
||||
)
|
||||
.icon(IconName::Folder)
|
||||
.disabled(!available)
|
||||
@@ -101,6 +103,7 @@ impl NebulaWorkspace {
|
||||
}
|
||||
self.activate_tab(ix, window, cx);
|
||||
let terminal = self.tabs.get(ix).is_some_and(WorkspaceTab::is_terminal);
|
||||
let language = crate::gpui_shell::config::ui_language(cx);
|
||||
let ai_fork = tab_ai_fork_enabled(self, ix, cx);
|
||||
let color = self.meta(ix).color;
|
||||
let tab_count = self.tabs.len();
|
||||
@@ -116,25 +119,18 @@ impl NebulaWorkspace {
|
||||
let menu = PopupMenu::build(window, cx, move |mut menu, _window, _cx| {
|
||||
if let Some(view) = choose_session {
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(
|
||||
super::workspace_ui_language()
|
||||
.text(crate::i18n::Message::SessionChooseConversation),
|
||||
)
|
||||
.on_click(move |_, _, cx| {
|
||||
view.update(cx, |view, cx| view.choose_recovery_session(cx))
|
||||
}),
|
||||
PopupMenuItem::new(language.text(Message::SessionChooseConversation)).on_click(
|
||||
move |_, _, cx| {
|
||||
view.update(cx, |view, cx| view.choose_recovery_session(cx))
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(view) = retry {
|
||||
menu = menu
|
||||
.item(
|
||||
PopupMenuItem::new(
|
||||
super::workspace_ui_language().text(crate::i18n::Message::SessionRetry),
|
||||
)
|
||||
.on_click(move |_, _, cx| {
|
||||
view.update(cx, |view, cx| view.retry_recovery(cx))
|
||||
}),
|
||||
)
|
||||
.item(PopupMenuItem::new(language.text(Message::SessionRetry)).on_click(
|
||||
move |_, _, cx| view.update(cx, |view, cx| view.retry_recovery(cx)),
|
||||
))
|
||||
.separator();
|
||||
}
|
||||
if let Some(copy_cwd) = copy_cwd {
|
||||
@@ -148,6 +144,7 @@ impl NebulaWorkspace {
|
||||
ai_fork,
|
||||
color,
|
||||
tab_count,
|
||||
language,
|
||||
)
|
||||
});
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
@@ -187,19 +184,22 @@ impl NebulaWorkspace {
|
||||
ai_fork: bool,
|
||||
color: Option<Rgb>,
|
||||
tab_count: usize,
|
||||
language: UiLanguage,
|
||||
) -> PopupMenu {
|
||||
if ai_fork {
|
||||
let target = workspace.clone();
|
||||
menu = menu
|
||||
.item(PopupMenuItem::new("分叉 AI 会话").icon(IconName::Bot).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(workspace) = target.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.fork_ai_session(ix, window, cx);
|
||||
});
|
||||
}
|
||||
},
|
||||
))
|
||||
.item(
|
||||
PopupMenuItem::new(language.text(Message::TabMenuForkAiSession))
|
||||
.icon(IconName::Bot)
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Some(workspace) = target.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.fork_ai_session(ix, window, cx);
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
.separator();
|
||||
}
|
||||
if terminal {
|
||||
@@ -209,7 +209,7 @@ impl NebulaWorkspace {
|
||||
let split_right = workspace.clone();
|
||||
let split_down = workspace.clone();
|
||||
menu = menu
|
||||
.item(PopupMenuItem::new("复制标签页").icon(IconName::Copy).on_click(
|
||||
.item(PopupMenuItem::new(language.text(Message::CommonDuplicateTab)).icon(IconName::Copy).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(workspace) = duplicate.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
@@ -219,7 +219,7 @@ impl NebulaWorkspace {
|
||||
},
|
||||
))
|
||||
.item(
|
||||
PopupMenuItem::new("移到新窗口")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuMoveToNewWindow))
|
||||
.icon(IconName::ExternalLink)
|
||||
.on_click(move |_, _, cx| {
|
||||
if let Some(workspace) = move_to_window.upgrade() {
|
||||
@@ -229,7 +229,7 @@ impl NebulaWorkspace {
|
||||
}
|
||||
}),
|
||||
)
|
||||
.item(PopupMenuItem::new("导出为工作区…").icon(IconName::Inbox).on_click(
|
||||
.item(PopupMenuItem::new(language.text(Message::TabMenuExportAsWorkspace)).icon(IconName::Inbox).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(workspace) = export.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
@@ -242,7 +242,7 @@ impl NebulaWorkspace {
|
||||
// `action` 只用来渲染键帽:handler 存在时组件不会 dispatch
|
||||
// 它(见 PopupMenu::confirm),所以命令仍然作用在 `ix` 上。
|
||||
.item(
|
||||
PopupMenuItem::new("左右分屏")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuSplitLeftRight))
|
||||
.icon(IconName::PanelRight)
|
||||
.action(Box::new(SplitRight))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -259,7 +259,7 @@ impl NebulaWorkspace {
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new("上下分屏")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuSplitTopBottom))
|
||||
.icon(IconName::PanelBottom)
|
||||
.action(Box::new(SplitDown))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -286,7 +286,7 @@ impl NebulaWorkspace {
|
||||
// 分屏两项)。首/末位灰掉而不是隐藏——菜单条目忽隐忽现比灰掉
|
||||
// 更难认。
|
||||
.item(
|
||||
PopupMenuItem::new("向左移动")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuMoveLeft))
|
||||
.icon(IconName::ArrowLeft)
|
||||
.action(Box::new(MoveTabLeft))
|
||||
.disabled(ix == 0)
|
||||
@@ -300,7 +300,7 @@ impl NebulaWorkspace {
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new("向右移动")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuMoveRight))
|
||||
.icon(IconName::ArrowRight)
|
||||
.action(Box::new(MoveTabRight))
|
||||
.disabled(ix + 1 >= tab_count)
|
||||
@@ -315,7 +315,7 @@ impl NebulaWorkspace {
|
||||
)
|
||||
.separator()
|
||||
.item(
|
||||
PopupMenuItem::new("重命名")
|
||||
PopupMenuItem::new(language.text(Message::CommonRename))
|
||||
.icon(IconName::ALargeSmall)
|
||||
.action(Box::new(RenameActiveTab))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -327,7 +327,7 @@ impl NebulaWorkspace {
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new("关闭")
|
||||
PopupMenuItem::new(language.text(Message::CommonClose))
|
||||
.icon(IconName::Close)
|
||||
.action(Box::new(CloseActiveTerminal))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -338,7 +338,7 @@ impl NebulaWorkspace {
|
||||
}
|
||||
}),
|
||||
);
|
||||
Self::tab_color_items(menu, workspace, ix, color)
|
||||
Self::tab_color_items(menu, workspace, ix, color, language)
|
||||
}
|
||||
|
||||
/// 标签颜色行(旧壳菜单尾部的色板):首槽 `A` = 无色,其后是 7 枚品牌
|
||||
@@ -348,9 +348,10 @@ impl NebulaWorkspace {
|
||||
workspace: gpui::WeakEntity<Self>,
|
||||
ix: usize,
|
||||
current: Option<Rgb>,
|
||||
language: UiLanguage,
|
||||
) -> PopupMenu {
|
||||
menu.separator().item(PopupMenuItem::label("标签颜色")).item(PopupMenuItem::element(
|
||||
move |_, cx| {
|
||||
menu.separator().item(PopupMenuItem::label(language.text(Message::TabMenuTabColor))).item(
|
||||
PopupMenuItem::element(move |_, cx| {
|
||||
let swatches = std::iter::once(None)
|
||||
.chain(crate::display::context_menu::TAB_COLORS.into_iter().map(Some));
|
||||
let mut row = h_flex().gap_1().py_1();
|
||||
@@ -395,8 +396,8 @@ impl NebulaWorkspace {
|
||||
);
|
||||
}
|
||||
row
|
||||
},
|
||||
))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,39 @@ fn typed_and_compatibility_lookups_agree() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_context_menu_has_translations_for_every_supported_language() {
|
||||
let items = [
|
||||
Message::CommonCopyWorkingDirectory,
|
||||
Message::CommonDuplicateTab,
|
||||
Message::TabMenuForkAiSession,
|
||||
Message::TabMenuMoveToNewWindow,
|
||||
Message::TabMenuExportAsWorkspace,
|
||||
Message::TabMenuSplitLeftRight,
|
||||
Message::TabMenuSplitTopBottom,
|
||||
Message::TabMenuMoveLeft,
|
||||
Message::TabMenuMoveRight,
|
||||
Message::CommonRename,
|
||||
Message::CommonClose,
|
||||
Message::TabMenuTabColor,
|
||||
];
|
||||
for language in UiLanguage::ALL {
|
||||
for item in items {
|
||||
assert!(!language.text(item).is_empty(), "{}: {item:?}", language.code());
|
||||
if *language != UiLanguage::EnUs {
|
||||
assert_ne!(
|
||||
language.text(item),
|
||||
UiLanguage::EnUs.text(item),
|
||||
"{}: {item:?}",
|
||||
language.code()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(UiLanguage::KoKr.text(Message::CommonCopyWorkingDirectory), "작업 디렉터리 복사");
|
||||
assert_eq!(UiLanguage::KoKr.text(Message::TabMenuTabColor), "탭 색상");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_migration_preserves_bilingual_text_and_english_fallback() {
|
||||
assert_eq!(UiLanguage::ZhCn.pick("网络", "Network"), "网络");
|
||||
|
||||
@@ -357,6 +357,10 @@ impl<T: EventListener> Execute<T> 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,
|
||||
)),
|
||||
|
||||
@@ -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"(?<![A-Za-z0-9_])tty[-_ ]?7(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])otty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])netcaxx(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])netcat[-_ ]?ty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])arxxx(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])cmux(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])ghostty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])herdr(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])kaku(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])orca(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])tmux(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])wezterm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])windows[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])alacritty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])kitty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])tabby(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])warp(?:[-_ ]?terminal)?(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])wave[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])electerm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])termius(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])xshell(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])windterm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])mobaxterm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])iterm2?(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])hyper[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])rio[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])contour(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])extraterm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])finalshell(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])royal[-_ ]?ts(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
re.compile(r"(?<![A-Za-z0-9_])securecrt(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||
)
|
||||
PROHIBITED_EXAMPLES = (
|
||||
"tty7",
|
||||
"otty",
|
||||
"netcaxx",
|
||||
"netcatty",
|
||||
"arxxx",
|
||||
"cmux",
|
||||
"ghostty",
|
||||
"herdr",
|
||||
"kaku",
|
||||
"orca",
|
||||
"tmux",
|
||||
"wezterm",
|
||||
"Windows Terminal",
|
||||
"Alacritty",
|
||||
"Kitty",
|
||||
"Tabby",
|
||||
"Warp",
|
||||
)
|
||||
|
||||
# 门禁必须能维护自己的关键词表;除此之外没有源码或文档例外。
|
||||
EXEMPT_PATHS = {
|
||||
"scripts/check_prohibited_names.py",
|
||||
# These fixtures intentionally contain both accepted and rejected names so
|
||||
# the policy can test its own false-positive boundaries.
|
||||
"scripts/tests/test_prohibited_names.py",
|
||||
"scripts/tests/fixtures/prohibited_names/ordinary-comparison.md",
|
||||
}
|
||||
LEGAL_ATTRIBUTION_PATTERNS = (
|
||||
re.compile(
|
||||
r"^\s*(?:[#/;*=-]+\s*)?copyright\b[^;\n]*\b(?:project|contributors?|authors?)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r"^\s*(?:[#/;*=-]+\s*)?(?:third[- ]party|upstream)\s+(?:notice|attribution|project)\b[^;\n]*",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
DEPENDENCY_REFERENCE_PATTERNS = (
|
||||
re.compile(r"\bgit\s*=\s*[\"']https?://[^\"']+[\"']", re.IGNORECASE),
|
||||
re.compile(r"\bsource\s*=\s*[\"']git\+https?://[^\"']+[\"']", re.IGNORECASE),
|
||||
)
|
||||
PROTOCOL_COMPATIBILITY_PATTERNS = (
|
||||
re.compile(r"\bKITTY_[A-Z0-9_]+\b"),
|
||||
re.compile(r"\bkitty_(?:keyboard|seq|event|release|escape|input)[A-Za-z0-9_]*\b", re.IGNORECASE),
|
||||
re.compile(r"\b(?:kitty|tmux)/(?:legacy|style|protocol)\b", re.IGNORECASE),
|
||||
re.compile(r"\b(?:kitty|tmux)\s+(?:keyboard|protocol|escape|sequence)\b", re.IGNORECASE),
|
||||
re.compile(r"\bThemeFormat::(?:WindowsTerminal|Kitty|Ghostty|WezTerm|Alacritty)\b"),
|
||||
re.compile(
|
||||
r"unsupported theme format; use Pebrel JSON, Windows Terminal JSON, Kitty, Ghostty, WezTerm or Alacritty",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(r"\\x1bPtmux;"),
|
||||
)
|
||||
|
||||
|
||||
def git(*args: str) -> 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)
|
||||
@@ -1 +1 @@
|
||||
484
|
||||
481
|
||||
@@ -0,0 +1 @@
|
||||
printf '%s\n' 'gh api repos/example/project'
|
||||
@@ -0,0 +1 @@
|
||||
gh api repos/Kuddev/pebrel
|
||||
@@ -0,0 +1 @@
|
||||
Copyright 2020 The Alacritty Project
|
||||
@@ -0,0 +1 @@
|
||||
Pebrel is faster than Ghostty.
|
||||
@@ -0,0 +1,3 @@
|
||||
const KITTY_KEYBOARD_PROTOCOL: bool = true;
|
||||
const TMUX_ESCAPE: &str = "\\x1bPtmux;";
|
||||
const FORMAT = ThemeFormat::Kitty;
|
||||
@@ -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 '<none>'}`", 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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user