Merge branch 'main' into codex/tab-menu-korean-i18n
This commit is contained in:
39 files changed
+2454
-238
No files matched your search
@@ -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
|
||||
@@ -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. / 快速提问与交流。
|
||||
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,50 @@ 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: 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 +85,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 +102,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 +110,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 +130,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 +156,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 +170,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 +186,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 +213,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 }}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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}`;
|
||||
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] });
|
||||
}
|
||||
|
||||
core.info(`counted ${counted} lines (${ignored} ignored) in ${files.length} files -> ${wanted}`);
|
||||
if (size !== "XL" || labels.has("size-exempt")) 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"),
|
||||
});
|
||||
}
|
||||
core.setFailed(`${counted} changed source lines exceed the ${LIMIT}-line limit; split the pull request or add size-exempt.`);
|
||||
@@ -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.
|
||||
|
||||
+20
-6
@@ -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,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,77 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
@@ -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,
|
||||
@@ -311,6 +314,27 @@ mod tests {
|
||||
assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}");
|
||||
assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}");
|
||||
assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV));
|
||||
assert!(names.contains(&"COLORTERM"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn 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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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{-}\\^⟨⟩`*?:,。;:!?、]+|\
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,17 +688,34 @@ impl Element for TerminalElement {
|
||||
layout.line_height,
|
||||
);
|
||||
}
|
||||
if dashed_link {
|
||||
paint_dashed_underline(
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
origin,
|
||||
layout.cell_width,
|
||||
layout.line_height,
|
||||
fg,
|
||||
cell_rect(row as usize, visual_col, 1),
|
||||
bounds.origin.x,
|
||||
color.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前,
|
||||
// 与旧壳 draw_rects → draw_overlays 的次序一致。
|
||||
@@ -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 按住时强制旁路——这是
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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`,两条都满足才算我们写的。发行版后来被移除也不会留下
|
||||
点不动的死项。 }
|
||||
procedure RemoveOwnedWslContextMenus;
|
||||
{ WSL 右键项按精确可执行文件前缀归属;级联菜单还校验所有子项,保留用户编辑。
|
||||
发行版移除后,下次安装重建菜单时不留下旧项。 }
|
||||
function IsOwnedWslCommand(Command, Executable: string): Boolean;
|
||||
var
|
||||
Roots: array[0..1] of string;
|
||||
Root, Key, Command: string;
|
||||
Names: TArrayOfString;
|
||||
Index, NameIndex: Integer;
|
||||
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
|
||||
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 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;
|
||||
Index: Integer;
|
||||
begin
|
||||
Roots[0] := 'Software\Classes\Directory\shell';
|
||||
Roots[1] := 'Software\Classes\Directory\Background\shell';
|
||||
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;
|
||||
|
||||
@@ -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 中运行的程序,然后重试安装。
|
||||
|
||||
+4
@@ -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;
|
||||
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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,48 @@ 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("python scripts/ci_plan.py", lint)
|
||||
self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint)
|
||||
self.assertLess(lint.index("cargo fmt"), lint.index("python scripts/ci_plan.py"))
|
||||
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,6 +62,11 @@ class NativeSuiteTests(unittest.TestCase):
|
||||
self.assertIsNotNone(declaration)
|
||||
self.assertNotIn("paths", declaration.group(1))
|
||||
self.assertNotIn("branches", 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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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 强制保护需单独验证,
|
||||
本清单不宣称启用了服务端保护。
|
||||
Reference in new issue
Block a user