merge: synchronize macOS updater with main

This commit is contained in:
kuddev committed 2026-09-24 12:09:10 +08:00
commit 3af0e5face
80 files changed
+4261 -365

No files matched your search

+140 -39
View File
@@ -1,114 +1,215 @@
name: Bug Report
description: Report a reproducible problem in Nebula
name: Bug Report / 问题反馈
description: Report a reproducible problem in Pebrel / 反馈一个可复现的问题
title: "[Bug] "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please search existing issues first and include enough detail for someone else to reproduce the problem.
Thanks for taking the time to report a bug. A report we can reproduce gets fixed in the next release; one we cannot reproduce is asked for details and waits.
感谢反馈。**能复现的问题会进下个版本;说不清的问题只能先挂起等补充。** 下拉选项会自动转成标签。
- type: dropdown
id: platform
attributes:
label: Operating system
label: Operating system / 操作系统
options:
- Windows
- Windows (WSL)
- macOS
- Linux
- Other
- Other / 其他
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version and display / 系统版本与显示
description: Windows 11 23H2 / macOS 26.0 / Ubuntu 24.04; DPI scaling and monitor count if display-related. / 系统版本;渲染问题请附缩放比例与显示器数量。
placeholder: "Windows 11 23H2, 150% scaling, 2 monitors"
validations:
required: true
- type: input
id: version
attributes:
label: Nebula version
description: Find it in Settings > Home, or on the latest release page.
placeholder: "e.g. 1.0.0"
label: Pebrel version / 版本号
description: Settings > Home, or the release page. / 设置 > 主页,或发布页。
placeholder: "e.g. 1.9.0"
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: How did you install Nebula?
label: How did you install Pebrel? / 安装方式
options:
- GitHub Release (.msi / .exe / portable archive)
- Built from source (cargo build / cargo run)
- Other
- GitHub Release (.exe / .zip / .dmg / .AppImage / .deb)
- Built from source / 源码构建 (cargo build)
- Other / 其他
validations:
required: true
- type: dropdown
id: shell
attributes:
label: Shell in the affected pane / 出问题的面板用的 Shell
multiple: true
options:
- PowerShell 7 (pwsh)
- Windows PowerShell 5.1
- cmd
- WSL (bash / zsh inside WSL)
- Git Bash / MSYS2
- zsh
- bash
- fish
- nushell
- Not sure / 不确定
validations:
required: true
- type: dropdown
id: program
attributes:
label: Program running in the pane / 面板里正在跑的程序
description: What was running when it happened? / 出问题时面板里跑着什么?
multiple: true
options:
- Nothing, plain shell prompt / 什么都没跑
- Claude Code
- Codex CLI
- Pi
- Gemini CLI / other AI agent — 其他 AI agent
- Terminal multiplexer (screen etc.) — 终端复用器
- vim / nvim / other full-screen TUI — 全屏 TUI
- ssh client / remote shell — 远程 shell
- A long-running command (build, ping, adb…) — 长时间运行的命令
- Other / 其他
validations:
required: true
- type: dropdown
id: connection
attributes:
label: Connection type / 连接方式
options:
- Local terminal / 本地终端
- SSH via Pebrel host list / Pebrel 内 SSH 主机
- SSH via command line (ssh user@host) / 手动 ssh 命令
- WSL
- Other / 其他
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
label: Affected area / 影响范围
description: Pick every area that applies. / 可多选。
multiple: true
options:
- Local terminal / shell
- SSH connection / authentication
- SFTP / file tree
- Tabs / splits / sessions
- AI assistant / providers
- Settings / backup / sync
- UI / layout / rendering
- Startup / crash
- Other
- Terminal rendering / scrolling / selection / garbled text — 终端渲染、滚动、选区、乱码
- Shell startup / PATH / start directory / context menu — Shell 启动、环境变量、起始目录、右键菜单
- SSH / proxy / jump host — SSH、代理、跳板
- File tree / drag-and-drop upload — 文件树、拖拽上传
- Tabs / splits / session restore / window size — 标签页、分屏、会话恢复、窗口尺寸
- AI agent (Claude Code / Codex / Pi / OMP) — AI Agent 集成与状态
- Markdown / LaTeX / image reader — 阅读页与内联渲染
- Settings / theme / layout / blur — 设置页、主题、布局、模糊背景
- Keyboard / IME / mouse / shortcuts / clipboard — 键盘、输入法、鼠标、快捷键、剪贴板
- Git / SVN
- Startup crash / hang / cannot exit — 启动崩溃、卡死、无法退出
- Performance / lag — 性能、卡顿
- Installer / update / packaging — 安装包、更新、打包
- Other / 其他
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Did it work in a previous version? / 旧版本是否正常?
options:
- Not sure / 不确定
- Yes, it worked before / 旧版本正常,新版本坏了
- No, it never worked / 一直如此
validations:
required: true
- type: dropdown
id: reproducibility
attributes:
label: Can you reproduce it?
label: Can you reproduce it? / 复现概率
options:
- Always (100%)
- Often (>50%)
- Sometimes
- Once / not sure
- Always (100%) / 必现
- Often (>50%) / 经常
- Sometimes / 偶尔
- Once / not sure / 仅一次
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Use numbered steps and include the shell or connection type when relevant.
label: Steps to reproduce / 复现步骤
description: |
Numbered, from a fresh launch, ending at the exact moment it goes wrong. "It's laggy" or "it doesn't work" cannot be acted on.
从启动开始按序编号,写到出错的那一步为止。"很卡"、"不好用"这种一句话我们没法处理。
placeholder: |
1. Open Nebula
2. Create or connect to ...
3. ...
1. Launch Pebrel, open a new PowerShell 7 tab
2. Run `codex` and ask it to ...
3. Split the pane with Ctrl+Shift+D
4. Output overlaps the input box at the bottom (see screenshot)
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
label: Expected behavior / 期望结果
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
label: Actual behavior / 实际结果
description: What you see instead, including any error text verbatim. / 实际看到什么,错误文字原样贴。
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: Include relevant lines or screenshots. Remove passwords, private keys, tokens, host addresses, and other secrets.
label: Logs / screenshots / 日志或截图
description: |
Required. Screenshot or short recording for anything visual; log lines for crashes, hangs and connection problems. Remove passwords, keys, tokens and host addresses first.
必填。显示类问题贴截图或短录屏;崩溃、卡死、连接类问题贴日志。请先删掉密码、私钥、token、主机地址。
Logs / 日志位置:
- Windows: `%APPDATA%\Pebrel\pebrel_debug.log` and `%TEMP%\Pebrel-<pid>.log`
- macOS: `~/Library/Application Support/Pebrel/pebrel_debug.log` and `$TMPDIR/Pebrel-<pid>.log`
- Linux: `~/.config/pebrel/pebrel_debug.log` and `/tmp/Pebrel-<pid>.log`
placeholder: Paste redacted logs or drag screenshots here.
validations:
required: true
- type: textarea
id: settings
attributes:
label: Non-default settings / 改过的设置
description: Theme, font, transparency/blur, GPU, shell profile, hooks — anything changed from defaults. Attach settings.txt if unsure. / 改过的主题、字体、透明/模糊、GPU、Shell 配置、hooks;不确定就贴 settings.txt。
placeholder: "Acrylic blur on, opacity 85%, font JetBrains Mono 13"
- type: checkboxes
id: checklist
attributes:
label: Before submitting
label: Before submitting / 提交前确认
options:
- label: I searched existing issues and did not find a duplicate
- label: I searched existing issues and did not find a duplicate / 已搜索,无重复
required: true
- label: I removed passwords, private keys, tokens, and other secrets from this report
- label: I tested with the latest release / 已用最新版本试过
required: true
- label: I removed passwords, private keys, tokens and other secrets / 已删除敏感信息
required: true
+9 -6
View File
@@ -1,8 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Existing issues
url: https://github.com/Kuddev/nebula/issues
about: Search existing reports before opening a new issue.
- name: Latest release
url: https://github.com/Kuddev/nebula/releases/latest
about: Check whether the problem is already fixed in the latest version.
- name: Existing issues / 已有 issue
url: https://github.com/Kuddev/pebrel/issues
about: Search existing reports before opening a new issue. / 先搜一下是否已有人反馈。
- name: Latest release / 最新版本
url: https://github.com/Kuddev/pebrel/releases/latest
about: Check whether the problem is already fixed in the latest version. / 看看最新版是否已修复。
- name: Discord
url: https://discord.gg/VFn4rcxmhn
about: Quick questions and discussion. / 快速提问与交流。
+41 -24
View File
@@ -1,5 +1,5 @@
name: Feature Request
description: Suggest an improvement or new capability for Nebula
name: Feature Request / 功能建议
description: Suggest an improvement or new capability for Pebrel / 提出改进或新功能
title: "[Feature] "
labels: ["enhancement", "triage"]
body:
@@ -7,12 +7,13 @@ body:
attributes:
value: |
Describe the workflow problem first, then the change you want. Concrete examples make the request easier to evaluate.
先说现在哪里不顺手,再说希望怎么改。具体的使用场景最有说服力。
- type: textarea
id: problem
attributes:
label: Problem / pain point
description: What is hard, missing, or repetitive today?
label: Problem / pain point — 现在的痛点
description: What is hard, missing, or repetitive today? / 什么事难做、缺失、或者每天重复?
placeholder: When I work with multiple terminals or hosts, I cannot ...
validations:
required: true
@@ -20,50 +21,66 @@ body:
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What would you like Nebula to do?
label: Proposed solution — 希望的做法
description: What would you like Pebrel to do? / 希望 Pebrel 怎么做?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: List workarounds, other tools, or designs you considered.
validations:
required: true
label: Alternatives considered — 现在的替代办法
description: Workarounds, other tools, or designs you considered. / 目前怎么绕过去的,别的终端怎么做的。
- type: dropdown
id: platform
attributes:
label: Platform / 平台
description: Leave empty if it applies everywhere. / 全平台通用可不选。
multiple: true
options:
- Windows
- Windows (WSL)
- macOS
- Linux
- type: dropdown
id: area
attributes:
label: Related area
label: Related area / 相关范围
multiple: true
options:
- Local terminal / shell
- SSH / SFTP
- Tabs / splits / sessions
- AI assistant / providers
- Settings / backup / sync
- UI / UX
- Other
- Terminal rendering / scrolling / selection — 终端渲染、滚动、选区
- Shell startup / PATH / start directory / context menu — Shell 启动、环境变量、起始目录、右键菜单
- SSH / proxy / jump host — SSH、代理、跳板
- File tree / drag-and-drop upload — 文件树、拖拽上传
- Tabs / splits / session restore / window size — 标签页、分屏、会话恢复、窗口尺寸
- AI agent (Claude Code / Codex / Pi / OMP) — AI Agent 集成与状态
- Markdown / LaTeX / image reader — 阅读页与内联渲染
- Settings / theme / layout / blur — 设置页、主题、布局、模糊背景
- Keyboard / IME / mouse / shortcuts / clipboard — 键盘、输入法、鼠标、快捷键、剪贴板
- Git / SVN
- Performance / lag — 性能、卡顿
- Installer / update / packaging — 安装包、更新、打包
- Other / 其他
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
label: How important is this to you? / 对你有多重要
options:
- Nice to have
- Would improve my daily workflow
- Blocking / critical for my use case
- Nice to have / 有更好
- Would improve my daily workflow / 每天都会用到
- Blocking / critical for my use case / 没有就没法用
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Before submitting
label: Before submitting / 提交前确认
options:
- label: I searched existing issues for similar requests
- label: I searched existing issues for similar requests / 已搜索,无重复
required: true
+54
View File
@@ -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
+21
View File
@@ -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 }}
+154
View File
@@ -0,0 +1,154 @@
name: Issue labels
# Turns the dropdown answers of the issue forms (.github/ISSUE_TEMPLATE/*.yml)
# into labels and flags bug reports that are too thin to act on. Labels are
# only added; maintainers' manual labels are never removed.
on:
issues:
types: [opened, edited]
permissions:
issues: write
concurrency:
group: issue-labels-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
label-from-form:
name: label-from-form
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Map form answers to labels
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || "";
// Issue forms render every field as "### <label>\n\n<value>";
// multi-select dropdowns join their values with ", ".
const fields = {};
const re = /^### (.+?)\s*\n+([\s\S]*?)(?=^### |\s*$(?![\s\S]))/gm;
for (const m of body.matchAll(re)) {
const value = m[2].trim();
if (value && value !== "_No response_") fields[m[1].trim()] = value;
}
const field = (prefix) => {
const key = Object.keys(fields).find((k) => k.startsWith(prefix));
return key ? fields[key] : "";
};
const answers = (prefix) => {
const value = field(prefix);
return value ? value.split(", ").map((s) => s.trim()) : [];
};
// Match on the English lead of each option so wording tweaks in
// the Chinese half never silently break the mapping.
const OS = [
["Windows (WSL)", ["os: windows", "os: wsl"]],
["Windows", ["os: windows"]],
["macOS", ["os: macos"]],
["Linux", ["os: linux"]],
];
const AREA = [
["Terminal rendering", ["area: terminal"]],
["Shell startup", ["area: shell"]],
["SSH", ["area: ssh"]],
["File tree", ["area: files"]],
["Tabs / splits", ["area: session"]],
["AI agent", ["area: agents"]],
["Markdown", ["area: reader"]],
["Settings", ["area: ui"]],
["Keyboard", ["area: input"]],
["Git", ["area: git"]],
["Startup crash", ["crash"]],
["Performance", ["performance"]],
["Installer", ["area: ci-release"]],
];
const PROGRAM = [
["Claude Code", ["area: agents"]],
["Codex", ["area: agents"]],
["Pi", ["area: agents"]],
["Gemini", ["area: agents"]],
];
const CONNECTION = [
["SSH", ["area: ssh"]],
["WSL", ["os: wsl"]],
];
const SHELL = [["WSL", ["os: wsl"]]];
const pick = (values, table) => {
const out = [];
for (const v of values) {
const hit = table.find(([lead]) => v.startsWith(lead));
if (hit) out.push(...hit[1]);
}
return out;
};
const labels = new Set([
...pick([...answers("Operating system"), ...answers("Platform")], OS),
...pick([...answers("Affected area"), ...answers("Related area")], AREA),
...pick(answers("Program running in the pane"), PROGRAM),
...pick(answers("Connection type"), CONNECTION),
...pick(answers("Shell in the affected pane"), SHELL),
]);
if (answers("Did it work in a previous version").some((v) => v.startsWith("Yes"))) {
labels.add("regression");
}
// Thin bug reports: no usable steps or no evidence. Ask once, on
// open; an edit that fills the gaps clears the label.
const existing = new Set(issue.labels.map((l) => l.name));
const isBug = existing.has("bug");
const steps = field("Steps to reproduce");
const logs = field("Logs / screenshots");
const missing = [];
// CJK carries more per character; weigh it double. Numbered
// multi-line steps count as an attempt regardless of length.
const compact = steps.replace(/\s+/g, "");
const weight = compact.length + (compact.match(/[ -鿿豈-﫿]/g) || []).length;
const numbered = (steps.match(/^\s*\d+[.、)]/gm) || []).length >= 2;
if (isBug && weight < 60 && !numbered) {
missing.push("**Steps to reproduce / 复现步骤**: numbered steps from launch to the failing moment / 从启动开始按序写到出错那一步");
}
if (isBug && !logs) {
missing.push("**Logs / screenshots / 日志或截图**: a screenshot for visual issues, `pebrel_debug.log` for crashes, hangs and connection problems / 显示类问题贴截图,崩溃卡死连接类贴日志");
}
if (isBug && !/\d+\.\d+/.test(field("Pebrel version"))) {
missing.push("**Pebrel version / 版本号**: e.g. `1.9.0` (Settings > Home)");
}
const toAdd = [...labels].filter((l) => !existing.has(l));
if (missing.length) {
if (!existing.has("needs-info")) toAdd.push("needs-info");
if (context.payload.action === "opened") {
await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: [
"Thanks for the report. It is missing details we need before anyone can reproduce it, so it is parked as `needs-info`. Please edit the issue and fill in:",
"感谢反馈。目前缺少复现所需的信息,已标记 `needs-info`。请**编辑 issue** 补齐以下内容,补齐后标签会自动移除:",
"",
...missing.map((m) => `- ${m}`),
"",
"Reports without these are closed after 14 days of silence. / 14 天无补充将关闭。",
].join("\n"),
});
}
} else if (isBug && existing.has("needs-info") && context.payload.action === "edited") {
await github.rest.issues.removeLabel({
...context.repo, issue_number: issue.number, name: "needs-info",
}).catch(() => {});
}
core.info(`form labels: ${[...labels].join(", ") || "(none)"}; adding: ${toAdd.join(", ") || "(none)"}; missing: ${missing.length}`);
if (toAdd.length) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issue.number,
labels: toAdd,
});
}
+62 -8
View File
@@ -11,7 +11,9 @@ on:
push:
branches: [main]
# Include docs, catalogs, assets and workflow edits: every PR gets test results.
# ready_for_review starts the scarce-runner jobs that draft pushes skip.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
concurrency:
@@ -29,13 +31,61 @@ jobs:
with:
runtime_run: ${{ inputs.windows_diagnostics_run }}
# rustfmt only for now. clippy joins once the existing warning backlog is
# cleared; several crates deny lints, so it cannot run advisory.
lint:
if: ${{ inputs.windows_diagnostics_run == '' }}
name: lint
outputs:
native_matrix: ${{ steps.matrix.outputs.native_matrix }}
release_matrix: ${{ steps.matrix.outputs.release_matrix }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.97.1"
components: rustfmt
- name: rustfmt
run: cargo fmt --all -- --check
- 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
run: |
python scripts/ci_plan.py \
--event-name "$GITHUB_EVENT_NAME" \
--event-path "$GITHUB_EVENT_PATH" \
--output "$GITHUB_OUTPUT"
# The required lint job validates the matrix before heavy runners are
# requested. Draft PRs omit deferred platforms entirely; ready PRs and
# release callers require the full matrix. Deferred checks remain missing
# on drafts instead of being reported as successful tests.
native-tests:
needs: lint
if: ${{ inputs.windows_diagnostics_run == '' }}
name: Tests (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2022, windows-11-arm, macos-26, macos-26-intel]
include: ${{ fromJSON(needs.lint.outputs.native_matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
env:
@@ -46,12 +96,13 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install native dependencies
if: runner.os == 'Linux'
if: ${{ runner.os == 'Linux' }}
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
@@ -62,7 +113,7 @@ jobs:
libx11-xcb-dev libxcb1-dev libxcb-render0-dev \
libxcb-shape0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcb-xkb-dev libxi-dev
- name: Require the AppKit SDK used by this PR
if: runner.os == 'macOS'
if: ${{ runner.os == 'macOS' }}
run: |
sdk_version="$(xcrun --sdk macosx --show-sdk-version)"
test "${sdk_version%%.*}" -ge 26
@@ -70,7 +121,7 @@ jobs:
with:
toolchain: "1.97.1"
- name: Require a native Windows ARM64 compiler
if: matrix.os == 'windows-11-arm'
if: ${{ matrix.os == 'windows-11-arm' }}
shell: pwsh
run: |
if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'Arm64') {
@@ -90,7 +141,7 @@ jobs:
${{ runner.os != 'macOS' && 'target/release' || '' }}
legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }}
- name: Prepare pinned Windows console runtime
if: runner.os == 'Windows'
if: ${{ runner.os == 'Windows' }}
shell: pwsh
run: |
$architecture = if ('${{ runner.arch }}' -eq 'ARM64') { 'arm64' } else { 'x64' }
@@ -116,7 +167,7 @@ jobs:
${{ runner.os != 'macOS' && 'target/release' || '' }}
key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Upload native compilation timings
if: always()
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: native-timings-${{ matrix.os }}
@@ -130,12 +181,13 @@ jobs:
# 34856491552. Both jobs remain part of this reusable workflow's result, so
# the stable release aggregation still requires every check to succeed.
macos-release-check:
needs: lint
if: ${{ inputs.windows_diagnostics_run == '' }}
name: Release workspace (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-26, macos-26-intel]
include: ${{ fromJSON(needs.lint.outputs.release_matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
env:
@@ -145,6 +197,8 @@ jobs:
RUST_TOOLCHAIN: "1.97.1"
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: "3.13"
@@ -170,7 +224,7 @@ jobs:
path: target/release
key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Upload release check compilation timings
if: always()
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: release-check-timings-${{ matrix.os }}
+93
View File
@@ -0,0 +1,93 @@
name: PR size
# Labels every pull request by the number of changed source lines and fails
# oversized ones so they get split before review. Generated and documentation
# files do not count. Maintainers bypass the limit with the `size-exempt`
# label (a release integration branch is the usual case).
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
# Required checks must also report inside the merge queue; the size was
# already enforced on the pull request, so this is a pass-through.
merge_group:
permissions:
pull-requests: write
concurrency:
group: pr-size-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pr-size:
name: pr-size
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const pr = context.payload.pull_request;
const LIMIT = 1500;
const IGNORED = [
/^Cargo\.lock$/, /(^|\/)package-lock\.json$/, /(^|\/)yarn\.lock$/,
/^docs\//, /\.md$/, /\.snap$/, /\.svg$/, /\.png$/, /\.ico$/,
/^assets\//, /^locales\//, /\.lock$/,
];
const files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo, pull_number: pr.number, per_page: 100,
});
let counted = 0, ignored = 0;
for (const f of files) {
const lines = f.additions + f.deletions;
if (IGNORED.some((re) => re.test(f.filename))) ignored += lines; else counted += lines;
}
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(() => {});
}
}
if (!labels.has(wanted)) {
await github.rest.issues.addLabels({ ...context.repo, issue_number: pr.number, labels: [wanted] });
}
if (!oversized) return;
const marker = "<!-- pr-size-guard -->";
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo, issue_number: pr.number, per_page: 100,
});
if (!comments.some((c) => c.body && c.body.includes(marker))) {
await github.rest.issues.createComment({
...context.repo, issue_number: pr.number,
body: [
marker,
`This pull request changes **${counted}** source lines (limit ${LIMIT}; docs, lockfiles and assets excluded). Changes this large cannot be reviewed reliably, so please split it into independent pull requests — one behaviour change each, with its own tests.`,
"",
`本 PR 改动 **${counted}** 行源码(上限 ${LIMIT},不计文档、lockfile、资源)。这么大的改动无法可靠审查,请拆成多个相互独立的 PR,每个只做一件事并附测试。`,
"",
"Maintainers can add the `size-exempt` label for integration branches. / 维护者可对集成分支加 `size-exempt` 放行。",
].join("\n"),
});
}
+1 -20
View File
@@ -4,26 +4,7 @@ run-name: >-
Cross-platform Preview (${{ inputs.preview_id || format('run-{0}', github.run_number) }})
on:
pull_request:
# Native tests cover every PR; package only when package inputs change.
paths:
- "**/Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- "nebula_app/build.rs"
- "nebula_app/build/**"
- "nebula_app/res/**"
- "nebula_app/windows/**"
- "assets/**"
- "packaging/**"
- "scripts/package*"
- "scripts/build-*"
- "scripts/installer*"
- "scripts/prepare-windows-runtime.ps1"
- "scripts/preview_release.py"
- "scripts/conformance/**"
- "scripts/tests/test_preview_release.py"
- ".github/workflows/preview-packages.yml"
# Pull requests use Full native tests; packages are built after merge or on request.
push:
branches: [main]
# Native tests cover every PR; package only when package inputs change.
+2
View File
@@ -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
+20 -6
View File
@@ -74,6 +74,8 @@ raise any existing tracked private artifact for an explicit maintainer decision.
`architecture/notes/`; ordinary fixes do not create a note.
2. Keep one conceptual change per PR. A necessary extraction and its behavior
tests may accompany the feature; unrelated rewrites and formatting may not.
The `pr-size` check fails above 1500 changed source lines (docs, lockfiles
and assets excluded); split instead of asking for an exemption.
3. Put shared rules in their existing authority. UI modules adapt those rules;
they must not fork persistence, state transitions or domain behavior.
4. Add regression tests that fail for the defect, and test error/cancellation
@@ -105,9 +107,11 @@ cargo check -p nebula --bin pebrel --features gpui-shell --tests --locked
`Full native tests` runs on every PR and merge-group update, and on `main` pushes.
It tests the full workspace on Linux, Windows x64 / ARM64 and both macOS
architectures. New commits cancel obsolete PR runs. Package validation has its own
input filters and can also be dispatched manually; a package job does not replace
the native test suite. These triggers do not configure required status checks.
architectures. New commits cancel obsolete PR runs. Pull requests run tests and
compile checks without building distribution packages. Package validation runs
after matching changes reach `main`, or by explicit manual dispatch; a package
job does not replace the native test suite. These triggers do not configure
required status checks.
GitHub may show **Waiting for approval** for a first-time fork contributor. A
maintainer must inspect the submitted changes and approve that workflow run from
@@ -122,9 +126,17 @@ compile check into a claim that UI tests or a packaged application were run.
## Review and enforcement
`architecture-contracts` is the stable PR job name. Maintainers must enable it as
a required check and require Code Owner approval in the target branch ruleset;
see the [activation checklist](docs/project-constraints.md#server-side-activation).
`architecture-contracts`, `lint`, `pr-size`, the five
`Tests (<os>)` jobs and both `Release workspace (<os>)` jobs are required checks
on `main`, together with Code Owner approval; see the
[activation checklist](docs/project-constraints.md#server-side-activation).
Open pull requests as drafts while iterating: drafts run Linux, Windows x64 and
Apple Silicon tests plus the Apple Silicon release-profile compile check. The
required lint job validates the event and selects the matrix before requesting
platform runners. Drafts omit Intel Mac and Windows ARM64 jobs entirely, so their
required contexts may be missing until the PR is marked ready. Ready PRs run the
full matrix; every required check must succeed before merging. The ready event
still reruns core validation rather than reusing an earlier check conclusion.
Local hooks are convenient, but bypassable; they are not the enforcement boundary.
Submitting a workflow or `CODEOWNERS` file does not configure server-side rules.
@@ -137,6 +149,8 @@ feature work; there is no routine `--skip-architecture` option.
## 中文摘要
- 先读架构图、工程合同和决策记录;按职责拆分,不按行号切片。
- 一个 PR 只做一件事;改动超过 1500 行源码(不计文档、lockfile、资源)`pr-size` 会失败,请拆分。
- Draft PR 先运行必需的格式检查和矩阵规划,再创建 Linux / Windows x64 / Apple Silicon 测试及 Apple Silicon release 编译检查;不创建 Intel Mac / Windows ARM64 任务。Ready 后运行完整矩阵,十项必需检查全绿才能合并。
- 2000 行是现有仓库的防灾上限,800 行只提示审查,不是“大厂标准”。
- 普通功能 PR 不得增加存量债务;有问题的规则可以修订,但要有反例、测试和维护者审批。
- 新增核心抽象、依赖方向、持久化或线程模型改变要先说明设计,不强迫每个小修复写 ADR。
@@ -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.
@@ -0,0 +1,64 @@
# Color capabilities belong to the local child environment
## Status
Implemented; pending review.
## Context
Windows users report that Codex lacks its sparse composer stars and user-message
backgrounds in Pebrel while they appear in the reference terminal. The same distinction
must work across themes without identifying message roles from screen text.
## Evidence
GPUI `local_options` refreshes a complete Windows environment before `spawn`
calls `tty::setup_env()`. The environment cache preserves its earlier snapshot;
changing the parent environment afterward cannot insert `COLORTERM` into the
already complete child environment. An Explorer launch need not have terminal
capability variables inherited from another terminal.
Codex's `rust-v0.154.0` `tui/src/terminal_palette.rs` returns the default color
for ANSI-16/unknown color levels. `style.rs` uses that result for user-message
backgrounds. `bottom_pane/chat_composer/sparkle.rs` requires truecolor and queried
foreground/background colors. This explains a shared failure path; settings,
model choice and Codex version still control whether its animation runs.
## Decision
After refreshing a local Windows environment, fill missing `TERM` with
`xterm-256color` and missing `COLORTERM` with `truecolor`. Preserve explicit
case-insensitive overrides and `NO_COLOR`/`FORCE_COLOR`. Forward `COLORTERM` through the existing WSL environment allowlist, preserving
explicit forwarding flags. Keep the actual Pebrel terminal identity. Existing OSC color-query and rendering paths remain authoritative.
## Rejected alternatives
- Pretending to be the reference terminal through `WT_SESSION` misstates identity.
- Recoloring arbitrary terminal rows as user messages would invent application
semantics and damage ordinary command output.
- Hardcoding a Nord-only fix leaves other themes and applications affected.
## Consequences
New local Windows panes advertise implemented capabilities independently of their
launching parent. Already running shells retain their inherited environment.
Providers can still intentionally suppress colors or animations. The two-second
Hook lifetime fix is unrelated to this environment declaration.
## Validation
Eight platform-environment tests pass, including an actual child launched with
an explicitly complete environment. VT replay preserves message surfaces and
Braille stars for all fifteen built-in themes, plus all 225 theme-switch pairs.
A native Debian control returned no `COLORTERM` without forwarding and
`truecolor` with it. These are capability/rendering tests, not a full live Codex
animation acceptance.
## Supersedes
None.
## Revisit when
Terminal capability defaults move into a shared per-child environment builder,
or the Windows environment refresh no longer creates a complete snapshot.
@@ -0,0 +1,63 @@
# Bound the lifetime of one-shot Hook forwarding
## Status
Implemented; pending review.
## Context
[Issue #258](https://github.com/Kuddev/pebrel/issues/258) reports an update that
first failed because `pebrel-hook.exe` remained in use after the application
closed, followed by a successful same-version repair incorrectly marked failed.
The helper must finish even when its caller or notification receiver stalls.
## Evidence
`nebula_hook/src/main.rs` drained stdin through `read_to_end` and wrote the
notification through synchronous `write_all`. Neither had a deadline. A caller
retaining stdin can prevent EOF; a receiver that stops reading can block a write.
Both conditions are reproduced by actual child processes in
[`lifetime.rs`](../../../nebula_hook/tests/lifetime.rs).
## Decision
Run the existing forwarding operation on one process-owned worker. The main
thread waits up to two seconds, then returns normally. Returning from this
one-shot process releases its worker and OS handles without leaving a resident
thread. This bounds stdin, oversize-input draining and notification I/O together.
No new dependency, background application service or platform thread is added.
The user's chained notifier is launched independently after forwarding/waiting;
it is neither joined nor terminated. Cursor's submission-allow response remains
on the main path, including timeout. All provider-visible exits remain successful.
## Rejected alternatives
- Timing only stdin leaves a blocked pipe write alive.
- Joining the worker after timeout defeats the bound.
- Terminating every process named `pebrel-hook.exe` can affect another installation
or invocation. This change retires only the helper's own process.
- Skipping stdin entirely breaks providers which stream their payload into it.
## Consequences
A stalled or unusually slow invocation can lose its best-effort notification.
The forwarding wait is bounded; this is not a universal process startup latency
guarantee. Already running binaries from an older installation do not acquire
the new deadline and may still require the user to close the owning CLI/restart.
## Validation
`cargo test --locked -p nebula_hook`: seven unit tests and five actual-process
tests passed on Windows, covering retained stdin, a connected non-reading named
pipe, normal completion, Cursor continuation and the original chained notifier.
Non-Windows integration paths still require their native CI run.
## Supersedes
None.
## Revisit when
Providers require a longer documented delivery window or the transport becomes
cancellable without a per-invocation worker.
@@ -0,0 +1,67 @@
# Keep automatic package builds out of pull-request checks
## Status
Accepted by the maintainer on 2026-09-23: pull requests run tests and compile
checks; distribution packages are built after merge or on explicit request.
## Context
Preview packaging subscribed to pull requests that changed package inputs.
A Windows installer-menu change therefore started Linux and both macOS package
builds alongside the independent native test matrix. These workflows requested
the same macOS runner types while required checks were waiting for runners.
## Evidence
- [PR #264](https://github.com/Kuddev/pebrel/pull/264) changed Windows installer
scripts and triggered [Preview run 35825643107](https://github.com/Kuddev/pebrel/actions/runs/35825643107).
- The Preview macOS jobs ran workspace tests, dialog tests and an optimized
product build before creating DMGs. The native workflow independently ran
workspace tests and checked the production feature graph.
- The required `Release workspace` jobs execute `cargo check --release`; they
do not generate distribution packages and remain required compile checks.
- Runner waiting is observable. The exact hosted-runner quota or scheduling
policy responsible for a particular delay is not established by these logs.
## Decision
Remove the `pull_request` trigger from `preview-packages.yml`. Keep its filtered
`main` push trigger and explicit `workflow_dispatch`, including all package and
runtime conformance validation. Public Preview publication still requires an
explicit dispatch with publication enabled. Stable release triggers are retained.
The native PR and merge-group workflows retain their test coverage. This change
does not alter required-check names or server-side protection rules.
## Rejected alternatives
- Cancel required macOS checks to merge sooner: loses required validation.
- Treat the release-profile compile checks as package creation: these commands
validate a different build profile without creating installers or archives.
- Remove package conformance tests: package behavior still needs verification
when a package is actually requested.
## Consequences
Pull requests no longer automatically provide Preview installation artifacts.
Maintainers can explicitly dispatch package validation for a candidate branch.
Already queued workflows must be handled separately; changing a trigger cannot
retroactively cancel them. This change does not remove duplicate native runs
when a draft becomes ready, or guarantee a fixed runner wait time.
## Validation
The native-suite workflow contract verifies that Preview has only filtered main
push and manual triggers, that stable release remains tag/manual, and that native
PR and merge-group checks remain unfiltered. Restoring the previous PR package
trigger must fail the new regression.
## Supersedes
The previous automatic Preview build policy for pull-request package-input changes.
## Revisit when
An explicit request for per-PR artifacts can be implemented with bounded platform
selection and a separate runner budget without delaying mandatory validation.
@@ -0,0 +1,60 @@
# Same-version repair is not identified by a changed main binary
## Status
Implemented; pending review.
## Context
[Issue #258](https://github.com/Kuddev/pebrel/issues/258) supplies an Inno log
reporting successful installation and the expected Pebrel version, while the
update helper reports that the same-version binary did not change.
## Evidence
The reported preceding update replaced the main executable but failed with a
locked Hook helper. Reinstalling the same package repairs other files while
leaving the main executable byte-identical. Its hash cannot distinguish that
successful repair from an installation which did no useful work.
## Decision
Retain package SHA-256/size checks, commit authority, participant identity and
exit waiting, installer exit-code checks, and the installed version probe.
Remove the additional requirement that a same-version main binary change hash.
Record the installed hash as evidence, not as a repair-success predicate.
Before starting setup, retry exclusive write access to installed Hook helper files
for five seconds. Short-lived forwarding can finish; persistent locks fail before
any installation file is replaced. The helper never kills unrelated processes.
## Rejected alternatives
- Suppressing every installer error would hide partial/failed installations.
- Forcing a main-binary byte difference does not validate the repaired files.
- Disabling same-version retries prevents recovery after a partial upgrade.
## Consequences
Installer success and the expected application version determine success for a
verified package. The updater does not independently audit every installed file;
installer errors continue to fail the transaction. No installed version tag or
asset naming rule changes.
## Validation
The native handoff suite passes twelve scenarios, including a byte-identical main
binary with a repaired helper. The former same-version/no-change assertion was
incorrect; its negative intent is retained as `upgrade-noop`, which returns
installer success but leaves the wrong application version and must still fail.
Cancellation, checksum/identity failures and unprepared processes remain covered.
A released helper-file lock permits installation; a held lock must prevent setup
from starting and recover the unchanged original application.
## Supersedes
The same-version changed-hash assertion in the handoff helper and its old fixture.
## Revisit when
A versioned installed-file manifest provides a stronger package-completeness
check without confusing unchanged files with failed installation.
@@ -0,0 +1,59 @@
# Group WSL launch entries under one Explorer submenu
## Status
Implemented; pending review.
## Context
One top-level context item per WSL distribution crowds Explorer menus. The user
requested one ordinary Pebrel entry and one expandable entry for distribution
choices, for both selected directories and directory backgrounds.
## Evidence
The previous installer registered `PebrelWsl<index>` directly under each Explorer
shell root. Those entries remain registered after an upgrade unless explicitly
migrated. WSL distribution enumeration already excludes Docker plumbing entries.
## Decision
Keep the ordinary entry. Register a static `PebrelWslMenu` cascade with an empty
`SubCommands` value and its distribution verbs under `shell`. Record its executable
owner and validate child commands before replacing or removing the subtree.
Migrate only flat entries whose command invokes the exact owned executable.
Preserve `%1` for selected directories, `%V` for backgrounds, and the existing
`--shell` before `--working-directory` order. No distributions means no empty
submenu. Conflicting/edited subtrees and other installations remain untouched.
## Rejected alternatives
- Removing distribution choices reduces existing launch functionality.
- Deleting every key with the product prefix can remove another installation or
user customization.
- Installing a new Explorer extension adds deployment and lifetime requirements
unnecessary for this static cascade.
## Consequences
Registration still reflects distributions present at installation time. Existing
menus migrate when the new installer runs; changing this source does not modify
the user's live registry. Conflicts fail visibly rather than overwrite unknown keys.
## Validation
The real Inno fixture uses an isolated HKCU test subtree and passes 117 migration
checks in total. New coverage includes both roots, zero/one/three distributions,
Unicode/space-containing names, repeat registration, stale-entry removal, edited
subtrees and foreign ownership. The complete installer compiles without executing
an installation. Explorer presentation/click acceptance remains separate.
## Supersedes
The installer's flat WSL verb layout; the ordinary Pebrel verb is retained.
## Revisit when
Distribution registration must update outside installation, or Explorer requires
a different supported menu registration mechanism.
@@ -0,0 +1,86 @@
# Plan the native matrix before requesting runners
## Status
Implemented for review. Preserves the existing ten required check names and
ready-PR platform coverage; server-side rules are not changed.
## Context
The initial draft tier skipped individual steps on Intel Mac and Windows ARM64
jobs. GitHub still assigned runners before skipping those steps. In PR #257's
draft run 35820869856, the Intel test job waited 309 seconds before spending
eight seconds on a job whose test steps were skipped.
## Evidence
`runs-on` belongs to a job, while the old draft conditions belonged to its steps.
Avoiding the platform command does not avoid allocating that job's runner.
The required `lint` check already runs on Ubuntu and can validate both formatting
and the platform plan before expensive jobs are created.
## Decision
`scripts/ci_plan.py` is the authority for platform selection. It reads the event
JSON and produces native-test and release-compile matrices. Draft PRs select the
three existing core platforms and the Apple Silicon release check. Ready PRs,
main pushes, merge groups and release callers retain all five native platforms
and both macOS release checks.
The existing required `lint` job owns planning, after rustfmt. Native and release
jobs depend on that job and consume its validated outputs. Invalid input fails
the required check rather than silently returning an empty matrix. Checkouts
explicitly discard Git credentials; the workflow retains read-only permissions.
Draft-only deferred checks are absent, not reported as successful executions.
A ready PR must still supply every required platform result. This change does
not copy previous runs' check conclusions. Cargo caches accelerate rebuilding;
they are not evidence that the current validation succeeded.
## Rejected alternatives
- Keep step-only skipping: retains the unnecessary runner allocation.
- Move planning into an unrequired job: its failure could be less visible than
a failure in an existing required check, especially on repeated runs.
- Remove release-profile checks with Preview packaging: `cargo check --release`
verifies compilation and does not create installation packages.
- Mirror an earlier green check onto a new run: requires independently verified
source, base, workflow, configuration and provenance rules; cache hits alone
cannot establish those facts.
## Consequences
Heavy jobs begin after the Ubuntu lint/planning job. Formatting errors and bad
event metadata stop before requesting platform runners. Draft PR pages may show
deferred required contexts as missing; drafts are not mergeable, and the ready
event runs the full matrix. Ready transitions still rerun core validation.
This reduces avoidable runner requests without promising a fixed queue time.
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
guards to be absent, and release compilation to remain present. Native PR and
merge-group coverage and explicit failure propagation retain their regressions.
Actual hosted-runner execution remains subject to the PR's required CI results.
## Supersedes
Step-level draft skipping in PR #257's initial native matrix.
## Revisit when
Queue and execution measurements justify a different platform schedule, or a
separately reviewed artifact-provenance design supports build-once test shards.
@@ -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.
+12
View File
@@ -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"
}
}
+12 -1
View File
@@ -237,6 +237,7 @@
"delete": "Delete",
"edit": "Edit",
"rename": "Rename",
"rename_tab": "Rename tab",
"copy": "Copy",
"paste": "Paste",
"cut": "Cut",
@@ -908,5 +909,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"
}
}
+12
View File
@@ -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"
}
}
+12
View File
@@ -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"
}
}
+12
View File
@@ -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"
}
}
+12
View File
@@ -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": "タブの色"
}
}
+12
View File
@@ -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": "연결 해제",
@@ -180,5 +182,15 @@
"no_verified_package": "이 릴리스에는 이 기기에 맞는 검증된 업데이트 패키지가 없습니다. 릴리스 페이지에서 직접 다운로드하세요.",
"check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.",
"check_unrecognized_release": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요."
},
"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": "탭 색상"
}
}
+12
View File
@@ -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"
}
}
+12
View File
@@ -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": "Цвет вкладки"
}
}
+12 -1
View File
@@ -237,6 +237,7 @@
"delete": "删除",
"edit": "编辑",
"rename": "重命名",
"rename_tab": "重命名标签页",
"copy": "复制",
"paste": "粘贴",
"cut": "剪切",
@@ -908,5 +909,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": "标签颜色"
}
}
+12 -1
View File
@@ -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": "分頁顏色"
}
}
+75 -45
View File
@@ -170,6 +170,9 @@ const WSLENV_ENTRIES: &[&str] = &[
crate::ai_hook::LEGACY_PANE_ENV,
TERM_PROGRAM_ENV,
TERM_PROGRAM_VERSION_ENV,
// WSL otherwise falls back to a 256-color TERM despite the host supporting
// truecolor. Keep the capability declaration; do not spoof another terminal.
"COLORTERM",
"PEBREL_CLI/p",
"PEBREL_BIN_DIR/p",
PROCESS_ENV,
@@ -301,56 +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));
}
mod wsl {
use super::*;
#[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 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"));
}
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 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"]);
}
#[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));
}
#[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_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(&"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}");
}
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_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));
}
#[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"));
}
}
}
+76 -2
View File
@@ -111,8 +111,10 @@ pub(super) fn ensure_codex_hooks() -> bool {
}
fn groups(helper: &str, mode: CodexHookMode) -> Value {
// Codex 的 Windows runner 使用 COMSPEC /C;可执行文件路径自身仍需引号。
let command = format!("\"{}\" codex {}", helper.replace('"', "\\\""), mode.argument());
// Codex runs Windows hooks through PowerShell -Command. A quoted path is
// a string expression until invoked with &, and single quotes keep $, `
// and other path characters literal. PowerShell escapes ' by doubling it.
let command = format!("& '{}' codex {}", helper.replace('\'', "''"), mode.argument());
installation::codex_groups(&command, Some(&command), mode)
}
@@ -260,6 +262,78 @@ fn remove(directory: &Path) -> io::Result<bool> {
mod tests {
use super::*;
#[test]
fn upgrades_cmd_style_owned_hooks_without_changing_user_entries() {
let root = tempfile::tempdir().unwrap();
let helper = "C:/Program Files/Pebrel/runtime/pebrel-hook.exe";
let old_command = format!("\"{helper}\" codex --hooks=full");
let previous =
installation::codex_groups(&old_command, Some(&old_command), CodexHookMode::Full);
let foreign = json!({"hooks":[{"type":"command","command":"user-hook"}]});
let mut hooks = json!({"hooks": previous});
hooks["hooks"]["Stop"].as_array_mut().unwrap().push(foreign.clone());
std::fs::write(root.path().join("hooks.json"), hooks.to_string()).unwrap();
std::fs::write(
root.path().join(MARKER),
json!({"groups": previous, "enabled_feature": true}).to_string(),
)
.unwrap();
let config = "notify = ['user-notifier']\n[features]\nhooks = true\n";
std::fs::write(root.path().join("config.toml"), config).unwrap();
assert!(!current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap());
assert!(install(root.path(), helper, CodexHookMode::Full).unwrap());
assert!(current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap());
assert!(!install(root.path(), helper, CodexHookMode::Full).unwrap());
let hooks: Value =
serde_json::from_str(&read(&root.path().join("hooks.json")).unwrap().unwrap()).unwrap();
let mut expected = json!({"hooks": groups(helper, CodexHookMode::Full)});
expected["hooks"]["Stop"].as_array_mut().unwrap().insert(0, foreign);
assert_eq!(hooks, expected);
assert_eq!(read(&root.path().join("config.toml")).unwrap().unwrap(), config);
}
#[test]
fn powershell_executes_generated_hooks_with_literal_paths_and_stdin() {
use std::io::Write as _;
use std::os::windows::process::CommandExt as _;
let root = tempfile::tempdir().unwrap();
let helper = root.path().join("Pebrel's $data `hook.ps1");
std::fs::write(
&helper,
"Write-Output ($args -join '|')\r\nWrite-Output ([Console]::In.ReadLine())\r\n",
)
.unwrap();
let powershell = Path::new(&std::env::var_os("SystemRoot").unwrap())
.join("System32/WindowsPowerShell/v1.0/powershell.exe");
for mode in [CodexHookMode::Turns, CodexHookMode::Full] {
let groups = groups(&helper.to_string_lossy().replace('\\', "/"), mode);
let command = groups["Stop"][0]["hooks"][0]["command"].as_str().unwrap();
let mut child = Command::new(&powershell)
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
command,
])
.creation_flags(0x0800_0000)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"{\"session_id\":\"fixture\"}\n").unwrap();
let output = child.wait_with_output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains(&format!("codex|{}", mode.argument())), "{stdout}");
assert!(stdout.contains(r#"{"session_id":"fixture"}"#), "{stdout}");
}
}
#[test]
fn install_upgrade_remove_preserves_notify_and_user_hooks() {
let root = tempfile::tempdir().unwrap();
+3
View File
@@ -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,
+1 -1
View File
@@ -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;
+3
View File
@@ -37,6 +37,9 @@ use crate::config::window::WindowConfig;
/// Regex used for default terminal hints (Markdown links, URLs, and local paths).
#[rustfmt::skip]
pub const DEFAULT_HINT_REGEX: &str = "(?:\
(?m:^[a-z0-9_.-]+@[a-z0-9_.-]+:/[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s`]*[$#])|\
\"(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}\"\\r\\n]+\"|\
'(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}'\\r\\n]+'|\
\\[[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n\\]]+\\]\\((?:[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]|\\([^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]*\\))+\\)|\
(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\]+|\
(?:(?-u:\\b)[a-z]:[/\\\\]|\\\\\\\\)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+|\
+14 -5
View File
@@ -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(),
+130 -2
View File
@@ -585,9 +585,45 @@ impl<'a, T> HintPostProcessor<'a, T> {
let mut iter = self.term.grid().iter_from(*regex_match.start());
let mut c = iter.cell().c;
let mut start = *regex_match.start();
let end = *regex_match.end();
if start == self.term.line_search_left(start) && c.is_ascii_alphanumeric() {
let mut prefix = self.term.grid().iter_from(start);
let mut at = false;
loop {
let ch = prefix.cell().c;
if ch == ':' && at {
if let Some(next) = prefix.next()
&& next.c == '/'
{
start = next.point;
iter = self.term.grid().iter_from(start);
c = iter.cell().c;
}
break;
}
if !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '@'))
|| prefix.point() >= end
{
break;
}
at |= ch == '@';
if prefix.next().is_none() {
break;
}
}
}
// Quoted local paths have an explicit boundary and may contain spaces,
// punctuation and parentheses. Keep the target, not its shell quotes.
if matches!(c, '\'' | '"') && start < end && self.term.grid()[end].c == c {
let inner_start = start.add(self.term, Boundary::Grid, 1);
let inner_end = end.sub(self.term, Boundary::Grid, 1);
if inner_start <= inner_end {
return Some(inner_start..=inner_end);
}
}
// Truncate uneven number of brackets.
let end = *regex_match.end();
let mut open_parents = 0;
let mut open_brackets = 0;
loop {
@@ -624,7 +660,14 @@ impl<'a, T> HintPostProcessor<'a, T> {
}
// Truncate trailing characters which are likely to be delimiters.
let start = *regex_match.start();
// A default Bash/WSL prompt has no space between its cwd and $/#.
// Only trim that suffix in a user@host:~/... prompt; '$' is otherwise
// legal in paths and URLs, and OSC 8 targets bypass these heuristics.
if matches!(c, '$' | '#') && self.is_shell_prompt_path(start, iter.point()) {
if let Some(indexed) = iter.prev() {
c = indexed.cell.c;
}
}
while iter.point() != start {
if !matches!(
c,
@@ -660,6 +703,32 @@ impl<'a, T> HintPostProcessor<'a, T> {
if start > iter.point() { None } else { Some(start..=iter.point()) }
}
fn is_shell_prompt_path(&self, start: Point, end: Point) -> bool {
if !matches!(self.term.grid()[start].c, '~' | '/') {
return false;
}
let line_start = self.term.line_search_left(start);
if start == line_start {
return false;
}
let mut after = self.term.grid().iter_from(end);
if after.next().is_some_and(|cell| !cell.c.is_whitespace()) {
return false;
}
let before = start.sub(self.term, Boundary::Grid, 1);
let prefix = self.term.bounds_to_string(line_start, before);
let Some(prefix) = prefix.split_whitespace().next_back() else { return false };
let Some((user, host)) = prefix.strip_suffix(':').and_then(|s| s.rsplit_once('@')) else {
return false;
};
!user.is_empty()
&& !host.is_empty()
&& user
.chars()
.chain(host.chars())
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
}
/// Loop over submatches until a non-empty post-processed match is found.
fn next_processed_match(&mut self, mut regex_match: Match) {
self.next_match = loop {
@@ -705,6 +774,65 @@ mod tests {
use super::*;
#[test]
fn shell_prompt_symbols_are_not_part_of_clickable_home_paths() {
for (text, expected) in [
("alice@workstation:/mnt/d/project$ ", "/mnt/d/project"),
("root@host:/root# ", "/root"),
("user@host:/# ", "/"),
("alice@workstation:~/.codex$ ", "~/.codex"),
("root@host:~/work# ", "~/work"),
("alice@workstation:~/工作文档$ echo test", "~/工作文档"),
("~/cost$ ", "~/cost$"),
("~/repo# ", "~/repo#"),
("https://example.com/cost$ ", "https://example.com/cost$"),
("user@host:~/price$usd ", "~/price$usd"),
] {
let term = mock_term(text);
let config = UiConfig::default();
let matches = visible_clickable_matches(&term, &config);
assert_eq!(matches.len(), 1, "{text}: {matches:?}");
assert_eq!(term.bounds_to_string(*matches[0].start(), *matches[0].end()), expected);
let end = *matches[0].end();
let hit = highlighted_at(&term, &config, end, ModifiersState::CONTROL).unwrap();
assert_eq!(hit.text(&term).unwrap().as_ref(), expected);
assert!(
highlighted_at(
&term,
&config,
end.add(&term, Boundary::Grid, 1),
ModifiersState::CONTROL
)
.is_none()
);
}
}
#[test]
fn quoted_paths_keep_spaces_and_literal_punctuation_in_hover_and_click_targets() {
for (text, expected) in [
(
r#"& "C:\Program Files\Example Tools\probe.exe" pr view"#,
r"C:\Program Files\Example Tools\probe.exe",
),
("open 'D:/My Documents/开始 菜单 (1).md' now", "D:/My Documents/开始 菜单 (1).md"),
("cat '~/cost$'", "~/cost$"),
(r#"open "\\server\my share\report.txt" now"#, r"\\server\my share\report.txt"),
] {
let term = mock_term(text);
let config = UiConfig::default();
let matches = visible_clickable_matches(&term, &config);
assert_eq!(matches.len(), 1, "{text}: {matches:?}");
let start = *matches[0].start();
let end = *matches[0].end();
assert_eq!(term.bounds_to_string(start, end), expected);
for point in [start, end] {
let hit = highlighted_at(&term, &config, point, ModifiersState::CONTROL).unwrap();
assert_eq!(hit.text(&term).unwrap().as_ref(), expected);
}
}
}
#[test]
fn hint_label_generation() {
let mut generator = HintLabels::new("0123", 0.5);
+70 -1
View File
@@ -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::*;
+8 -8
View File
@@ -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 {
+5 -6
View File
@@ -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) {
+71 -49
View File
@@ -9,8 +9,6 @@
//! 共同塑形,再由 `ligatures` 把字形簇映射回固定列号。其余字符逐格
//! 绘制,组合字符跟随基字;编辑、光标和选区不能改变无关格的原点。
use std::collections::HashSet;
use gpui::{
App, Bounds, ContentMask, Corners, CursorStyle, DispatchPhase, Element, ElementId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, MouseButton,
@@ -28,6 +26,10 @@ use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor};
use super::colors::Palette;
use super::view::TerminalView;
#[cfg(test)]
#[path = "element/color_tests.rs"]
mod color_tests;
pub struct TerminalElement {
view: gpui::Entity<TerminalView>,
}
@@ -84,7 +86,8 @@ impl TerminalElement {
rows: usize,
cols: usize,
cx: &App,
) -> Option<(RenderSnapshot, Option<String>, usize, HashSet<(u16, u16)>, usize, i64)> {
) -> Option<(RenderSnapshot, Option<String>, usize, super::osc_links::LinkCells, usize, i64)>
{
let view = self.view.read(cx);
let session = view.session.as_ref()?;
let hint_config = view.hint_config.clone();
@@ -139,12 +142,23 @@ impl TerminalElement {
fn resolve_app_colors(
&self,
snap: &mut RenderSnapshot,
links: &mut super::osc_links::LinkCells,
theme: &Palette,
overrides: &Colors,
cx: &mut App,
) {
self.view.update(cx, |view, _| {
resolve_app_colors_into(snap, theme, overrides, &mut view.color_resolver);
for cell in links.values_mut() {
cell.fg = resolve_text_foreground(
cell.fg,
cell.bg,
cell.bold,
theme,
overrides,
&mut view.color_resolver,
);
}
});
}
}
@@ -281,7 +295,7 @@ impl Element for TerminalElement {
let focused = focus_handle.is_focused(window);
// 旧壳只让光标本身参与闪烁;ghost、弹窗补齐和 IME 仍复用同一个坐标锚点。
let cursor_visible = self.view.read(cx).cursor_visible();
let Some((mut snap, prompt_line, history, dashed, scrollback_floor, viewport_top_abs)) =
let Some((mut snap, prompt_line, history, mut dashed, scrollback_floor, viewport_top_abs)) =
self.snapshot(layout.rows, layout.cols, cx)
else {
return;
@@ -295,7 +309,7 @@ impl Element for TerminalElement {
view.drive_pending_remote_dir(cx);
});
let overrides = snap.color_overrides;
self.resolve_app_colors(&mut snap, &theme, &overrides, cx);
self.resolve_app_colors(&mut snap, &mut dashed, &theme, &overrides, cx);
let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx);
let host_cursor_follows_theme = is_default_host_cursor(&theme);
let app_cursor = snap.cursor.as_ref().filter(|cursor| {
@@ -606,7 +620,7 @@ impl Element for TerminalElement {
} else {
theme.resolve(cell.fg, &overrides, cell.bold).into()
};
let dashed_link = dashed.contains(&(seg.row, cell.col));
let dashed_link = dashed.contains_key(&(seg.row, cell.col));
let underline = (cell.underline && !dashed_link).then(|| UnderlineStyle {
thickness: px(1.0),
color: Some(fg),
@@ -639,7 +653,7 @@ impl Element for TerminalElement {
== cursor_inverts(seg.row, cell.col)
&& selected_foreground(seg.row, next.col)
== selected_foreground(seg.row, cell.col)
&& !dashed.contains(&(seg.row, next.col))
&& !dashed.contains_key(&(seg.row, next.col))
})
} else {
1
@@ -674,18 +688,35 @@ impl Element for TerminalElement {
layout.line_height,
);
}
if dashed_link {
paint_dashed_underline(
window,
origin,
layout.cell_width,
layout.line_height,
fg,
);
}
}
}
// Link decoration follows grid columns, including wide-character spacer
// cells and spaces omitted by text shaping. Every cell shares one dash
// phase, so font fallback and ASCII/CJK boundaries cannot restart it.
for (&(row, col), cell) in &dashed {
if math_frame.covers(row as usize, col as usize) {
continue;
}
let Some(visual_col) = math_frame.project_cell(row as usize, col as usize, layout.cols)
else {
continue;
};
let color = if cursor_inverts(row, col) {
theme.cursor_text.unwrap_or(theme.background)
} else if let Some(foreground) = selected_foreground(row, col) {
foreground
} else {
theme.resolve(cell.fg, &overrides, cell.bold)
};
super::link_underline::paint(
window,
cell_rect(row as usize, visual_col, 1),
bounds.origin.x,
color.into(),
);
}
// 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前,
// 与旧壳 draw_rects → draw_overlays 的次序一致。
if !math_frame.is_empty() {
@@ -945,27 +976,6 @@ impl Element for TerminalElement {
}
}
fn paint_dashed_underline(
window: &mut Window,
origin: gpui::Point<Pixels>,
cell_width: Pixels,
line_height: Pixels,
color: Hsla,
) {
let y = origin.y + line_height - px(1.0);
let end: f32 = origin.x.as_f32() + cell_width.as_f32();
let mut x: f32 = origin.x.as_f32();
let dash: f32 = 3.0;
let gap: f32 = 2.0;
while x < end {
let width = f32::min(dash, end - x);
if width > 0.0 {
window.paint_quad(fill(Bounds::new(point(px(x), y), size(px(width), px(1.0))), color));
}
x += dash + gap;
}
}
/// Align geometry-only terminal primitives to the device pixel grid. Cell
/// widths/heights are already measured in device pixels and converted back to
/// logical units, but a pane origin can still be fractional after a split or
@@ -1642,9 +1652,6 @@ fn resolve_app_colors_into(
use crate::display::content::is_terminal_graphic;
use crate::display::terminal_color::is_fixed_color;
let theme_fg = rgb_from_rgba(theme.foreground);
let theme_bg = rgb_from_rgba(theme.background);
for run in &mut snap.bg_runs {
let base = rgb_from_rgba(theme.resolve(run.color, overrides, false));
let resolved = resolver.resolve_background(base, is_fixed_color(run.color, overrides));
@@ -1662,18 +1669,33 @@ fn resolve_app_colors_into(
// 对比度是一对颜色的属性:这个前景可不可读,取决于它**这一格**底下是
// 什么,而不是主题底色。默认底色的格子没有 bg run,所以 `SnapCell::bg`
// 单独带着这个值。
let bg_base = rgb_from_rgba(theme.resolve(cell.bg, overrides, false));
let bg = resolver.resolve_background(bg_base, is_fixed_color(cell.bg, overrides));
// bold 提亮(0-7 → 8-15)必须发生在矫正**之前**,否则写回的 `Spec` 会把
// 提亮吃掉。
let base = rgb_from_rgba(theme.resolve(cell.fg, overrides, cell.bold));
let resolved = resolver.resolve_foreground(base, bg, true, theme_fg, theme_bg);
if resolved != base {
cell.fg = Color::Spec(resolved.0);
}
cell.fg = resolve_text_foreground(cell.fg, cell.bg, cell.bold, theme, overrides, resolver);
}
}
fn resolve_text_foreground(
fg: Color,
bg: Color,
bold: bool,
theme: &Palette,
overrides: &Colors,
resolver: &mut crate::display::terminal_color::TerminalColorResolver,
) -> Color {
use crate::display::terminal_color::is_fixed_color;
let bg_base = rgb_from_rgba(theme.resolve(bg, overrides, false));
let bg = resolver.resolve_background(bg_base, is_fixed_color(bg, overrides));
// Resolve bold before contrast adjustment; Spec must retain that brightening.
let base = rgb_from_rgba(theme.resolve(fg, overrides, bold));
let resolved = resolver.resolve_foreground(
base,
bg,
true,
rgb_from_rgba(theme.foreground),
rgb_from_rgba(theme.background),
);
if resolved != base { Color::Spec(resolved.0) } else { fg }
}
pub(super) fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba {
Rgba {
r: f32::from(color.r) / 255.0,
@@ -0,0 +1,99 @@
//! Replay the terminal features required by Codex message surfaces and stars.
//! Fixture colors follow codex rust-v0.154.0 tui/src/style.rs and
//! bottom_pane/chat_composer/sparkle.rs; this does not emulate a live Codex session.
use nebula_terminal::event::VoidListener;
use nebula_terminal::render::{RenderSnapshot, SnapshotConfig};
use nebula_terminal::term::color::Colors;
use nebula_terminal::term::test::TermSize;
use nebula_terminal::term::{Config, Term};
use nebula_terminal::vte::ansi::{self, Color, NamedColor, Rgb};
use super::{Palette, resolve_app_colors_into, rgb_from_rgba};
use crate::display::terminal_color::TerminalColorResolver;
use crate::gpui_shell::terminal::colors::from_ansi_rgb;
use crate::gpui_shell::theme::ResolvedTheme;
fn palette(name: nebula_settings::ThemeName) -> Palette {
let resolved = ResolvedTheme::builtin(name, None);
let color = |[r, g, b]: [u8; 3]| from_ansi_rgb(Rgb { r, g, b });
Palette {
background: color(resolved.terminal_background()),
foreground: color(resolved.terminal_foreground()),
..Palette::default()
}
}
fn capture(palette: &Palette) -> (RenderSnapshot, Rgb) {
let bg = palette.query_reply(NamedColor::Background as usize, &Colors::default());
let fg = palette.query_reply(NamedColor::Foreground as usize, &Colors::default());
let (top, alpha) = if palette.is_dark() { (255.0, 0.12) } else { (0.0, 0.04) };
let blend = |channel: u8| (channel as f32 * (1.0 - alpha) + top * alpha).round() as u8;
let user = Rgb { r: blend(bg.r), g: blend(bg.g), b: blend(bg.b) };
let bytes = format!(
"\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m用户 message\x1b[K\r\n⠁⠂⠄⠈⠐⠠⡀⢀\x1b[K\r\n\x1b[0mAI reply",
user.r, user.g, user.b, fg.r, fg.g, fg.b,
);
let mut term = Term::new(Config::default(), &TermSize::new(40, 4), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
parser.advance(&mut term, bytes.as_bytes());
(RenderSnapshot::capture(&term, &SnapshotConfig { rows: 4, cols: 40 }), user)
}
#[test]
fn every_builtin_theme_keeps_message_surfaces_and_braille_stars() {
for name in nebula_settings::ThemeName::BUILTIN {
let palette = palette(name);
let (mut snapshot, user) = capture(&palette);
assert_ne!(from_ansi_rgb(user), palette.background, "{name:?}");
let star_colors: Vec<_> = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.fg))
.collect();
let mut resolver = TerminalColorResolver::default();
resolve_app_colors_into(&mut snapshot, &palette, &Colors::default(), &mut resolver);
for row in [0, 1] {
let run = snapshot.bg_runs.iter().find(|run| run.row == row).unwrap();
assert_eq!((run.start, run.end, run.color), (0, 40, Color::Spec(user)), "{name:?}");
}
assert!(
snapshot.bg_runs.iter().all(|run| run.row != 2),
"{name:?}: AI reply acquired a message background"
);
let stars: String = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.text.as_str()))
.collect();
assert_eq!(stars, "⠁⠂⠄⠈⠐⠠⡀⢀");
let after: Vec<_> = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.fg))
.collect();
assert_eq!(star_colors, after, "{name:?}: star colors were changed");
}
}
#[test]
fn message_background_remains_distinct_after_every_builtin_theme_switch() {
for from in nebula_settings::ThemeName::BUILTIN {
for to in nebula_settings::ThemeName::BUILTIN {
let original = palette(from);
let next = palette(to);
let (mut snapshot, _) = capture(&original);
let mut resolver = TerminalColorResolver::default();
resolver
.theme_changed(rgb_from_rgba(original.background), rgb_from_rgba(next.background));
resolve_app_colors_into(&mut snapshot, &next, &Colors::default(), &mut resolver);
let user = snapshot.bg_runs.iter().find(|run| run.row == 0).unwrap();
let background = next.resolve(user.color, &Colors::default(), false);
assert_ne!(background, next.background, "{from:?} -> {to:?}");
assert!(snapshot.bg_runs.iter().all(|run| run.row != 2));
}
}
}
@@ -0,0 +1,130 @@
//! Link dashes in physical grid coordinates, independent of glyph width/shaping.
use gpui::{Bounds, Hsla, Pixels, Window, fill, point, px, size};
pub(super) fn paint(window: &mut Window, cell: Bounds<Pixels>, grid_left: Pixels, color: Hsla) {
let scale = window.scale_factor().max(0.5);
let y = (cell.bottom().as_f32() * scale).round();
let thickness = scale.round().max(1.0);
for (left, right) in
dash_segments(cell.left().as_f32(), cell.right().as_f32(), grid_left.as_f32(), scale)
{
window.paint_quad(fill(
Bounds::new(
point(px(left / scale), px((y - thickness) / scale)),
size(px((right - left) / scale), px(thickness / scale)),
),
color,
));
}
}
fn dash_segments(
left: f32,
right: f32,
anchor: f32,
scale: f32,
) -> impl Iterator<Item = (f32, f32)> {
let left = (left * scale).round();
let right = (right * scale).round();
let anchor = (anchor * scale).round();
let dash = (3.0 * scale).round().max(1.0);
let period = dash + (2.0 * scale).round().max(1.0);
let mut x = anchor + ((left - anchor) / period).floor() * period;
std::iter::from_fn(move || {
while x < right {
let start = x.max(left);
let end = (x + dash).min(right);
x += period;
if start < end {
return Some((start, end));
}
}
None
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
use crate::config::UiConfig;
use crate::gpui_shell::terminal::osc_links::dashed_cells;
use nebula_terminal::event::VoidListener;
use nebula_terminal::term::test::TermSize;
use nebula_terminal::term::{Config, Term};
use nebula_terminal::vte::ansi;
#[test]
#[ignore = "informational hot-path measurement; no machine-specific timing gate"]
fn measure_visible_link_decoration_work() {
for (name, linked, text) in
[("plain", false, "x"), ("ascii", true, "x"), ("cjk", true, "中")]
{
let mut term = Term::new(Config::default(), &TermSize::new(120, 40), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
let text = text.repeat(if name == "cjk" { 50 } else { 100 });
let row = if linked {
format!("\x1b]8;;file:///fixture\x1b\\{text}\x1b]8;;\x1b\\\r\n")
} else {
format!("{text}\r\n")
};
parser.advance(&mut term, row.repeat(35).as_bytes());
let config = UiConfig::default();
let started = std::time::Instant::now();
let mut last_cells = 0;
for _ in 0..200 {
let cells = dashed_cells(&term, &config, 40, 120);
last_cells = cells.len();
let segments: usize = cells
.keys()
.map(|&(_, col)| {
dash_segments(
2.3 + f32::from(col) * 8.4,
2.3 + f32::from(col + 1) * 8.4,
2.3,
1.25,
)
.count()
})
.sum();
std::hint::black_box((cells, segments));
}
crate::gpui_shell::try_write_stderr(format_args!(
"{name}: 120x40 grid, {last_cells} linked columns, {:.1} us/pass (capture + dash geometry, no GPU)",
started.elapsed().as_secs_f64() * 1_000_000.0 / 200.0
));
}
}
#[test]
fn real_osc8_grid_covers_cjk_spacers_and_spaces_with_one_dash_phase() {
let mut term = Term::new(Config::default(), &TermSize::new(40, 2), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
parser.advance(
&mut term,
"\x1b]8;;file:///tmp/example\x1b\\A开始 菜单Z\x1b]8;;\x1b\\".as_bytes(),
);
let cells = dashed_cells(&term, &UiConfig::default(), 2, 40);
assert_eq!(cells.len(), 11);
assert!((0..11).all(|col| cells.contains_key(&(0, col))));
for scale in [1.0, 1.25, 1.5, 2.0] {
for width in [7.0, 8.4, 9.5, 12.0] {
let anchor = 2.3;
let pixels = |left, right| {
dash_segments(left, right, anchor, scale)
.flat_map(|(left, right)| left as i32..right as i32)
};
let actual: BTreeSet<_> = cells
.keys()
.flat_map(|&(_, col)| {
pixels(anchor + f32::from(col) * width, anchor + f32::from(col + 1) * width)
})
.collect();
let expected: BTreeSet<_> = pixels(anchor, anchor + 11.0 * width).collect();
assert_eq!(actual, expected, "scale={scale}, width={width}");
}
}
}
}
@@ -12,6 +12,7 @@ mod event_mailbox;
mod inline_image;
pub mod keymap;
mod ligatures;
mod link_underline;
pub mod math_overlay;
mod molecule_overlay;
pub mod mouse_protocol;
@@ -3,13 +3,15 @@
//! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做
//! 视口坐标、GPUI 修饰键和打开入口。
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::Arc;
use gpui::{App, ClipboardItem, Window};
use nebula_terminal::event::EventListener;
use nebula_terminal::index::Point;
use nebula_terminal::term::cell::Flags;
use nebula_terminal::term::{Term, point_to_viewport_from};
use nebula_terminal::vte::ansi::Color;
use unicode_width::UnicodeWidthChar;
use winit::keyboard::ModifiersState;
@@ -48,25 +50,42 @@ pub(super) fn winit_mouse_mods(mods: &gpui::Modifiers) -> ModifiersState {
}
/// 可见可点范围(OSC 8 + 正则 URL)映射到当前视口格子,供虚线下划线使用。
pub(super) struct LinkCell {
pub fg: Color,
pub bg: Color,
pub bold: bool,
}
pub(super) type LinkCells = HashMap<(u16, u16), LinkCell>;
pub(super) fn dashed_cells<T: EventListener>(
term: &Term<T>,
config: &UiConfig,
rows: usize,
cols: usize,
) -> HashSet<(u16, u16)> {
) -> LinkCells {
let matches = hint::visible_clickable_matches(term, config);
if matches.is_empty() {
return HashSet::new();
return HashMap::new();
}
let origin = term.viewport_origin_for(rows);
let mut cells = HashSet::new();
let mut cells = HashMap::new();
for indexed in term.grid().display_iter() {
if !matches.iter().any(|bounds| bounds.contains(&indexed.point)) {
if indexed.flags.intersects(Flags::HIDDEN | Flags::LEADING_WIDE_CHAR_SPACER)
|| !matches.iter().any(|bounds| bounds.contains(&indexed.point))
{
continue;
}
let Some(vp) = point_to_viewport_from(origin, indexed.point) else { continue };
if vp.line < rows && vp.column.0 < cols {
cells.insert((vp.line as u16, vp.column.0 as u16));
let (mut fg, mut bg) = (indexed.fg, indexed.bg);
if indexed.flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
cells.insert(
(vp.line as u16, vp.column.0 as u16),
LinkCell { fg, bg, bold: indexed.flags.contains(Flags::BOLD) },
);
}
}
cells
@@ -110,12 +129,61 @@ pub(super) fn open_hint_match(
hint: &HintMatch,
text: &str,
cwd: Option<&std::path::Path>,
launch: &crate::session::LaunchSession,
window: &mut Window,
cx: &mut App,
) {
let target = (hint.hyperlink().is_none()
&& hint.action() == &HintAction::Command(default_hint_command()))
.then(|| wsl_absolute_target(text, launch))
.flatten();
let text = target.as_deref().unwrap_or(text);
dispatch_hint_action(hint.action(), hint.hyperlink().is_some(), text, cwd, window, cx);
}
fn wsl_absolute_target(text: &str, launch: &crate::session::LaunchSession) -> Option<String> {
if !text.starts_with('/') || text.starts_with("//") {
return None;
}
let (program, args) = match launch {
crate::session::LaunchSession::Shell { program, args, .. } => (program, args),
crate::session::LaunchSession::Profile { command, args, .. } => (command, args),
_ => return None,
};
let distro = crate::shell_detect::wsl_launch_distro(program, args)?;
Some(crate::shell_detect::wsl_unc_path(distro, text).to_string_lossy().into_owned())
}
#[cfg(test)]
mod path_tests {
use super::*;
#[test]
fn absolute_prompt_paths_use_the_owning_wsl_distribution() {
let launch = crate::session::LaunchSession::Shell {
name: "Debian".into(),
program: "wsl.exe".into(),
args: vec!["-d".into(), "Debian".into()],
};
assert_eq!(
wsl_absolute_target("/mnt/d/project", &launch).as_deref(),
Some(r"\\wsl.localhost\Debian\mnt\d\project")
);
assert!(wsl_absolute_target("//example.com/file", &launch).is_none());
assert!(wsl_absolute_target("https://example.com", &launch).is_none());
assert!(
wsl_absolute_target(
"/home/user",
&crate::session::LaunchSession::Ssh { host: "remote".into() }
)
.is_none()
);
assert!(
wsl_absolute_target("/home/user", &crate::session::LaunchSession::Default).is_none()
);
}
}
fn dispatch_hint_action(
action: &HintAction,
hyperlink: bool,
@@ -178,7 +178,14 @@ impl TerminalView {
};
let Some(text) = text else { return };
let cwd = self.local_cwd();
super::super::osc_links::open_hint_match(&hover.hint, &text, cwd.as_deref(), window, cx);
super::super::osc_links::open_hint_match(
&hover.hint,
&text,
cwd.as_deref(),
&self.session_launch,
window,
cx,
);
}
/// 应用是否接管了鼠标(vim/htop 等)。Shift 按住时强制旁路——这是
@@ -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");
}
});
}
}
}
+40 -39
View File
@@ -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
View File
@@ -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"), "网络");
+4
View File
@@ -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,
)),
+96 -9
View File
@@ -9,7 +9,7 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) {
}
let inherited_override =
!options.env_is_complete && std::env::var_os(GROK_LEGACY_CONSOLE).is_some();
apply_windows_terminal_defaults(options, inherited_override);
apply_local_console_defaults(options, inherited_override);
}
#[cfg(not(windows))]
let _ = options;
@@ -19,10 +19,20 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) {
const GROK_LEGACY_CONSOLE: &str = "GROK_FORCE_LEGACY_CONSOLE";
#[cfg(windows)]
fn apply_windows_terminal_defaults(
fn apply_local_console_defaults(
options: &mut nebula_terminal::tty::Options,
inherited_override: bool,
) {
// The refreshed, complete environment is captured before the GPUI spawn
// calls tty::setup_env(). Updating the parent later cannot change that
// snapshot. Advertise our actual capabilities in the child environment,
// including Explorer launches with no parent terminal. Keep explicit values.
for (name, value) in [("TERM", "xterm-256color"), ("COLORTERM", "truecolor")] {
if !options.env.keys().any(|key| key.eq_ignore_ascii_case(name)) {
let inherited = if options.env_is_complete { None } else { std::env::var(name).ok() };
options.env.insert(name.to_owned(), inherited.unwrap_or_else(|| value.to_owned()));
}
}
// Grok 1.0.25 treats unknown Windows terminal names as legacy consoles and
// omits its Braille logo. Keep our real identity and use its capability override.
// Remove this default when Grok recognizes Pebrel's terminal capabilities.
@@ -94,13 +104,13 @@ mod tests {
env_is_complete: true,
..Default::default()
};
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0");
assert_eq!(options.env["TERM_PROGRAM"], "pebrel");
assert_eq!(options.env["WSLENV"], "KEEP/p");
assert!(!options.env.contains_key("WT_SESSION"));
let once = options.clone();
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options, once);
}
@@ -110,12 +120,18 @@ mod tests {
for name in [super::GROK_LEGACY_CONSOLE, "grok_force_legacy_console"] {
for value in ["1", "true", "0", "false", ""] {
let mut options = nebula_terminal::tty::Options {
env: [(name.to_owned(), value.to_owned())].into_iter().collect(),
env: [
(name.to_owned(), value.to_owned()),
("TERM".to_owned(), "xterm-256color".to_owned()),
("COLORTERM".to_owned(), "truecolor".to_owned()),
]
.into_iter()
.collect(),
env_is_complete: true,
..Default::default()
};
let original = options.clone();
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options, original);
}
}
@@ -125,21 +141,92 @@ mod tests {
#[test]
fn inherited_grok_override_only_applies_to_incomplete_environments() {
let mut options = nebula_terminal::tty::Options::default();
super::apply_windows_terminal_defaults(&mut options, true);
super::apply_local_console_defaults(&mut options, true);
assert!(!options.env.contains_key(super::GROK_LEGACY_CONSOLE));
// A successful refresh can remove a stale value from the parent's registry
// snapshot. Do not resurrect it when the new complete environment omits it.
options.env_is_complete = true;
super::apply_windows_terminal_defaults(&mut options, true);
super::apply_local_console_defaults(&mut options, true);
assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0");
let mut fallback = nebula_terminal::tty::Options::default();
super::apply_windows_terminal_defaults(&mut fallback, false);
super::apply_local_console_defaults(&mut fallback, false);
assert_eq!(fallback.env[super::GROK_LEGACY_CONSOLE], "0");
assert!(!fallback.env_is_complete);
}
#[cfg(windows)]
#[test]
fn complete_explorer_environment_advertises_truecolor_to_the_child_process() {
let mut options = nebula_terminal::tty::Options {
env: std::env::vars()
.filter(|(name, _)| {
!["TERM", "COLORTERM", "WT_SESSION", "TERM_PROGRAM"]
.iter()
.any(|key| name.eq_ignore_ascii_case(key))
})
.collect(),
env_is_complete: true,
..Default::default()
};
options.env.insert("TERM_PROGRAM".into(), "pebrel".into());
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env["TERM"], "xterm-256color");
assert_eq!(options.env["COLORTERM"], "truecolor");
assert!(!options.env.contains_key("WT_SESSION"));
// Match the complete-environment child launch, independent of the test
// runner's terminal. Read the variables from the actual child.
let system = std::env::var_os("SystemRoot").unwrap();
let command =
std::path::Path::new(&system).join("System32/WindowsPowerShell/v1.0/powershell.exe");
let output = std::process::Command::new(command)
.args([
"-NoProfile",
"-Command",
"[Console]::WriteLine(\"$env:TERM $env:COLORTERM $env:TERM_PROGRAM\")",
])
.env_clear()
.env("SystemRoot", system)
.envs(&options.env)
.output()
.unwrap();
assert!(
output.status.success(),
"status={}, stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"xterm-256color truecolor pebrel"
);
}
#[cfg(windows)]
#[test]
fn color_capability_defaults_preserve_explicit_overrides() {
let mut options = nebula_terminal::tty::Options {
env: [
("term", "dumb"),
("ColorTerm", "24bit"),
("NO_COLOR", "1"),
("FORCE_COLOR", "0"),
]
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect(),
env_is_complete: true,
..Default::default()
};
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env["term"], "dumb");
assert_eq!(options.env["ColorTerm"], "24bit");
assert_eq!(options.env["NO_COLOR"], "1");
assert_eq!(options.env["FORCE_COLOR"], "0");
assert!(!options.env.contains_key("TERM") && !options.env.contains_key("COLORTERM"));
}
#[cfg(windows)]
#[test]
fn refreshed_local_terminal_preserves_the_pane_grok_override() {
+28 -3
View File
@@ -67,6 +67,30 @@ function Check-UnpreparedProcesses {
}
}
function Wait-RuntimeHelperFiles {
# New helpers have a bounded lifetime, but can still be draining as the
# application exits. Check before setup copies any file. Old stuck helpers
# remain a visible failure; never terminate processes by their image name.
$deadline = [DateTime]::UtcNow.AddSeconds(5)
$helpers = @('runtime\pebrel-hook.exe', 'pebrel-hook.exe',
'runtime\nebula-hook.exe', 'nebula-hook.exe')
while ($true) {
$busy = $null
foreach ($relative in $helpers) {
$path = Join-Path $installation $relative
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
try {
$probe = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
} catch { $busy = $_.Exception }
}
if (-not $busy) { return }
if ([DateTime]::UtcNow -ge $deadline) { throw $busy }
Start-Sleep -Milliseconds 100
}
}
try {
if ((Get-Item -LiteralPath $PlanPath).Length -gt 1048576) { throw 'Update plan exceeds limit' }
$plan = Get-Content -LiteralPath $PlanPath -Raw -Encoding UTF8 | ConvertFrom-Json
@@ -133,6 +157,7 @@ try {
Check-UnpreparedProcesses
# No Restart Manager process-name shutdown: every participant has already
# saved and exited. DIR reuses this validated installation without a chooser.
Wait-RuntimeHelperFiles
$setupLog = Join-Path $transaction 'installer.log'
$arguments = '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NOCLOSEAPPLICATIONS /NORESTARTAPPLICATIONS' +
' /DIR="' + $installation + '" /LOG="' + $setupLog + '"'
@@ -144,9 +169,9 @@ try {
throw 'Installed application did not report the expected version'
}
$installedDigest = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash
if ($plan.version -eq $plan.original_version -and $installedDigest -eq $originalDigest) {
throw 'Same-version installation did not replace the application binary'
}
# A repair can replace a previously locked helper while leaving pebrel.exe
# byte-identical. Setup success plus the expected version is authoritative;
# a changed main-executable hash is not a same-version success requirement.
Write-State 'result.json' @{
transaction = $plan.transaction; success = $true; version = $plan.version
executable_sha256 = $installedDigest
+28 -6
View File
@@ -12,14 +12,15 @@
//! surfaces an error banner, 2 even blocks the turn), and kimi's `Stop` is
//! likewise a blockable event. Every path — including panic — must exit 0,
//! fast. Claude and kimi also write the payload to our stdin, so those modes
//! always drain stdin even when the message goes nowhere: an unread pipe
//! could surface as a hook write error.
//! drain stdin within a bounded invocation even when the message goes nowhere.
//! A caller retaining stdin or a stalled receiver must not leave this helper
//! running indefinitely and holding the installed executable open.
//! 2. SCOPED: the hook config is global (settings.json / kimi's config.toml),
//! but the effect must be Nebula-only. The scope guard is the environment:
//! NEBULA_NOTIFY_PIPE only exists for processes spawned inside Nebula.
//! Anywhere else this is an invisible ~10 ms no-op.
//! 3. FAST: pure std, no JSON handling (Nebula parses), one pipe write.
//! Keeps the whole claude→toast chain under ~50 ms.
//! Anywhere else it forwards nothing and exits without affecting the caller.
//! 3. BOUNDED: pure std, no JSON handling (Pebrel parses), one pipe write.
//! Forwarding has a deadline; startup and notification latency depend on the host.
//!
//! Usage (installed by `nebula setup-ai` / Nebula's boot self-heal):
//! ```text
@@ -42,6 +43,7 @@
use std::io::{Read, Write};
const MAX_PAYLOAD_BYTES: usize = 1 << 20;
const FORWARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// 其他 agent 的 hook runner 独有的环境变量。
///
@@ -167,7 +169,25 @@ fn log_outcome(source: &str, pane: &str, bytes: usize, outcome: &Outcome) {
fn main() {
// Constraint 1: never leak a failure to the calling CLI.
let args: Vec<String> = std::env::args().skip(1).collect();
let _ = std::panic::catch_unwind(|| run(&args));
let forwarding_args = args.clone();
let (done, finished) = std::sync::mpsc::sync_channel(1);
// Bound the entire forwarding operation, including stdin drain and pipe
// writes. A read-only timeout would still leave a blocked writer alive.
// This one-shot process never joins a stuck worker: returning from main
// retires all of its threads and handles. The provider sees exit code 0.
if std::thread::Builder::new()
.name("hook-forward".into())
.spawn(move || {
let _ = std::panic::catch_unwind(|| run(&forwarding_args));
let _ = done.send(());
})
.is_ok()
{
let _ = finished.recv_timeout(FORWARD_TIMEOUT);
}
// A user-owned notifier is independent of our best-effort transport. Never
// wait for it and do not suppress it when the Pebrel pipe is unavailable.
chain_notifier(&args);
// Cursor 的提交前 Hook 有响应合同;传输失败也不能阻止用户提交。
if args.first().is_some_and(|source| source == "cursor")
&& native_event(&args) == Some("prompt")
@@ -307,7 +327,9 @@ fn run(args: &[String]) {
}
}
log_outcome(source, &pane, payload.len(), &outcome);
}
fn chain_notifier(args: &[String]) {
// Chain mode: keep a pre-existing codex notifier working. Runs even
// outside Nebula — the original program must keep firing everywhere.
let strs: Vec<&str> = args.iter().map(String::as_str).collect();
+142
View File
@@ -0,0 +1,142 @@
//! Real-process lifetime regressions: a provider may retain its stdin handle,
//! and a pipe receiver may stop draining. Neither can pin the installed helper.
use std::io::Write as _;
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
struct Invocation(Option<Child>);
impl Invocation {
fn spawn(args: &[&str], pipe: Option<&str>) -> Self {
let mut command = Command::new(env!("CARGO_BIN_EXE_pebrel-hook"));
command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
for suffix in ["NOTIFY_PIPE", "REMOTE_HOOK_TOKEN", "HOOK_LOG"] {
command.env_remove(format!("PEBREL_{suffix}"));
command.env_remove(format!("NEBULA_{suffix}"));
}
if let Some(pipe) = pipe {
command.env("PEBREL_NOTIFY_PIPE", pipe);
}
Self(Some(command.spawn().unwrap()))
}
fn finish(mut self) -> Output {
let deadline = Instant::now() + Duration::from_secs(6);
let child = self.0.as_mut().unwrap();
loop {
if child.try_wait().unwrap().is_some() {
let output = self.0.take().unwrap().wait_with_output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
return output;
}
assert!(Instant::now() < deadline, "helper retained a blocked invocation");
std::thread::sleep(Duration::from_millis(10));
}
}
}
impl Drop for Invocation {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn stdin_kept_open_cannot_pin_a_native_hook_executable() {
for args in [&["codex", "--hooks=full"][..], &["claude"][..]] {
let mut invocation = Invocation::spawn(args, None);
let mut input = invocation.0.as_mut().unwrap().stdin.take().unwrap();
input.write_all(b"{\"hook_event_name\":\"Stop\"}").unwrap();
// Hold stdin open across finish: EOF must not be necessary for exit.
let output = invocation.finish();
assert!(output.stdout.is_empty());
drop(input);
}
}
#[test]
fn cursor_still_allows_submission_when_stdin_never_closes() {
let mut invocation = Invocation::spawn(&["cursor", "--event", "prompt"], None);
let input = invocation.0.as_mut().unwrap().stdin.take().unwrap();
let output = invocation.finish();
assert_eq!(output.stdout, b"{\"continue\":true}\n");
drop(input);
}
#[test]
fn ordinary_completed_payload_exits_silently() {
let mut invocation = Invocation::spawn(&["codex", "--hooks=full"], None);
invocation.0.as_mut().unwrap().stdin.take().unwrap().write_all(b"{}").unwrap();
let output = invocation.finish();
assert!(output.stdout.is_empty() && output.stderr.is_empty());
}
#[test]
fn legacy_notify_still_invokes_the_users_chained_program() {
let executable = std::env::current_exe().unwrap();
let invocation = Invocation::spawn(
&["codex", "--chain", executable.to_str().unwrap(), "--list", "--format=terse"],
None,
);
let output = invocation.finish();
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("ordinary_completed_payload_exits_silently")
);
}
#[cfg(windows)]
#[test]
fn a_receiver_that_never_reads_cannot_pin_the_helper() {
use std::ffi::c_void;
#[link(name = "kernel32")]
unsafe extern "system" {
fn CreateNamedPipeW(
name: *const u16,
mode: u32,
pipe_mode: u32,
instances: u32,
output: u32,
input: u32,
timeout: u32,
security: *const c_void,
) -> *mut c_void;
fn CloseHandle(handle: *mut c_void) -> i32;
fn GetNamedPipeClientProcessId(handle: *mut c_void, pid: *mut u32) -> i32;
}
struct Pipe(*mut c_void);
impl Drop for Pipe {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
let name = format!(r"\\.\pipe\pebrel-hook-lifetime-test-{}", std::process::id());
let wide: Vec<u16> = name.encode_utf16().chain(Some(0)).collect();
// The server owns an inbound byte pipe and deliberately never drains it.
let handle =
unsafe { CreateNamedPipeW(wide.as_ptr(), 1, 0, 1, 4096, 4096, 0, std::ptr::null()) };
assert_ne!(handle as isize, -1, "{}", std::io::Error::last_os_error());
let _pipe = Pipe(handle);
let payload = "x".repeat(16 * 1024);
let invocation = Invocation::spawn(&["codex", &payload], Some(&name));
let deadline = Instant::now() + Duration::from_secs(6);
loop {
let mut pid = 0;
if unsafe { GetNamedPipeClientProcessId(handle, &mut pid) } != 0 {
assert_eq!(pid, invocation.0.as_ref().unwrap().id());
break;
}
assert!(Instant::now() < deadline, "helper never connected to the blocked receiver");
std::thread::sleep(Duration::from_millis(10));
}
let output = invocation.finish();
assert!(output.stdout.is_empty() && output.stderr.is_empty());
}
+20 -2
View File
@@ -402,6 +402,22 @@ mod tests {
let event = Event::readable(KEY);
let mut reader = UnblockedReader::new(ScriptedSource { rx, pending: Vec::new() }, 4096);
// Readiness permits another poll; it does not guarantee bytes. piper
// can deliberately yield for fairness and wake us again, and a queued
// notification can outlive the data that originally triggered it.
// Retry only after a notification so a genuinely lost wake still fails.
let read_chunk = |reader: &mut UnblockedReader<ScriptedSource>, buf: &mut [u8]| {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let read = reader.try_read(buf);
if read > 0 {
return read;
}
assert!(Instant::now() < deadline, "readiness never produced data");
assert!(readable_arrived(&poller, KEY), "pending read was not woken");
}
};
// 首次 register 自带一次无条件投递;先消化掉,后面等到的事件就只可能
// 来自 try_read 的补投。
reader.register(&poller, event, PollMode::Level);
@@ -414,7 +430,8 @@ mod tests {
);
let mut buf = [0u8; 64];
assert_eq!(reader.try_read(&mut buf), 5);
assert_eq!(read_chunk(&mut reader, &mut buf), 5);
assert_eq!(&buf[..5], b"hello");
assert!(reader.pipe.is_empty(), "这一读须把管道读空,否则测不到 TOCTOU 的那一半");
assert!(
@@ -427,6 +444,7 @@ mod tests {
assert_eq!(reader.try_read(&mut buf), 0);
tx.send(b"world".to_vec()).unwrap();
assert!(readable_arrived(&poller, KEY), "重新注册的 waker 失效,后续输出会卡住");
assert_eq!(reader.try_read(&mut buf), 5);
assert_eq!(read_chunk(&mut reader, &mut buf), 5);
assert_eq!(&buf[..5], b"world");
}
}
+328
View File
@@ -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)
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Plan the native CI matrices before GitHub creates runner jobs.
The output file is the path normally supplied as ``GITHUB_OUTPUT``. Planning
is deliberately separate from workflow steps: a draft pull request therefore
does not materialize jobs for the scarce platforms.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
@dataclass(frozen=True)
class Platform:
runner: str
draft: bool
release_check: bool = False
# One platform catalog owns both draft eligibility and release compilation.
PLATFORMS = (
Platform("ubuntu-24.04", draft=True),
Platform("windows-2022", draft=True),
Platform("macos-26", draft=True, release_check=True),
Platform("windows-11-arm", draft=False),
Platform("macos-26-intel", draft=False, release_check=True),
)
FULL_EVENTS = frozenset(
{"push", "merge_group", "workflow_dispatch", "workflow_call", "schedule"}
)
class PlanError(ValueError):
"""An event cannot be converted into a safe CI plan."""
def _matrices(draft: bool) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
selected = [platform for platform in PLATFORMS if not draft or platform.draft]
return (
[{"os": platform.runner} for platform in selected],
[{"os": platform.runner} for platform in selected if platform.release_check],
)
def _reject_json_constant(value: str) -> None:
raise ValueError(f"non-standard JSON constant {value}")
def _read_event(path: Path) -> dict[str, Any]:
try:
raw = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise PlanError(f"cannot read event payload {path}: {exc}") from exc
try:
payload = json.loads(raw, parse_constant=_reject_json_constant)
except (json.JSONDecodeError, ValueError) as exc:
raise PlanError(f"invalid event JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise PlanError("event payload must be a JSON object")
return payload
def plan_matrices(event_name: str, payload: dict[str, Any]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Return native and release matrices for one validated event."""
if event_name == "pull_request":
pull_request = payload.get("pull_request")
if not isinstance(pull_request, dict):
raise PlanError("pull_request event must contain a pull_request object")
draft = pull_request.get("draft")
if not isinstance(draft, bool):
raise PlanError("pull_request.draft must be a boolean")
return _matrices(draft)
if event_name in FULL_EVENTS:
return _matrices(draft=False)
supported = sorted(FULL_EVENTS | {"pull_request"})
raise PlanError(f"unsupported event name {event_name!r}; expected one of {', '.join(supported)}")
def _compact_output(name: str, value: list[dict[str, str]]) -> str:
return f"{name}={json.dumps(value, separators=(',', ':'), ensure_ascii=True)}"
def write_outputs(path: Path, native_matrix: list[dict[str, str]], release_matrix: list[dict[str, str]]) -> None:
"""Append both outputs in one write after planning has fully succeeded."""
content = "\n".join(
(
_compact_output("native_matrix", native_matrix),
_compact_output("release_matrix", release_matrix),
)
) + "\n"
try:
with path.open("a", encoding="utf-8", newline="\n") as output:
output.write(content)
except (OSError, UnicodeError) as exc:
raise PlanError(f"cannot append CI plan to {path}: {exc}") from exc
def _arguments(argv: Sequence[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--event-name", required=True)
parser.add_argument("--event-path", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
arguments = _arguments(argv)
try:
payload = _read_event(arguments.event_path)
native_matrix, release_matrix = plan_matrices(arguments.event_name, payload)
write_outputs(arguments.output, native_matrix, release_matrix)
except PlanError as exc:
print(f"ci_plan: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
+98 -48
View File
@@ -277,13 +277,13 @@ end;
{ —— 按 WSL 发行版注册的右键项 ——
「在 Pebrel 中打开(Ubuntu)」这类菜单项按**安装那一刻**机器上的发行版动态生成:
WSL 发行版子菜单按**安装那一刻**机器上的发行版动态生成:
`Lxss` 里的 `DistributionName` 是唯一真相,枚举口径与 Rust 侧
`shell_detect::find_wsl_distros` 一致(跳过 `docker-desktop*` 这类 plumbing 发行版)。
走 `[Code]` 而不是 `[Registry]` 的原因只有一个:键的条数取决于机器上有几个发行版。
代价是这些键没有 `uninsdeletekey`,卸载由 `RemoveOwnedWslContextMenus` 按名字前缀
加命令串里的可执行文件与 `--shell` 认领后删除。 }
加精确命令与级联菜单归属标记认领后删除。 }
const
LxssRoot = 'Software\Microsoft\Windows\CurrentVersion\Lxss';
@@ -308,67 +308,117 @@ begin
SetArrayLength(Result, Count);
end;
{ 删掉本安装器写下的 WSL 右键项:键名以 `PebrelWsl` 开头、命令串里同时有我们的
可执行文件和 `--shell`,两条都满足才算我们写的。发行版后来被移除也不会留下
点不动的死项。 }
{ WSL 右键项按精确可执行文件前缀归属;级联菜单还校验所有子项,保留用户编辑。
发行版移除后,下次安装重建菜单时不留下旧项。 }
function IsOwnedWslCommand(Command, Executable: string): Boolean;
var
Prefix: string;
begin
Prefix := '"' + Executable + '" --gpui --shell "wsl:';
Result := CompareText(Copy(Command, 1, Length(Prefix)), Prefix) = 0;
end;
function IsOwnedWslMenu(Key, Executable: string): Boolean;
var
Owner, Command: string;
Children, Subkeys: TArrayOfString;
Index: Integer;
begin
Result := False;
if not RegQueryStringValue(HKCU, Key, 'PebrelOwner', Owner) or
(CompareText(Owner, Executable) <> 0) then
Exit;
if not RegGetSubkeyNames(HKCU, Key, Subkeys) then
Exit;
for Index := 0 to GetArrayLength(Subkeys) - 1 do
if CompareText(Subkeys[Index], 'shell') <> 0 then
Exit;
if GetArrayLength(Subkeys) > 0 then begin
if not RegGetSubkeyNames(HKCU, Key + '\shell', Children) then
Exit;
for Index := 0 to GetArrayLength(Children) - 1 do begin
if not RegQueryStringValue(HKCU, Key + '\shell\' + Children[Index] + '\command', '', Command) or
not IsOwnedWslCommand(Command, Executable) then
Exit;
end;
end;
Result := True;
end;
procedure RemoveOwnedWslContextMenusAt(Root, Executable: string);
var
Key, Command: string;
Names: TArrayOfString;
NameIndex: Integer;
begin
if RegGetSubkeyNames(HKCU, Root, Names) then
for NameIndex := 0 to GetArrayLength(Names) - 1 do
if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin
Key := Root + '\' + Names[NameIndex];
Command := '';
if IsOwnedWslMenu(Key, Executable) or
(RegQueryStringValue(HKCU, Key + '\command', '', Command) and
IsOwnedWslCommand(Command, Executable)) then
if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then
RaiseException('Unable to remove an owned WSL context menu: ' + Key);
end;
end;
procedure RemoveOwnedWslContextMenus;
var
Roots: array[0..1] of string;
Root, Key, Command: string;
Names: TArrayOfString;
Index, NameIndex: Integer;
Index: Integer;
begin
Roots[0] := 'Software\Classes\Directory\shell';
Roots[1] := 'Software\Classes\Directory\Background\shell';
for Index := 0 to 1 do begin
Root := Roots[Index];
if RegGetSubkeyNames(HKCU, Root, Names) then
for NameIndex := 0 to GetArrayLength(Names) - 1 do
if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin
Key := Root + '\' + Names[NameIndex];
Command := '';
if RegQueryStringValue(HKCU, Key + '\command', '', Command) and
(Pos(ExpandConstant('{app}\pebrel.exe'), Command) > 0) and
(Pos('--shell', Command) > 0) then
if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then
RaiseException('Unable to remove an owned WSL context menu: ' + Key);
end;
for Index := 0 to 1 do
RemoveOwnedWslContextMenusAt(Roots[Index], ExpandConstant('{app}\pebrel.exe'));
end;
procedure RegisterWslContextMenuAt(Root, Executable, DirectoryArgument: string;
Distros: TArrayOfString);
var
Index: Integer;
Distro, Verb, Command, Key, Menu: string;
begin
Menu := Root + '\PebrelWslMenu';
{ Never overwrite an unknown installation or an edited submenu. }
if RegKeyExists(HKCU, Menu) and not IsOwnedWslMenu(Menu, Executable) then
RaiseException(FmtMessage(CustomMessage('WslMenuConflict'), [Menu]));
RemoveOwnedWslContextMenusAt(Root, Executable);
if GetArrayLength(Distros) = 0 then
Exit;
if not RegWriteStringValue(HKCU, Menu, 'MUIVerb', CustomMessage('OpenInPebrelWsl')) or
not RegWriteStringValue(HKCU, Menu, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Menu, 'SubCommands', '') or
not RegWriteStringValue(HKCU, Menu, 'PebrelOwner', Executable) then
RaiseException(CustomMessage('WslMenuRegistrationFailed'));
for Index := 0 to GetArrayLength(Distros) - 1 do begin
Distro := Distros[Index];
{ 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦,
何况我们靠前缀认领。 }
Verb := 'PebrelWsl' + IntToStr(Index);
{ `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾
反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面),
顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 }
Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory ';
Key := Menu + '\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', Distro) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"' + DirectoryArgument + '"') then
RaiseException('Unable to register the WSL context menu for ' + Distro + '.');
end;
end;
procedure RegisterWslContextMenus;
var
Distros: TArrayOfString;
Index: Integer;
Distro, Verb, LabelText, Executable, Command, Key: string;
Executable: string;
begin
{ 先清掉上一次写下的:发行版被移除后,残留的索引键会指向一个已经不存在的发行版。 }
RemoveOwnedWslContextMenus;
Distros := WslDistroNames;
if GetArrayLength(Distros) = 0 then
Exit;
Executable := ExpandConstant('{app}\pebrel.exe');
for Index := 0 to GetArrayLength(Distros) - 1 do begin
Distro := Distros[Index];
{ 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦,
何况我们靠前缀认领。 }
Verb := 'PebrelWsl' + IntToStr(Index);
LabelText := CustomMessage('OpenInPebrelWsl') + ' (' + Distro + ')';
{ `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾
反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面),
顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 }
Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory ';
Key := 'Software\Classes\Directory\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%1"') then
RaiseException('Unable to register the WSL context menu for ' + Distro + '.');
Key := 'Software\Classes\Directory\Background\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%V"') then
RaiseException('Unable to register the WSL background context menu for ' + Distro + '.');
end;
RegisterWslContextMenuAt('Software\Classes\Directory\shell', Executable, '%1', Distros);
RegisterWslContextMenuAt('Software\Classes\Directory\Background\shell', Executable, '%V', Distros);
end;
procedure MigrateLegacyIntegrations;
+6 -2
View File
@@ -77,7 +77,9 @@ english.AutoStart=Start Pebrel when I sign in to Windows
english.InstallFont=Install Maple Mono font for the current user
english.AddToPath=Add Pebrel to the user PATH
english.OpenInPebrel=Open in Pebrel
english.OpenInPebrelWsl=Open in Pebrel
english.OpenInPebrelWsl=Open in Pebrel (WSL)
english.WslMenuConflict=An existing WSL submenu belongs to another installation or was edited, so it was preserved: %1.
english.WslMenuRegistrationFailed=Unable to register the WSL context submenu.
english.LaunchProgram=Launch Pebrel
english.UninstallProgram=Uninstall Pebrel
english.CloseLegacyProgram=Close the application at %1, then retry the installation.
@@ -89,7 +91,9 @@ chinesesimplified.AutoStart=登录 Windows 后启动 Pebrel
chinesesimplified.InstallFont=为当前用户安装 Maple Mono 字体
chinesesimplified.AddToPath=将 Pebrel 添加到当前用户 PATH
chinesesimplified.OpenInPebrel=在 Pebrel 中打开
chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开
chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开(WSL)
chinesesimplified.WslMenuConflict=现有 WSL 子菜单属于其他安装或已被修改,因此已保留:%1。
chinesesimplified.WslMenuRegistrationFailed=无法注册 WSL 右键子菜单。
chinesesimplified.LaunchProgram=启动 Pebrel
chinesesimplified.UninstallProgram=卸载 Pebrel
chinesesimplified.CloseLegacyProgram=请关闭 %1 中运行的程序,然后重试安装。
+1 -1
View File
@@ -1 +1 @@
484
481
@@ -0,0 +1 @@
printf '%s\n' 'gh api repos/example/project'
+1
View File
@@ -0,0 +1 @@
gh api repos/Kuddev/pebrel
@@ -0,0 +1 @@
Copyright 2020 The Alacritty Project
@@ -0,0 +1 @@
Pebrel is faster than Ghostty.
+3
View File
@@ -0,0 +1,3 @@
const KITTY_KEYBOARD_PROTOCOL: bool = true;
const TMUX_ESCAPE: &str = "\\x1bPtmux;";
const FORMAT = ThemeFormat::Kitty;
+5 -1
View File
@@ -17,7 +17,11 @@ class UpdateProcess {
if (!File.Exists(Path.Combine(directory, "parent-exited"))) return 3;
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_FAIL_INSTALL") == "1") return 17;
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_NOOP_INSTALL") == "1") return 0;
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
try {
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
Directory.CreateDirectory(Path.Combine(target, "runtime"));
File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture");
} catch (IOException) { return 5; }
return 0;
#else
if (args.Length > 0 && args[0] == "--version") {
+70 -1
View File
@@ -1,4 +1,4 @@
#ifndef FixtureRoot
#ifndef FixtureRoot
#error FixtureRoot must point at an isolated directory under the workspace tmp directory.
#endif
#define MigrationFixture
@@ -15,6 +15,11 @@ OutputBaseFilename=installer-migration-fixture
Compression=none
SetupLogging=no
[CustomMessages]
OpenInPebrelWsl=Open in Pebrel (WSL)
WslMenuConflict=Preserved an edited or foreign submenu: %1.
WslMenuRegistrationFailed=Unable to register the WSL context submenu.
[Code]
#include "..\installer-migration.iss"
@@ -180,6 +185,69 @@ begin
Check(FileExists(NewLink), 'shortcut pointing at another program is preserved');
end;
procedure CheckContextMenus(Root: string);
var
RegistryRoot, ShellRoot, Executable, Value, Menu, ForeignCommand: string;
Distros, Names: TArrayOfString;
Index: Integer;
Failed: Boolean;
begin
RegistryRoot := 'Software\PebrelTestFixtures\' + ExtractFileName(Root);
Executable := Root + '\Program Files\pebrel.exe';
ForeignCommand := '"D:\other\pebrel.exe" --gpui --shell "wsl:Other" --working-directory "%1"';
try
for Index := 0 to 1 do begin
ShellRoot := RegistryRoot + '\root' + IntToStr(Index);
Menu := ShellRoot + '\PebrelWslMenu';
Check(RegWriteStringValue(HKCU, ShellRoot + '\Pebrel\command', '', 'ordinary-open'), 'seed ordinary entry');
Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', ForeignCommand), 'seed foreign entry');
Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWsl0\command', '',
'"' + Executable + '" --gpui --shell "wsl:Old" --working-directory "%1"'), 'seed old flat entry');
SetArrayLength(Distros, 3);
Distros[0] := 'Debian';
Distros[1] := 'Ubuntu Test';
Distros[2] := '开发环境';
if Index = 0 then Value := '%1' else Value := '%V';
RegisterWslContextMenuAt(ShellRoot, Executable, Value, Distros);
Check(not RegKeyExists(HKCU, ShellRoot + '\PebrelWsl0'), 'migrate old flat entry');
Check(RegGetSubkeyNames(HKCU, ShellRoot, Names) and (GetArrayLength(Names) = 3), 'only normal, cascade and foreign roots remain');
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'three distros live inside cascade');
Check(RegQueryStringValue(HKCU, Menu, 'SubCommands', Value) and (Value = ''), 'enable static cascade');
Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl1\command', '', Value), 'child command exists');
if Index = 0 then
Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%1"', 'selected directory argv')
else
Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%V"', 'background directory argv');
Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl2', 'MUIVerb', Value) and (Value = '开发环境'), 'Unicode label retained');
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'repeat registration has no duplicates');
SetArrayLength(Distros, 1);
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 1), 'removed distros leave no stale entries');
Check(RegWriteStringValue(HKCU, Menu + '\shell\Custom\command', '', ForeignCommand), 'seed edited submenu');
Failed := False;
try
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
except
Failed := True;
end;
Check(Failed and RegKeyExists(HKCU, Menu + '\shell\Custom'), 'edited submenu is not overwritten');
RemoveOwnedWslContextMenusAt(ShellRoot, Executable);
Check(RegKeyExists(HKCU, Menu + '\shell\Custom'), 'uninstall preserves edited subtree');
Check(RegDeleteKeyIncludingSubkeys(HKCU, Menu + '\shell\Custom'), 'remove fixture edit');
RemoveOwnedWslContextMenusAt(ShellRoot, 'D:\other\install.exe');
Check(RegKeyExists(HKCU, Menu), 'other installation cannot remove this menu');
SetArrayLength(Distros, 0);
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
Check(not RegKeyExists(HKCU, Menu), 'zero distros removes empty cascade');
Check(RegQueryStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', Value) and (Value = ForeignCommand), 'foreign entry preserved');
Check(RegQueryStringValue(HKCU, ShellRoot + '\Pebrel\command', '', Value) and (Value = 'ordinary-open'), 'ordinary entry preserved');
end;
finally
RegDeleteKeyIncludingSubkeys(HKCU, RegistryRoot);
end;
end;
function InitializeSetup: Boolean;
var
Root, Report: string;
@@ -190,6 +258,7 @@ begin
CheckPayloadMigration(Root);
CheckFailures(Root);
CheckShortcuts(Root);
CheckContextMenus(Root);
Report := 'PASS: ' + IntToStr(Checks) + ' migration checks';
except
Report := 'FAIL: ' + GetExceptionMessage;
+97
View File
@@ -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()
+72 -1
View File
@@ -11,6 +11,72 @@ from scripts.ci_native_tests import main, native_commands
class NativeSuiteTests(unittest.TestCase):
def test_required_lint_plans_runners_before_native_jobs(self):
root = Path(__file__).resolve().parents[2]
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("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]
body = re.split(r"\n [a-z][a-z-]*:\n", body, maxsplit=1)[0]
self.assertIn("needs: lint", body)
self.assertIn(f"fromJSON(needs.lint.outputs.{output})", body)
self.assertNotIn("pull_request.draft", body)
self.assertNotIn("matrix.tier", body)
self.assertIn("cargo check --locked --workspace --release", workflow)
# Native validation never needs to retain checkout credentials.
checkouts = re.findall(r"- uses: actions/checkout@[^\n]+\n(.*?)(?= - |\Z)",
workflow, re.S)
self.assertTrue(checkouts)
for checkout in checkouts:
self.assertIn("persist-credentials: false", checkout)
def test_packages_run_after_merge_or_manual_dispatch_not_for_prs(self):
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/preview-packages.yml").read_text()
events = workflow.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0]
triggers = set(re.findall(r"^ ([a-z_]+):", events, re.M))
self.assertEqual(triggers, {"push", "workflow_dispatch"})
push = re.search(r"^ push:(.*?)(?=^ [a-z_]+:|\Z)", events, re.M | re.S)
self.assertIsNotNone(push)
self.assertIn("branches: [main]", push.group(1))
self.assertIn("paths:", push.group(1))
# Packaging still needs explicit dispatch to create a public release.
self.assertIn("github.event_name == 'workflow_dispatch' && inputs.publish == true", workflow)
stable = (root / ".github/workflows/release.yml").read_text()
stable_events = stable.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0]
self.assertEqual(set(re.findall(r"^ ([a-z_]+):", stable_events, re.M)),
{"push", "workflow_dispatch"})
self.assertIn('tags: ["v*.*.*"]', stable_events)
def test_every_pr_and_merge_group_runs_without_path_exclusions(self):
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/linux-lua.yml").read_text()
@@ -20,7 +86,12 @@ class NativeSuiteTests(unittest.TestCase):
self.assertIsNotNone(declaration)
self.assertNotIn("paths", declaration.group(1))
self.assertNotIn("branches", declaration.group(1))
self.assertNotIn("types", declaration.group(1))
if event == "pull_request":
# ready_for_review starts the scarce-runner jobs that draft
# pushes skip; the default activity types must stay listed.
self.assertIn("types: [opened, synchronize, reopened, ready_for_review]", declaration.group(1))
else:
self.assertNotIn("types", declaration.group(1))
self.assertIn("branches: [main]", events)
self.assertNotIn("branches-ignore", events)
self.assertNotIn("pull_request_target", workflow)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "ci_plan.py"
class CiPlanCliTests(unittest.TestCase):
def run_cli(self, event_name: str, payload: object, output: Path) -> subprocess.CompletedProcess[str]:
event_path = output.parent / "event.json"
event_path.write_text(json.dumps(payload), encoding="utf-8")
return subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
event_name,
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
@staticmethod
def outputs(path: Path) -> dict[str, object]:
lines = path.read_text(encoding="utf-8").splitlines()
return {name: json.loads(value) for name, value in (line.split("=", 1) for line in lines)}
def test_draft_pr_does_not_materialize_scarce_runner_rows(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
result = self.run_cli("pull_request", {"pull_request": {"draft": True}}, output)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
output.read_text(encoding="utf-8"),
'native_matrix=[{"os":"ubuntu-24.04"},{"os":"windows-2022"},{"os":"macos-26"}]\n'
'release_matrix=[{"os":"macos-26"}]\n',
)
plan = self.outputs(output)
self.assertNotIn({"os": "windows-11-arm"}, plan["native_matrix"])
self.assertNotIn({"os": "macos-26-intel"}, plan["native_matrix"])
def test_ready_pr_has_all_native_and_release_checks(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
result = self.run_cli("pull_request", {"pull_request": {"draft": False}}, output)
self.assertEqual(result.returncode, 0, result.stderr)
plan = self.outputs(output)
self.assertEqual(
[row["os"] for row in plan["native_matrix"]],
["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"],
)
self.assertEqual(
[row["os"] for row in plan["release_matrix"]],
["macos-26", "macos-26-intel"],
)
def test_success_appends_outputs_without_replacing_existing_lines(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
output.write_text("existing=preserved\n", encoding="utf-8")
result = self.run_cli("push", {}, output)
self.assertEqual(result.returncode, 0, result.stderr)
lines = output.read_text(encoding="utf-8").splitlines()
self.assertEqual(lines[0], "existing=preserved")
self.assertTrue(lines[1].startswith("native_matrix=[{"))
self.assertTrue(lines[2].startswith("release_matrix=[{"))
def test_merge_group_and_non_pr_events_have_full_coverage(self) -> None:
events = ("merge_group", "push", "workflow_dispatch", "workflow_call", "schedule")
with tempfile.TemporaryDirectory() as directory:
for event_name in events:
with self.subTest(event_name=event_name):
output = Path(directory) / f"{event_name}.out"
result = self.run_cli(event_name, {}, output)
self.assertEqual(result.returncode, 0, result.stderr)
plan = self.outputs(output)
self.assertEqual(
[row["os"] for row in plan["native_matrix"]],
["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"],
)
self.assertEqual(
[row["os"] for row in plan["release_matrix"]],
["macos-26", "macos-26-intel"],
)
def test_invalid_events_fail_without_matrix_outputs(self) -> None:
cases = (
("pull_request", {}, "missing pull request"),
("pull_request", {"pull_request": []}, "wrong pull request type"),
("pull_request", {"pull_request": {}}, "missing draft"),
("pull_request", {"pull_request": {"draft": "false"}}, "wrong draft type"),
("pull_request", {"pull_request": {"draft": 0}}, "integer draft type"),
("unknown", {}, "unknown event"),
("push", [], "non-object payload"),
)
with tempfile.TemporaryDirectory() as directory:
for event_name, payload, label in cases:
with self.subTest(label=label):
output = Path(directory) / f"{label.replace(' ', '-')}.out"
output.write_text("existing=preserved\n", encoding="utf-8")
result = self.run_cli(event_name, payload, output)
self.assertNotEqual(result.returncode, 0)
self.assertIn("ci_plan:", result.stderr)
self.assertEqual(output.read_text(encoding="utf-8"), "existing=preserved\n")
def test_malformed_json_fails_without_creating_output(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
event_path = root / "broken.json"
output = root / "github-output"
event_path.write_text('{"pull_request":', encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
"pull_request",
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(output.exists())
def test_non_standard_json_constant_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
event_path = root / "non-standard.json"
output = root / "github-output"
event_path.write_text('{"value":NaN}', encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
"push",
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(output.exists())
if __name__ == "__main__":
unittest.main()
+103
View File
@@ -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()
+345
View File
@@ -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()
+16 -2
View File
@@ -16,6 +16,7 @@ from scripts.stable_release import (
)
from scripts.preview_release import sha256
from scripts.preview_release import MIN_ASSET_SIZE
from scripts.ci_plan import plan_matrices
VERSION = "1.6.0"
@@ -78,8 +79,21 @@ class StableReleaseTests(unittest.TestCase):
self.assertNotIn(" native-tests:\n", workflow)
self.assertNotIn("uses: ./.github/workflows/linux-lua.yml", workflow)
shared = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8")
for platform in ("ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"):
self.assertIn(platform, shared)
# Platform coverage comes from the event plan, not literal runner names
# in YAML. Verify both the consumer wiring and the full caller matrices.
for output in ("native_matrix", "release_matrix"):
self.assertIn(f"fromJSON(needs.lint.outputs.{output})", shared)
self.assertIn("runs-on: ${{ matrix.os }}", shared)
for event in ("push", "merge_group", "workflow_call", "workflow_dispatch"):
with self.subTest(event=event):
native, release = plan_matrices(event, {})
self.assertCountEqual(
[row["os"] for row in native],
["ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"],
)
self.assertCountEqual(
[row["os"] for row in release], ["macos-26", "macos-26-intel"],
)
for trigger in ("pull_request:", "merge_group:", "branches: [main]"):
self.assertIn(trigger, shared)
self.assertIn("run: python scripts/ci_native_tests.py", shared)
+28 -7
View File
@@ -36,7 +36,7 @@ function Write-Json([string]$Path, $Value) {
}
$results = @()
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'reinstall-noop')) {
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
$directory = Join-Path $OutputRoot $scenario
$installation = Join-Path $directory 'installed app'
$transaction = Join-Path $directory 'transaction'
@@ -44,17 +44,25 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
$null = New-Item -ItemType Directory -Path $installation, $transaction, $config -Force
$executable = Join-Path $installation 'pebrel.exe'
Copy-Item -LiteralPath $old -Destination $executable
$payload = if ($scenario -eq 'reinstall') { $reinstall } else { $candidate }
$payload = if ($scenario -eq 'reinstall') { $reinstall } elseif ($scenario -eq 'repair-identical') { $old } else { $candidate }
Copy-Item -LiteralPath $payload -Destination (Join-Path $directory 'candidate.exe')
[System.IO.File]::WriteAllText((Join-Path $installation 'unins000.exe'), 'fixture marker')
$env:PEBREL_HANDOFF_FIXTURE = $directory
$env:PEBREL_HANDOFF_FAIL_INSTALL = if ($scenario -eq 'installer-failure') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'reinstall-noop') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'upgrade-noop') { '1' } else { '0' }
$parent = Start-Process -FilePath $executable -ArgumentList 'wait' -PassThru
$null = $parent.Handle
$runner = $null
$other = $null
$helperLock = $null
try {
if ($scenario -in @('helper-unlocked', 'helper-held')) {
$null = New-Item -ItemType Directory -Path (Join-Path $installation 'runtime') -Force
$helperPath = Join-Path $installation 'runtime\pebrel-hook.exe'
[System.IO.File]::WriteAllText($helperPath, 'old helper fixture')
$helperLock = [System.IO.FileStream]::new($helperPath, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
}
$plan = @{
schema = 1; transaction = $scenario; executable = $executable; installation = $installation
config_directory = $config; installer = $installer
@@ -64,7 +72,7 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
participants = @(@{ pid = $parent.Id; created = $parent.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() })
}
if ($scenario -eq 'checksum') { $plan.sha256 = '0' * 64 }
if ($scenario -in @('reinstall', 'reinstall-noop')) { $plan.version = '1.8.0' }
if ($scenario -in @('reinstall', 'repair-identical')) { $plan.version = '1.8.0' }
if ($scenario -eq 'creation-time') { $plan.participants[0].created = '1' }
if ($scenario -eq 'other-process') {
$other = Start-Process -FilePath $executable -ArgumentList 'wait-other' -PassThru
@@ -104,8 +112,14 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran while the old process was alive'
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
Assert ($parent.WaitForExit(5000)) 'Fixture parent did not exit'
if ($scenario -eq 'helper-unlocked') {
Start-Sleep -Milliseconds 350
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup started with the helper still locked'
$helperLock.Dispose()
$helperLock = $null
}
Wait-File $resultPath
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'reinstall-noop')) {
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
Wait-File (Join-Path $directory 'new-launch')
$launch = [System.IO.File]::ReadAllLines((Join-Path $directory 'new-launch'))
Assert ($launch[0] -eq $executable) 'Relaunch selected another installation'
@@ -117,24 +131,31 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
}
Assert ($runner.WaitForExit(5000)) 'Helper did not finish'
$result = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json
Assert ($result.success -eq ($scenario -in @('success', 'reinstall'))) 'Unexpected helper outcome'
Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical', 'helper-unlocked'))) 'Unexpected helper outcome'
if ($result.success) {
$expected = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash
Assert ($result.executable_sha256 -eq $expected) 'Installed binary differs from candidate'
Assert (Test-Path -LiteralPath (Join-Path $installation 'runtime\pebrel-hook.exe')) 'Successful setup did not repair the helper'
Assert ([System.IO.File]::ReadAllText((Join-Path $installation 'runtime\pebrel-hook.exe')) -eq 'repaired helper fixture') 'Helper still contains the original payload'
}
if ($scenario -in @('other-process', 'late-process')) {
Assert (-not $other.HasExited) 'Update stopped an unprepared process'
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran with an unprepared process'
}
if ($scenario -in @('installer-failure', 'reinstall-noop')) {
if ($scenario -in @('installer-failure', 'upgrade-noop', 'helper-held')) {
Assert $result.recovered_original 'Untouched old executable was not recovered'
}
if ($scenario -eq 'helper-held') {
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'A blocked helper allowed a partial installation'
Assert ($result.error -match 'pebrel-hook.exe') 'The blocked file is missing from the failure details'
}
$probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
$results += @{ scenario = $scenario; passed = $true }
Write-Output "$scenario : passed"
} finally {
if ($helperLock) { $helperLock.Dispose() }
if ($runner -and -not $runner.HasExited) { $runner.Kill(); $runner.WaitForExit() }
if (-not $parent.HasExited) {
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
@@ -0,0 +1,69 @@
# 用户反馈回归测试清单
本清单覆盖 2026-09-23 汇总的 Hook、更新、窗口材质、链接、Codex 显示与
Windows 右键菜单反馈。它是验收合同,不表示下列功能已经全部修复。
`待验证`、`等待日志`、`部分覆盖` 均不得作为 Issue 已解决的依据。
## 执行与证据
- 每轮记录源码提交、未提交补丁、产品版本、Windows build、DPI、主题、字体、
Shell 与 Codex 版本。记录实际命令、通过/失败数量及截图或日志位置。
- 自动回归使用实际生产函数、真实终端网格或进程夹具;不能只检查源码中出现
某个字符串。原生截图不替代行为测试,编译成功也不替代视觉验收。
- 对修复先验证旧实现失败,再验证新实现通过;保留相邻功能的反例。
- 浅色/深色、100%/125%/150%/200% DPI、宽/窄窗口、分屏、滚动和主题热切换
按条目覆盖。平台条件导致无法执行的项必须注明,不计入通过数量。
- 实机操作使用隔离配置与测试窗口。安装/注册表夹具只修改其临时测试根,
不关闭用户会话,不覆盖正式安装,不触碰用户 Hook、凭据和发行版配置。
- 仅关闭已有代码与验收证据覆盖的问题单;部分修复说明剩余范围。
发布版本尚未包含修复时必须说明交付状态,不把本地通过写成已发布。
## 回归项目
| ID | 问题与来源 | 自动回归 / 通过条件 | 实机验收 / 反例 | 当前证据 |
| --- | --- | --- | --- | --- |
| HOOK-255 | [#255](https://github.com/Kuddev/pebrel/issues/255):Windows Codex Hook 被 PowerShell 解析为字符串而未执行 | 生成命令实际执行;`codex`、`--hooks=full/turns` 和 stdin JSON 完整传入;带空格、单引号、`$`、反引号的路径保持字面值 | Windows PowerShell 5.1 与 PowerShell 7;Codex 0.155.1/0.156.0;新会话、工具调用、提问、停止都无 ParserError | Windows 5.1 进程夹具通过;完整 Codex/PowerShell 7 会话待验收 |
| HOOK-LIFETIME | #258 前置故障:关闭 Pebrel 后 Hook 仍占用安装文件 | 保持 stdin 打开、连接后不读取命名管道时,真实 Hook 子进程仍在期限内以 0 退出;Cursor 继续允许提交;用户原有 notifier 仍执行 | 已运行的旧版 Hook 不会自动获得新期限;分别验证新启动的 native Hook 与旧进程恢复步骤 | 7 项单元 + 5 项真实进程回归通过 |
| HOOK-MIGRATE | 更新后旧 `hooks.json` / 归属标记仍含失效命令 | 旧受管命令迁移;重复安装幂等;保留 notify、第三方 Hook、显式禁用及用户编辑;卸载只移除自有条目 | 重启 Pebrel 后自动修复;用户修改过的冲突条目不能静默覆盖 | `ai_hook::` 120 通过、1 项既有忽略;后续改动需重跑相关项 |
| UPDATE-403 | 手动检查更新返回 HTTP 403 | 公共 Release 无需用户 token;覆盖 403/429、正常 JSON、其他错误、代理、受限响应;下载仍须官方 URL、精确资产名和 SHA-256 | 同一网络直连/代理、启动检查/手动检查;离线不得假报最新或成功 | **等待日志,按用户要求暂停**;未验收、不结项 |
| UPDATE-EXIT5 | 安装器 `exit code 5`,当前/最新版本显示相同 | 失败不得标成功;保留原快照与安装日志;已退出进程、安装失败、部分替换、相同版本、校验失败分别验证 | 基于反馈者 `installer.log` 重现;核对原版本、目标版本、实际 EXE;不能把 Inno 退出码 5 当作 Win32 拒绝访问码 | 后续 #258 已提供修复安装成功日志;无限等待的 Hook 已补期限;安装前等待短暂占用释放,持续占用时在启动安装器前失败,避免部分替换;旧版本已有挂起进程仍需另行处理 |
| UPDATE-258 | [#258](https://github.com/Kuddev/pebrel/issues/258):同版本修复成功却因主程序哈希不变报错 | 同版本主程序字节不变、Hook 文件修复后应成功;安装器失败或目标版本仍错误必须失败 | 同版本重装、首次升级部分成功后的修复;日志、实际版本和结果提示一致;原会话快照保留 | 12 项原生 handoff 场景通过,包含 `repair-identical` 与 `upgrade-noop` 正反例 |
| BLUR-236-A | [#236](https://github.com/Kuddev/pebrel/issues/236):Acrylic 在屏幕边缘透出背景内容 | 切换材质/透明度不重复创建资源;新窗口、关闭、失焦、最大化/还原、队列退出及运行库失败路径 | 0% 与非零不透明度;明显文字/色块背景;左右/上下屏幕边缘、最大化、任务栏恢复、混合 DPI;失焦不出现不透明灰底 | [PR #250](https://github.com/Kuddev/pebrel/pull/250) 尚未合并;其跨平台 CI 通过不等于本轮实机验收 |
| BLUR-236-B | #236 的 Aero 与 Acrylic 兼容回退范围 | Windows 10、Win11 22H2+、缺失/不匹配运行库与失败挂接均可正常启动/切换/退出 | Aero 需单独验收;缺少受支持 Windows App Runtime 时旧边缘现象不能标为已解决 | #250 仅覆盖受支持 Acrylic;**Aero 和回退范围仍开放** |
| LINK-CJK | OSC 8 中文/全角字符的虚线只画半字、错位 | 从真实 VT 输出构建网格,中文两格、空格、ASCII、组合字符均按列覆盖;逐格绘制与连续区间的物理像素集合相同 | `A开始 菜单Z`、纯中文、混排;DPI/分屏/窄窗/换行;不随字体回补或字形分段重置虚线 | 真实 OSC 8 网格与 16 种 DPI/格宽组合像素覆盖回归通过;原生 Nord / Paper 混排截图已核对 |
| LINK-PATH | 英文路径下划线断开,带空格路径只匹配前半段 | 连续虚线相位;引号包围的盘符/UNC/`~/` 路径完整匹配;hover、点击使用同一边界;普通正文不误连 | `"C:\Program Files\Example Tools\probe.exe"`、中文与括号目录、相邻命令参数、路径换行 | 8 项 hint 测试与 5 项配置解析测试通过;原生带空格路径截图已核对 |
| LINK-PROMPT | WSL `user@host:~/.codex$` 吞入 `$`;绝对路径提示符没有下划线 | `~/` 与 `/mnt/d/...` 提示符中的 `$`/`#` 不可点;真实文件名 `~/cost$`、URL 尾部 `$`、路径内部 `$` 保留;下划线与命中边界一致 | Bash/WSL 彩色提示符、root 提示符、输入命令后、换行;OSC 8 显式 URI 不被启发式改写 | 正反匹配/命中回归通过;绝对路径打开应使用所属 WSL 发行版,不得套用宿主目录或 SSH 目标 |
| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex 可执行文件/模型/设置,对照 WT;分别覆盖 low/high/xhigh/max/ultra、Max→xhigh 回退、持续星点与一次性等级动画;以 CLI 实际输出为准,不能由终端自行按等级添加/过滤星点;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归、10 项身份/WSL 透传回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;已观察到 Codex 0.154.0 在 xhigh 持续输出变化的星点;其源码持续星点与 Max/Ultra 一次性动画分别控制;与对照终端的等级切换差异仍待验收 |
| CODEX-MESSAGE | **所有主题**的 Codex 用户消息与 AI 回复背景缺少区分 | 保留/正确映射应用给出的用户消息背景;未提供背景时不得凭屏幕文字猜测角色;消息文本对比度不退化 | 遍历主题注册表中的全部内置主题;输入框与已发送的单/多行、中英混排消息;深浅切换、滚动回看;用户消息应有可辨认浅色背景区 | 15 个内置主题 + 225 种主题切换的背景保留回归通过;真实产品 Nord / Paper 的背景和星点截图已核对;其余主题有自动回放覆盖,仍需原生视觉抽查 |
| SHELL-MENU | 多个 WSL 发行版把资源管理器右键菜单铺满 | 保留一个普通“在 Pebrel 中打开”;其余发行版集中到一个级联菜单;零/一/多个发行版、重复安装、移除发行版、卸载与旧项迁移 | 文件夹对象/文件夹空白处;中文/空格/盘根路径;点击普通项与每个子项验证 Shell 和 cwd;保留第三方/其他安装拥有的键 | 已实现;真实 Inno 夹具累计 117 项通过,完整安装器编译通过;正式 Explorer 点击验收仍需进行 |
## 修复与评审入口
- Hook 命令及退出期限:[PR #260](https://github.com/Kuddev/pebrel/pull/260),关联 #254 / #255 及 #258 的前置占用故障。
- 同版本修复判定:[PR #261](https://github.com/Kuddev/pebrel/pull/261),关联 #258。
- 下划线与路径边界:[PR #262](https://github.com/Kuddev/pebrel/pull/262)。
- Windows / WSL 真彩色能力:[PR #263](https://github.com/Kuddev/pebrel/pull/263),关联 #136。
- WSL 级联菜单:[PR #264](https://github.com/Kuddev/pebrel/pull/264)。
本地组合回归与各 PR 的 CI、Code Owner 评审是不同证据;未合并、未发布不能写成已经交付。
## 当前测试入口
```powershell
cargo test --locked -p nebula_hook
cargo test --locked -p nebula --bin pebrel --features gpui-shell platform::environment::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell ai_hook::
cargo test --locked -p nebula --bin pebrel --features gpui-shell display::hint::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell config::ui_config::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::link_underline::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::element::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell display::terminal_color::tests
```
安装器既有入口为 `scripts/tests/installer.tests.ps1`、
`scripts/tests/installer-migration.tests.ps1`、`scripts/tests/test_update_handoff.ps1`。
菜单夹具已扩展零/一/多个发行版及所有权保护;handoff 已扩展同版本修复正例与错误版本反例。它们不代替正式安装或 Explorer 点击验收。
修改完成后还需按 `CONTRIBUTING.md` 执行架构、格式、受影响模块与真实产品检查;
架构门禁使用实际目标分支基线。新增门禁或 GitHub 强制保护需单独验证,
本清单不宣称启用了服务端保护。