Merge branch 'main' into codex/render-settings-cache

This commit is contained in:
Kuddev authored and GitHub committed 2026-09-25 21:59:50 +08:00
commit 9cf6f517f1
117 files changed
+6420 -527

No files matched your search

+140 -39
View File
@@ -1,114 +1,215 @@
name: Bug Report
description: Report a reproducible problem in Nebula
name: Bug Report / 问题反馈
description: Report a reproducible problem in Pebrel / 反馈一个可复现的问题
title: "[Bug] "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please search existing issues first and include enough detail for someone else to reproduce the problem.
Thanks for taking the time to report a bug. A report we can reproduce gets fixed in the next release; one we cannot reproduce is asked for details and waits.
感谢反馈。**能复现的问题会进下个版本;说不清的问题只能先挂起等补充。** 下拉选项会自动转成标签。
- type: dropdown
id: platform
attributes:
label: Operating system
label: Operating system / 操作系统
options:
- Windows
- Windows (WSL)
- macOS
- Linux
- Other
- Other / 其他
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version and display / 系统版本与显示
description: Windows 11 23H2 / macOS 26.0 / Ubuntu 24.04; DPI scaling and monitor count if display-related. / 系统版本;渲染问题请附缩放比例与显示器数量。
placeholder: "Windows 11 23H2, 150% scaling, 2 monitors"
validations:
required: true
- type: input
id: version
attributes:
label: Nebula version
description: Find it in Settings > Home, or on the latest release page.
placeholder: "e.g. 1.0.0"
label: Pebrel version / 版本号
description: Settings > Home, or the release page. / 设置 > 主页,或发布页。
placeholder: "e.g. 1.9.0"
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: How did you install Nebula?
label: How did you install Pebrel? / 安装方式
options:
- GitHub Release (.msi / .exe / portable archive)
- Built from source (cargo build / cargo run)
- Other
- GitHub Release (.exe / .zip / .dmg / .AppImage / .deb)
- Built from source / 源码构建 (cargo build)
- Other / 其他
validations:
required: true
- type: dropdown
id: shell
attributes:
label: Shell in the affected pane / 出问题的面板用的 Shell
multiple: true
options:
- PowerShell 7 (pwsh)
- Windows PowerShell 5.1
- cmd
- WSL (bash / zsh inside WSL)
- Git Bash / MSYS2
- zsh
- bash
- fish
- nushell
- Not sure / 不确定
validations:
required: true
- type: dropdown
id: program
attributes:
label: Program running in the pane / 面板里正在跑的程序
description: What was running when it happened? / 出问题时面板里跑着什么?
multiple: true
options:
- Nothing, plain shell prompt / 什么都没跑
- Claude Code
- Codex CLI
- Pi
- Gemini CLI / other AI agent — 其他 AI agent
- Terminal multiplexer (screen etc.) — 终端复用器
- vim / nvim / other full-screen TUI — 全屏 TUI
- ssh client / remote shell — 远程 shell
- A long-running command (build, ping, adb…) — 长时间运行的命令
- Other / 其他
validations:
required: true
- type: dropdown
id: connection
attributes:
label: Connection type / 连接方式
options:
- Local terminal / 本地终端
- SSH via Pebrel host list / Pebrel 内 SSH 主机
- SSH via command line (ssh user@host) / 手动 ssh 命令
- WSL
- Other / 其他
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
label: Affected area / 影响范围
description: Pick every area that applies. / 可多选。
multiple: true
options:
- Local terminal / shell
- SSH connection / authentication
- SFTP / file tree
- Tabs / splits / sessions
- AI assistant / providers
- Settings / backup / sync
- UI / layout / rendering
- Startup / crash
- Other
- Terminal rendering / scrolling / selection / garbled text — 终端渲染、滚动、选区、乱码
- Shell startup / PATH / start directory / context menu — Shell 启动、环境变量、起始目录、右键菜单
- SSH / proxy / jump host — SSH、代理、跳板
- File tree / drag-and-drop upload — 文件树、拖拽上传
- Tabs / splits / session restore / window size — 标签页、分屏、会话恢复、窗口尺寸
- AI agent (Claude Code / Codex / Pi / OMP) — AI Agent 集成与状态
- Markdown / LaTeX / image reader — 阅读页与内联渲染
- Settings / theme / layout / blur — 设置页、主题、布局、模糊背景
- Keyboard / IME / mouse / shortcuts / clipboard — 键盘、输入法、鼠标、快捷键、剪贴板
- Git / SVN
- Startup crash / hang / cannot exit — 启动崩溃、卡死、无法退出
- Performance / lag — 性能、卡顿
- Installer / update / packaging — 安装包、更新、打包
- Other / 其他
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Did it work in a previous version? / 旧版本是否正常?
options:
- Not sure / 不确定
- Yes, it worked before / 旧版本正常,新版本坏了
- No, it never worked / 一直如此
validations:
required: true
- type: dropdown
id: reproducibility
attributes:
label: Can you reproduce it?
label: Can you reproduce it? / 复现概率
options:
- Always (100%)
- Often (>50%)
- Sometimes
- Once / not sure
- Always (100%) / 必现
- Often (>50%) / 经常
- Sometimes / 偶尔
- Once / not sure / 仅一次
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Use numbered steps and include the shell or connection type when relevant.
label: Steps to reproduce / 复现步骤
description: |
Numbered, from a fresh launch, ending at the exact moment it goes wrong. "It's laggy" or "it doesn't work" cannot be acted on.
从启动开始按序编号,写到出错的那一步为止。"很卡"、"不好用"这种一句话我们没法处理。
placeholder: |
1. Open Nebula
2. Create or connect to ...
3. ...
1. Launch Pebrel, open a new PowerShell 7 tab
2. Run `codex` and ask it to ...
3. Split the pane with Ctrl+Shift+D
4. Output overlaps the input box at the bottom (see screenshot)
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
label: Expected behavior / 期望结果
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
label: Actual behavior / 实际结果
description: What you see instead, including any error text verbatim. / 实际看到什么,错误文字原样贴。
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: Include relevant lines or screenshots. Remove passwords, private keys, tokens, host addresses, and other secrets.
label: Logs / screenshots / 日志或截图
description: |
Required. Screenshot or short recording for anything visual; log lines for crashes, hangs and connection problems. Remove passwords, keys, tokens and host addresses first.
必填。显示类问题贴截图或短录屏;崩溃、卡死、连接类问题贴日志。请先删掉密码、私钥、token、主机地址。
Logs / 日志位置:
- Windows: `%APPDATA%\Pebrel\pebrel_debug.log` and `%TEMP%\Pebrel-<pid>.log`
- macOS: `~/Library/Application Support/Pebrel/pebrel_debug.log` and `$TMPDIR/Pebrel-<pid>.log`
- Linux: `~/.config/pebrel/pebrel_debug.log` and `/tmp/Pebrel-<pid>.log`
placeholder: Paste redacted logs or drag screenshots here.
validations:
required: true
- type: textarea
id: settings
attributes:
label: Non-default settings / 改过的设置
description: Theme, font, transparency/blur, GPU, shell profile, hooks — anything changed from defaults. Attach settings.txt if unsure. / 改过的主题、字体、透明/模糊、GPU、Shell 配置、hooks;不确定就贴 settings.txt。
placeholder: "Acrylic blur on, opacity 85%, font JetBrains Mono 13"
- type: checkboxes
id: checklist
attributes:
label: Before submitting
label: Before submitting / 提交前确认
options:
- label: I searched existing issues and did not find a duplicate
- label: I searched existing issues and did not find a duplicate / 已搜索,无重复
required: true
- label: I removed passwords, private keys, tokens, and other secrets from this report
- label: I tested with the latest release / 已用最新版本试过
required: true
- label: I removed passwords, private keys, tokens and other secrets / 已删除敏感信息
required: true
+9 -6
View File
@@ -1,8 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Existing issues
url: https://github.com/Kuddev/nebula/issues
about: Search existing reports before opening a new issue.
- name: Latest release
url: https://github.com/Kuddev/nebula/releases/latest
about: Check whether the problem is already fixed in the latest version.
- name: Existing issues / 已有 issue
url: https://github.com/Kuddev/pebrel/issues
about: Search existing reports before opening a new issue. / 先搜一下是否已有人反馈。
- name: Latest release / 最新版本
url: https://github.com/Kuddev/pebrel/releases/latest
about: Check whether the problem is already fixed in the latest version. / 看看最新版是否已修复。
- name: Discord
url: https://discord.gg/VFn4rcxmhn
about: Quick questions and discussion. / 快速提问与交流。
+41 -24
View File
@@ -1,5 +1,5 @@
name: Feature Request
description: Suggest an improvement or new capability for Nebula
name: Feature Request / 功能建议
description: Suggest an improvement or new capability for Pebrel / 提出改进或新功能
title: "[Feature] "
labels: ["enhancement", "triage"]
body:
@@ -7,12 +7,13 @@ body:
attributes:
value: |
Describe the workflow problem first, then the change you want. Concrete examples make the request easier to evaluate.
先说现在哪里不顺手,再说希望怎么改。具体的使用场景最有说服力。
- type: textarea
id: problem
attributes:
label: Problem / pain point
description: What is hard, missing, or repetitive today?
label: Problem / pain point — 现在的痛点
description: What is hard, missing, or repetitive today? / 什么事难做、缺失、或者每天重复?
placeholder: When I work with multiple terminals or hosts, I cannot ...
validations:
required: true
@@ -20,50 +21,66 @@ body:
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What would you like Nebula to do?
label: Proposed solution — 希望的做法
description: What would you like Pebrel to do? / 希望 Pebrel 怎么做?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: List workarounds, other tools, or designs you considered.
validations:
required: true
label: Alternatives considered — 现在的替代办法
description: Workarounds, other tools, or designs you considered. / 目前怎么绕过去的,别的终端怎么做的。
- type: dropdown
id: platform
attributes:
label: Platform / 平台
description: Leave empty if it applies everywhere. / 全平台通用可不选。
multiple: true
options:
- Windows
- Windows (WSL)
- macOS
- Linux
- type: dropdown
id: area
attributes:
label: Related area
label: Related area / 相关范围
multiple: true
options:
- Local terminal / shell
- SSH / SFTP
- Tabs / splits / sessions
- AI assistant / providers
- Settings / backup / sync
- UI / UX
- Other
- Terminal rendering / scrolling / selection — 终端渲染、滚动、选区
- Shell startup / PATH / start directory / context menu — Shell 启动、环境变量、起始目录、右键菜单
- SSH / proxy / jump host — SSH、代理、跳板
- File tree / drag-and-drop upload — 文件树、拖拽上传
- Tabs / splits / session restore / window size — 标签页、分屏、会话恢复、窗口尺寸
- AI agent (Claude Code / Codex / Pi / OMP) — AI Agent 集成与状态
- Markdown / LaTeX / image reader — 阅读页与内联渲染
- Settings / theme / layout / blur — 设置页、主题、布局、模糊背景
- Keyboard / IME / mouse / shortcuts / clipboard — 键盘、输入法、鼠标、快捷键、剪贴板
- Git / SVN
- Performance / lag — 性能、卡顿
- Installer / update / packaging — 安装包、更新、打包
- Other / 其他
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
label: How important is this to you? / 对你有多重要
options:
- Nice to have
- Would improve my daily workflow
- Blocking / critical for my use case
- Nice to have / 有更好
- Would improve my daily workflow / 每天都会用到
- Blocking / critical for my use case / 没有就没法用
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Before submitting
label: Before submitting / 提交前确认
options:
- label: I searched existing issues for similar requests
- label: I searched existing issues for similar requests / 已搜索,无重复
required: true
+54
View File
@@ -22,6 +22,15 @@ outputs:
cache-hit:
description: Whether the compiled target cache matched exactly
value: ${{ steps.target.outputs.cache-hit }}
cache-matched-key:
description: The compiled target cache key actually restored by actions/cache
value: ${{ steps.target.outputs.cache-matched-key }}
legacy-fallback-invoked:
description: Whether the optional legacy cache fallback action was invoked
value: ${{ steps.cache-summary.outputs.legacy-fallback-invoked }}
legacy-fallback-outcome:
description: Outcome of the optional legacy cache fallback action
value: ${{ steps.cache-summary.outputs.legacy-fallback-outcome }}
runs:
using: composite
@@ -96,6 +105,7 @@ runs:
${{ steps.identity.outputs.restore-key }}
${{ steps.identity.outputs.migration-key }}
- name: Recover an existing combined cache during migration
id: legacy-fallback
if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != ''
uses: Swatinem/rust-cache@v2
with:
@@ -104,6 +114,50 @@ runs:
cache-bin: false
cache-on-failure: true
save-if: false
- name: Summarize compiled target cache
id: cache-summary
if: always()
shell: bash
env:
TARGET_CACHE_HIT: ${{ steps.target.outputs.cache-hit }}
TARGET_CACHE_MATCHED_KEY: ${{ steps.target.outputs.cache-matched-key }}
SAVE_KEY: ${{ steps.identity.outputs.key }}
LEGACY_CONFIGURED: ${{ inputs.legacy-key != '' && 'true' || 'false' }}
LEGACY_OUTCOME: ${{ steps.legacy-fallback.outcome }}
CACHE_WORKLOAD: ${{ inputs.key }}
RUNNER_OS_VALUE: ${{ runner.os }}
RUNNER_ARCH_VALUE: ${{ runner.arch }}
run: |
set -euo pipefail
target_cache_hit="${TARGET_CACHE_HIT:-false}"
target_cache_matched_key="${TARGET_CACHE_MATCHED_KEY:-<none>}"
save_key="${SAVE_KEY:-<none>}"
legacy_configured="${LEGACY_CONFIGURED:-false}"
legacy_outcome="${LEGACY_OUTCOME:-skipped}"
legacy_invoked=false
if [[ "$legacy_configured" == 'true' && "$legacy_outcome" != 'skipped' && -n "$legacy_outcome" ]]; then
legacy_invoked=true
fi
{
echo '### Rust compiled target cache'
echo
echo "- Workload: \`$CACHE_WORKLOAD\`"
echo "- Runner: \`$RUNNER_OS_VALUE / $RUNNER_ARCH_VALUE\`"
echo "- Target cache exact hit: \`$target_cache_hit\`"
echo "- Target cache matched key: \`$target_cache_matched_key\`"
echo "- Save key: \`$save_key\`"
echo "- Legacy fallback configured: \`$legacy_configured\`"
echo "- Legacy fallback invoked: \`$legacy_invoked\`"
echo "- Legacy fallback outcome: \`$legacy_outcome\`"
echo '- Legacy fallback status is separate and is not counted as target exact hit'
} >> "$GITHUB_STEP_SUMMARY"
{
echo "legacy-fallback-invoked=$legacy_invoked"
echo "legacy-fallback-outcome=$legacy_outcome"
} >> "$GITHUB_OUTPUT"
- name: Populate complete downloads before sharing the cache
if: steps.downloads.outputs.cache-hit != 'true'
shell: bash
+21
View File
@@ -36,6 +36,27 @@ jobs:
scripts.tests.test_architecture_budgets
scripts.tests.test_architecture_dependencies
scripts.tests.test_architecture_governance
scripts.tests.test_prohibited_names
- name: Check prohibited product names in the new range
env:
PROHIBITED_NAMES_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
PROHIBITED_NAMES_HEAD: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
run: |
set -euo pipefail
base="$PROHIBITED_NAMES_BASE"
head="$PROHIBITED_NAMES_HEAD"
# The first push has no before SHA. Derive its known parent when one
# exists; an unresolved/zero base must still fail closed in the checker.
if [[ -z "$base" || "$base" =~ ^0+$ ]]; then
base="$(git rev-parse "${head}^" 2>/dev/null || true)"
fi
if [[ -z "$base" || "$base" =~ ^0+$ || -z "$head" || "$head" =~ ^0+$ ]]; then
echo "::error::Cannot determine a non-zero base/head for prohibited-name range" >&2
exit 1
fi
python3 scripts/check_prohibited_names.py range \
--base "$base" \
--head "$head"
- name: Check source size and dependency direction
env:
ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
+154
View File
@@ -0,0 +1,154 @@
name: Issue labels
# Turns the dropdown answers of the issue forms (.github/ISSUE_TEMPLATE/*.yml)
# into labels and flags bug reports that are too thin to act on. Labels are
# only added; maintainers' manual labels are never removed.
on:
issues:
types: [opened, edited]
permissions:
issues: write
concurrency:
group: issue-labels-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
label-from-form:
name: label-from-form
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Map form answers to labels
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || "";
// Issue forms render every field as "### <label>\n\n<value>";
// multi-select dropdowns join their values with ", ".
const fields = {};
const re = /^### (.+?)\s*\n+([\s\S]*?)(?=^### |\s*$(?![\s\S]))/gm;
for (const m of body.matchAll(re)) {
const value = m[2].trim();
if (value && value !== "_No response_") fields[m[1].trim()] = value;
}
const field = (prefix) => {
const key = Object.keys(fields).find((k) => k.startsWith(prefix));
return key ? fields[key] : "";
};
const answers = (prefix) => {
const value = field(prefix);
return value ? value.split(", ").map((s) => s.trim()) : [];
};
// Match on the English lead of each option so wording tweaks in
// the Chinese half never silently break the mapping.
const OS = [
["Windows (WSL)", ["os: windows", "os: wsl"]],
["Windows", ["os: windows"]],
["macOS", ["os: macos"]],
["Linux", ["os: linux"]],
];
const AREA = [
["Terminal rendering", ["area: terminal"]],
["Shell startup", ["area: shell"]],
["SSH", ["area: ssh"]],
["File tree", ["area: files"]],
["Tabs / splits", ["area: session"]],
["AI agent", ["area: agents"]],
["Markdown", ["area: reader"]],
["Settings", ["area: ui"]],
["Keyboard", ["area: input"]],
["Git", ["area: git"]],
["Startup crash", ["crash"]],
["Performance", ["performance"]],
["Installer", ["area: ci-release"]],
];
const PROGRAM = [
["Claude Code", ["area: agents"]],
["Codex", ["area: agents"]],
["Pi", ["area: agents"]],
["Gemini", ["area: agents"]],
];
const CONNECTION = [
["SSH", ["area: ssh"]],
["WSL", ["os: wsl"]],
];
const SHELL = [["WSL", ["os: wsl"]]];
const pick = (values, table) => {
const out = [];
for (const v of values) {
const hit = table.find(([lead]) => v.startsWith(lead));
if (hit) out.push(...hit[1]);
}
return out;
};
const labels = new Set([
...pick([...answers("Operating system"), ...answers("Platform")], OS),
...pick([...answers("Affected area"), ...answers("Related area")], AREA),
...pick(answers("Program running in the pane"), PROGRAM),
...pick(answers("Connection type"), CONNECTION),
...pick(answers("Shell in the affected pane"), SHELL),
]);
if (answers("Did it work in a previous version").some((v) => v.startsWith("Yes"))) {
labels.add("regression");
}
// Thin bug reports: no usable steps or no evidence. Ask once, on
// open; an edit that fills the gaps clears the label.
const existing = new Set(issue.labels.map((l) => l.name));
const isBug = existing.has("bug");
const steps = field("Steps to reproduce");
const logs = field("Logs / screenshots");
const missing = [];
// CJK carries more per character; weigh it double. Numbered
// multi-line steps count as an attempt regardless of length.
const compact = steps.replace(/\s+/g, "");
const weight = compact.length + (compact.match(/[ -鿿豈-﫿]/g) || []).length;
const numbered = (steps.match(/^\s*\d+[.、)]/gm) || []).length >= 2;
if (isBug && weight < 60 && !numbered) {
missing.push("**Steps to reproduce / 复现步骤**: numbered steps from launch to the failing moment / 从启动开始按序写到出错那一步");
}
if (isBug && !logs) {
missing.push("**Logs / screenshots / 日志或截图**: a screenshot for visual issues, `pebrel_debug.log` for crashes, hangs and connection problems / 显示类问题贴截图,崩溃卡死连接类贴日志");
}
if (isBug && !/\d+\.\d+/.test(field("Pebrel version"))) {
missing.push("**Pebrel version / 版本号**: e.g. `1.9.0` (Settings > Home)");
}
const toAdd = [...labels].filter((l) => !existing.has(l));
if (missing.length) {
if (!existing.has("needs-info")) toAdd.push("needs-info");
if (context.payload.action === "opened") {
await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: [
"Thanks for the report. It is missing details we need before anyone can reproduce it, so it is parked as `needs-info`. Please edit the issue and fill in:",
"感谢反馈。目前缺少复现所需的信息,已标记 `needs-info`。请**编辑 issue** 补齐以下内容,补齐后标签会自动移除:",
"",
...missing.map((m) => `- ${m}`),
"",
"Reports without these are closed after 14 days of silence. / 14 天无补充将关闭。",
].join("\n"),
});
}
} else if (isBug && existing.has("needs-info") && context.payload.action === "edited") {
await github.rest.issues.removeLabel({
...context.repo, issue_number: issue.number, name: "needs-info",
}).catch(() => {});
}
core.info(`form labels: ${[...labels].join(", ") || "(none)"}; adding: ${toAdd.join(", ") || "(none)"}; missing: ${missing.length}`);
if (toAdd.length) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issue.number,
labels: toAdd,
});
}
+62 -8
View File
@@ -11,7 +11,9 @@ on:
push:
branches: [main]
# Include docs, catalogs, assets and workflow edits: every PR gets test results.
# ready_for_review starts the scarce-runner jobs that draft pushes skip.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
concurrency:
@@ -29,13 +31,61 @@ jobs:
with:
runtime_run: ${{ inputs.windows_diagnostics_run }}
# rustfmt only for now. clippy joins once the existing warning backlog is
# cleared; several crates deny lints, so it cannot run advisory.
lint:
if: ${{ inputs.windows_diagnostics_run == '' }}
name: lint
outputs:
native_matrix: ${{ steps.matrix.outputs.native_matrix }}
release_matrix: ${{ steps.matrix.outputs.release_matrix }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.97.1"
components: rustfmt
- name: rustfmt
run: cargo fmt --all -- --check
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Test CI contracts before matrix planning
run: |
python3 -m unittest \
scripts.tests.test_ci_plan \
scripts.tests.test_ci_native_tests \
scripts.tests.test_stable_release \
scripts.tests.test_ci_cache \
scripts.tests.test_platform_cfg \
scripts.tests.test_pr_size_workflow -v
- name: Check platform cfg budget before native jobs
run: python3 scripts/check_platform_cfg.py
- name: Plan native validation
id: matrix
shell: bash
run: |
python scripts/ci_plan.py \
--event-name "$GITHUB_EVENT_NAME" \
--event-path "$GITHUB_EVENT_PATH" \
--output "$GITHUB_OUTPUT"
# The required lint job validates the matrix before heavy runners are
# requested. Draft PRs omit deferred platforms entirely; ready PRs and
# release callers require the full matrix. Deferred checks remain missing
# on drafts instead of being reported as successful tests.
native-tests:
needs: lint
if: ${{ inputs.windows_diagnostics_run == '' }}
name: Tests (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2022, windows-11-arm, macos-26, macos-26-intel]
include: ${{ fromJSON(needs.lint.outputs.native_matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
env:
@@ -46,12 +96,13 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install native dependencies
if: runner.os == 'Linux'
if: ${{ runner.os == 'Linux' }}
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
@@ -62,7 +113,7 @@ jobs:
libx11-xcb-dev libxcb1-dev libxcb-render0-dev \
libxcb-shape0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcb-xkb-dev libxi-dev
- name: Require the AppKit SDK used by this PR
if: runner.os == 'macOS'
if: ${{ runner.os == 'macOS' }}
run: |
sdk_version="$(xcrun --sdk macosx --show-sdk-version)"
test "${sdk_version%%.*}" -ge 26
@@ -70,7 +121,7 @@ jobs:
with:
toolchain: "1.97.1"
- name: Require a native Windows ARM64 compiler
if: matrix.os == 'windows-11-arm'
if: ${{ matrix.os == 'windows-11-arm' }}
shell: pwsh
run: |
if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'Arm64') {
@@ -90,7 +141,7 @@ jobs:
${{ runner.os != 'macOS' && 'target/release' || '' }}
legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }}
- name: Prepare pinned Windows console runtime
if: runner.os == 'Windows'
if: ${{ runner.os == 'Windows' }}
shell: pwsh
run: |
$architecture = if ('${{ runner.arch }}' -eq 'ARM64') { 'arm64' } else { 'x64' }
@@ -116,7 +167,7 @@ jobs:
${{ runner.os != 'macOS' && 'target/release' || '' }}
key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Upload native compilation timings
if: always()
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: native-timings-${{ matrix.os }}
@@ -130,12 +181,13 @@ jobs:
# 34856491552. Both jobs remain part of this reusable workflow's result, so
# the stable release aggregation still requires every check to succeed.
macos-release-check:
needs: lint
if: ${{ inputs.windows_diagnostics_run == '' }}
name: Release workspace (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-26, macos-26-intel]
include: ${{ fromJSON(needs.lint.outputs.release_matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
env:
@@ -145,6 +197,8 @@ jobs:
RUST_TOOLCHAIN: "1.97.1"
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: "3.13"
@@ -170,7 +224,7 @@ jobs:
path: target/release
key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Upload release check compilation timings
if: always()
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: release-check-timings-${{ matrix.os }}
+93
View File
@@ -0,0 +1,93 @@
name: PR size
# Labels every pull request by the number of changed source lines and fails
# oversized ones so they get split before review. Generated and documentation
# files do not count. Maintainers bypass the limit with the `size-exempt`
# label (a release integration branch is the usual case).
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
# Required checks must also report inside the merge queue; the size was
# already enforced on the pull request, so this is a pass-through.
merge_group:
permissions:
pull-requests: write
concurrency:
group: pr-size-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pr-size:
name: pr-size
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const pr = context.payload.pull_request;
const LIMIT = 1500;
const IGNORED = [
/^Cargo\.lock$/, /(^|\/)package-lock\.json$/, /(^|\/)yarn\.lock$/,
/^docs\//, /\.md$/, /\.snap$/, /\.svg$/, /\.png$/, /\.ico$/,
/^assets\//, /^locales\//, /\.lock$/,
];
const files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo, pull_number: pr.number, per_page: 100,
});
let counted = 0, ignored = 0;
for (const f of files) {
const lines = f.additions + f.deletions;
if (IGNORED.some((re) => re.test(f.filename))) ignored += lines; else counted += lines;
}
const size = counted <= 100 ? "S" : counted <= 500 ? "M" : counted <= LIMIT ? "L" : "XL";
const labels = new Set(pr.labels.map((l) => l.name));
const wanted = `size/${size}`;
const oversized = size === "XL" && !labels.has("size-exempt");
core.info(`counted ${counted} lines (${ignored} ignored) in ${files.length} files -> ${wanted}`);
await core.summary
.addRaw(`PR size: **${size}**, ${counted} counted lines (${ignored} ignored), limit ${LIMIT}.`)
.write();
if (oversized) {
core.setFailed(`${counted} changed source lines exceed the ${LIMIT}-line limit; split the pull request or add size-exempt.`);
}
// Fork tokens are read-only. The size decision above is still enforced;
// optional labels and comments must not turn a valid PR into a 403 failure.
if (pr.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
core.info("Fork PR: size is reported in the summary; no label/comment writes.");
return;
}
for (const l of labels) {
if (l.startsWith("size/") && l !== wanted) {
await github.rest.issues.removeLabel({ ...context.repo, issue_number: pr.number, name: l }).catch(() => {});
}
}
if (!labels.has(wanted)) {
await github.rest.issues.addLabels({ ...context.repo, issue_number: pr.number, labels: [wanted] });
}
if (!oversized) return;
const marker = "<!-- pr-size-guard -->";
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo, issue_number: pr.number, per_page: 100,
});
if (!comments.some((c) => c.body && c.body.includes(marker))) {
await github.rest.issues.createComment({
...context.repo, issue_number: pr.number,
body: [
marker,
`This pull request changes **${counted}** source lines (limit ${LIMIT}; docs, lockfiles and assets excluded). Changes this large cannot be reviewed reliably, so please split it into independent pull requests — one behaviour change each, with its own tests.`,
"",
`本 PR 改动 **${counted}** 行源码(上限 ${LIMIT},不计文档、lockfile、资源)。这么大的改动无法可靠审查,请拆成多个相互独立的 PR,每个只做一件事并附测试。`,
"",
"Maintainers can add the `size-exempt` label for integration branches. / 维护者可对集成分支加 `size-exempt` 放行。",
].join("\n"),
});
}
+1 -20
View File
@@ -4,26 +4,7 @@ run-name: >-
Cross-platform Preview (${{ inputs.preview_id || format('run-{0}', github.run_number) }})
on:
pull_request:
# Native tests cover every PR; package only when package inputs change.
paths:
- "**/Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- "nebula_app/build.rs"
- "nebula_app/build/**"
- "nebula_app/res/**"
- "nebula_app/windows/**"
- "assets/**"
- "packaging/**"
- "scripts/package*"
- "scripts/build-*"
- "scripts/installer*"
- "scripts/prepare-windows-runtime.ps1"
- "scripts/preview_release.py"
- "scripts/conformance/**"
- "scripts/tests/test_preview_release.py"
- ".github/workflows/preview-packages.yml"
# Pull requests use Full native tests; packages are built after merge or on request.
push:
branches: [main]
# Native tests cover every PR; package only when package inputs change.
+2
View File
@@ -159,6 +159,8 @@ scripts/drawer_repro.ps1
scripts/ghost_repro/
scripts/icon-build/
scripts/render_shell_icon.html
# The competitor-name policy is a maintained CI and hook contract, not a local probe.
!/scripts/check_prohibited_names.py
# Only the maintained Markdown stress runner is public; its scratch variants stay local.
/scripts/markdown-memory-*
!/scripts/markdown-memory-stress.ps1
+81
View File
@@ -4,6 +4,87 @@ Every release entry is provided in English and Simplified Chinese.
每个版本条目均同时提供英文和简体中文说明。
## 1.9.1 - 2026-09-24
### English
#### Added
- Added in-app updates for packaged macOS applications on Apple Silicon and Intel. Verified updates can install after restart or on the next launch, then restore the saved workspace. Immediate relaunch failure rolls back to the original application. Installation requires a writable application location; copies running from a disk image or App Translocation must first be moved.
- Added SSH display names to the Shell picker, Quick Jump and command palette. Named hosts show their configured name alongside the original connection target; unnamed hosts keep the previous display. Search matches both the name and target, and selecting a row still connects to the original destination.
- Added a configurable tab-rename shortcut in Settings → Key Bindings → Tabs. The default remains F2; changing or clearing the shortcut lets F2 reach terminal applications such as Codex CLI. Changes apply without restarting, and menu shortcut hints follow the effective binding.
#### Fixed
- Fixed update checks failing when GitHub returns HTTP 403 or 429. The updater falls back to the official latest-release page and checksum manifest; if verified package metadata is unavailable, manual download remains available.
- Fixed macOS terminal links not opening with Command+left click. Link gestures also work when an application enables mouse reporting, while ordinary clicks still reach the application. Hover hints show the correct platform modifier in the selected language. Addresses [#196](https://github.com/Kuddev/pebrel/issues/196).
- Fixed Windows Codex Hook commands failing during PowerShell parsing, including executable paths with spaces or special characters. Existing Pebrel-managed commands are migrated while preserving user and third-party hooks. Addresses [#254](https://github.com/Kuddev/pebrel/issues/254) and [#255](https://github.com/Kuddev/pebrel/issues/255).
- Bounded Hook event forwarding when input remains open or the receiving application stops reading, so forwarding cannot wait indefinitely and keep the helper executable locked. Already running helpers from older versions do not acquire this timeout.
- Fixed successful same-version repair installations being reported as failures when the main executable remains byte-identical. Before setup starts, the updater briefly waits for installed Hook files to become writable; a persistent lock stops the update before setup can partially replace the installation. Installer success, package verification and the expected application version remain required. Addresses [#258](https://github.com/Kuddev/pebrel/issues/258).
- Fixed dashed link underlines covering only part of Chinese and other wide characters, and restarting their pattern at spaces or font boundaries. Underlines now follow terminal columns and display scaling.
- Fixed quoted Windows, UNC and home-directory paths containing spaces being recognized only up to the first space. Link highlighting and opening use the complete quoted path.
- Fixed WSL shell-prompt links including the trailing `$` or `#`, and absolute prompt paths losing their underline. Opening an absolute WSL prompt path uses the originating distribution; copying keeps the original path text.
- Fixed missing truecolor declarations in fresh Windows and WSL panes. Applications can retain their intended message-background colors across terminal themes, including Codex user-message backgrounds. Animation output remains controlled by the CLI; Pebrel does not add or filter stars according to reasoning level.
- Fixed tab context menus mixing hardcoded Chinese with the selected interface language. Menu labels now follow the active language across all 11 supported locales, including Korean, in both sidebar and top tabs.
#### Improved
- Organized Windows Explorer integration into one normal Pebrel open command and one WSL submenu containing the installed distributions. Installation migrates Pebrel-owned flat entries while preserving unrelated or user-edited commands.
- Expanded issue forms with operating-system, shell, pane-program and connection details, and clearer reproduction and evidence fields to make problem reports easier to investigate.
Windows x64 provides an installer and portable ZIP; Windows ARM64 provides a native portable ZIP. On Windows, the installer and automatic installation remain x64-only; packaged macOS applications support in-app installation. Linux x64 packages and macOS Apple Silicon / Intel DMGs remain Preview releases.
### 中文
#### 新增
- 为 Apple Silicon 和 Intel 上的 macOS 打包应用加入应用内更新:校验通过后,可选择重启安装或下次启动时安装,并恢复已保存的工作区;新应用立即重启失败时回滚到原应用。安装位置必须可写,从磁盘映像或 App Translocation 路径运行的副本需先移动到正常安装位置。
- 在 Shell 选择器、快速跳转和命令面板中显示 SSH 主机名称:已命名的主机显示名称与原始连接目标,未命名的主机保持原来的显示。搜索同时匹配名称和目标,选择后仍连接原始地址。
- 在“设置 → 按键映射 → 标签页”中新增可修改的“重命名标签”快捷键,默认仍为 F2。修改或清除后,F2 可传递给 Codex CLI 等终端程序;设置无需重启即可生效,菜单提示同步显示实际快捷键。
#### 修复
- 修复 GitHub 返回 HTTP 403 或 429 时检查更新失败的问题;改用官方最新版本页面与校验清单恢复查询,无法取得已验证安装包信息时仍可手动下载。
- 修复 macOS 终端链接无法通过 Command+左键打开的问题;终端应用启用鼠标上报后,链接手势仍可使用,普通点击继续传给应用。悬停提示按当前语言显示对应平台的修饰键。对应 [#196](https://github.com/Kuddev/pebrel/issues/196)。
- 修复 Windows Codex Hook 命令在 PowerShell 解析阶段失败的问题,正确处理程序路径中的空格和特殊字符;迁移 Pebrel 管理的旧命令,同时保留用户及第三方 Hook。对应 [#254](https://github.com/Kuddev/pebrel/issues/254)、[#255](https://github.com/Kuddev/pebrel/issues/255)。
- 为 Hook 事件转发增加等待期限,避免输入一直未关闭或接收端停止读取时无限等待并持续占用辅助程序文件。已经运行的旧版本 Hook 不会自动获得此超时机制。
- 修复同版本修复安装成功后,仅因主程序字节未改变而误报失败的问题。启动安装器前,会短暂等待已安装的 Hook 文件恢复可写;持续占用时提前停止,避免安装器只替换部分文件。仍要求安装器成功、安装包校验通过且最终应用版本正确。对应 [#258](https://github.com/Kuddev/pebrel/issues/258)。
- 修复中文及其他宽字符的链接虚线只覆盖半个字符,以及虚线在空格或字体边界处重新起算的问题;下划线按终端列和显示缩放连续绘制。
- 修复带引号的 Windows、UNC 和主目录路径包含空格时,只识别到第一个空格的问题;链接高亮与打开操作使用完整的引号内路径。
- 修复 WSL 提示符路径把末尾 `$`、`#` 包含进链接,以及绝对路径提示符缺少下划线的问题;打开 WSL 绝对提示符路径时使用所属发行版,复制时保留原始路径文本。
- 修复新建 Windows 和 WSL 面板缺少真彩色声明的问题,使应用能在不同终端主题下保留其指定的消息背景色,包括 Codex 用户消息背景。动画仍由 CLI 决定,Pebrel 不根据思考等级添加或过滤星点。
- 修复标签右键菜单在非中文界面中混用硬编码中文的问题;侧栏和顶部标签的菜单均跟随当前界面语言,覆盖包括韩语在内的全部 11 种支持语言。
#### 改进
- 整理 Windows 资源管理器右键菜单,保留一个普通 Pebrel 打开入口,并将已安装的 WSL 发行版集中到一个子菜单中;安装时迁移 Pebrel 自有的旧平铺入口,保留无关或经过用户编辑的命令。
- 完善问题反馈表单,补充操作系统、Shell、面板程序和连接方式等信息,并明确复现步骤与日志、截图要求,便于定位问题。
Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 ZIP;Windows 安装器和自动安装仍仅支持 x64;macOS 打包应用支持应用内安装。Linux x64 包及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。
### Contributors
<a href="https://github.com/ZiChuanLan"><img src="https://github.com/ZiChuanLan.png?size=96" width="64" height="64" alt="@ZiChuanLan avatar"></a><a href="https://github.com/YinBuLiao"><img src="https://github.com/YinBuLiao.png?size=96" width="64" height="64" alt="@YinBuLiao avatar"></a><a href="https://github.com/Kuddev"><img src="https://github.com/Kuddev.png?size=96" width="64" height="64" alt="@Kuddev avatar"></a>
- **[@ZiChuanLan](https://github.com/ZiChuanLan)** — SSH host names in the Shell picker, Quick Jump and command palette. / Shell 选择器、快速跳转及命令面板中的 SSH 主机名称。([#274](https://github.com/Kuddev/pebrel/pull/274))
- **[@YinBuLiao](https://github.com/YinBuLiao)** — Tab-menu localization, macOS Command-click links, rate-limited update recovery and macOS installation. / 标签菜单多语言、macOS Command+点击链接、更新限流恢复及 macOS 安装。([#265](https://github.com/Kuddev/pebrel/pull/265), [#266](https://github.com/Kuddev/pebrel/pull/266), [#269](https://github.com/Kuddev/pebrel/pull/269))
- **[@Kuddev](https://github.com/Kuddev)** — Hook execution and lifetime, repair installation, terminal links and colors, Explorer integration, configurable tab renaming and issue reporting. / Hook 执行与生命周期、修复安装、终端链接和颜色、资源管理器集成、可配置的标签重命名及问题反馈。([#257](https://github.com/Kuddev/pebrel/pull/257), [#260](https://github.com/Kuddev/pebrel/pull/260), [#261](https://github.com/Kuddev/pebrel/pull/261), [#262](https://github.com/Kuddev/pebrel/pull/262), [#263](https://github.com/Kuddev/pebrel/pull/263), [#264](https://github.com/Kuddev/pebrel/pull/264), [#272](https://github.com/Kuddev/pebrel/pull/272))
---
**SHA256**
Checksums will be filled from the final release artifacts. / 校验值将在最终发布产物生成后填写。
- `Pebrel-v1.9.1-linux-x64-preview.AppImage`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-linux-x64-preview.deb`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-macos-arm64-preview.dmg`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-macos-x64-preview.dmg`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-windows-x64.zip`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-windows-x64-setup.exe`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-windows-arm64.zip`: `PENDING FINAL BUILD`
## 1.9.0 - 2026-09-22
### English
+20 -6
View File
@@ -74,6 +74,8 @@ raise any existing tracked private artifact for an explicit maintainer decision.
`architecture/notes/`; ordinary fixes do not create a note.
2. Keep one conceptual change per PR. A necessary extraction and its behavior
tests may accompany the feature; unrelated rewrites and formatting may not.
The `pr-size` check fails above 1500 changed source lines (docs, lockfiles
and assets excluded); split instead of asking for an exemption.
3. Put shared rules in their existing authority. UI modules adapt those rules;
they must not fork persistence, state transitions or domain behavior.
4. Add regression tests that fail for the defect, and test error/cancellation
@@ -105,9 +107,11 @@ cargo check -p nebula --bin pebrel --features gpui-shell --tests --locked
`Full native tests` runs on every PR and merge-group update, and on `main` pushes.
It tests the full workspace on Linux, Windows x64 / ARM64 and both macOS
architectures. New commits cancel obsolete PR runs. Package validation has its own
input filters and can also be dispatched manually; a package job does not replace
the native test suite. These triggers do not configure required status checks.
architectures. New commits cancel obsolete PR runs. Pull requests run tests and
compile checks without building distribution packages. Package validation runs
after matching changes reach `main`, or by explicit manual dispatch; a package
job does not replace the native test suite. These triggers do not configure
required status checks.
GitHub may show **Waiting for approval** for a first-time fork contributor. A
maintainer must inspect the submitted changes and approve that workflow run from
@@ -122,9 +126,17 @@ compile check into a claim that UI tests or a packaged application were run.
## Review and enforcement
`architecture-contracts` is the stable PR job name. Maintainers must enable it as
a required check and require Code Owner approval in the target branch ruleset;
see the [activation checklist](docs/project-constraints.md#server-side-activation).
`architecture-contracts`, `lint`, `pr-size`, the five
`Tests (<os>)` jobs and both `Release workspace (<os>)` jobs are required checks
on `main`, together with Code Owner approval; see the
[activation checklist](docs/project-constraints.md#server-side-activation).
Open pull requests as drafts while iterating: drafts run Linux, Windows x64 and
Apple Silicon tests plus the Apple Silicon release-profile compile check. The
required lint job validates the event and selects the matrix before requesting
platform runners. Drafts omit Intel Mac and Windows ARM64 jobs entirely, so their
required contexts may be missing until the PR is marked ready. Ready PRs run the
full matrix; every required check must succeed before merging. The ready event
still reruns core validation rather than reusing an earlier check conclusion.
Local hooks are convenient, but bypassable; they are not the enforcement boundary.
Submitting a workflow or `CODEOWNERS` file does not configure server-side rules.
@@ -137,6 +149,8 @@ feature work; there is no routine `--skip-architecture` option.
## 中文摘要
- 先读架构图、工程合同和决策记录;按职责拆分,不按行号切片。
- 一个 PR 只做一件事;改动超过 1500 行源码(不计文档、lockfile、资源)`pr-size` 会失败,请拆分。
- Draft PR 先运行必需的格式检查和矩阵规划,再创建 Linux / Windows x64 / Apple Silicon 测试及 Apple Silicon release 编译检查;不创建 Intel Mac / Windows ARM64 任务。Ready 后运行完整矩阵,十项必需检查全绿才能合并。
- 2000 行是现有仓库的防灾上限,800 行只提示审查,不是“大厂标准”。
- 普通功能 PR 不得增加存量债务;有问题的规则可以修订,但要有反例、测试和维护者审批。
- 新增核心抽象、依赖方向、持久化或线程模型改变要先说明设计,不强迫每个小修复写 ADR。
Generated
+7 -7
View File
@@ -4984,7 +4984,7 @@ dependencies = [
[[package]]
name = "nebula"
version = "1.9.0"
version = "1.9.1"
dependencies = [
"ab_glyph_rasterizer",
"aes-gcm",
@@ -5069,7 +5069,7 @@ dependencies = [
[[package]]
name = "nebula-completions"
version = "1.9.0"
version = "1.9.1"
dependencies = [
"lscolors",
"nu-ansi-term",
@@ -5090,15 +5090,15 @@ dependencies = [
[[package]]
name = "nebula-settings"
version = "1.9.0"
version = "1.9.1"
[[package]]
name = "nebula-split"
version = "1.9.0"
version = "1.9.1"
[[package]]
name = "nebula_config"
version = "1.9.0"
version = "1.9.1"
dependencies = [
"log",
"nebula_config_derive",
@@ -5108,7 +5108,7 @@ dependencies = [
[[package]]
name = "nebula_config_derive"
version = "1.9.0"
version = "1.9.1"
dependencies = [
"log",
"nebula_config",
@@ -5125,7 +5125,7 @@ version = "0.6.0"
[[package]]
name = "nebula_terminal"
version = "1.9.0"
version = "1.9.1"
dependencies = [
"base64",
"bitflags 2.13.0",
@@ -0,0 +1,68 @@
# Configurable tab rename uses the shared keymap
## Status
Implemented for review.
## Context
Tab rename registered F2 directly in the GPUI workspace. The editable keymap did
not contain a rename action, so users could not release that key for a terminal
application. Adding a settings row alone would leave the original F2 binding active.
## Evidence
The workspace previously registered `f2` with `RenameActiveTab`, independently
of the shared defaults and settings action list. The keymap editor previously
added a replacement chord without disabling the action's original default.
## Decision
Add the shared `RenameTab` action with default F2 and expose its localized label
in the Tabs keymap group. Both shells use the existing `keybind=combo:action`
storage format, clear/reset operations and effective-binding calculation.
Editing an action now uses one shared rebinding operation. It releases the
action's model-defined defaults and previous custom chords through `ReceiveChar`,
then assigns the replacement. Other explicit key owners are preserved. Raw
handwritten keybind entries retain their existing ordering and override semantics.
The GPUI default comes from the shared table and adapts to the existing rename
action. Runtime overrides are registered at workspace and terminal scope. Menu
shortcut hints follow the effective binding instead of a hardcoded F2 label.
## Rejected alternatives
- Add only an editable row: changes the display while F2 still intercepts input.
- Detect a particular CLI and change its keys: terminal input ownership belongs
to the user's keymap and the application's negotiated keyboard protocol.
- Add another persistent rename-shortcut setting: duplicates the existing keymap.
- Remove the default for everyone: changes existing behavior without a user choice.
## Consequences
The default remains F2. Users can move it, clear it, or explicitly restore it;
changes apply without restarting the workspace. Keymap row edits move a binding
rather than leaving the model's previous chords as hidden aliases. Moving or
clearing the rename binding allows F2 to reach terminal applications.
## Validation
Model tests cover persistence round trips, default/custom binding release,
clear/reset and preservation of another action's explicit ownership. GPUI tests
exercise actual event dispatch in both tab layouts, menu binding lookup, the
rendered settings row and capture cancellation. A terminal-context probe verifies
F2 delivery with VT, Win32 and enhanced keyboard modes after remapping.
The explicit legacy-shell build and independent translation contracts are also
checked. Virtual-window tests do not claim an authenticated CLI session or full
desktop visual acceptance.
## Supersedes
The workspace-only F2 default and UI row edits that retained old default chords.
## Revisit when
The keymap gains explicit multiple-shortcut editing or a unified ownership model
for additional platform-specific aliases.
@@ -0,0 +1,64 @@
# Color capabilities belong to the local child environment
## Status
Implemented; pending review.
## Context
Windows users report that Codex lacks its sparse composer stars and user-message
backgrounds in Pebrel while they appear in the reference terminal. The same distinction
must work across themes without identifying message roles from screen text.
## Evidence
GPUI `local_options` refreshes a complete Windows environment before `spawn`
calls `tty::setup_env()`. The environment cache preserves its earlier snapshot;
changing the parent environment afterward cannot insert `COLORTERM` into the
already complete child environment. An Explorer launch need not have terminal
capability variables inherited from another terminal.
Codex's `rust-v0.154.0` `tui/src/terminal_palette.rs` returns the default color
for ANSI-16/unknown color levels. `style.rs` uses that result for user-message
backgrounds. `bottom_pane/chat_composer/sparkle.rs` requires truecolor and queried
foreground/background colors. This explains a shared failure path; settings,
model choice and Codex version still control whether its animation runs.
## Decision
After refreshing a local Windows environment, fill missing `TERM` with
`xterm-256color` and missing `COLORTERM` with `truecolor`. Preserve explicit
case-insensitive overrides and `NO_COLOR`/`FORCE_COLOR`. Forward `COLORTERM` through the existing WSL environment allowlist, preserving
explicit forwarding flags. Keep the actual Pebrel terminal identity. Existing OSC color-query and rendering paths remain authoritative.
## Rejected alternatives
- Pretending to be the reference terminal through `WT_SESSION` misstates identity.
- Recoloring arbitrary terminal rows as user messages would invent application
semantics and damage ordinary command output.
- Hardcoding a Nord-only fix leaves other themes and applications affected.
## Consequences
New local Windows panes advertise implemented capabilities independently of their
launching parent. Already running shells retain their inherited environment.
Providers can still intentionally suppress colors or animations. The two-second
Hook lifetime fix is unrelated to this environment declaration.
## Validation
Eight platform-environment tests pass, including an actual child launched with
an explicitly complete environment. VT replay preserves message surfaces and
Braille stars for all fifteen built-in themes, plus all 225 theme-switch pairs.
A native Debian control returned no `COLORTERM` without forwarding and
`truecolor` with it. These are capability/rendering tests, not a full live Codex
animation acceptance.
## Supersedes
None.
## Revisit when
Terminal capability defaults move into a shared per-child environment builder,
or the Windows environment refresh no longer creates a complete snapshot.
@@ -0,0 +1,73 @@
# Public release discovery when GitHub REST access is limited
## Status
Proposed for review; implemented and verified on macOS arm64.
## Context
A macOS user received HTTP 403 when checking for updates. Startup and manual
checks use the same unauthenticated GitHub REST endpoint, so shared egress IP
quota exhaustion can disable both even when the release website is reachable.
## Evidence
On 2026-09-23, the public REST endpoint returned HTTP 403 with `API rate limit
exceeded`. The official `/releases/latest` website returned HTTP 200 after
redirecting to `/Kuddev/pebrel/releases/tag/v1.9.0`. No account credentials were
needed for the website request. The official macOS 1.9.0 DMG contains a signed
`Pebrel.app` with bundle identifier `io.github.kuddev.pebrel` and version 1.9.0.
## Decision
Only REST HTTP 403/429 invokes a second, bounded request to the official latest
release page. Resolve its proxy independently for github.com. The HTTP client's
final URI must name this repository, use HTTPS, and contain an exact numeric
major.minor.patch tag (optional v/V prefix). Do not parse website markup.
After version discovery, fetch the official `SHA256SUMS` release asset with a
separate ten-second deadline and a 64 KiB limit. Only one valid checksum for an
exact native package name grants download metadata; absence or failure retains
version discovery with manual download. Package names are shared with download
validation. Normal API responses retain digest/release-body checksum handling.
Both automatic and manual checks use this path. Local update rehearsals never
fall back to the public network.
## Rejected alternatives
- Embedding a GitHub token: introduces credentials and distribution risk for a
public read-only operation.
- Treating an API failure as up to date: hides real failures and misses upgrades.
- Guessing package URLs or checksums from a tag: discovery does not authorize an
installer. Scraping HTML would add a fragile metadata parser.
## Consequences
A limited API may add two ten-second requests; the website lookup permits at
most five redirects. If version discovery fails, manual checking reports an
error. Without a valid manifest entry, a newer fallback version offers manual
download only. No unverified package can be auto-executed. Unsupported future
tag formats fail explicitly rather than producing a guessed version.
## Validation
Update-check and proxy regressions passed on macOS. Coverage
includes 403/429, ordinary API success, malformed responses, fallback failure,
redirect transport and disallowed hosts/tags. An explicit live test successfully
queried the production checker and the public fallback, both returning 1.9.0.
Exact architecture/name preference and checksum manifest rejection tests passed.
Independent i18n contracts passed, including zero-allocation lookup. A native
macOS test window completed the Settings check from checking to up to date
(GitHub v1.9.0), without the previous HTTP 403. The separate
[macOS installation decision](../update_download/2026-09-23-macos-bundle-handoff.md)
records installation evidence. Windows/Linux runtime acceptance is not claimed.
## Supersedes
None.
## Revisit when
GitHub changes the public redirect contract, release tags gain another stable
format, or a first-party metadata endpoint supplies authenticated package hashes
without requiring client credentials.
@@ -0,0 +1,115 @@
# Native macOS application replacement
## Status
Proposed for review; implemented with native macOS arm64 acceptance.
## Context
macOS could discover releases but had no in-app installer adapter. The requested
update flow must preserve the running application until the downloaded replacement
is verified and all windows have authorized shutdown. API rate-limit recovery and
installation are delivered together because discovery alone left manual upgrades.
## Evidence
The official v1.9.0 ARM64 DMG was downloaded and mounted read-only on 2026-09-23.
Its legacy `-preview.dmg` asset name contains the formal `Pebrel.app`, identifier
`io.github.kuddev.pebrel`, bundle version 1.9.0 and executable version 1.9.0;
`codesign --verify --deep --strict` passed. Current packaging also emits the
preferred `macos-arm64.dmg` / `macos-x64.dmg` names without the legacy suffix.
## Decision
`update_check::assets` owns exact native package selection. `update_download`
continues to own streaming, size limits, SHA-256 validation and cache publication;
a DMG additionally needs a UDIF trailer. This uses existing dependencies.
Reuse the existing schema-1 prepare/commit/result/workspace transaction. The
macOS coordinator remains in `update_download::handoff::macos`; the platform
adapter owns plist, signature, process identity, mounting and atomic filesystem
operations. No UI module owns installation persistence or invokes shell commands.
Copy the current executable into its private transaction directory and enter a
non-GUI helper mode before normal startup. Validate every plan's configuration,
installation, package, version and participant identity. A lifetime lock beside
the app survives replacement and is shared across configuration overrides. Hash
the bundle filename for an extension-free lock base; the existing lock API remains
the authority for its final filename. PID plus native creation time avoids PID
reuse. Refuse updates while another process executes the same installed binary.
Mount the verified DMG read-only in a private transaction directory. Copy its
single top-level app beside the installed app, then validate bundle ID, plist and
executable versions, native architecture and strict code signature. A Developer
ID signed installation also requires the same signing team. Existing ad-hoc
packages remain supported. Unmount before reporting readiness.
Only commit authorizes installation. The application saves its workspace and
exits through the existing shutdown flow. The helper waits for that exact process
to stop, rechecks that the old executable has not changed and that no other copy
started, then atomically swaps same-volume bundle directories with `RENAME_SWAP`.
The complete old app remains at the unique hidden staging path as a backup.
Persist the result before launching the new app with the existing restore ticket.
If launch fails or exits unsuccessfully during the immediate two-second check,
reacquire the installation lock, ensure no copy is running, exchange back and
restart the original with a recovery ticket. Later crashes are not classified by
this bounded launch check; the retained backup and durable workspace remain.
Cancelled or rejected transactions keep the original application running. Helpers
have bounded preparation, commit and exit waits and never kill the user's process.
The platform adapter also owns helper materialization and launch selection. The
shared transaction requests a helper through one platform-independent entry,
without adding operating-system branches to prepare/commit orchestration.
Native command output is spooled to temporary files while enforcing the existing
process deadline, then read with a 2 MiB limit per stream. Polling a child before
reading piped output can otherwise block both processes once a pipe fills. A
native regression produces 256 KiB on each stream and checks command failure.
## Rejected alternatives
- Replacing a running bundle: risks mixed resources and incomplete session saves.
- Removing the original before copying: leaves no working app after interruption.
- Shell interpolation / privileged installer: unnecessary for a writable app
directory; paths are separate arguments and no elevation is requested.
- Installing an unverified package when metadata is unavailable: retain the
existing manual download path instead.
- A second session format: reuse the shared update restore/acknowledgement flow.
## Consequences
Only packaged official-identity apps in writable locations support installation.
Developer binaries, read-only disk-image launches and App Translocation return an
actionable preparation error while keeping the app open. Preparation is background
work; scheduled installation uses the existing pre-window startup path. The old
bundle is retained until manually removed; no backup-retention policy is added.
Windows retains its existing installer helper. Linux retains manual installation.
No release assets, version tags, signing settings or distribution channels change.
## Validation
Native tests create disposable ad-hoc-signed Mach-O apps and real HFS+ DMGs. They
exercise the actual copied helper in successful replacement/relaunch, immediate
launch failure and rollback, cancellation, SHA-256 rejection and a second running
instance. Tests also cover atomic swap rollback, process identity, developer-path
rejection, native asset selection, DMG trailer validation and shared restore ticket
success/recovery/acknowledgement/crash-loop limits. All passed on macOS arm64. A separate packaged debug build with the existing
loopback-only update-test-source feature also completed the native UI flow:
verified DMG download, Restart and install, replacement, reopened terminal window,
and persisted `success: true` plus `restored.json`. This rehearsal used isolated
settings and same-version reinstall, which normal builds do not permit.
The isolated i18n contract passed, including zero-allocation lookup. Architecture
validation against the actual PR base passed without budget changes. Intel macOS,
Windows and Linux execution, Developer ID team migration and notarization remain
outside local runtime acceptance and need their platform CI/release validation.
## Supersedes
None. Extends the existing shared update handoff to a native macOS participant.
## Revisit when
Distribution changes to a notarized update feed, privileged installation becomes
a supported requirement, or backup retention / longer startup health acknowledgement
is given an explicit product policy.
@@ -0,0 +1,63 @@
# Bound the lifetime of one-shot Hook forwarding
## Status
Implemented; pending review.
## Context
[Issue #258](https://github.com/Kuddev/pebrel/issues/258) reports an update that
first failed because `pebrel-hook.exe` remained in use after the application
closed, followed by a successful same-version repair incorrectly marked failed.
The helper must finish even when its caller or notification receiver stalls.
## Evidence
`nebula_hook/src/main.rs` drained stdin through `read_to_end` and wrote the
notification through synchronous `write_all`. Neither had a deadline. A caller
retaining stdin can prevent EOF; a receiver that stops reading can block a write.
Both conditions are reproduced by actual child processes in
[`lifetime.rs`](../../../nebula_hook/tests/lifetime.rs).
## Decision
Run the existing forwarding operation on one process-owned worker. The main
thread waits up to two seconds, then returns normally. Returning from this
one-shot process releases its worker and OS handles without leaving a resident
thread. This bounds stdin, oversize-input draining and notification I/O together.
No new dependency, background application service or platform thread is added.
The user's chained notifier is launched independently after forwarding/waiting;
it is neither joined nor terminated. Cursor's submission-allow response remains
on the main path, including timeout. All provider-visible exits remain successful.
## Rejected alternatives
- Timing only stdin leaves a blocked pipe write alive.
- Joining the worker after timeout defeats the bound.
- Terminating every process named `pebrel-hook.exe` can affect another installation
or invocation. This change retires only the helper's own process.
- Skipping stdin entirely breaks providers which stream their payload into it.
## Consequences
A stalled or unusually slow invocation can lose its best-effort notification.
The forwarding wait is bounded; this is not a universal process startup latency
guarantee. Already running binaries from an older installation do not acquire
the new deadline and may still require the user to close the owning CLI/restart.
## Validation
`cargo test --locked -p nebula_hook`: seven unit tests and five actual-process
tests passed on Windows, covering retained stdin, a connected non-reading named
pipe, normal completion, Cursor continuation and the original chained notifier.
Non-Windows integration paths still require their native CI run.
## Supersedes
None.
## Revisit when
Providers require a longer documented delivery window or the transport becomes
cancellable without a per-invocation worker.
@@ -0,0 +1,67 @@
# Keep automatic package builds out of pull-request checks
## Status
Accepted by the maintainer on 2026-09-23: pull requests run tests and compile
checks; distribution packages are built after merge or on explicit request.
## Context
Preview packaging subscribed to pull requests that changed package inputs.
A Windows installer-menu change therefore started Linux and both macOS package
builds alongside the independent native test matrix. These workflows requested
the same macOS runner types while required checks were waiting for runners.
## Evidence
- [PR #264](https://github.com/Kuddev/pebrel/pull/264) changed Windows installer
scripts and triggered [Preview run 35825643107](https://github.com/Kuddev/pebrel/actions/runs/35825643107).
- The Preview macOS jobs ran workspace tests, dialog tests and an optimized
product build before creating DMGs. The native workflow independently ran
workspace tests and checked the production feature graph.
- The required `Release workspace` jobs execute `cargo check --release`; they
do not generate distribution packages and remain required compile checks.
- Runner waiting is observable. The exact hosted-runner quota or scheduling
policy responsible for a particular delay is not established by these logs.
## Decision
Remove the `pull_request` trigger from `preview-packages.yml`. Keep its filtered
`main` push trigger and explicit `workflow_dispatch`, including all package and
runtime conformance validation. Public Preview publication still requires an
explicit dispatch with publication enabled. Stable release triggers are retained.
The native PR and merge-group workflows retain their test coverage. This change
does not alter required-check names or server-side protection rules.
## Rejected alternatives
- Cancel required macOS checks to merge sooner: loses required validation.
- Treat the release-profile compile checks as package creation: these commands
validate a different build profile without creating installers or archives.
- Remove package conformance tests: package behavior still needs verification
when a package is actually requested.
## Consequences
Pull requests no longer automatically provide Preview installation artifacts.
Maintainers can explicitly dispatch package validation for a candidate branch.
Already queued workflows must be handled separately; changing a trigger cannot
retroactively cancel them. This change does not remove duplicate native runs
when a draft becomes ready, or guarantee a fixed runner wait time.
## Validation
The native-suite workflow contract verifies that Preview has only filtered main
push and manual triggers, that stable release remains tag/manual, and that native
PR and merge-group checks remain unfiltered. Restoring the previous PR package
trigger must fail the new regression.
## Supersedes
The previous automatic Preview build policy for pull-request package-input changes.
## Revisit when
An explicit request for per-PR artifacts can be implemented with bounded platform
selection and a separate runner budget without delaying mandatory validation.
@@ -0,0 +1,60 @@
# Same-version repair is not identified by a changed main binary
## Status
Implemented; pending review.
## Context
[Issue #258](https://github.com/Kuddev/pebrel/issues/258) supplies an Inno log
reporting successful installation and the expected Pebrel version, while the
update helper reports that the same-version binary did not change.
## Evidence
The reported preceding update replaced the main executable but failed with a
locked Hook helper. Reinstalling the same package repairs other files while
leaving the main executable byte-identical. Its hash cannot distinguish that
successful repair from an installation which did no useful work.
## Decision
Retain package SHA-256/size checks, commit authority, participant identity and
exit waiting, installer exit-code checks, and the installed version probe.
Remove the additional requirement that a same-version main binary change hash.
Record the installed hash as evidence, not as a repair-success predicate.
Before starting setup, retry exclusive write access to installed Hook helper files
for five seconds. Short-lived forwarding can finish; persistent locks fail before
any installation file is replaced. The helper never kills unrelated processes.
## Rejected alternatives
- Suppressing every installer error would hide partial/failed installations.
- Forcing a main-binary byte difference does not validate the repaired files.
- Disabling same-version retries prevents recovery after a partial upgrade.
## Consequences
Installer success and the expected application version determine success for a
verified package. The updater does not independently audit every installed file;
installer errors continue to fail the transaction. No installed version tag or
asset naming rule changes.
## Validation
The native handoff suite passes twelve scenarios, including a byte-identical main
binary with a repaired helper. The former same-version/no-change assertion was
incorrect; its negative intent is retained as `upgrade-noop`, which returns
installer success but leaves the wrong application version and must still fail.
Cancellation, checksum/identity failures and unprepared processes remain covered.
A released helper-file lock permits installation; a held lock must prevent setup
from starting and recover the unchanged original application.
## Supersedes
The same-version changed-hash assertion in the handoff helper and its old fixture.
## Revisit when
A versioned installed-file manifest provides a stronger package-completeness
check without confusing unchanged files with failed installation.
@@ -0,0 +1,59 @@
# Group WSL launch entries under one Explorer submenu
## Status
Implemented; pending review.
## Context
One top-level context item per WSL distribution crowds Explorer menus. The user
requested one ordinary Pebrel entry and one expandable entry for distribution
choices, for both selected directories and directory backgrounds.
## Evidence
The previous installer registered `PebrelWsl<index>` directly under each Explorer
shell root. Those entries remain registered after an upgrade unless explicitly
migrated. WSL distribution enumeration already excludes Docker plumbing entries.
## Decision
Keep the ordinary entry. Register a static `PebrelWslMenu` cascade with an empty
`SubCommands` value and its distribution verbs under `shell`. Record its executable
owner and validate child commands before replacing or removing the subtree.
Migrate only flat entries whose command invokes the exact owned executable.
Preserve `%1` for selected directories, `%V` for backgrounds, and the existing
`--shell` before `--working-directory` order. No distributions means no empty
submenu. Conflicting/edited subtrees and other installations remain untouched.
## Rejected alternatives
- Removing distribution choices reduces existing launch functionality.
- Deleting every key with the product prefix can remove another installation or
user customization.
- Installing a new Explorer extension adds deployment and lifetime requirements
unnecessary for this static cascade.
## Consequences
Registration still reflects distributions present at installation time. Existing
menus migrate when the new installer runs; changing this source does not modify
the user's live registry. Conflicts fail visibly rather than overwrite unknown keys.
## Validation
The real Inno fixture uses an isolated HKCU test subtree and passes 117 migration
checks in total. New coverage includes both roots, zero/one/three distributions,
Unicode/space-containing names, repeat registration, stale-entry removal, edited
subtrees and foreign ownership. The complete installer compiles without executing
an installation. Explorer presentation/click acceptance remains separate.
## Supersedes
The installer's flat WSL verb layout; the ordinary Pebrel verb is retained.
## Revisit when
Distribution registration must update outside installation, or Explorer requires
a different supported menu registration mechanism.
@@ -0,0 +1,86 @@
# Plan the native matrix before requesting runners
## Status
Implemented for review. Preserves the existing ten required check names and
ready-PR platform coverage; server-side rules are not changed.
## Context
The initial draft tier skipped individual steps on Intel Mac and Windows ARM64
jobs. GitHub still assigned runners before skipping those steps. In PR #257's
draft run 35820869856, the Intel test job waited 309 seconds before spending
eight seconds on a job whose test steps were skipped.
## Evidence
`runs-on` belongs to a job, while the old draft conditions belonged to its steps.
Avoiding the platform command does not avoid allocating that job's runner.
The required `lint` check already runs on Ubuntu and can validate both formatting
and the platform plan before expensive jobs are created.
## Decision
`scripts/ci_plan.py` is the authority for platform selection. It reads the event
JSON and produces native-test and release-compile matrices. Draft PRs select the
three existing core platforms and the Apple Silicon release check. Ready PRs,
main pushes, merge groups and release callers retain all five native platforms
and both macOS release checks.
The existing required `lint` job owns planning, after rustfmt. Native and release
jobs depend on that job and consume its validated outputs. Invalid input fails
the required check rather than silently returning an empty matrix. Checkouts
explicitly discard Git credentials; the workflow retains read-only permissions.
Draft-only deferred checks are absent, not reported as successful executions.
A ready PR must still supply every required platform result. This change does
not copy previous runs' check conclusions. Cargo caches accelerate rebuilding;
they are not evidence that the current validation succeeded.
## Rejected alternatives
- Keep step-only skipping: retains the unnecessary runner allocation.
- Move planning into an unrequired job: its failure could be less visible than
a failure in an existing required check, especially on repeated runs.
- Remove release-profile checks with Preview packaging: `cargo check --release`
verifies compilation and does not create installation packages.
- Mirror an earlier green check onto a new run: requires independently verified
source, base, workflow, configuration and provenance rules; cache hits alone
cannot establish those facts.
## Consequences
Heavy jobs begin after the Ubuntu lint/planning job. Formatting errors and bad
event metadata stop before requesting platform runners. Draft PR pages may show
deferred required contexts as missing; drafts are not mergeable, and the ready
event runs the full matrix. Ready transitions still rerun core validation.
This reduces avoidable runner requests without promising a fixed queue time.
The existing Windows diagnostic dispatch continues to use its separate workflow.
## Validation
The required `lint` job now runs the lightweight planner/native-workflow/
stable-release and cache contract tests after `cargo fmt --all -- --check` and before
`ci_plan.py` writes either matrix. The count is 7 planner tests, 10 native
workflow tests, 17 stable-release tests and 8 cache contract tests. A final local run of the exact
command completed 42 tests (41 passed, one Windows-only case skipped) in 3.341 seconds on 2026-09-23; this is a local
measurement, not a general speed guarantee. The workflow contract test checks
that this step precedes planning and that both dynamic matrices depend on the
required lint job.
Planner tests cover draft and ready PRs, all full-validation caller events,
malformed input and real command-line output. Workflow contract tests require the
existing lint check to own planning, both matrices to depend on it, draft step
guards to be absent, and release compilation to remain present. Native PR and
merge-group coverage and explicit failure propagation retain their regressions.
Actual hosted-runner execution remains subject to the PR's required CI results.
## Supersedes
Step-level draft skipping in PR #257's initial native matrix.
## Revisit when
Queue and execution measurements justify a different platform schedule, or a
separately reviewed artifact-provenance design supports build-once test shards.
@@ -0,0 +1,49 @@
# Prohibited-name range checks
## Status
Implemented for review. The existing local hook modes remain unchanged; the public CI entry point is `range --base BASE --head HEAD`.
## Context
The repository already used an ignored `scripts/check_prohibited_names.py` in staged, commit-message, and pre-push hooks. The policy must also run in the existing `architecture-contracts` check for pull requests, merge groups, and pushes to `main`, without rescanning old release history or making legitimate attribution and protocol compatibility impossible.
## Evidence
- `.github/workflows/architecture.yml` already runs on `pull_request`, `merge_group`, and `push` to `main`, with a full checkout.
- The local checker already owns the prohibited-name list and hook behavior; duplicating the list in workflow YAML would create a second policy source.
- Inline copyright/third-party attribution can contain a project name, while Cargo git/source references and protocol/external-format identifiers include key-reporting constants, multiplexer escape sequences, and theme-format variants.
- macOS run 35950152526 rejected a malformed UTF-8 filename before the checker
could run. The fixture now writes a Git tree directly, preserving the same
invalid-path regression without depending on filesystem filename support.
## Decision
- Publish the existing checker as the single policy implementation and add a fail-closed range mode.
- Resolve both revisions, compute their `merge-base`, scan ordinary commits against their first parent and merge commits with a combined diff, and scan every commit message in the resulting range. Missing, zero, unreadable, or unrelated revisions return failure. Invalid UTF-8 in paths, patches or messages fails explicitly rather than substituting another path or text.
- Keep only the checker, its contract tests and the intentional negative comparison fixture as exact path exceptions. Strip only reviewed inline attribution, Cargo git/source references, and protocol compatibility identifiers before applying the existing patterns.
- Run the checker as a step inside `architecture-contracts` and run its unit tests with the other guardrail tests. No new required check or server-side ruleset change is introduced.
## Rejected alternatives
- Scanning the whole repository would fail on historical, already-reviewed attribution and compatibility names.
- Using only `git diff base..head` would miss a prohibited name added and removed by separate commits; range mode reuses the hook's per-commit added-line scan. Comparing every merge to its first parent would replay second-parent main changes, so merge commits use combined diff instead.
- Treating an unknown base as an empty range would make an unavailable GitHub event input look green; the workflow derives only a known first-push parent and otherwise fails. A normal PR that has diverged from main uses its merge-base.
- Adding a broad `docs/`, `src/`, or product-directory exemption would hide ordinary competitor comparisons, so exemptions remain exact and line-scoped.
## Consequences
New PR/merge-group/main-push text and commit subjects are checked by the existing required job. A legitimate inline attribution, real dependency source, or supported protocol identifier remains usable. Adding another legal/protocol/dependency spelling requires a narrow fixture and policy update rather than silently bypassing the scan.
## Validation
`scripts/tests/test_prohibited_names.py` covers ordinary comparison failure, inline attribution positive/negative paths, protocol identifiers, Cargo dependency references, GitHub CLI text, range text, range commit messages, diverged related bases, unrelated/missing bases, old-history exclusion, added-then-deleted text, and merge-resolution additions. The checker also covers malformed UTF-8 paths/text and normal Unicode/control-character paths; the malformed-path fixture constructs Git tree objects without checking them out, so it also runs on filesystems that reject those bytes.
## Supersedes
None.
## Revisit when
GitHub changes the event payload fields used for base/head selection, the repository adopts a different merge queue event, or a new legal/protocol/dependency contract requires a narrowly documented exception.
@@ -0,0 +1,58 @@
# Keep the size decision independent of optional PR writes
## Status
Implemented for review.
## Context
Fork pull requests have a read-only token. The required size check counted files,
then tried to add a size label before reporting its decision. An ordinary small
contribution therefore failed with HTTP 403 instead of reporting its actual size.
## Evidence
PR 274 at `98fad00b01da9dd089581e4d4f66a251bfc1404d` changes 127 Rust source lines.
Run 35893377849 had PullRequests-read permission and failed at POST issue labels
while trying to add the ordinary `size/M` classification. Its native tests passed.
## Decision
Compute and publish the size result before optional writes. The existing 1500-line
limit, ignored documentation/assets and maintainer exemption semantics remain the
same. An oversized fork fails immediately. Forks report their result in the job
summary and do not attempt labels or comments with a read-only token. Same-repository
pull requests retain their existing labels and oversized-change guidance.
Tests execute the actual inline workflow script with fake APIs, so there is one
implementation of the counting and fork boundary. Node is already available on
the supported hosted images; the tests require it and use no npm dependencies.
## Rejected alternatives
Broadening fork token access or executing fork code in a privileged PR event is
unnecessary. Suppressing the entire size check on forks would waive its invariant.
A separate privileged labeling service adds another trust boundary and is not
needed to validate source size.
## Consequences
Fork contributors receive a genuine size-check result without write access.
Automatic size labels remain available for same-repository contributions;
maintainers may apply the ordinary size label to a fork. Such a label is not a
budget exemption, and the source count is still recomputed by the required check.
## Validation
The workflow-script tests cover a small fork, oversized fork, exact limit,
existing maintainer exemption, deleted-fork metadata and same-repository guidance.
No test calls the real GitHub write APIs. Actionlint validates the workflow.
## Supersedes
None.
## Revisit when
A metadata-only labeling service is justified, or the source-size policy changes
through a separately reviewed rule update.
@@ -0,0 +1,62 @@
# Check the platform budget before allocating native runners
## Status
Implemented for review.
## Context
The platform-condition budget ran only during packaging. A Windows-only WSL
regression introduced one additional test-level condition, leaving the source
count at 485 against a budget of 484. All ten PR checks passed because none ran
that budget checker. Preview preparation then failed before any package build.
## Evidence
- Preview run 35882296442 failed in `Check platform cfg budget` on main commit
`1d26c2dc9860a6cfb61c14b630c12d36dbf95e43`.
- The added condition guarded the WSL truecolor regression, not a new production
platform branch. Five related WSL forwarding tests repeated the same Windows
compilation condition.
- Sharing that condition at the WSL test-module boundary preserves all five test
bodies and lowers the repository count to 481 without moving source outside
the scanner's declared roots or adding an exclusion.
## Decision
- Keep the WSL forwarding regressions together under one Windows test module in
their existing owning source file. Other identity and PATH tests retain their
existing boundaries. Production environment behavior is unchanged.
- Tighten the existing budget from 484 to the measured count of 481.
- Execute the same authoritative platform checker in required lint before matrix
planning. Keep the packaging check as an independent release precondition.
- Run the checker's contract tests with the existing lightweight CI contracts.
## Rejected alternatives
Raising the budget, deleting the new regression, excluding test files from the
scanner, or ignoring the packaging failure would hide the mismatch. None is
needed. This change also does not turn the historical text-count heuristic into
a claim that architectural coupling has been proven absent.
## Consequences
The five WSL tests share one platform boundary and remain Windows-only. Budget
failures are visible before native runner allocation. Legitimate future scanner
policy changes still need their own reproducer, tests and review.
## Validation
The platform checker reports 481 against 481. Validation also compares the test
function inventory, runs the Windows identity/WSL regression group, and checks
that lint executes the gate before planning without automatic budget updates or
failure suppression.
## Supersedes
None.
## Revisit when
The text-based platform counter is replaced with a reviewed semantic contract,
or native runner planning is moved to another shared preflight owner.
+80
View File
@@ -0,0 +1,80 @@
# Pebrel 1.9.1
## English
### Added
- Added in-app updates for packaged macOS applications on Apple Silicon and Intel. Verified updates can install after restart or on the next launch, then restore the saved workspace. Immediate relaunch failure rolls back to the original application. Installation requires a writable application location; copies running from a disk image or App Translocation must first be moved.
- Added SSH display names to the Shell picker, Quick Jump and command palette. Named hosts show their configured name alongside the original connection target; unnamed hosts keep the previous display. Search matches both the name and target, and selecting a row still connects to the original destination.
- Added a configurable tab-rename shortcut in Settings → Key Bindings → Tabs. The default remains F2; changing or clearing the shortcut lets F2 reach terminal applications such as Codex CLI. Changes apply without restarting, and menu shortcut hints follow the effective binding.
### Fixed
- Fixed update checks failing when GitHub returns HTTP 403 or 429. The updater falls back to the official latest-release page and checksum manifest; if verified package metadata is unavailable, manual download remains available.
- Fixed macOS terminal links not opening with Command+left click. Link gestures also work when an application enables mouse reporting, while ordinary clicks still reach the application. Hover hints show the correct platform modifier in the selected language. Addresses [#196](https://github.com/Kuddev/pebrel/issues/196).
- Fixed Windows Codex Hook commands failing during PowerShell parsing, including executable paths with spaces or special characters. Existing Pebrel-managed commands are migrated while preserving user and third-party hooks. Addresses [#254](https://github.com/Kuddev/pebrel/issues/254) and [#255](https://github.com/Kuddev/pebrel/issues/255).
- Bounded Hook event forwarding when input remains open or the receiving application stops reading, so forwarding cannot wait indefinitely and keep the helper executable locked. Already running helpers from older versions do not acquire this timeout.
- Fixed successful same-version repair installations being reported as failures when the main executable remains byte-identical. Before setup starts, the updater briefly waits for installed Hook files to become writable; a persistent lock stops the update before setup can partially replace the installation. Installer success, package verification and the expected application version remain required. Addresses [#258](https://github.com/Kuddev/pebrel/issues/258).
- Fixed dashed link underlines covering only part of Chinese and other wide characters, and restarting their pattern at spaces or font boundaries. Underlines now follow terminal columns and display scaling.
- Fixed quoted Windows, UNC and home-directory paths containing spaces being recognized only up to the first space. Link highlighting and opening use the complete quoted path.
- Fixed WSL shell-prompt links including the trailing `$` or `#`, and absolute prompt paths losing their underline. Opening an absolute WSL prompt path uses the originating distribution; copying keeps the original path text.
- Fixed missing truecolor declarations in fresh Windows and WSL panes. Applications can retain their intended message-background colors across terminal themes, including Codex user-message backgrounds. Animation output remains controlled by the CLI; Pebrel does not add or filter stars according to reasoning level.
- Fixed tab context menus mixing hardcoded Chinese with the selected interface language. Menu labels now follow the active language across all 11 supported locales, including Korean, in both sidebar and top tabs.
### Improved
- Organized Windows Explorer integration into one normal Pebrel open command and one WSL submenu containing the installed distributions. Installation migrates Pebrel-owned flat entries while preserving unrelated or user-edited commands.
- Expanded issue forms with operating-system, shell, pane-program and connection details, and clearer reproduction and evidence fields to make problem reports easier to investigate.
Windows x64 provides an installer and portable ZIP; Windows ARM64 provides a native portable ZIP. On Windows, the installer and automatic installation remain x64-only; packaged macOS applications support in-app installation. Linux x64 packages and macOS Apple Silicon / Intel DMGs remain Preview releases.
## 中文
### 新增
- 为 Apple Silicon 和 Intel 上的 macOS 打包应用加入应用内更新:校验通过后,可选择重启安装或下次启动时安装,并恢复已保存的工作区;新应用立即重启失败时回滚到原应用。安装位置必须可写,从磁盘映像或 App Translocation 路径运行的副本需先移动到正常安装位置。
- 在 Shell 选择器、快速跳转和命令面板中显示 SSH 主机名称:已命名的主机显示名称与原始连接目标,未命名的主机保持原来的显示。搜索同时匹配名称和目标,选择后仍连接原始地址。
- 在“设置 → 按键映射 → 标签页”中新增可修改的“重命名标签”快捷键,默认仍为 F2。修改或清除后,F2 可传递给 Codex CLI 等终端程序;设置无需重启即可生效,菜单提示同步显示实际快捷键。
### 修复
- 修复 GitHub 返回 HTTP 403 或 429 时检查更新失败的问题;改用官方最新版本页面与校验清单恢复查询,无法取得已验证安装包信息时仍可手动下载。
- 修复 macOS 终端链接无法通过 Command+左键打开的问题;终端应用启用鼠标上报后,链接手势仍可使用,普通点击继续传给应用。悬停提示按当前语言显示对应平台的修饰键。对应 [#196](https://github.com/Kuddev/pebrel/issues/196)。
- 修复 Windows Codex Hook 命令在 PowerShell 解析阶段失败的问题,正确处理程序路径中的空格和特殊字符;迁移 Pebrel 管理的旧命令,同时保留用户及第三方 Hook。对应 [#254](https://github.com/Kuddev/pebrel/issues/254)、[#255](https://github.com/Kuddev/pebrel/issues/255)。
- 为 Hook 事件转发增加等待期限,避免输入一直未关闭或接收端停止读取时无限等待并持续占用辅助程序文件。已经运行的旧版本 Hook 不会自动获得此超时机制。
- 修复同版本修复安装成功后,仅因主程序字节未改变而误报失败的问题。启动安装器前,会短暂等待已安装的 Hook 文件恢复可写;持续占用时提前停止,避免安装器只替换部分文件。仍要求安装器成功、安装包校验通过且最终应用版本正确。对应 [#258](https://github.com/Kuddev/pebrel/issues/258)。
- 修复中文及其他宽字符的链接虚线只覆盖半个字符,以及虚线在空格或字体边界处重新起算的问题;下划线按终端列和显示缩放连续绘制。
- 修复带引号的 Windows、UNC 和主目录路径包含空格时,只识别到第一个空格的问题;链接高亮与打开操作使用完整的引号内路径。
- 修复 WSL 提示符路径把末尾 `$`、`#` 包含进链接,以及绝对路径提示符缺少下划线的问题;打开 WSL 绝对提示符路径时使用所属发行版,复制时保留原始路径文本。
- 修复新建 Windows 和 WSL 面板缺少真彩色声明的问题,使应用能在不同终端主题下保留其指定的消息背景色,包括 Codex 用户消息背景。动画仍由 CLI 决定,Pebrel 不根据思考等级添加或过滤星点。
- 修复标签右键菜单在非中文界面中混用硬编码中文的问题;侧栏和顶部标签的菜单均跟随当前界面语言,覆盖包括韩语在内的全部 11 种支持语言。
### 改进
- 整理 Windows 资源管理器右键菜单,保留一个普通 Pebrel 打开入口,并将已安装的 WSL 发行版集中到一个子菜单中;安装时迁移 Pebrel 自有的旧平铺入口,保留无关或经过用户编辑的命令。
- 完善问题反馈表单,补充操作系统、Shell、面板程序和连接方式等信息,并明确复现步骤与日志、截图要求,便于定位问题。
Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 ZIP;Windows 安装器和自动安装仍仅支持 x64;macOS 打包应用支持应用内安装。Linux x64 包及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。
## Contributors
<a href="https://github.com/ZiChuanLan"><img src="https://github.com/ZiChuanLan.png?size=96" width="64" height="64" alt="@ZiChuanLan avatar"></a><a href="https://github.com/YinBuLiao"><img src="https://github.com/YinBuLiao.png?size=96" width="64" height="64" alt="@YinBuLiao avatar"></a><a href="https://github.com/Kuddev"><img src="https://github.com/Kuddev.png?size=96" width="64" height="64" alt="@Kuddev avatar"></a>
- **[@ZiChuanLan](https://github.com/ZiChuanLan)** — SSH host names in the Shell picker, Quick Jump and command palette. / Shell 选择器、快速跳转及命令面板中的 SSH 主机名称。([#274](https://github.com/Kuddev/pebrel/pull/274))
- **[@YinBuLiao](https://github.com/YinBuLiao)** — Tab-menu localization, macOS Command-click links, rate-limited update recovery and macOS installation. / 标签菜单多语言、macOS Command+点击链接、更新限流恢复及 macOS 安装。([#265](https://github.com/Kuddev/pebrel/pull/265), [#266](https://github.com/Kuddev/pebrel/pull/266), [#269](https://github.com/Kuddev/pebrel/pull/269))
- **[@Kuddev](https://github.com/Kuddev)** — Hook execution and lifetime, repair installation, terminal links and colors, Explorer integration, configurable tab renaming and issue reporting. / Hook 执行与生命周期、修复安装、终端链接和颜色、资源管理器集成、可配置的标签重命名及问题反馈。([#257](https://github.com/Kuddev/pebrel/pull/257), [#260](https://github.com/Kuddev/pebrel/pull/260), [#261](https://github.com/Kuddev/pebrel/pull/261), [#262](https://github.com/Kuddev/pebrel/pull/262), [#263](https://github.com/Kuddev/pebrel/pull/263), [#264](https://github.com/Kuddev/pebrel/pull/264), [#272](https://github.com/Kuddev/pebrel/pull/272))
---
**SHA256**
Checksums will be filled from the final release artifacts. / 校验值将在最终发布产物生成后填写。
- `Pebrel-v1.9.1-linux-x64-preview.AppImage`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-linux-x64-preview.deb`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-macos-arm64-preview.dmg`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-macos-x64-preview.dmg`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-windows-x64.zip`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-windows-x64-setup.exe`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.1-windows-arm64.zip`: `PENDING FINAL BUILD`
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula-completions"
version = "1.9.0"
version = "1.9.1"
edition.workspace = true
[features]
+6 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula"
version = "1.9.0"
version = "1.9.1"
license = "GPL-3.0-or-later"
description = "Pebrel — a GPU-accelerated terminal emulator"
readme = "README.md"
@@ -15,15 +15,15 @@ path = "src/main.rs"
[dependencies.nebula_terminal]
path = "../nebula_terminal"
version = "1.9.0"
version = "1.9.1"
[dependencies.nebula_config_derive]
path = "../nebula_config_derive"
version = "1.9.0"
version = "1.9.1"
[dependencies.nebula_config]
path = "../nebula_config"
version = "1.9.0"
version = "1.9.1"
# 可选:GPUI UI 层(feature = "gpui-shell",模块 src/gpui_shell/)。
# GPUI/component 的版本和 Git 来源由根 Cargo.toml 统一精确固定。
@@ -67,7 +67,7 @@ optional = true
[dependencies.nebula-completions]
path = "../nebula-completions"
version = "1.9.0"
version = "1.9.1"
[dependencies]
ahash = { version = "0.8.6", features = ["no-rng"] }
@@ -143,7 +143,7 @@ rusqlite = { version = "0.40.2", features = ["bundled"] }
sha1 = "0.11.0"
[build-dependencies]
nebula-settings = { path = "../nebula_settings", version = "1.9.0" }
nebula-settings = { path = "../nebula_settings", version = "1.9.1" }
gl_generator = { version = "0.14.0", optional = true }
serde = "1"
serde_json = "1"
+14
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+Linksklick zum Öffnen",
"link_ctrl_click": "Strg+Linksklick zum Öffnen",
"settings": "Einstellungen",
"language": "Sprache",
"about": "Über",
@@ -73,6 +75,7 @@
"delete": "Löschen",
"edit": "Bearbeiten",
"rename": "Umbenennen",
"rename_tab": "Tab umbenennen",
"copy": "Kopieren",
"paste": "Einfügen",
"cut": "Ausschneiden",
@@ -104,6 +107,7 @@
"new_window": "Neues Fenster",
"close_tab": "Tab schließen",
"duplicate_tab": "Tab duplizieren",
"copy_working_directory": "Arbeitsverzeichnis kopieren",
"terminal": "Terminal",
"connect": "Verbinden",
"disconnect": "Trennen",
@@ -173,5 +177,15 @@
"copied_lines": "{lines} Zeilen in die Zwischenablage kopiert",
"paste_lines": "{lines} Zeilen einfügen?",
"sent_to": "An {target} gesendet"
},
"tab_menu": {
"fork_ai_session": "KI-Sitzung verzweigen",
"move_to_new_window": "In neues Fenster verschieben",
"export_as_workspace": "Als Arbeitsbereich exportieren…",
"split_left_right": "Links und rechts teilen",
"split_top_bottom": "Oben und unten teilen",
"move_left": "Nach links verschieben",
"move_right": "Nach rechts verschieben",
"tab_color": "Tab-Farbe"
}
}
+20 -2
View File
@@ -209,6 +209,8 @@
}
},
"common": {
"link_command_click": "Command+left click to open",
"link_ctrl_click": "Ctrl+left click to open",
"settings": "Settings",
"language": "Language",
"about": "About",
@@ -237,6 +239,7 @@
"delete": "Delete",
"edit": "Edit",
"rename": "Rename",
"rename_tab": "Rename tab",
"copy": "Copy",
"paste": "Paste",
"cut": "Cut",
@@ -780,6 +783,10 @@
"too_large": "The background image exceeds the memory limit. Choose a smaller image."
},
"update": {
"downloading_hint": "Downloading and verifying the update package for this device.",
"no_verified_package": "This release has no verified update package for this device. Open the Releases page to download it manually.",
"check_fallback_failed": "GitHub API access is restricted (HTTP {status}), and the official Releases page could not be reached: {error}. Retry later or open the Releases page.",
"check_unrecognized_release": "The official Releases page did not identify a supported stable version. Retry later or open the Releases page.",
"installation_failed": "Update installation failed",
"installation_failed_hint": "The update did not complete. Your original session snapshot is retained. Review the error, recheck the package, then confirm installation again.",
"recheck_package": "Recheck package",
@@ -796,8 +803,9 @@
},
"auto_download": "Download updates in the background",
"stop_download": "Stop download",
"skip_version": "Skip version",
"restart_install": "Restart and install",
"install_next_launch": "Install on next launch",
"install_next_launch": "Install on launch",
"ready_hint": "The update is ready. Pebrel will save your workspace and exit before installation, then reopen it.",
"later": "Later",
"prepare_failed": "Could not prepare the update: {error}. Pebrel will stay open.",
@@ -903,5 +911,15 @@
"files.path_placeholder": "Absolute or relative directory path",
"files.path_choose": "Choose a directory",
"files.path_open": "Open directory",
"files.path_failed": "Could not open this directory: {error}"
"files.path_failed": "Could not open this directory: {error}",
"tab_menu": {
"fork_ai_session": "Fork AI session",
"move_to_new_window": "Move to new window",
"export_as_workspace": "Export as workspace…",
"split_left_right": "Split left and right",
"split_top_bottom": "Split top and bottom",
"move_left": "Move left",
"move_right": "Move right",
"tab_color": "Tab color"
}
}
+14
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+clic izquierdo para abrir",
"link_ctrl_click": "Ctrl+clic izquierdo para abrir",
"settings": "Configuración",
"language": "Idioma",
"about": "Acerca de",
@@ -73,6 +75,7 @@
"delete": "Eliminar",
"edit": "Editar",
"rename": "Cambiar nombre",
"rename_tab": "Renombrar pestaña",
"copy": "Copiar",
"paste": "Pegar",
"cut": "Cortar",
@@ -104,6 +107,7 @@
"new_window": "Nueva ventana",
"close_tab": "Cerrar pestaña",
"duplicate_tab": "Duplicar pestaña",
"copy_working_directory": "Copiar directorio de trabajo",
"terminal": "Terminal",
"connect": "Conectar",
"disconnect": "Desconectar",
@@ -173,5 +177,15 @@
"copied_lines": "Se copiaron {lines} líneas al portapapeles",
"paste_lines": "¿Pegar {lines} líneas?",
"sent_to": "Enviado a {target}"
},
"tab_menu": {
"fork_ai_session": "Bifurcar sesión de IA",
"move_to_new_window": "Mover a una ventana nueva",
"export_as_workspace": "Exportar como espacio de trabajo…",
"split_left_right": "Dividir a izquierda y derecha",
"split_top_bottom": "Dividir arriba y abajo",
"move_left": "Mover a la izquierda",
"move_right": "Mover a la derecha",
"tab_color": "Color de la pestaña"
}
}
+14
View File
@@ -95,6 +95,8 @@
}
},
"common": {
"link_command_click": "Command+clic gauche pour ouvrir",
"link_ctrl_click": "Ctrl+clic gauche pour ouvrir",
"settings": "Paramètres",
"language": "Langue",
"about": "À propos",
@@ -123,6 +125,7 @@
"delete": "Supprimer",
"edit": "Modifier",
"rename": "Renommer",
"rename_tab": "Renommer l’onglet",
"copy": "Copier",
"paste": "Coller",
"cut": "Couper",
@@ -154,6 +157,7 @@
"new_window": "Nouvelle fenêtre",
"close_tab": "Fermer l’onglet",
"duplicate_tab": "Dupliquer l’onglet",
"copy_working_directory": "Copier le répertoire de travail",
"terminal": "Terminal",
"connect": "Connecter",
"disconnect": "Déconnecter",
@@ -244,5 +248,15 @@
"http_status": "Le service a renvoyé HTTP {status}",
"start_failed": "Impossible de démarrer le test du fournisseur : {error}"
}
},
"tab_menu": {
"fork_ai_session": "Dupliquer la session IA",
"move_to_new_window": "Déplacer vers une nouvelle fenêtre",
"export_as_workspace": "Exporter en espace de travail…",
"split_left_right": "Diviser verticalement",
"split_top_bottom": "Diviser horizontalement",
"move_left": "Déplacer vers la gauche",
"move_right": "Déplacer vers la droite",
"tab_color": "Couleur de l’onglet"
}
}
+14
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+clic sinistro per aprire",
"link_ctrl_click": "Ctrl+clic sinistro per aprire",
"settings": "Impostazioni",
"language": "Lingua",
"about": "Informazioni",
@@ -73,6 +75,7 @@
"delete": "Elimina",
"edit": "Modifica",
"rename": "Rinomina",
"rename_tab": "Rinomina scheda",
"copy": "Copia",
"paste": "Incolla",
"cut": "Taglia",
@@ -104,6 +107,7 @@
"new_window": "Nuova finestra",
"close_tab": "Chiudi scheda",
"duplicate_tab": "Duplica scheda",
"copy_working_directory": "Copia la directory di lavoro",
"terminal": "Terminale",
"connect": "Connetti",
"disconnect": "Disconnetti",
@@ -173,5 +177,15 @@
"copied_lines": "{lines} righe copiate negli appunti",
"paste_lines": "Incollare {lines} righe?",
"sent_to": "Inviato a {target}"
},
"tab_menu": {
"fork_ai_session": "Dirama sessione IA",
"move_to_new_window": "Sposta in una nuova finestra",
"export_as_workspace": "Esporta come area di lavoro…",
"split_left_right": "Dividi a sinistra e a destra",
"split_top_bottom": "Dividi in alto e in basso",
"move_left": "Sposta a sinistra",
"move_right": "Sposta a destra",
"tab_color": "Colore della scheda"
}
}
+14
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+左クリックで開く",
"link_ctrl_click": "Ctrl+左クリックで開く",
"settings": "設定",
"language": "言語",
"about": "このアプリについて",
@@ -73,6 +75,7 @@
"delete": "削除",
"edit": "編集",
"rename": "名前を変更",
"rename_tab": "タブの名前を変更",
"copy": "コピー",
"paste": "貼り付け",
"cut": "切り取り",
@@ -104,6 +107,7 @@
"new_window": "新しいウィンドウ",
"close_tab": "タブを閉じる",
"duplicate_tab": "タブを複製",
"copy_working_directory": "作業ディレクトリをコピー",
"terminal": "ターミナル",
"connect": "接続",
"disconnect": "切断",
@@ -173,5 +177,15 @@
"copied_lines": "{lines} 行をクリップボードにコピーしました",
"paste_lines": "{lines} 行を貼り付けますか?",
"sent_to": "{target} に送信しました"
},
"tab_menu": {
"fork_ai_session": "AI セッションを分岐",
"move_to_new_window": "新しいウィンドウに移動",
"export_as_workspace": "ワークスペースとしてエクスポート…",
"split_left_right": "左右に分割",
"split_top_bottom": "上下に分割",
"move_left": "左に移動",
"move_right": "右に移動",
"tab_color": "タブの色"
}
}
+21
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+왼쪽 클릭으로 열기",
"link_ctrl_click": "Ctrl+왼쪽 클릭으로 열기",
"settings": "설정",
"language": "언어",
"about": "정보",
@@ -73,6 +75,7 @@
"delete": "삭제",
"edit": "편집",
"rename": "이름 바꾸기",
"rename_tab": "탭 이름 바꾸기",
"copy": "복사",
"paste": "붙여넣기",
"cut": "잘라내기",
@@ -104,6 +107,7 @@
"new_window": "새 창",
"close_tab": "탭 닫기",
"duplicate_tab": "탭 복제",
"copy_working_directory": "작업 디렉터리 복사",
"terminal": "터미널",
"connect": "연결",
"disconnect": "연결 해제",
@@ -173,5 +177,22 @@
"copied_lines": "{lines}줄을 클립보드에 복사했습니다",
"paste_lines": "{lines}줄을 붙여넣을까요?",
"sent_to": "{target}(으)로 전송했습니다"
},
"update": {
"skip_version": "이 버전 건너뛰기",
"downloading_hint": "이 기기에 맞는 업데이트 패키지를 다운로드하고 검증하는 중입니다.",
"no_verified_package": "이 릴리스에는 이 기기에 맞는 검증된 업데이트 패키지가 없습니다. 릴리스 페이지에서 직접 다운로드하세요.",
"check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.",
"check_unrecognized_release": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요."
},
"tab_menu": {
"fork_ai_session": "AI 세션 분기",
"move_to_new_window": "새 창으로 이동",
"export_as_workspace": "작업 공간으로 내보내기…",
"split_left_right": "좌우로 분할",
"split_top_bottom": "위아래로 분할",
"move_left": "왼쪽으로 이동",
"move_right": "오른쪽으로 이동",
"tab_color": "탭 색상"
}
}
+14
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+clique esquerdo para abrir",
"link_ctrl_click": "Ctrl+clique esquerdo para abrir",
"settings": "Configurações",
"language": "Idioma",
"about": "Sobre",
@@ -73,6 +75,7 @@
"delete": "Excluir",
"edit": "Editar",
"rename": "Renomear",
"rename_tab": "Renomear aba",
"copy": "Copiar",
"paste": "Colar",
"cut": "Recortar",
@@ -104,6 +107,7 @@
"new_window": "Nova janela",
"close_tab": "Fechar aba",
"duplicate_tab": "Duplicar aba",
"copy_working_directory": "Copiar diretório de trabalho",
"terminal": "Terminal",
"connect": "Conectar",
"disconnect": "Desconectar",
@@ -173,5 +177,15 @@
"copied_lines": "{lines} linhas copiadas para a área de transferência",
"paste_lines": "Colar {lines} linhas?",
"sent_to": "Enviado para {target}"
},
"tab_menu": {
"fork_ai_session": "Bifurcar sessão de IA",
"move_to_new_window": "Mover para nova janela",
"export_as_workspace": "Exportar como espaço de trabalho…",
"split_left_right": "Dividir lado a lado",
"split_top_bottom": "Dividir em cima e embaixo",
"move_left": "Mover para a esquerda",
"move_right": "Mover para a direita",
"tab_color": "Cor da aba"
}
}
+14
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+щелчок левой кнопкой для открытия",
"link_ctrl_click": "Ctrl+щелчок левой кнопкой для открытия",
"settings": "Настройки",
"language": "Язык",
"about": "О программе",
@@ -73,6 +75,7 @@
"delete": "Удалить",
"edit": "Изменить",
"rename": "Переименовать",
"rename_tab": "Переименовать вкладку",
"copy": "Копировать",
"paste": "Вставить",
"cut": "Вырезать",
@@ -104,6 +107,7 @@
"new_window": "Новое окно",
"close_tab": "Закрыть вкладку",
"duplicate_tab": "Дублировать вкладку",
"copy_working_directory": "Копировать рабочий каталог",
"terminal": "Терминал",
"connect": "Подключиться",
"disconnect": "Отключиться",
@@ -173,5 +177,15 @@
"copied_lines": "Скопировано строк в буфер обмена: {lines}",
"paste_lines": "Вставить строки ({lines})?",
"sent_to": "Отправлено в {target}"
},
"tab_menu": {
"fork_ai_session": "Создать ответвление сеанса ИИ",
"move_to_new_window": "Переместить в новое окно",
"export_as_workspace": "Экспортировать как рабочую область…",
"split_left_right": "Разделить слева и справа",
"split_top_bottom": "Разделить сверху и снизу",
"move_left": "Переместить влево",
"move_right": "Переместить вправо",
"tab_color": "Цвет вкладки"
}
}
+20 -2
View File
@@ -209,6 +209,8 @@
}
},
"common": {
"link_command_click": "Command+左键跳转",
"link_ctrl_click": "Ctrl+左键跳转",
"settings": "设置",
"language": "语言",
"about": "关于",
@@ -237,6 +239,7 @@
"delete": "删除",
"edit": "编辑",
"rename": "重命名",
"rename_tab": "重命名标签页",
"copy": "复制",
"paste": "粘贴",
"cut": "剪切",
@@ -780,6 +783,10 @@
"too_large": "背景图超出内存限制,请选择尺寸更小的图片。"
},
"update": {
"downloading_hint": "正在后台下载并校验适用于此设备的更新安装包。",
"no_verified_package": "此版本没有适用于此设备的可验证安装包,请打开发布页手动下载。",
"check_fallback_failed": "GitHub API 访问受限(HTTP {status}),备用发布页查询也失败了:{error}。请稍后重试或打开发布页。",
"check_unrecognized_release": "官方发布页未返回可识别的稳定版本,请稍后重试或打开发布页。",
"installation_failed": "更新安装失败",
"installation_failed_hint": "更新未完成,原会话快照仍保留。检查错误后可重新校验安装包,再确认安装。",
"recheck_package": "重新校验安装包",
@@ -796,9 +803,10 @@
},
"auto_download": "在后台预下载更新",
"stop_download": "停止下载",
"skip_version": "跳过此版本",
"restart_install": "重启并安装",
"install_next_launch": "下次启动时安装",
"ready_hint": "更新已就绪。Pebrel 将先保存工作区并退出,再安装更新并重新打开。",
"ready_hint": "更新已就绪,将保存工作区、安装更新并重启。",
"later": "稍后",
"prepare_failed": "更新准备失败:{error}。Pebrel 将保持打开。",
"draft_changed": "准备退出期间文档发生了变化。请保存或放弃最新修改后重试。",
@@ -903,5 +911,15 @@
"files.path_placeholder": "输入绝对路径或相对路径",
"files.path_choose": "选择目录",
"files.path_open": "打开目录",
"files.path_failed": "无法打开这个目录:{error}"
"files.path_failed": "无法打开这个目录:{error}",
"tab_menu": {
"fork_ai_session": "分叉 AI 会话",
"move_to_new_window": "移到新窗口",
"export_as_workspace": "导出为工作区…",
"split_left_right": "左右分屏",
"split_top_bottom": "上下分屏",
"move_left": "向左移动",
"move_right": "向右移动",
"tab_color": "标签颜色"
}
}
+14 -1
View File
@@ -69,6 +69,8 @@
}
},
"common": {
"link_command_click": "Command+左鍵開啟",
"link_ctrl_click": "Ctrl+左鍵開啟",
"settings": "設定",
"language": "語言",
"about": "關於",
@@ -97,6 +99,7 @@
"delete": "刪除",
"edit": "編輯",
"rename": "重新命名",
"rename_tab": "重新命名分頁",
"copy": "複製",
"paste": "貼上",
"cut": "剪下",
@@ -218,5 +221,15 @@
"session.choose_conversation": "選擇要繼續的對話…",
"session.exit_incomplete_title": "仍有 AI 工作階段無法識別,繼續關閉?",
"session.exit_incomplete_body": "關閉前會儲存視窗配置及已知的對話 ID,並停止正在執行的命令。尚未取得 ID 的對話,重新開啟後可能需要手動選擇。",
"session.exit_anyway": "儲存已知狀態並關閉"
"session.exit_anyway": "儲存已知狀態並關閉",
"tab_menu": {
"fork_ai_session": "分叉 AI 工作階段",
"move_to_new_window": "移到新視窗",
"export_as_workspace": "匯出為工作區…",
"split_left_right": "左右分割",
"split_top_bottom": "上下分割",
"move_left": "向左移動",
"move_right": "向右移動",
"tab_color": "分頁顏色"
}
}
+75 -45
View File
@@ -170,6 +170,9 @@ const WSLENV_ENTRIES: &[&str] = &[
crate::ai_hook::LEGACY_PANE_ENV,
TERM_PROGRAM_ENV,
TERM_PROGRAM_VERSION_ENV,
// WSL otherwise falls back to a 256-color TERM despite the host supporting
// truecolor. Keep the capability declaration; do not spoof another terminal.
"COLORTERM",
"PEBREL_CLI/p",
"PEBREL_BIN_DIR/p",
PROCESS_ENV,
@@ -301,56 +304,83 @@ mod tests {
assert_eq!(entries.iter().filter(|entry| same_directory(entry, directory)).count(), 1);
}
// WSL forwarding has one Windows compilation boundary for its whole contract.
#[cfg(windows)]
#[test]
fn wslenv_entries_match_variables() {
// 透传表里的路径条目是字面量。变量改名而这里忘记跟着改,就会静默丢掉
// WSL 侧的可达性——用一个断言把它变成编译期之后立刻可见的失败。
let names: Vec<&str> = WSLENV_ENTRIES.iter().copied().map(variable_name).collect();
assert!(names.contains(&CLI_ENV), "{CLI_ENV} missing from WSLENV passthrough: {names:?}");
assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}");
assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}");
assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV));
}
mod wsl {
use super::*;
#[cfg(windows)]
#[test]
fn wslenv_keeps_foreign_entries() {
let mut env = HashMap::new();
// `shell_detect::wsl_cwd_report_env` 先写过 cwd 上报的透传条目。
env.insert("WSLENV".to_owned(), "PROMPT_COMMAND".to_owned());
apply(&mut env, 3);
#[test]
fn wslenv_entries_match_variables() {
// 透传表里的路径条目是字面量。变量改名而这里忘记跟着改,就会静默丢掉
// WSL 侧的可达性——用一个断言把它变成编译期之后立刻可见的失败。
let names: Vec<&str> = WSLENV_ENTRIES.iter().copied().map(variable_name).collect();
assert!(
names.contains(&CLI_ENV),
"{CLI_ENV} missing from WSLENV passthrough: {names:?}"
);
assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}");
assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}");
assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV));
assert!(names.contains(&"COLORTERM"));
}
let wslenv = env.get("WSLENV").expect("WSLENV");
let entries: Vec<&str> = wslenv.split(':').collect();
assert!(entries.contains(&"PROMPT_COMMAND"), "cwd reporting must survive: {wslenv}");
assert!(entries.contains(&PANE_ENV), "pane identity must cross into WSL: {wslenv}");
// 路径必须带 `/p`,否则来宾拿到无法执行的 `D:\…` 字面量。
assert!(entries.contains(&"NEBULA_CLI/p"), "cli path needs translation: {wslenv}");
}
#[test]
fn wsl_receives_truecolor_without_replacing_an_explicit_forwarding_rule() {
let mut env = HashMap::from([
("COLORTERM".to_owned(), "truecolor".to_owned()),
("WSLENV".to_owned(), "KEEP/p".to_owned()),
]);
apply(&mut env, 8);
assert_eq!(env["COLORTERM"], "truecolor");
assert!(env["WSLENV"].split(':').any(|entry| entry == "COLORTERM"));
assert!(env["WSLENV"].split(':').any(|entry| entry == "KEEP/p"));
env.insert("WSLENV".into(), "KEEP/p:COLORTERM/u".into());
env.insert("COLORTERM".into(), "24bit".into());
apply(&mut env, 8);
assert_eq!(env["COLORTERM"], "24bit");
let entries: Vec<_> = env["WSLENV"]
.split(':')
.filter(|entry| variable_name(entry) == "COLORTERM")
.collect();
assert_eq!(entries, ["COLORTERM/u"]);
}
#[cfg(windows)]
#[test]
fn wslenv_merge_is_idempotent() {
let mut env = HashMap::new();
apply(&mut env, 4);
let once = env.get("WSLENV").cloned().expect("WSLENV");
apply(&mut env, 4);
assert_eq!(env.get("WSLENV"), Some(&once));
}
#[test]
fn wslenv_keeps_foreign_entries() {
let mut env = HashMap::new();
// `shell_detect::wsl_cwd_report_env` 先写过 cwd 上报的透传条目。
env.insert("WSLENV".to_owned(), "PROMPT_COMMAND".to_owned());
apply(&mut env, 3);
#[cfg(windows)]
#[test]
fn wslenv_respects_foreign_flags_for_same_variable() {
let mut env = HashMap::new();
// 宿主已用 `/l`(列表)传同名变量:尊重它,不改成我们的 `/p`,
// 否则会破坏那个工具原有的转换语义。
env.insert("WSLENV".to_owned(), "NEBULA_CLI/l".to_owned());
apply(&mut env, 5);
let wslenv = env.get("WSLENV").expect("WSLENV");
let entries: Vec<&str> = wslenv.split(':').collect();
assert!(entries.contains(&"PROMPT_COMMAND"), "cwd reporting must survive: {wslenv}");
assert!(entries.contains(&PANE_ENV), "pane identity must cross into WSL: {wslenv}");
// 路径必须带 `/p`,否则来宾拿到无法执行的 `D:\…` 字面量。
assert!(entries.contains(&"NEBULA_CLI/p"), "cli path needs translation: {wslenv}");
}
let wslenv = env.get("WSLENV").expect("WSLENV");
let entries: Vec<&str> = wslenv.split(':').collect();
assert!(entries.contains(&"NEBULA_CLI/l"));
assert!(!entries.contains(&"NEBULA_CLI/p"));
#[test]
fn wslenv_merge_is_idempotent() {
let mut env = HashMap::new();
apply(&mut env, 4);
let once = env.get("WSLENV").cloned().expect("WSLENV");
apply(&mut env, 4);
assert_eq!(env.get("WSLENV"), Some(&once));
}
#[test]
fn wslenv_respects_foreign_flags_for_same_variable() {
let mut env = HashMap::new();
// 宿主已用 `/l`(列表)传同名变量:尊重它,不改成我们的 `/p`,
// 否则会破坏那个工具原有的转换语义。
env.insert("WSLENV".to_owned(), "NEBULA_CLI/l".to_owned());
apply(&mut env, 5);
let wslenv = env.get("WSLENV").expect("WSLENV");
let entries: Vec<&str> = wslenv.split(':').collect();
assert!(entries.contains(&"NEBULA_CLI/l"));
assert!(!entries.contains(&"NEBULA_CLI/p"));
}
}
}
+76 -2
View File
@@ -111,8 +111,10 @@ pub(super) fn ensure_codex_hooks() -> bool {
}
fn groups(helper: &str, mode: CodexHookMode) -> Value {
// Codex 的 Windows runner 使用 COMSPEC /C;可执行文件路径自身仍需引号。
let command = format!("\"{}\" codex {}", helper.replace('"', "\\\""), mode.argument());
// Codex runs Windows hooks through PowerShell -Command. A quoted path is
// a string expression until invoked with &, and single quotes keep $, `
// and other path characters literal. PowerShell escapes ' by doubling it.
let command = format!("& '{}' codex {}", helper.replace('\'', "''"), mode.argument());
installation::codex_groups(&command, Some(&command), mode)
}
@@ -260,6 +262,78 @@ fn remove(directory: &Path) -> io::Result<bool> {
mod tests {
use super::*;
#[test]
fn upgrades_cmd_style_owned_hooks_without_changing_user_entries() {
let root = tempfile::tempdir().unwrap();
let helper = "C:/Program Files/Pebrel/runtime/pebrel-hook.exe";
let old_command = format!("\"{helper}\" codex --hooks=full");
let previous =
installation::codex_groups(&old_command, Some(&old_command), CodexHookMode::Full);
let foreign = json!({"hooks":[{"type":"command","command":"user-hook"}]});
let mut hooks = json!({"hooks": previous});
hooks["hooks"]["Stop"].as_array_mut().unwrap().push(foreign.clone());
std::fs::write(root.path().join("hooks.json"), hooks.to_string()).unwrap();
std::fs::write(
root.path().join(MARKER),
json!({"groups": previous, "enabled_feature": true}).to_string(),
)
.unwrap();
let config = "notify = ['user-notifier']\n[features]\nhooks = true\n";
std::fs::write(root.path().join("config.toml"), config).unwrap();
assert!(!current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap());
assert!(install(root.path(), helper, CodexHookMode::Full).unwrap());
assert!(current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap());
assert!(!install(root.path(), helper, CodexHookMode::Full).unwrap());
let hooks: Value =
serde_json::from_str(&read(&root.path().join("hooks.json")).unwrap().unwrap()).unwrap();
let mut expected = json!({"hooks": groups(helper, CodexHookMode::Full)});
expected["hooks"]["Stop"].as_array_mut().unwrap().insert(0, foreign);
assert_eq!(hooks, expected);
assert_eq!(read(&root.path().join("config.toml")).unwrap().unwrap(), config);
}
#[test]
fn powershell_executes_generated_hooks_with_literal_paths_and_stdin() {
use std::io::Write as _;
use std::os::windows::process::CommandExt as _;
let root = tempfile::tempdir().unwrap();
let helper = root.path().join("Pebrel's $data `hook.ps1");
std::fs::write(
&helper,
"Write-Output ($args -join '|')\r\nWrite-Output ([Console]::In.ReadLine())\r\n",
)
.unwrap();
let powershell = Path::new(&std::env::var_os("SystemRoot").unwrap())
.join("System32/WindowsPowerShell/v1.0/powershell.exe");
for mode in [CodexHookMode::Turns, CodexHookMode::Full] {
let groups = groups(&helper.to_string_lossy().replace('\\', "/"), mode);
let command = groups["Stop"][0]["hooks"][0]["command"].as_str().unwrap();
let mut child = Command::new(&powershell)
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
command,
])
.creation_flags(0x0800_0000)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"{\"session_id\":\"fixture\"}\n").unwrap();
let output = child.wait_with_output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains(&format!("codex|{}", mode.argument())), "{stdout}");
assert!(stdout.contains(r#"{"session_id":"fixture"}"#), "{stdout}");
}
}
#[test]
fn install_upgrade_remove_preserves_notify_and_user_hooks() {
let root = tempfile::tempdir().unwrap();
+3
View File
@@ -226,6 +226,9 @@ pub enum Action {
/// Close the focused tab (or pane, when the tab is split).
CloseTab,
/// Rename the active tab.
RenameTab,
/// Toggle a left/right split in the focused tab.
SplitRight,
+1 -1
View File
@@ -92,7 +92,6 @@ pub fn default_key_bindings() -> Vec<KeyBinding> {
ArrowLeft, +BindingMode::APP_CURSOR, ~BindingMode::VI, ~BindingMode::SEARCH; Action::Esc("\x1bOD".into());
// Legacy keys handling which can't be automatically encoded.
F1, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOP".into());
F2, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOQ".into());
F3, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOR".into());
F4, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOS".into());
Tab, ModifiersState::SHIFT, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1b[Z".into());
@@ -201,6 +200,7 @@ pub fn nebula_key_bindings() -> Vec<KeyBinding> {
KeyBinding;
"t", ctrl_shift; Action::CreateNewTab;
"w", ctrl_shift; Action::CloseTab;
F2; Action::RenameTab;
Tab, ctrl; Action::SelectNextTab;
Tab, ctrl_shift; Action::SelectPreviousTab;
"e", ctrl_shift; Action::CreateNewWindow;
+3
View File
@@ -37,6 +37,9 @@ use crate::config::window::WindowConfig;
/// Regex used for default terminal hints (Markdown links, URLs, and local paths).
#[rustfmt::skip]
pub const DEFAULT_HINT_REGEX: &str = "(?:\
(?m:^[a-z0-9_.-]+@[a-z0-9_.-]+:/[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s`]*[$#])|\
\"(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}\"\\r\\n]+\"|\
'(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}'\\r\\n]+'|\
\\[[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n\\]]+\\]\\((?:[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]|\\([^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]*\\))+\\)|\
(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\]+|\
(?:(?-u:\\b)[a-z]:[/\\\\]|\\\\\\\\)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+|\
+14 -5
View File
@@ -99,6 +99,10 @@ struct Row {
danger: bool,
}
fn row_shortcut<'a>(row: &Row, rename: &'a str) -> &'a str {
if matches!(row.action, ContextMenuAction::RenameTab(_)) { rename } else { row.hint }
}
#[derive(Debug, Clone)]
struct MenuLayout {
panel: (f32, f32, f32, f32),
@@ -225,7 +229,7 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32
ContextMenuAction::RenameTab(index),
ICON_EDIT,
"重命名",
"F2",
"",
cursor_y,
false,
));
@@ -520,10 +524,14 @@ pub(super) fn draw(display: &mut Display) {
}
// 右键菜单和命令面板共用同一颗键帽组件;快捷键不再是贴在右缘的一串
// 灰字,独立小键帽给出清晰的可按暗示,也让两类浮层形成同一套视觉语法。
let rename = keymap::effective_combo(&crate::config::Action::RenameTab, &display.nebula_keymap)
.map(|(combo, _)| combo)
.unwrap_or_default();
for row in &layout.rows {
if !row.hint.is_empty() {
let hint = row_shortcut(row, &rename);
if !hint.is_empty() {
let combo = keycap::layout_combo(
row.hint,
hint,
row.rect.0 + row.rect.2 - s(10.0),
row.rect.1 + row.rect.3 * 0.5,
size.cell_width(),
@@ -558,9 +566,10 @@ pub(super) fn draw(display: &mut Display) {
row.label,
&mut display.glyph_cache,
);
if !row.hint.is_empty() {
let hint = row_shortcut(row, &rename);
if !hint.is_empty() {
let combo = keycap::layout_combo(
row.hint,
hint,
row.rect.0 + row.rect.2 - s(10.0),
row.rect.1 + row.rect.3 * 0.5,
size.cell_width(),
+145 -3
View File
@@ -469,6 +469,18 @@ pub fn highlighted_at<T>(
config: &UiConfig,
point: Point,
mouse_mods: ModifiersState,
) -> Option<HintMatch> {
highlighted_at_with_mouse_override(term, config, point, mouse_mods, false)
}
/// Allow a shell's explicit link gesture to override application mouse reporting.
/// Configured hint modifiers and the mouse-enabled flag are still respected.
pub fn highlighted_at_with_mouse_override<T>(
term: &Term<T>,
config: &UiConfig,
point: Point,
mouse_mods: ModifiersState,
override_mouse_mode: bool,
) -> Option<HintMatch> {
let mouse_mode = term.mode().intersects(TermMode::MOUSE_MODE);
@@ -477,7 +489,9 @@ pub fn highlighted_at<T>(
let highlight = hint.mouse.is_some_and(|mouse| {
mouse.enabled
&& mouse_mods.contains(mouse.mods.0)
&& (!mouse_mode || mouse_mods.contains(ModifiersState::SHIFT))
&& (!mouse_mode
|| override_mouse_mode
|| mouse_mods.contains(ModifiersState::SHIFT))
});
if !highlight {
return None;
@@ -585,9 +599,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 +674,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 +717,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 +788,65 @@ mod tests {
use super::*;
#[test]
fn shell_prompt_symbols_are_not_part_of_clickable_home_paths() {
for (text, expected) in [
("alice@workstation:/mnt/d/project$ ", "/mnt/d/project"),
("root@host:/root# ", "/root"),
("user@host:/# ", "/"),
("alice@workstation:~/.codex$ ", "~/.codex"),
("root@host:~/work# ", "~/work"),
("alice@workstation:~/工作文档$ echo test", "~/工作文档"),
("~/cost$ ", "~/cost$"),
("~/repo# ", "~/repo#"),
("https://example.com/cost$ ", "https://example.com/cost$"),
("user@host:~/price$usd ", "~/price$usd"),
] {
let term = mock_term(text);
let config = UiConfig::default();
let matches = visible_clickable_matches(&term, &config);
assert_eq!(matches.len(), 1, "{text}: {matches:?}");
assert_eq!(term.bounds_to_string(*matches[0].start(), *matches[0].end()), expected);
let end = *matches[0].end();
let hit = highlighted_at(&term, &config, end, ModifiersState::CONTROL).unwrap();
assert_eq!(hit.text(&term).unwrap().as_ref(), expected);
assert!(
highlighted_at(
&term,
&config,
end.add(&term, Boundary::Grid, 1),
ModifiersState::CONTROL
)
.is_none()
);
}
}
#[test]
fn quoted_paths_keep_spaces_and_literal_punctuation_in_hover_and_click_targets() {
for (text, expected) in [
(
r#"& "C:\Program Files\Example Tools\probe.exe" pr view"#,
r"C:\Program Files\Example Tools\probe.exe",
),
("open 'D:/My Documents/开始 菜单 (1).md' now", "D:/My Documents/开始 菜单 (1).md"),
("cat '~/cost$'", "~/cost$"),
(r#"open "\\server\my share\report.txt" now"#, r"\\server\my share\report.txt"),
] {
let term = mock_term(text);
let config = UiConfig::default();
let matches = visible_clickable_matches(&term, &config);
assert_eq!(matches.len(), 1, "{text}: {matches:?}");
let start = *matches[0].start();
let end = *matches[0].end();
assert_eq!(term.bounds_to_string(start, end), expected);
for point in [start, end] {
let hit = highlighted_at(&term, &config, point, ModifiersState::CONTROL).unwrap();
assert_eq!(hit.text(&term).unwrap().as_ref(), expected);
}
}
}
#[test]
fn hint_label_generation() {
let mut generator = HintLabels::new("0123", 0.5);
+70 -1
View File
@@ -45,6 +45,7 @@ pub(crate) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[
// -- 标签页 --
(Action::CreateNewTab, "新建标签页", "New tab"),
(Action::CloseTab, "关闭标签页 / 分屏", "Close tab / pane"),
(Action::RenameTab, "重命名标签页", "Rename tab"),
(Action::SelectNextTab, "下一个标签页", "Next tab"),
(Action::SelectPreviousTab, "上一个标签页", "Previous tab"),
// -- 窗格 --
@@ -74,7 +75,7 @@ pub(crate) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[
/// `usize` 是本组行数;区间连续覆盖全部可编辑行(含第 0 行快速终端)。
pub(crate) const GROUPS: &[(&str, &str, usize)] = &[
("全局", "Global", 6),
("标签页", "Tabs", 4),
("标签页", "Tabs", 5),
("窗格", "Panes", 7),
("侧栏面板", "Side panels", 2),
("终端", "Terminal", 9),
@@ -87,6 +88,17 @@ pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[
("贴入 AI 修复建议", "Paste AI fix suggestion", "Ctrl+."),
];
pub(crate) fn action_label(
row: &(Action, &'static str, &'static str),
language: super::UiLanguage,
) -> &'static str {
if row.0 == Action::RenameTab {
language.text(crate::i18n::Message::CommonRenameTab)
} else {
language.pick(row.1, row.2)
}
}
#[cfg(test)]
mod group_tests {
use super::*;
@@ -115,6 +127,13 @@ pub(crate) fn default_shortcuts() -> Vec<(String, Action)> {
.collect()
}
/// Editing one action moves its shortcut: old keys pass through to the terminal.
/// Keep explicit bindings for other actions so the editor can report conflicts.
pub(crate) fn rebind_action(raw: &mut Vec<(String, String)>, action: &Action, combo: String) {
clear_action(raw, action);
raw.push((combo, action_storage_name(action)));
}
/// Preserve the existing keybind format: ReceiveChar explicitly passes a key
/// to the terminal. Clearing an action disables its defaults and its custom key.
pub(crate) fn clear_action(raw: &mut Vec<(String, String)>, action: &Action) {
@@ -574,6 +593,56 @@ pub(crate) fn gpui_mods_prefix(modifiers: &::gpui::Modifiers) -> String {
#[cfg(test)]
mod tests {
#[test]
fn rename_binding_moves_persists_clears_and_restores() {
use super::*;
let action = Action::RenameTab;
assert_eq!(effective_combo(&action, &[]), Some(("F2".into(), false)));
let mut raw = Vec::new();
rebind_action(&mut raw, &action, "ctrl+alt+r".into());
let saved = nebula_settings::apply_keybinds("theme=nord\n", &raw);
let mut restored = nebula_settings::keybind_pairs_from_text(&saved);
let bindings = build_bindings(&restored);
assert_eq!(effective_combo(&action, &bindings), Some(("Ctrl+Alt+R".into(), true)));
let (mods, trigger) = parse_combo("f2").unwrap();
let f2 = bindings.iter().find(|b| b.mods == mods && b.trigger == trigger).unwrap();
assert_eq!(f2.action, Action::ReceiveChar, "the old default must reach the CLI");
clear_action(&mut restored, &action);
assert_eq!(effective_combo(&action, &build_bindings(&restored)), None);
reset_action(&mut restored, &action);
assert_eq!(
effective_combo(&action, &build_bindings(&restored)),
Some(("F2".into(), false))
);
}
#[test]
fn rename_rebinding_preserves_explicit_ownership_of_the_old_key() {
use super::*;
let mut raw = vec![("f2".into(), "CreateNewTab".into())];
rebind_action(&mut raw, &Action::RenameTab, "ctrl+alt+r".into());
assert_eq!(raw[0], ("f2".into(), "CreateNewTab".into()));
assert!(
!raw.iter()
.any(|(key, action)| key.eq_ignore_ascii_case("f2") && action == "ReceiveChar")
);
}
#[test]
fn rename_is_editable_and_localized_in_every_language() {
use super::*;
let row =
EDITABLE_ACTIONS.iter().find(|(action, ..)| *action == Action::RenameTab).unwrap();
for language in super::super::UiLanguage::ALL {
assert_eq!(
action_label(row, *language),
language.text(crate::i18n::Message::CommonRenameTab)
);
assert!(!action_label(row, *language).is_empty());
}
assert_eq!(action_label(row, super::super::UiLanguage::ZhCn), "重命名标签页");
}
#[test]
fn clearing_ctrl_k_survives_persistence_and_restore_is_explicit() {
use super::*;
+8 -8
View File
@@ -5670,7 +5670,7 @@ impl Display {
} else {
keymap::EDITABLE_ACTIONS
.get(flat - 1)
.map(|(_, zh, en)| self.nebula_language.pick(zh, en).to_owned())
.map(|row| keymap::action_label(row, self.nebula_language).to_owned())
.unwrap_or_default()
}
};
@@ -7202,8 +7202,7 @@ impl Display {
}
}
/// 捕获完成:`combo` 归属该行动作。同 combo 的旧自定义行被移除(键随
/// 最后写入者),该动作旧的自定义行也移除(一动作一自定义键)。
/// Apply the shared rebinding policy, then refresh the editor.
pub fn keymap_assign(&mut self, row: usize, combo: String) {
if row == keymap::QUICK_TERMINAL_ROW {
self.nebula_keymap_capture = None;
@@ -7215,14 +7214,15 @@ impl Display {
self.pending_update.dirty = true;
return;
}
let action_row = row.saturating_sub(1);
let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(action_row) else { return };
let name = keymap::action_storage_name(action);
self.nebula_keybinds.retain(|(c, a)| c != &combo && !a.eq_ignore_ascii_case(&name));
self.nebula_keybinds.push((combo, name));
let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return };
keymap::rebind_action(&mut self.nebula_keybinds, action, combo);
self.keymap_commit();
}
pub fn active_tab_index(&self) -> usize {
self.nebula_active_tab
}
/// Bare Backspace disables the action, preserving pass-through on reload.
pub fn keymap_clear_custom(&mut self, row: usize) {
if row == keymap::QUICK_TERMINAL_ROW {
+5 -6
View File
@@ -7338,15 +7338,14 @@ pub(super) fn draw_text(
}
let i = flat;
let ty = rect.1 + (kh - cell_h) / 2.0;
let (zh_label, en_label) = if i == keymap::QUICK_TERMINAL_ROW {
let label = if i == keymap::QUICK_TERMINAL_ROW {
if view.quick_hotkey_error.is_some() {
("快速终端(注册失败)", "Quick terminal (failed)")
language.pick("快速终端(注册失败)", "Quick terminal (failed)")
} else {
("快速终端", "Quick terminal")
language.pick("快速终端", "Quick terminal")
}
} else {
let (_, zh, en) = keymap::EDITABLE_ACTIONS[i - 1];
(zh, en)
keymap::action_label(&keymap::EDITABLE_ACTIONS[i - 1], language)
};
r.draw_chrome_text(
size,
@@ -7357,7 +7356,7 @@ pub(super) fn draw_text(
} else {
sk.ink
},
language.pick(zh_label, en_label),
label,
gc,
);
let hovered = view.hover == SettingsHit::KeymapRow(slot);
@@ -101,7 +101,7 @@ impl SettingsPane {
} else {
keymap::EDITABLE_ACTIONS
.get(flat - 1)
.map(|(_, zh, en)| language.pick(zh, en).to_owned())
.map(|row| keymap::action_label(row, language).to_owned())
.unwrap_or_default()
}
};
@@ -164,9 +164,7 @@ impl SettingsPane {
return;
}
let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return };
let name = keymap::action_storage_name(action);
self.keymap_binds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name));
self.keymap_binds.push((combo, name));
keymap::rebind_action(&mut self.keymap_binds, action, combo);
self.persist_keybinds(cx);
}
@@ -271,7 +269,7 @@ impl SettingsPane {
} else {
keymap::EDITABLE_ACTIONS
.get(flat - 1)
.map(|(_, zh, en)| language.pick(zh, en).to_owned())
.map(|row| keymap::action_label(row, language).to_owned())
.unwrap_or_default()
.into()
};
@@ -1,5 +1,49 @@
use super::*;
#[cfg(feature = "gpui-test-support")]
#[gpui::test]
fn rename_keymap_row_is_searchable_and_enters_capture(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(crate::gpui_shell::config::Settings::load(ThemeName::Nord));
});
let mut pane_out = None;
let (_, cx) = cx.add_window_view(|window, cx| {
let pane = cx.new(|cx| SettingsPane::new(window, cx));
pane.update(cx, |pane, cx| {
pane.keymap_binds.clear();
pane.settings_search_input
.update(cx, |input, cx| input.replace_all("Rename tab", window, cx));
});
pane_out = Some(pane.clone());
gpui_component::Root::new(pane, window, cx)
});
let pane = pane_out.unwrap();
cx.simulate_resize(gpui::size(px(1280.0), px(900.0)));
cx.run_until_parked();
cx.update(|window, cx| {
let _ = window.draw(cx);
});
let row = crate::display::keymap::EDITABLE_ACTIONS
.iter()
.position(|(action, ..)| *action == crate::config::Action::RenameTab)
.unwrap()
+ 1;
let bounds = cx
.debug_bounds(Box::leak(format!("settings-keymap-row-{row}").into_boxed_str()))
.expect("rename row is rendered");
assert!(bounds.size.width > px(0.0) && bounds.size.height > px(0.0));
cx.simulate_click(bounds.center(), gpui::Modifiers::default());
assert_eq!(pane.read_with(cx, |pane, _| pane.keymap_capture), Some(row));
cx.simulate_event(gpui::KeyDownEvent {
keystroke: gpui::Keystroke::parse("escape").unwrap(),
is_held: false,
prefer_character_input: false,
});
cx.run_until_parked();
assert_eq!(pane.read_with(cx, |pane, _| pane.keymap_capture), None);
}
#[cfg(feature = "gpui-test-support")]
#[gpui::test]
fn settings_search_replaces_keymap_search_and_keeps_section_queries(cx: &mut gpui::TestAppContext) {
+15
View File
@@ -64,6 +64,21 @@ impl SshHostLists {
)
}
/// 与 [`Self::merged`] 同一顺序权威,同时带出用户在 SSH 设置里起的
/// 主机名称(地址 → 别名;没起名的是空串,展示端回落地址本身)。
/// profiles 在 [`Self::load`] 时已经读盘,这里不重复读 JSON;launcher /
/// Quick Jump / 命令面板三处 SSH 行共用,避免各自再解析一次。
pub fn merged_with_labels(&self) -> Vec<(String, String)> {
let labels = self.profiles.labels();
self.merged()
.into_iter()
.map(|host| {
let label = labels.get(&host).cloned().unwrap_or_default();
(host, label)
})
.collect()
}
/// 隐藏区(设置页"已隐藏"折叠列表的数据源)。
pub fn hidden_hosts(&self) -> Vec<String> {
self.hidden.iter().filter(|host| self.configured.contains(host)).cloned().collect()
+71 -49
View File
@@ -9,8 +9,6 @@
//! 共同塑形,再由 `ligatures` 把字形簇映射回固定列号。其余字符逐格
//! 绘制,组合字符跟随基字;编辑、光标和选区不能改变无关格的原点。
use std::collections::HashSet;
use gpui::{
App, Bounds, ContentMask, Corners, CursorStyle, DispatchPhase, Element, ElementId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, MouseButton,
@@ -28,6 +26,10 @@ use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor};
use super::colors::Palette;
use super::view::TerminalView;
#[cfg(test)]
#[path = "element/color_tests.rs"]
mod color_tests;
pub struct TerminalElement {
view: gpui::Entity<TerminalView>,
}
@@ -84,7 +86,8 @@ impl TerminalElement {
rows: usize,
cols: usize,
cx: &App,
) -> Option<(RenderSnapshot, Option<String>, usize, HashSet<(u16, u16)>, usize, i64)> {
) -> Option<(RenderSnapshot, Option<String>, usize, super::osc_links::LinkCells, usize, i64)>
{
let view = self.view.read(cx);
let session = view.session.as_ref()?;
let hint_config = view.hint_config.clone();
@@ -139,12 +142,23 @@ impl TerminalElement {
fn resolve_app_colors(
&self,
snap: &mut RenderSnapshot,
links: &mut super::osc_links::LinkCells,
theme: &Palette,
overrides: &Colors,
cx: &mut App,
) {
self.view.update(cx, |view, _| {
resolve_app_colors_into(snap, theme, overrides, &mut view.color_resolver);
for cell in links.values_mut() {
cell.fg = resolve_text_foreground(
cell.fg,
cell.bg,
cell.bold,
theme,
overrides,
&mut view.color_resolver,
);
}
});
}
}
@@ -281,7 +295,7 @@ impl Element for TerminalElement {
let focused = focus_handle.is_focused(window);
// 旧壳只让光标本身参与闪烁;ghost、弹窗补齐和 IME 仍复用同一个坐标锚点。
let cursor_visible = self.view.read(cx).cursor_visible();
let Some((mut snap, prompt_line, history, dashed, scrollback_floor, viewport_top_abs)) =
let Some((mut snap, prompt_line, history, mut dashed, scrollback_floor, viewport_top_abs)) =
self.snapshot(layout.rows, layout.cols, cx)
else {
return;
@@ -295,7 +309,7 @@ impl Element for TerminalElement {
view.drive_pending_remote_dir(cx);
});
let overrides = snap.color_overrides;
self.resolve_app_colors(&mut snap, &theme, &overrides, cx);
self.resolve_app_colors(&mut snap, &mut dashed, &theme, &overrides, cx);
let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx);
let host_cursor_follows_theme = is_default_host_cursor(&theme);
let app_cursor = snap.cursor.as_ref().filter(|cursor| {
@@ -606,7 +620,7 @@ impl Element for TerminalElement {
} else {
theme.resolve(cell.fg, &overrides, cell.bold).into()
};
let dashed_link = dashed.contains(&(seg.row, cell.col));
let dashed_link = dashed.contains_key(&(seg.row, cell.col));
let underline = (cell.underline && !dashed_link).then(|| UnderlineStyle {
thickness: px(1.0),
color: Some(fg),
@@ -639,7 +653,7 @@ impl Element for TerminalElement {
== cursor_inverts(seg.row, cell.col)
&& selected_foreground(seg.row, next.col)
== selected_foreground(seg.row, cell.col)
&& !dashed.contains(&(seg.row, next.col))
&& !dashed.contains_key(&(seg.row, next.col))
})
} else {
1
@@ -674,18 +688,35 @@ impl Element for TerminalElement {
layout.line_height,
);
}
if dashed_link {
paint_dashed_underline(
window,
origin,
layout.cell_width,
layout.line_height,
fg,
);
}
}
}
// Link decoration follows grid columns, including wide-character spacer
// cells and spaces omitted by text shaping. Every cell shares one dash
// phase, so font fallback and ASCII/CJK boundaries cannot restart it.
for (&(row, col), cell) in &dashed {
if math_frame.covers(row as usize, col as usize) {
continue;
}
let Some(visual_col) = math_frame.project_cell(row as usize, col as usize, layout.cols)
else {
continue;
};
let color = if cursor_inverts(row, col) {
theme.cursor_text.unwrap_or(theme.background)
} else if let Some(foreground) = selected_foreground(row, col) {
foreground
} else {
theme.resolve(cell.fg, &overrides, cell.bold)
};
super::link_underline::paint(
window,
cell_rect(row as usize, visual_col, 1),
bounds.origin.x,
color.into(),
);
}
// 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前,
// 与旧壳 draw_rects → draw_overlays 的次序一致。
if !math_frame.is_empty() {
@@ -945,27 +976,6 @@ impl Element for TerminalElement {
}
}
fn paint_dashed_underline(
window: &mut Window,
origin: gpui::Point<Pixels>,
cell_width: Pixels,
line_height: Pixels,
color: Hsla,
) {
let y = origin.y + line_height - px(1.0);
let end: f32 = origin.x.as_f32() + cell_width.as_f32();
let mut x: f32 = origin.x.as_f32();
let dash: f32 = 3.0;
let gap: f32 = 2.0;
while x < end {
let width = f32::min(dash, end - x);
if width > 0.0 {
window.paint_quad(fill(Bounds::new(point(px(x), y), size(px(width), px(1.0))), color));
}
x += dash + gap;
}
}
/// Align geometry-only terminal primitives to the device pixel grid. Cell
/// widths/heights are already measured in device pixels and converted back to
/// logical units, but a pane origin can still be fractional after a split or
@@ -1642,9 +1652,6 @@ fn resolve_app_colors_into(
use crate::display::content::is_terminal_graphic;
use crate::display::terminal_color::is_fixed_color;
let theme_fg = rgb_from_rgba(theme.foreground);
let theme_bg = rgb_from_rgba(theme.background);
for run in &mut snap.bg_runs {
let base = rgb_from_rgba(theme.resolve(run.color, overrides, false));
let resolved = resolver.resolve_background(base, is_fixed_color(run.color, overrides));
@@ -1662,18 +1669,33 @@ fn resolve_app_colors_into(
// 对比度是一对颜色的属性:这个前景可不可读,取决于它**这一格**底下是
// 什么,而不是主题底色。默认底色的格子没有 bg run,所以 `SnapCell::bg`
// 单独带着这个值。
let bg_base = rgb_from_rgba(theme.resolve(cell.bg, overrides, false));
let bg = resolver.resolve_background(bg_base, is_fixed_color(cell.bg, overrides));
// bold 提亮(0-7 → 8-15)必须发生在矫正**之前**,否则写回的 `Spec` 会把
// 提亮吃掉。
let base = rgb_from_rgba(theme.resolve(cell.fg, overrides, cell.bold));
let resolved = resolver.resolve_foreground(base, bg, true, theme_fg, theme_bg);
if resolved != base {
cell.fg = Color::Spec(resolved.0);
}
cell.fg = resolve_text_foreground(cell.fg, cell.bg, cell.bold, theme, overrides, resolver);
}
}
fn resolve_text_foreground(
fg: Color,
bg: Color,
bold: bool,
theme: &Palette,
overrides: &Colors,
resolver: &mut crate::display::terminal_color::TerminalColorResolver,
) -> Color {
use crate::display::terminal_color::is_fixed_color;
let bg_base = rgb_from_rgba(theme.resolve(bg, overrides, false));
let bg = resolver.resolve_background(bg_base, is_fixed_color(bg, overrides));
// Resolve bold before contrast adjustment; Spec must retain that brightening.
let base = rgb_from_rgba(theme.resolve(fg, overrides, bold));
let resolved = resolver.resolve_foreground(
base,
bg,
true,
rgb_from_rgba(theme.foreground),
rgb_from_rgba(theme.background),
);
if resolved != base { Color::Spec(resolved.0) } else { fg }
}
pub(super) fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba {
Rgba {
r: f32::from(color.r) / 255.0,
@@ -0,0 +1,99 @@
//! Replay the terminal features required by Codex message surfaces and stars.
//! Fixture colors follow codex rust-v0.154.0 tui/src/style.rs and
//! bottom_pane/chat_composer/sparkle.rs; this does not emulate a live Codex session.
use nebula_terminal::event::VoidListener;
use nebula_terminal::render::{RenderSnapshot, SnapshotConfig};
use nebula_terminal::term::color::Colors;
use nebula_terminal::term::test::TermSize;
use nebula_terminal::term::{Config, Term};
use nebula_terminal::vte::ansi::{self, Color, NamedColor, Rgb};
use super::{Palette, resolve_app_colors_into, rgb_from_rgba};
use crate::display::terminal_color::TerminalColorResolver;
use crate::gpui_shell::terminal::colors::from_ansi_rgb;
use crate::gpui_shell::theme::ResolvedTheme;
fn palette(name: nebula_settings::ThemeName) -> Palette {
let resolved = ResolvedTheme::builtin(name, None);
let color = |[r, g, b]: [u8; 3]| from_ansi_rgb(Rgb { r, g, b });
Palette {
background: color(resolved.terminal_background()),
foreground: color(resolved.terminal_foreground()),
..Palette::default()
}
}
fn capture(palette: &Palette) -> (RenderSnapshot, Rgb) {
let bg = palette.query_reply(NamedColor::Background as usize, &Colors::default());
let fg = palette.query_reply(NamedColor::Foreground as usize, &Colors::default());
let (top, alpha) = if palette.is_dark() { (255.0, 0.12) } else { (0.0, 0.04) };
let blend = |channel: u8| (channel as f32 * (1.0 - alpha) + top * alpha).round() as u8;
let user = Rgb { r: blend(bg.r), g: blend(bg.g), b: blend(bg.b) };
let bytes = format!(
"\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m用户 message\x1b[K\r\n⠁⠂⠄⠈⠐⠠⡀⢀\x1b[K\r\n\x1b[0mAI reply",
user.r, user.g, user.b, fg.r, fg.g, fg.b,
);
let mut term = Term::new(Config::default(), &TermSize::new(40, 4), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
parser.advance(&mut term, bytes.as_bytes());
(RenderSnapshot::capture(&term, &SnapshotConfig { rows: 4, cols: 40 }), user)
}
#[test]
fn every_builtin_theme_keeps_message_surfaces_and_braille_stars() {
for name in nebula_settings::ThemeName::BUILTIN {
let palette = palette(name);
let (mut snapshot, user) = capture(&palette);
assert_ne!(from_ansi_rgb(user), palette.background, "{name:?}");
let star_colors: Vec<_> = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.fg))
.collect();
let mut resolver = TerminalColorResolver::default();
resolve_app_colors_into(&mut snapshot, &palette, &Colors::default(), &mut resolver);
for row in [0, 1] {
let run = snapshot.bg_runs.iter().find(|run| run.row == row).unwrap();
assert_eq!((run.start, run.end, run.color), (0, 40, Color::Spec(user)), "{name:?}");
}
assert!(
snapshot.bg_runs.iter().all(|run| run.row != 2),
"{name:?}: AI reply acquired a message background"
);
let stars: String = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.text.as_str()))
.collect();
assert_eq!(stars, "⠁⠂⠄⠈⠐⠠⡀⢀");
let after: Vec<_> = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.fg))
.collect();
assert_eq!(star_colors, after, "{name:?}: star colors were changed");
}
}
#[test]
fn message_background_remains_distinct_after_every_builtin_theme_switch() {
for from in nebula_settings::ThemeName::BUILTIN {
for to in nebula_settings::ThemeName::BUILTIN {
let original = palette(from);
let next = palette(to);
let (mut snapshot, _) = capture(&original);
let mut resolver = TerminalColorResolver::default();
resolver
.theme_changed(rgb_from_rgba(original.background), rgb_from_rgba(next.background));
resolve_app_colors_into(&mut snapshot, &next, &Colors::default(), &mut resolver);
let user = snapshot.bg_runs.iter().find(|run| run.row == 0).unwrap();
let background = next.resolve(user.color, &Colors::default(), false);
assert_ne!(background, next.background, "{from:?} -> {to:?}");
assert!(snapshot.bg_runs.iter().all(|run| run.row != 2));
}
}
}
@@ -0,0 +1,130 @@
//! Link dashes in physical grid coordinates, independent of glyph width/shaping.
use gpui::{Bounds, Hsla, Pixels, Window, fill, point, px, size};
pub(super) fn paint(window: &mut Window, cell: Bounds<Pixels>, grid_left: Pixels, color: Hsla) {
let scale = window.scale_factor().max(0.5);
let y = (cell.bottom().as_f32() * scale).round();
let thickness = scale.round().max(1.0);
for (left, right) in
dash_segments(cell.left().as_f32(), cell.right().as_f32(), grid_left.as_f32(), scale)
{
window.paint_quad(fill(
Bounds::new(
point(px(left / scale), px((y - thickness) / scale)),
size(px((right - left) / scale), px(thickness / scale)),
),
color,
));
}
}
fn dash_segments(
left: f32,
right: f32,
anchor: f32,
scale: f32,
) -> impl Iterator<Item = (f32, f32)> {
let left = (left * scale).round();
let right = (right * scale).round();
let anchor = (anchor * scale).round();
let dash = (3.0 * scale).round().max(1.0);
let period = dash + (2.0 * scale).round().max(1.0);
let mut x = anchor + ((left - anchor) / period).floor() * period;
std::iter::from_fn(move || {
while x < right {
let start = x.max(left);
let end = (x + dash).min(right);
x += period;
if start < end {
return Some((start, end));
}
}
None
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
use crate::config::UiConfig;
use crate::gpui_shell::terminal::osc_links::dashed_cells;
use nebula_terminal::event::VoidListener;
use nebula_terminal::term::test::TermSize;
use nebula_terminal::term::{Config, Term};
use nebula_terminal::vte::ansi;
#[test]
#[ignore = "informational hot-path measurement; no machine-specific timing gate"]
fn measure_visible_link_decoration_work() {
for (name, linked, text) in
[("plain", false, "x"), ("ascii", true, "x"), ("cjk", true, "中")]
{
let mut term = Term::new(Config::default(), &TermSize::new(120, 40), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
let text = text.repeat(if name == "cjk" { 50 } else { 100 });
let row = if linked {
format!("\x1b]8;;file:///fixture\x1b\\{text}\x1b]8;;\x1b\\\r\n")
} else {
format!("{text}\r\n")
};
parser.advance(&mut term, row.repeat(35).as_bytes());
let config = UiConfig::default();
let started = std::time::Instant::now();
let mut last_cells = 0;
for _ in 0..200 {
let cells = dashed_cells(&term, &config, 40, 120);
last_cells = cells.len();
let segments: usize = cells
.keys()
.map(|&(_, col)| {
dash_segments(
2.3 + f32::from(col) * 8.4,
2.3 + f32::from(col + 1) * 8.4,
2.3,
1.25,
)
.count()
})
.sum();
std::hint::black_box((cells, segments));
}
crate::gpui_shell::try_write_stderr(format_args!(
"{name}: 120x40 grid, {last_cells} linked columns, {:.1} us/pass (capture + dash geometry, no GPU)",
started.elapsed().as_secs_f64() * 1_000_000.0 / 200.0
));
}
}
#[test]
fn real_osc8_grid_covers_cjk_spacers_and_spaces_with_one_dash_phase() {
let mut term = Term::new(Config::default(), &TermSize::new(40, 2), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
parser.advance(
&mut term,
"\x1b]8;;file:///tmp/example\x1b\\A开始 菜单Z\x1b]8;;\x1b\\".as_bytes(),
);
let cells = dashed_cells(&term, &UiConfig::default(), 2, 40);
assert_eq!(cells.len(), 11);
assert!((0..11).all(|col| cells.contains_key(&(0, col))));
for scale in [1.0, 1.25, 1.5, 2.0] {
for width in [7.0, 8.4, 9.5, 12.0] {
let anchor = 2.3;
let pixels = |left, right| {
dash_segments(left, right, anchor, scale)
.flat_map(|(left, right)| left as i32..right as i32)
};
let actual: BTreeSet<_> = cells
.keys()
.flat_map(|&(_, col)| {
pixels(anchor + f32::from(col) * width, anchor + f32::from(col + 1) * width)
})
.collect();
let expected: BTreeSet<_> = pixels(anchor, anchor + 11.0 * width).collect();
assert_eq!(actual, expected, "scale={scale}, width={width}");
}
}
}
}
@@ -12,6 +12,7 @@ mod event_mailbox;
mod inline_image;
pub mod keymap;
mod ligatures;
mod link_underline;
pub mod math_overlay;
mod molecule_overlay;
pub mod mouse_protocol;
+98 -11
View File
@@ -1,21 +1,32 @@
//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、Ctrl+点击打开。
//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、平台修饰键+点击打开。
//!
//! 匹配与动作全部复用旧壳 `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;
use crate::config::UiConfig;
use crate::config::ui_config::{HintAction, HintInternalAction, default_hint_command};
use crate::display::hint::{self, HintMatch};
use crate::i18n::{Message, UiLanguage};
use crate::platform::Platform;
pub(super) fn link_modifier(mods: &gpui::Modifiers) -> bool {
match Platform::current() {
Platform::MacOS => mods.platform,
Platform::Windows | Platform::Linux => mods.control,
}
}
/// 悬停目标:旧壳 `highlighted_hint` + 已经解码好的预览文案。
#[derive(Clone)]
@@ -48,25 +59,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
@@ -78,7 +106,13 @@ pub(super) fn highlighted_at<T: EventListener>(
point: Point,
mods: &gpui::Modifiers,
) -> Option<HintMatch> {
hint::highlighted_at(term, config, point, winit_mouse_mods(mods))
hint::highlighted_at_with_mouse_override(
term,
config,
point,
winit_mouse_mods(mods),
link_modifier(mods),
)
}
pub(super) fn hover_from_hint<T: EventListener>(
@@ -86,6 +120,7 @@ pub(super) fn hover_from_hint<T: EventListener>(
hint: HintMatch,
rows: usize,
cols: usize,
language: UiLanguage,
) -> Option<LinkHover> {
let raw = hint
.hyperlink()
@@ -98,24 +133,76 @@ pub(super) fn hover_from_hint<T: EventListener>(
point_to_viewport_from(origin, start).filter(|vp| vp.line < rows && vp.column.0 < cols);
let (anchor_row, anchor_col) =
vp.map(|vp| (vp.line as u16, vp.column.0 as u16)).unwrap_or((0, 0));
const HINT: &str = " · Ctrl+点击";
let gesture = language.text(match Platform::current() {
Platform::MacOS => Message::CommonLinkCommandClick,
Platform::Windows | Platform::Linux => Message::CommonLinkCtrlClick,
});
let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() };
let target = crate::display::strip_file_scheme(uri);
let budget = cols.saturating_sub(width(HINT) + 1);
let budget = cols.saturating_sub(width(gesture) + width(" · ") + 1);
let target = crate::display::fit_tail(&target, budget);
Some(LinkHover { hint, preview: format!("{target}{HINT}"), anchor_row, anchor_col })
Some(LinkHover { hint, preview: format!("{target} · {gesture}"), anchor_row, anchor_col })
}
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,
+1 -1
View File
@@ -402,7 +402,7 @@ pub struct TerminalView {
selection_scroll_epoch: u64,
/// 是否已有一条自动回滚定时器链在跑——每次 move 都开一条会叠出 N 倍速。
selection_scroll_active: bool,
/// OSC 8 / 正则 URL:虚线下划线、悬停预览、Ctrl+点击打开。
/// OSC 8 / 正则 URL:虚线下划线、悬停预览、平台修饰键+点击打开。
pub(super) hint_config: Arc<UiConfig>,
pub(super) link_hover: Option<super::osc_links::LinkHover>,
pending_link_open: bool,
@@ -1,3 +1,4 @@
use super::super::osc_links::link_modifier;
use super::*;
use gpui_component::WindowExt as _;
@@ -143,7 +144,13 @@ impl TerminalView {
let term = session.term.lock();
super::super::osc_links::highlighted_at(&term, &self.hint_config, point, mods)
.and_then(|hint| {
super::super::osc_links::hover_from_hint(&term, hint, self.rows, self.cols)
super::super::osc_links::hover_from_hint(
&term,
hint,
self.rows,
self.cols,
crate::gpui_shell::config::ui_language(cx),
)
})
})
};
@@ -178,7 +185,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 按住时强制旁路——这是
@@ -355,6 +369,7 @@ impl TerminalView {
cx: &mut Context<Self>,
) {
window.focus(&self.focus_handle, cx);
self.pending_link_open = false;
cx.emit(TerminalViewEvent::FocusRequested);
if self.session.is_none() {
return;
@@ -400,6 +415,17 @@ impl TerminalView {
cx.notify();
return;
}
if link_modifier(&event.modifiers) && event.click_count == 1 {
self.update_link_hover(event.position, &event.modifiers, cx);
if self.link_hover.is_some() {
if let Some(session) = &self.session {
session.term.lock().selection = None;
}
self.selecting = false;
self.pending_link_open = true;
return;
}
}
if self.mouse_mode_active(&event.modifiers) {
self.send_mouse_report(
event.position,
@@ -409,28 +435,6 @@ impl TerminalView {
);
return;
}
if event.modifiers.control && event.click_count == 1 {
let (point, _) = self.grid_point(event.position);
let hit = self.session.as_ref().is_some_and(|session| {
let term = session.term.lock();
super::super::osc_links::highlighted_at(
&term,
&self.hint_config,
point,
&event.modifiers,
)
.is_some()
});
if hit {
if let Some(session) = &self.session {
session.term.lock().selection = None;
}
self.selecting = false;
self.pending_link_open = true;
self.update_link_hover(event.position, &event.modifiers, cx);
return;
}
}
let (point, side) = self.grid_point(event.position);
let ty = match event.click_count {
1 => SelectionType::Simple,
@@ -465,6 +469,11 @@ impl TerminalView {
window: &mut Window,
cx: &mut Context<Self>,
) {
// Retain the pressed link until release; dragging must not retarget it
// or leak part of the consumed gesture to the application.
if self.pending_link_open {
return;
}
if self.move_completion_popup_scrollbar(event, cx) {
cx.stop_propagation();
return;
@@ -524,6 +533,12 @@ impl TerminalView {
window.focus(&self.focus_handle, cx);
cx.emit(TerminalViewEvent::FocusRequested);
}
if event.pressed_button.is_none() && link_modifier(&event.modifiers) {
self.update_link_hover(event.position, &event.modifiers, cx);
if self.link_hover.is_some() {
return;
}
}
if self.mouse_mode_active(&event.modifiers) {
self.clear_link_hover(cx);
// 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一),
@@ -574,7 +589,8 @@ impl TerminalView {
cx.notify();
return;
}
if !self.selecting && self.mouse_mode_active(&event.modifiers) {
let pending_link_open = std::mem::take(&mut self.pending_link_open);
if !pending_link_open && !self.selecting && self.mouse_mode_active(&event.modifiers) {
self.send_mouse_report(
event.position,
mouse_protocol::BUTTON_LEFT,
@@ -584,15 +600,15 @@ impl TerminalView {
return;
}
self.selecting = false;
let open_link = (self.pending_link_open || event.modifiers.control)
let (point, _) = self.grid_point(event.position);
let open_link = pending_link_open
&& self.selection_is_empty()
&& self.link_hover.is_some();
&& self.link_hover.as_ref().is_some_and(|hover| hover.hint.bounds().contains(&point));
if open_link {
self.try_open_hovered_link(window, cx);
} else if self.copy_on_select {
self.copy_selection(false, window, cx);
}
self.pending_link_open = false;
cx.notify();
}
@@ -612,6 +628,7 @@ impl TerminalView {
}
self.stop_selection_scroll();
let dragging_scrollbar = self.scrollbar_drag.take().is_some();
self.pending_link_open = false;
if !self.selecting {
if dragging_scrollbar {
cx.notify();
@@ -619,7 +636,6 @@ impl TerminalView {
return;
}
self.selecting = false;
self.pending_link_open = false;
// 指针不在终端上,链接打开不该发生;只补选中即复制这一条收尾。
if self.copy_on_select {
self.copy_selection(false, window, cx);
@@ -151,3 +151,178 @@ fn hover_does_not_steal_focus_during_drag_overlay_dialog_or_inactive_window(
move_over(&mut cx, None);
assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 0);
}
fn link_modifiers() -> Modifiers {
match crate::platform::Platform::current() {
crate::platform::Platform::MacOS => Modifiers { platform: true, ..Modifiers::default() },
_ => Modifiers { control: true, ..Modifiers::default() },
}
}
fn link_fixture(
cx: &mut TestAppContext,
text: &[u8],
) -> (Entity<TerminalView>, VisualTestContext, std::sync::mpsc::Receiver<Msg>) {
cx.update(crate::gpui_shell::math_view::register);
let (probe, mut cx) = open(cx);
let terminal = probe.read_with(&cx, |probe, _| probe.terminal.clone());
let receiver = terminal.update(&mut cx, |view, cx| {
let (session, receiver) = session::test_session();
view.session = Some(session);
view.error = None;
view.exited = None;
view.copy_on_select = false;
// Use the production matcher and dispatcher with a deterministic action;
// tests must not launch a user's browser or editor.
let mut config = UiConfig::default();
Arc::make_mut(&mut config.hints.enabled[0]).action =
crate::config::ui_config::HintAction::Action(
crate::config::ui_config::HintInternalAction::Copy,
);
view.hint_config = Arc::new(config);
super::super::startup_tests::feed(view, text);
cx.notify();
receiver
});
draw(&mut cx);
cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into())));
(terminal, cx, receiver)
}
fn cell(view: &Entity<TerminalView>, cx: &VisualTestContext, col: usize) -> Point<Pixels> {
view.read_with(cx, |view, _| {
point(
view.origin.x + view.cell_width * (col as f32 + 0.5),
view.origin.y + view.line_height * 0.5,
)
})
}
fn clipboard(cx: &mut VisualTestContext) -> Option<String> {
cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()))
}
#[gpui::test]
fn link_gesture_opens_regex_files_and_osc8_with_or_without_mouse_reporting(
cx: &mut TestAppContext,
) {
for (output, target) in [
("https://example.com", "https://example.com"),
("file:///tmp/pebrel-notes.md", "file:///tmp/pebrel-notes.md"),
("[notes](./notes.md)", "[notes](./notes.md)"),
("\x1b]8;;https://example.com/osc8\x1b\\site\x1b]8;;\x1b\\", "https://example.com/osc8"),
] {
for mouse_mode in [false, true] {
let output = if mouse_mode {
format!("\x1b[?1003h\x1b[?1006h{output}")
} else {
output.to_owned()
};
let (view, mut window, receiver) = link_fixture(cx, output.as_bytes());
let position = cell(&view, &window, 1);
window.simulate_mouse_move(position, None, link_modifiers());
assert!(view.read_with(&window, |view, _| view.link_hover.is_some()));
window.simulate_mouse_down(position, MouseButton::Left, link_modifiers());
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
assert!(view.read_with(&window, |view, _| view.pending_link_open));
// Releasing Command/Ctrl first must not send an orphan mouse-up to the TUI.
window.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
assert_eq!(clipboard(&mut window).as_deref(), Some(target));
assert!(!receiver.try_iter().any(|event| matches!(event, Msg::Input(_))));
}
}
}
#[gpui::test]
fn plain_click_and_wrong_modifier_do_not_open_links(cx: &mut TestAppContext) {
let (view, mut window, _) = link_fixture(cx, b"https://example.com");
let position = cell(&view, &window, 1);
let wrong = if link_modifiers().platform {
Modifiers { control: true, ..Modifiers::default() }
} else {
Modifiers { platform: true, ..Modifiers::default() }
};
for modifiers in [Modifiers::default(), wrong] {
window.simulate_mouse_move(position, None, modifiers);
window.simulate_mouse_down(position, MouseButton::Left, modifiers);
window.simulate_mouse_up(position, MouseButton::Left, modifiers);
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
}
}
#[gpui::test]
fn link_drag_cannot_retarget_or_leave_a_pending_open(cx: &mut TestAppContext) {
let (view, mut window, _) = link_fixture(cx, b"https://one.test https://two.test");
let start = cell(&view, &window, 1);
for end in [cell(&view, &window, 20), point(px(10.0), px(10.0))] {
window.simulate_mouse_down(start, MouseButton::Left, link_modifiers());
window.simulate_mouse_move(end, Some(MouseButton::Left), link_modifiers());
window.simulate_mouse_up(end, MouseButton::Left, link_modifiers());
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
assert!(!view.read_with(&window, |view, _| view.pending_link_open));
}
}
#[gpui::test]
fn mouse_reporting_still_receives_ordinary_and_non_link_clicks(cx: &mut TestAppContext) {
let (view, mut window, receiver) =
link_fixture(cx, b"\x1b[?1000h\x1b[?1006hhttps://example.com plain");
for (col, modifiers) in [(1, Modifiers::default()), (22, link_modifiers())] {
let position = cell(&view, &window, col);
window.simulate_mouse_down(position, MouseButton::Left, modifiers);
window.simulate_mouse_up(position, MouseButton::Left, modifiers);
let reports: Vec<_> = receiver
.try_iter()
.filter_map(|event| match event {
Msg::Input(bytes) => Some(bytes.into_owned()),
_ => None,
})
.collect();
assert_eq!(reports.len(), 2);
assert!(reports[0].ends_with(b"M"));
assert!(reports[1].ends_with(b"m"));
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
}
}
#[gpui::test]
fn link_hover_uses_current_language_and_platform(cx: &mut TestAppContext) {
use crate::i18n::UiLanguage;
let (view, mut window, _) = link_fixture(cx, b"https://example.com");
let position = cell(&view, &window, 1);
let modifier = if link_modifiers().platform { "Command" } else { "Ctrl" };
for (language, suffix) in [
(UiLanguage::ZhCn, "+左键跳转"),
(UiLanguage::EnUs, "+left click to open"),
(UiLanguage::KoKr, "+왼쪽 클릭으로 열기"),
] {
window.update(|_, cx| cx.global_mut::<Settings>().ui_language = language);
window.simulate_mouse_move(position, None, Modifiers::default());
let preview =
view.read_with(&window, |view, _| view.link_hover.as_ref().unwrap().preview.clone());
assert!(preview.ends_with(&format!(" · {modifier}{suffix}")), "{preview}");
draw(&mut window);
}
}
#[gpui::test]
fn explicit_link_gesture_respects_disabled_hints_and_required_modifiers(cx: &mut TestAppContext) {
let (view, mut window, _) = link_fixture(cx, b"\x1b[?1000hhttps://example.com");
let position = cell(&view, &window, 1);
for enabled in [false, true] {
view.update(&mut window, |view, _| {
let config = Arc::make_mut(&mut view.hint_config);
let hint = Arc::make_mut(&mut config.hints.enabled[0]);
let mouse = hint.mouse.as_mut().unwrap();
mouse.enabled = enabled;
mouse.mods.0 = winit::keyboard::ModifiersState::SHIFT;
});
window.simulate_mouse_down(position, MouseButton::Left, link_modifiers());
window.simulate_mouse_up(position, MouseButton::Left, link_modifiers());
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
}
let modifiers = Modifiers { shift: true, ..link_modifiers() };
window.simulate_mouse_down(position, MouseButton::Left, modifiers);
window.simulate_mouse_up(position, MouseButton::Left, modifiers);
assert_eq!(clipboard(&mut window).as_deref(), Some("https://example.com"));
}
+1 -1
View File
@@ -2228,7 +2228,7 @@ impl NebulaWorkspace {
crate::terminal_profiles::TerminalProfiles::load()
.map(|store| store.as_config_profiles())
.unwrap_or_default(),
crate::gpui_shell::ssh_hosts::SshHostLists::load().merged(),
crate::gpui_shell::ssh_hosts::SshHostLists::load().merged_with_labels(),
&default_shell_id,
language,
window.scale_factor().max(0.5),
@@ -81,8 +81,6 @@ pub(super) fn default_workspace_bindings() -> Vec<KeyBinding> {
// ctrl+shift+s 上下、ctrl+shift+enter 缩放、ctrl+alt+方向切聚焦。
KeyBinding::new("ctrl-shift-d", SplitRight, None),
KeyBinding::new("ctrl-shift-s", SplitDown, None),
// F2 重命名活动标签(旧壳同键位);右键菜单的键帽读的就是这条。
KeyBinding::new("f2", RenameActiveTab, None),
KeyBinding::new("ctrl-shift-enter", ToggleZoom, None),
KeyBinding::new("ctrl-alt-left", FocusPaneLeft, None),
KeyBinding::new("ctrl-alt-right", FocusPaneRight, None),
@@ -117,10 +115,12 @@ pub(super) fn default_workspace_bindings() -> Vec<KeyBinding> {
KeyBinding::new("alt-enter", ToggleFullscreen, None),
KeyBinding::new("ctrl-shift-o", OpenQuickJump, None),
];
// Numeric shortcuts come from the same defaults shown by Settings and
// used by the legacy shell, including each platform's existing modifiers.
// Tab selection and rename use the same defaults displayed by Settings.
bindings.extend(crate::display::keymap::default_shortcuts().into_iter().filter_map(
|(combo, action)| {
if action == crate::config::Action::RenameTab {
return custom_workspace_binding(&combo, &action);
}
let action = SelectTab::from_config(&action)?;
Some(KeyBinding::new(&gpui_binding_combo(&combo), action, None))
},
@@ -223,6 +223,7 @@ fn workspace_binding_in_context(
Action::CreateNewTab => Some(KeyBinding::new(&combo, NewTerminal, scope)),
Action::CreateNewWindow => Some(KeyBinding::new(&combo, NewWindow, scope)),
Action::CloseTab => Some(KeyBinding::new(&combo, CloseActiveTerminal, scope)),
Action::RenameTab => Some(KeyBinding::new(&combo, RenameActiveTab, scope)),
Action::ToggleFilesPanel => Some(KeyBinding::new(&combo, ToggleFileTree, scope)),
Action::ToggleGitPanel => Some(KeyBinding::new(&combo, ToggleGitPanel, scope)),
Action::SplitRight => Some(KeyBinding::new(&combo, SplitRight, scope)),
@@ -249,8 +250,8 @@ fn workspace_binding_in_context(
)),
Action::ToggleFullscreen => Some(KeyBinding::new(&combo, ToggleFullscreen, scope)),
Action::OpenQuickJump => Some(KeyBinding::new(&combo, OpenQuickJump, scope)),
// `none` 禁用键:gpui 的 NoAction 绑定在最高优先级命中时吞掉按键,
// 与旧壳 keybind=combo:none 的语义一致。
// Mask the app action. ReceiveChar entries from the editor allow
// the terminal input handler to encode the released key.
Action::None | Action::ReceiveChar => Some(KeyBinding::new(&combo, gpui::NoAction, scope)),
_ => None,
}
@@ -200,9 +200,78 @@ mod dispatch {
assert_active(&workspace, 0, &mut cx);
}
#[gpui::test]
fn rename_shortcut_remaps_clears_and_restores_in_both_tab_layouts(cx: &mut TestAppContext) {
use crate::config::Action;
use crate::display::keymap;
let (_directory, workspace, mut cx) = open_workspace(3, cx);
for position in
[nebula_settings::TabsPositionName::Sidebar, nebula_settings::TabsPositionName::Top]
{
workspace.update(&mut cx, |workspace, cx| {
workspace.tabs_position = position;
workspace.update_keybinds(Vec::new(), cx);
});
press("ctrl-3", &mut cx);
press("f2", &mut cx);
assert_eq!(
workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)),
Some(2)
);
cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx)));
cx.run_until_parked();
let mut raw = Vec::new();
keymap::rebind_action(&mut raw, &Action::RenameTab, "ctrl+alt+r".into());
let saved = nebula_settings::apply_keybinds("", &raw);
let mut raw = nebula_settings::keybind_pairs_from_text(&saved);
workspace.update(&mut cx, |w, cx| w.update_keybinds(raw.clone(), cx));
press("f2", &mut cx);
assert!(workspace.read_with(&cx, |w, _| w.tab_rename.is_none()));
press("ctrl-alt-r", &mut cx);
assert_eq!(
workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)),
Some(2)
);
cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx)));
cx.run_until_parked();
cx.update(|window, _| {
let bindings = window.bindings_for_action(&RenameActiveTab);
assert!(
bindings
.iter()
.any(|b| b.match_keystrokes(&[Keystroke::parse("ctrl-alt-r").unwrap()])
== Some(false))
);
assert!(
!bindings
.iter()
.any(|b| b.match_keystrokes(&[Keystroke::parse("f2").unwrap()])
== Some(false))
);
});
keymap::clear_action(&mut raw, &Action::RenameTab);
workspace.update(&mut cx, |w, cx| w.update_keybinds(raw.clone(), cx));
press("ctrl-alt-r", &mut cx);
press("f2", &mut cx);
assert!(workspace.read_with(&cx, |w, _| w.tab_rename.is_none()));
keymap::reset_action(&mut raw, &Action::RenameTab);
workspace.update(&mut cx, |w, cx| w.update_keybinds(raw, cx));
press("f2", &mut cx);
assert_eq!(
workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)),
Some(2)
);
cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx)));
cx.run_until_parked();
}
}
struct TerminalKeyProbe {
focus: FocusHandle,
selected: Option<usize>,
renamed: usize,
mode: TermMode,
input: Vec<Keystroke>,
encoded: Vec<u8>,
@@ -215,6 +284,7 @@ mod dispatch {
.on_action(cx.listener(|this, action: &SelectTab, _, _| {
this.selected = action.index_for(10);
}))
.on_action(cx.listener(|this, _: &RenameActiveTab, _, _| this.renamed += 1))
.child(
div()
.size_full()
@@ -247,6 +317,7 @@ mod dispatch {
let probe = cx.new(|cx| TerminalKeyProbe {
focus: cx.focus_handle(),
selected: None,
renamed: 0,
mode: TermMode::empty(),
input: Vec::new(),
encoded: Vec::new(),
@@ -298,4 +369,67 @@ mod dispatch {
assert!(!probe.encoded.is_empty());
});
}
#[gpui::test]
fn remapped_rename_releases_f2_to_negotiated_terminal_input(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_component::init(cx);
init(cx);
let mut raw = Vec::new();
crate::display::keymap::rebind_action(
&mut raw,
&crate::config::Action::RenameTab,
"ctrl+alt+r".into(),
);
for (combo, name) in raw {
let action = crate::display::keymap::parse_action(&name).unwrap();
for scope in [None, Some(crate::gpui_shell::terminal::KEY_CONTEXT)] {
cx.bind_keys([workspace_binding_in_context(&combo, &action, scope).unwrap()]);
}
}
});
let mut probe_out = None;
let (_, cx) = cx.add_window_view(|window, cx| {
let probe = cx.new(|cx| TerminalKeyProbe {
focus: cx.focus_handle(),
selected: None,
renamed: 0,
mode: TermMode::empty(),
input: Vec::new(),
encoded: Vec::new(),
});
let focus = probe.read(cx).focus.clone();
focus.focus(window, cx);
probe_out = Some(probe.clone());
Root::new(probe, window, cx)
});
let probe = probe_out.unwrap();
for mode in [
TermMode::empty(),
TermMode::WIN32_INPUT_MODE,
TermMode::DISAMBIGUATE_ESC_CODES,
TermMode::REPORT_ALL_KEYS_AS_ESC,
] {
probe.update(cx, |p, _| {
p.mode = mode;
p.input.clear();
p.encoded.clear();
p.renamed = 0;
});
press("ctrl-alt-r", cx);
probe.read_with(cx, |p, _| {
assert_eq!(p.renamed, 1);
assert!(p.input.is_empty());
});
press("f2", cx);
probe.read_with(cx, |p, _| {
assert_eq!(p.renamed, 1);
assert_eq!(p.input.last(), Some(&Keystroke::parse("f2").unwrap()));
assert!(!p.encoded.is_empty(), "F2 must reach the CLI");
if mode.is_empty() {
assert_eq!(p.encoded, b"\x1bOQ");
}
});
}
}
}
@@ -37,7 +37,7 @@ fn open(cx: &mut TestAppContext) -> (Entity<NebulaWorkspace>, VisualTestContext)
workspace.palette_override = Some(shell_palette_rows(
vec![shell("cmd"), shell("pwsh")],
vec![],
["test-host".into()],
[("test-host".into(), String::new())],
"cmd",
workspace_ui_language(),
1.0,
+12 -7
View File
@@ -100,26 +100,31 @@ impl NebulaWorkspace {
// 次序由共享 merge 权威裁定。
let ssh_icons = ssh_host_icon_ids(&crate::display::nebula_data_dir());
rows.extend(
crate::gpui_shell::ssh_hosts::SshHostLists::load().merged().into_iter().map(
|host| {
crate::gpui_shell::ssh_hosts::SshHostLists::load()
.merged_with_labels()
.into_iter()
.map(|(host, label)| {
let glyph = crate::display::ui::os_icons::resolve(
ssh_icons.get(&host).map(String::as_str),
)
.glyph;
let named = (!label.is_empty()).then_some(label.as_str());
let (label, hint) = shell_picker::ssh_host_display(named, &host);
let search =
format!("{label} {host} ssh host remote lianjie 连接").to_lowercase();
WorkspacePaletteRow {
group_order: usize::MAX,
group: language.pick("SSH 主机", "SSH HOSTS").to_owned(),
label: host.clone(),
hint: "SSH".to_owned(),
label,
hint,
hint_style: WorkspacePaletteHintStyle::Metadata,
search: format!("{host} ssh host remote lianjie 连接").to_lowercase(),
search,
action: WorkspacePaletteAction::LaunchSshHost(host),
icon: None,
icon_glyph: Some(glyph),
icon_path: None,
}
},
),
}),
);
rows
});
@@ -143,18 +143,21 @@ fn ssh_rows(language: crate::display::UiLanguage) -> Vec<WorkspacePaletteRow> {
let group = language.pick("SSH 主机", "SSH hosts");
let icons = ssh_host_icon_ids(&crate::display::nebula_data_dir());
crate::gpui_shell::ssh_hosts::SshHostLists::load()
.merged()
.merged_with_labels()
.into_iter()
.map(|host| {
.map(|(host, label)| {
let glyph =
crate::display::ui::os_icons::resolve(icons.get(&host).map(String::as_str)).glyph;
let named = (!label.is_empty()).then_some(label.as_str());
let (label, hint) = shell_picker::ssh_host_display(named, &host);
let search = format!("{label} {host} ssh host remote 远程 连接").to_lowercase();
WorkspacePaletteRow {
group_order: 3,
group: group.to_owned(),
label: host.clone(),
hint: "SSH".to_owned(),
label,
hint,
hint_style: super::WorkspacePaletteHintStyle::Metadata,
search: format!("{host} ssh host remote 远程 连接").to_lowercase(),
search,
action: WorkspacePaletteAction::LaunchSshHost(host),
icon: None,
icon_glyph: Some(glyph),
@@ -15,13 +15,24 @@ fn fallback_shell_glyph(id: &str, has_brand: bool) -> Option<char> {
crate::shell_detect::icon_for_id(id).chars().next()
}
/// SSH 主机的展示名与次要信息,与旧壳 `SshRow` 同口径:
///
/// - 用户在 SSH 设置里起过"主机名称":label 显示别名,hint 给真实连接地址;
/// - 没起名:回落地址本身当 label,hint 保持 "SSH" 类型标签,避免重复。
pub(super) fn ssh_host_display(label: Option<&str>, host: &str) -> (String, String) {
match label {
Some(label) if !label.trim().is_empty() => (label.trim().to_owned(), host.to_owned()),
_ => (host.to_owned(), "SSH".to_owned()),
}
}
/// 新建终端弹窗的行:已检测 shell + SSH 主机,分组对照旧壳
/// `CommandPalette::open_profiles`(推荐 / 所有 Shell / SSH 主机)。
/// 三点菜单与 Ctrl+K 打开的是这份列表,不是通用命令面板。
pub(super) fn shell_palette_rows(
shells: Vec<crate::shell_detect::DetectedShell>,
profiles: Vec<crate::config::ui_config::Profile>,
ssh_hosts: impl IntoIterator<Item = String>,
ssh_hosts: impl IntoIterator<Item = (String, String)>,
default_shell_id: &str,
language: crate::display::UiLanguage,
scale_factor: f32,
@@ -85,16 +96,20 @@ pub(super) fn shell_palette_rows(
rows.insert(0, default_row);
}
let ssh_icons = ssh_host_icon_ids(&crate::display::nebula_data_dir());
rows.extend(ssh_hosts.into_iter().map(|host| {
rows.extend(ssh_hosts.into_iter().map(|(host, label)| {
let glyph =
crate::display::ui::os_icons::resolve(ssh_icons.get(&host).map(String::as_str)).glyph;
// 空串 = 没起名:`ssh_host_display` 回落地址本身,hint 保持 "SSH"。
let named = (!label.is_empty()).then_some(label.as_str());
let (label, hint) = ssh_host_display(named, &host);
let search = format!("{label} {host} ssh host remote lianjie 连接").to_lowercase();
WorkspacePaletteRow {
group_order: 2,
group: ssh_group.to_owned(),
label: host.clone(),
hint: "SSH".to_owned(),
label,
hint,
hint_style: WorkspacePaletteHintStyle::Metadata,
search: format!("{host} ssh host remote lianjie 连接").to_lowercase(),
search,
action: WorkspacePaletteAction::LaunchSshHost(host),
icon: None,
icon_glyph: Some(glyph),
+40 -39
View File
@@ -27,8 +27,10 @@ use gpui::{
};
use gpui_component::menu::PopupMenuItem;
use crate::display::UiLanguage;
use crate::display::color::Rgb;
use crate::gpui_shell::prelude::*;
use crate::i18n::Message;
use crate::session::LaunchSession;
use nebula_split::SplitDirection;
@@ -73,7 +75,7 @@ pub(super) fn copy_working_directory_item(
let available = source.read(cx).working_directory().is_some();
let source = source.downgrade();
PopupMenuItem::new(
super::workspace_ui_language().text(crate::i18n::Message::CommonCopyWorkingDirectory),
crate::gpui_shell::config::ui_language(cx).text(Message::CommonCopyWorkingDirectory),
)
.icon(IconName::Folder)
.disabled(!available)
@@ -101,6 +103,7 @@ impl NebulaWorkspace {
}
self.activate_tab(ix, window, cx);
let terminal = self.tabs.get(ix).is_some_and(WorkspaceTab::is_terminal);
let language = crate::gpui_shell::config::ui_language(cx);
let ai_fork = tab_ai_fork_enabled(self, ix, cx);
let color = self.meta(ix).color;
let tab_count = self.tabs.len();
@@ -116,25 +119,18 @@ impl NebulaWorkspace {
let menu = PopupMenu::build(window, cx, move |mut menu, _window, _cx| {
if let Some(view) = choose_session {
menu = menu.item(
PopupMenuItem::new(
super::workspace_ui_language()
.text(crate::i18n::Message::SessionChooseConversation),
)
.on_click(move |_, _, cx| {
view.update(cx, |view, cx| view.choose_recovery_session(cx))
}),
PopupMenuItem::new(language.text(Message::SessionChooseConversation)).on_click(
move |_, _, cx| {
view.update(cx, |view, cx| view.choose_recovery_session(cx))
},
),
);
}
if let Some(view) = retry {
menu = menu
.item(
PopupMenuItem::new(
super::workspace_ui_language().text(crate::i18n::Message::SessionRetry),
)
.on_click(move |_, _, cx| {
view.update(cx, |view, cx| view.retry_recovery(cx))
}),
)
.item(PopupMenuItem::new(language.text(Message::SessionRetry)).on_click(
move |_, _, cx| view.update(cx, |view, cx| view.retry_recovery(cx)),
))
.separator();
}
if let Some(copy_cwd) = copy_cwd {
@@ -148,6 +144,7 @@ impl NebulaWorkspace {
ai_fork,
color,
tab_count,
language,
)
});
menu.focus_handle(cx).focus(window, cx);
@@ -187,19 +184,22 @@ impl NebulaWorkspace {
ai_fork: bool,
color: Option<Rgb>,
tab_count: usize,
language: UiLanguage,
) -> PopupMenu {
if ai_fork {
let target = workspace.clone();
menu = menu
.item(PopupMenuItem::new("分叉 AI 会话").icon(IconName::Bot).on_click(
move |_, window, cx| {
if let Some(workspace) = target.upgrade() {
workspace.update(cx, |workspace, cx| {
workspace.fork_ai_session(ix, window, cx);
});
}
},
))
.item(
PopupMenuItem::new(language.text(Message::TabMenuForkAiSession))
.icon(IconName::Bot)
.on_click(move |_, window, cx| {
if let Some(workspace) = target.upgrade() {
workspace.update(cx, |workspace, cx| {
workspace.fork_ai_session(ix, window, cx);
});
}
}),
)
.separator();
}
if terminal {
@@ -209,7 +209,7 @@ impl NebulaWorkspace {
let split_right = workspace.clone();
let split_down = workspace.clone();
menu = menu
.item(PopupMenuItem::new("复制标签页").icon(IconName::Copy).on_click(
.item(PopupMenuItem::new(language.text(Message::CommonDuplicateTab)).icon(IconName::Copy).on_click(
move |_, window, cx| {
if let Some(workspace) = duplicate.upgrade() {
workspace.update(cx, |workspace, cx| {
@@ -219,7 +219,7 @@ impl NebulaWorkspace {
},
))
.item(
PopupMenuItem::new("移到新窗口")
PopupMenuItem::new(language.text(Message::TabMenuMoveToNewWindow))
.icon(IconName::ExternalLink)
.on_click(move |_, _, cx| {
if let Some(workspace) = move_to_window.upgrade() {
@@ -229,7 +229,7 @@ impl NebulaWorkspace {
}
}),
)
.item(PopupMenuItem::new("导出为工作区…").icon(IconName::Inbox).on_click(
.item(PopupMenuItem::new(language.text(Message::TabMenuExportAsWorkspace)).icon(IconName::Inbox).on_click(
move |_, window, cx| {
if let Some(workspace) = export.upgrade() {
workspace.update(cx, |workspace, cx| {
@@ -242,7 +242,7 @@ impl NebulaWorkspace {
// `action` 只用来渲染键帽:handler 存在时组件不会 dispatch
// 它(见 PopupMenu::confirm),所以命令仍然作用在 `ix` 上。
.item(
PopupMenuItem::new("左右分屏")
PopupMenuItem::new(language.text(Message::TabMenuSplitLeftRight))
.icon(IconName::PanelRight)
.action(Box::new(SplitRight))
.on_click(move |_, window, cx| {
@@ -259,7 +259,7 @@ impl NebulaWorkspace {
}),
)
.item(
PopupMenuItem::new("上下分屏")
PopupMenuItem::new(language.text(Message::TabMenuSplitTopBottom))
.icon(IconName::PanelBottom)
.action(Box::new(SplitDown))
.on_click(move |_, window, cx| {
@@ -286,7 +286,7 @@ impl NebulaWorkspace {
// 分屏两项)。首/末位灰掉而不是隐藏——菜单条目忽隐忽现比灰掉
// 更难认。
.item(
PopupMenuItem::new("向左移动")
PopupMenuItem::new(language.text(Message::TabMenuMoveLeft))
.icon(IconName::ArrowLeft)
.action(Box::new(MoveTabLeft))
.disabled(ix == 0)
@@ -300,7 +300,7 @@ impl NebulaWorkspace {
}),
)
.item(
PopupMenuItem::new("向右移动")
PopupMenuItem::new(language.text(Message::TabMenuMoveRight))
.icon(IconName::ArrowRight)
.action(Box::new(MoveTabRight))
.disabled(ix + 1 >= tab_count)
@@ -315,7 +315,7 @@ impl NebulaWorkspace {
)
.separator()
.item(
PopupMenuItem::new("重命名")
PopupMenuItem::new(language.text(Message::CommonRename))
.icon(IconName::ALargeSmall)
.action(Box::new(RenameActiveTab))
.on_click(move |_, window, cx| {
@@ -327,7 +327,7 @@ impl NebulaWorkspace {
}),
)
.item(
PopupMenuItem::new("关闭")
PopupMenuItem::new(language.text(Message::CommonClose))
.icon(IconName::Close)
.action(Box::new(CloseActiveTerminal))
.on_click(move |_, window, cx| {
@@ -338,7 +338,7 @@ impl NebulaWorkspace {
}
}),
);
Self::tab_color_items(menu, workspace, ix, color)
Self::tab_color_items(menu, workspace, ix, color, language)
}
/// 标签颜色行(旧壳菜单尾部的色板):首槽 `A` = 无色,其后是 7 枚品牌
@@ -348,9 +348,10 @@ impl NebulaWorkspace {
workspace: gpui::WeakEntity<Self>,
ix: usize,
current: Option<Rgb>,
language: UiLanguage,
) -> PopupMenu {
menu.separator().item(PopupMenuItem::label("标签颜色")).item(PopupMenuItem::element(
move |_, cx| {
menu.separator().item(PopupMenuItem::label(language.text(Message::TabMenuTabColor))).item(
PopupMenuItem::element(move |_, cx| {
let swatches = std::iter::once(None)
.chain(crate::display::context_menu::TAB_COLORS.into_iter().map(Some));
let mut row = h_flex().gap_1().py_1();
@@ -395,8 +396,8 @@ impl NebulaWorkspace {
);
}
row
},
))
}),
)
}
}
+47 -4
View File
@@ -556,7 +556,7 @@ fn shell_palette_puts_the_default_shell_first() {
let rows = shell_palette_rows(
shells.clone(),
Vec::new(),
["box.example".to_owned()],
[("box.example".to_owned(), String::new())],
"nu",
language,
1.0,
@@ -583,7 +583,14 @@ fn shell_palette_puts_the_default_shell_first() {
assert_eq!(rows[4].group, "SSH 主机");
// 默认 id 没在检测结果里(WSL 发行版被卸载等):不置顶也不 panic。
let rows = shell_palette_rows(shells, Vec::new(), None::<String>, "wsl:Ghost", language, 1.0);
let rows = shell_palette_rows(
shells,
Vec::new(),
None::<(String, String)>,
"wsl:Ghost",
language,
1.0,
);
assert!(matches!(
&rows[0].action,
WorkspacePaletteAction::LaunchShell(shell) if shell.id == "pwsh"
@@ -591,6 +598,42 @@ fn shell_palette_puts_the_default_shell_first() {
assert_eq!(rows[0].group, "所有 Shell");
}
/// 启动器 SSH 行的"主机名称":起过名的显示别名、hint 给真实地址;没起名
/// 回落地址本身、hint 保持 "SSH"。搜索词必须同时覆盖别名与地址。
#[test]
fn shell_palette_ssh_rows_show_the_configured_host_name() {
let rows = shell_palette_rows(
Vec::new(),
Vec::new(),
[
("root@box.example".to_owned(), "生产机".to_owned()),
("plain.example".to_owned(), String::new()),
],
"cmd",
crate::display::UiLanguage::ZhCn,
1.0,
);
assert_eq!(rows.len(), 2);
let named = &rows[0];
assert_eq!(named.label, "生产机");
assert_eq!(named.hint, "root@box.example");
assert!(named.search.contains("生产机"));
assert!(named.search.contains("root@box.example"));
assert!(matches!(
&named.action,
WorkspacePaletteAction::LaunchSshHost(host) if host == "root@box.example"
));
let plain = &rows[1];
assert_eq!(plain.label, "plain.example");
assert_eq!(plain.hint, "SSH");
assert!(matches!(
&plain.action,
WorkspacePaletteAction::LaunchSshHost(host) if host == "plain.example"
));
}
/// 回归锁:新建终端弹窗里,没有品牌贴图的 shell 不能空着——必须回落到按 id
/// 取字的 Nerd Font 字形。之前这一列只填了 `icon`,于是 zsh、csh、ksh 这些
/// 整行左边什么都没有。
@@ -608,7 +651,7 @@ fn shell_palette_falls_back_to_an_id_glyph_when_brand_art_is_absent() {
let rows = shell_palette_rows(
shells,
Vec::new(),
None::<String>,
None::<(String, String)>,
"zsh",
crate::display::UiLanguage::ZhCn,
1.0,
@@ -649,7 +692,7 @@ fn shell_palette_includes_imported_terminal_profiles() {
let rows = shell_palette_rows(
Vec::new(),
vec![profile],
None::<String>,
None::<(String, String)>,
&id,
crate::display::UiLanguage::ZhCn,
1.0,
@@ -203,7 +203,7 @@ pub(crate) fn open_update_dialog(
let latest_version: SharedString = format!("v{}", dialog_result.latest).into();
let later_text: SharedString =
language.text(Message::UpdateLater).into();
let skip_text: SharedString = language.pick("跳过此版本", "Skip this version").into();
let skip_text: SharedString = language.text(Message::UpdateSkipVersion).into();
let muted = cx.theme().muted_foreground;
let latest_color = cx.theme().warning;
let version_background = cx.theme().muted;
@@ -226,12 +226,7 @@ pub(crate) fn open_update_dialog(
};
let hint: SharedString = match (&status, asset.as_ref()) {
(DownloadStatus::Downloading { .. }, _) => language
.pick(
"正在后台下载并校验 Windows x64 安装包。",
"Downloading and verifying the Windows x64 installer in the background.",
)
.into(),
(DownloadStatus::Downloading { .. }, _) => language.text(Message::UpdateDownloadingHint).into(),
(DownloadStatus::Ready { .. }, _) => language.text(Message::UpdateReadyHint).into(),
(DownloadStatus::InstallFailed(_), _) => language.text(Message::UpdateInstallationFailedHint).into(),
(DownloadStatus::Failed(_), _) => language
@@ -246,20 +241,14 @@ pub(crate) fn open_update_dialog(
"Pebrel will download and verify the installer, then wait for your confirmation before installing.",
)
.into(),
// 非 Windows 目前没有自动安装路径(能力表 `self_update_install`):
// 不说「缺 Windows 安装包」,那对 Mac/Linux 用户是句错话。
// Platforms without an installation adapter retain manual downloads.
_ if !crate::platform::CAPABILITIES.self_update_install => language
.pick(
"此平台暂不支持应用内自动更新;请到发布页下载对应的安装包。",
"In-app automatic updates are not available on this platform yet. Download the matching package from the Releases page.",
)
.into(),
_ => language
.pick(
"此 release 没有可验证的 Windows x64 安装包,已禁用自动执行;可打开发布页手动处理。",
"This release has no verifiable Windows x64 installer, so automatic execution is disabled. Use the Releases page instead.",
)
.into(),
_ => language.text(Message::UpdateNoVerifiedPackage).into(),
};
let primary_text: SharedString = match status {
@@ -371,6 +360,7 @@ pub(crate) fn open_update_dialog(
let cancel_save_failed_prefix = save_failed_prefix.to_owned();
let cancel_error_separator = error_separator.to_owned();
let mut footer = DialogFooter::new()
.gap(px(6.0))
.child(
Button::new("skip-nebula-update")
.label(skip_text)
+33
View File
@@ -33,6 +33,39 @@ fn typed_and_compatibility_lookups_agree() {
}
}
#[test]
fn tab_context_menu_has_translations_for_every_supported_language() {
let items = [
Message::CommonCopyWorkingDirectory,
Message::CommonDuplicateTab,
Message::TabMenuForkAiSession,
Message::TabMenuMoveToNewWindow,
Message::TabMenuExportAsWorkspace,
Message::TabMenuSplitLeftRight,
Message::TabMenuSplitTopBottom,
Message::TabMenuMoveLeft,
Message::TabMenuMoveRight,
Message::CommonRename,
Message::CommonClose,
Message::TabMenuTabColor,
];
for language in UiLanguage::ALL {
for item in items {
assert!(!language.text(item).is_empty(), "{}: {item:?}", language.code());
if *language != UiLanguage::EnUs {
assert_ne!(
language.text(item),
UiLanguage::EnUs.text(item),
"{}: {item:?}",
language.code()
);
}
}
}
assert_eq!(UiLanguage::KoKr.text(Message::CommonCopyWorkingDirectory), "작업 디렉터리 복사");
assert_eq!(UiLanguage::KoKr.text(Message::TabMenuTabColor), "탭 색상");
}
#[test]
fn inline_migration_preserves_bilingual_text_and_english_fallback() {
assert_eq!(UiLanguage::ZhCn.pick("网络", "Network"), "网络");
+4
View File
@@ -357,6 +357,10 @@ impl<T: EventListener> Execute<T> for Action {
// and profiles all ride the shared tab-request/display plumbing,
// which is platform-independent (the tab bar is self-drawn).
Action::CloseTab => ctx.nebula_tab(crate::event::TabRequest::Close),
Action::RenameTab => {
let index = ctx.display().active_tab_index();
ctx.nebula_tab(crate::event::TabRequest::BeginRename(index));
},
Action::SplitRight => ctx.nebula_tab(crate::event::TabRequest::SplitToggle(
crate::display::SplitDirection::LeftRight,
)),
+4
View File
@@ -166,6 +166,10 @@ use crate::macos::locale;
use crate::polling::{IoListener, ipc};
fn main() -> Result<(), Box<dyn Error>> {
#[cfg(all(target_os = "macos", feature = "gpui-shell"))]
if let Some(code) = update_download::handoff::macos::run_helper_if_requested() {
std::process::exit(code);
}
if let Some(code) = platform::ai_session_identity::run_helper_if_requested() {
std::process::exit(code);
}
+3 -3
View File
@@ -21,7 +21,7 @@ pub struct Capabilities {
pub system_notifications: bool,
/// 系统提示音(`platform::beep`)。
pub system_bell: bool,
/// 下载并静默运行安装包完成更新;其余平台只能打开 Releases 页。
/// Verified Windows installer or macOS bundle replacement is implemented.
pub self_update_install: bool,
/// 自动往 Claude / Codex 配置里写入 hook 与 skill(`crate::ai_hook`)。
pub ai_hook_server: bool,
@@ -60,7 +60,7 @@ pub const CAPABILITIES: Capabilities = {
launch_at_login: false,
system_notifications: true,
system_bell: false,
self_update_install: false,
self_update_install: cfg!(target_os = "macos"),
ai_hook_server: false,
quick_terminal_hotkey: false,
system_font_enumeration: true,
@@ -98,7 +98,7 @@ mod tests {
assert!(!CAPABILITIES.hide_window_on_close);
assert!(!CAPABILITIES.system_tray);
assert!(!CAPABILITIES.system_bell);
assert!(!CAPABILITIES.self_update_install);
assert_eq!(CAPABILITIES.self_update_install, cfg!(target_os = "macos"));
assert!(!CAPABILITIES.ai_hook_server);
assert!(!CAPABILITIES.quick_terminal_hotkey);
assert!(!CAPABILITIES.shell_context_menu);
+96 -9
View File
@@ -9,7 +9,7 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) {
}
let inherited_override =
!options.env_is_complete && std::env::var_os(GROK_LEGACY_CONSOLE).is_some();
apply_windows_terminal_defaults(options, inherited_override);
apply_local_console_defaults(options, inherited_override);
}
#[cfg(not(windows))]
let _ = options;
@@ -19,10 +19,20 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) {
const GROK_LEGACY_CONSOLE: &str = "GROK_FORCE_LEGACY_CONSOLE";
#[cfg(windows)]
fn apply_windows_terminal_defaults(
fn apply_local_console_defaults(
options: &mut nebula_terminal::tty::Options,
inherited_override: bool,
) {
// The refreshed, complete environment is captured before the GPUI spawn
// calls tty::setup_env(). Updating the parent later cannot change that
// snapshot. Advertise our actual capabilities in the child environment,
// including Explorer launches with no parent terminal. Keep explicit values.
for (name, value) in [("TERM", "xterm-256color"), ("COLORTERM", "truecolor")] {
if !options.env.keys().any(|key| key.eq_ignore_ascii_case(name)) {
let inherited = if options.env_is_complete { None } else { std::env::var(name).ok() };
options.env.insert(name.to_owned(), inherited.unwrap_or_else(|| value.to_owned()));
}
}
// Grok 1.0.25 treats unknown Windows terminal names as legacy consoles and
// omits its Braille logo. Keep our real identity and use its capability override.
// Remove this default when Grok recognizes Pebrel's terminal capabilities.
@@ -94,13 +104,13 @@ mod tests {
env_is_complete: true,
..Default::default()
};
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0");
assert_eq!(options.env["TERM_PROGRAM"], "pebrel");
assert_eq!(options.env["WSLENV"], "KEEP/p");
assert!(!options.env.contains_key("WT_SESSION"));
let once = options.clone();
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options, once);
}
@@ -110,12 +120,18 @@ mod tests {
for name in [super::GROK_LEGACY_CONSOLE, "grok_force_legacy_console"] {
for value in ["1", "true", "0", "false", ""] {
let mut options = nebula_terminal::tty::Options {
env: [(name.to_owned(), value.to_owned())].into_iter().collect(),
env: [
(name.to_owned(), value.to_owned()),
("TERM".to_owned(), "xterm-256color".to_owned()),
("COLORTERM".to_owned(), "truecolor".to_owned()),
]
.into_iter()
.collect(),
env_is_complete: true,
..Default::default()
};
let original = options.clone();
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options, original);
}
}
@@ -125,21 +141,92 @@ mod tests {
#[test]
fn inherited_grok_override_only_applies_to_incomplete_environments() {
let mut options = nebula_terminal::tty::Options::default();
super::apply_windows_terminal_defaults(&mut options, true);
super::apply_local_console_defaults(&mut options, true);
assert!(!options.env.contains_key(super::GROK_LEGACY_CONSOLE));
// A successful refresh can remove a stale value from the parent's registry
// snapshot. Do not resurrect it when the new complete environment omits it.
options.env_is_complete = true;
super::apply_windows_terminal_defaults(&mut options, true);
super::apply_local_console_defaults(&mut options, true);
assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0");
let mut fallback = nebula_terminal::tty::Options::default();
super::apply_windows_terminal_defaults(&mut fallback, false);
super::apply_local_console_defaults(&mut fallback, false);
assert_eq!(fallback.env[super::GROK_LEGACY_CONSOLE], "0");
assert!(!fallback.env_is_complete);
}
#[cfg(windows)]
#[test]
fn complete_explorer_environment_advertises_truecolor_to_the_child_process() {
let mut options = nebula_terminal::tty::Options {
env: std::env::vars()
.filter(|(name, _)| {
!["TERM", "COLORTERM", "WT_SESSION", "TERM_PROGRAM"]
.iter()
.any(|key| name.eq_ignore_ascii_case(key))
})
.collect(),
env_is_complete: true,
..Default::default()
};
options.env.insert("TERM_PROGRAM".into(), "pebrel".into());
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env["TERM"], "xterm-256color");
assert_eq!(options.env["COLORTERM"], "truecolor");
assert!(!options.env.contains_key("WT_SESSION"));
// Match the complete-environment child launch, independent of the test
// runner's terminal. Read the variables from the actual child.
let system = std::env::var_os("SystemRoot").unwrap();
let command =
std::path::Path::new(&system).join("System32/WindowsPowerShell/v1.0/powershell.exe");
let output = std::process::Command::new(command)
.args([
"-NoProfile",
"-Command",
"[Console]::WriteLine(\"$env:TERM $env:COLORTERM $env:TERM_PROGRAM\")",
])
.env_clear()
.env("SystemRoot", system)
.envs(&options.env)
.output()
.unwrap();
assert!(
output.status.success(),
"status={}, stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"xterm-256color truecolor pebrel"
);
}
#[cfg(windows)]
#[test]
fn color_capability_defaults_preserve_explicit_overrides() {
let mut options = nebula_terminal::tty::Options {
env: [
("term", "dumb"),
("ColorTerm", "24bit"),
("NO_COLOR", "1"),
("FORCE_COLOR", "0"),
]
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect(),
env_is_complete: true,
..Default::default()
};
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env["term"], "dumb");
assert_eq!(options.env["ColorTerm"], "24bit");
assert_eq!(options.env["NO_COLOR"], "1");
assert_eq!(options.env["FORCE_COLOR"], "0");
assert!(!options.env.contains_key("TERM") && !options.env.contains_key("COLORTERM"));
}
#[cfg(windows)]
#[test]
fn refreshed_local_terminal_preserves_the_pane_grok_override() {
+51 -3
View File
@@ -1,9 +1,12 @@
//! Native paths and process identity for the Windows installer handoff.
//! Native paths and process identity for the platform installer handoff.
//! Transaction persistence and commit authority remain in `update_download`.
use std::io;
use std::path::{Path, PathBuf};
use std::process::Child;
#[cfg(target_os = "macos")]
pub(crate) mod macos;
pub(crate) fn canonical(path: &Path) -> io::Result<PathBuf> {
let path = std::fs::canonicalize(path)?;
#[cfg(windows)]
@@ -19,7 +22,7 @@ pub(crate) fn canonical(path: &Path) -> io::Result<PathBuf> {
Ok(path)
}
/// FILETIME identity is compared with the exact process handle by the helper.
/// Native creation identity prevents a recycled PID from authorizing a handoff.
pub(crate) fn current_process_created() -> io::Result<u64> {
#[cfg(windows)]
{
@@ -38,7 +41,10 @@ pub(crate) fn current_process_created() -> io::Result<u64> {
}
Ok((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
}
#[cfg(not(windows))]
#[cfg(target_os = "macos")]
return macos::process_created(std::process::id())?
.ok_or_else(|| io::Error::other("Process identity unavailable"));
#[cfg(not(any(windows, target_os = "macos")))]
Err(io::Error::new(io::ErrorKind::Unsupported, "Windows process identity is unavailable"))
}
@@ -70,3 +76,45 @@ pub(crate) fn spawn_helper(helper: &Path, plan: &Path) -> io::Result<Child> {
Err(io::Error::new(io::ErrorKind::Unsupported, "Windows installation is unavailable"))
}
}
pub(crate) fn installation_directory(executable: &Path) -> Result<PathBuf, String> {
#[cfg(target_os = "macos")]
return macos::bundle(executable);
#[cfg(not(target_os = "macos"))]
{
let directory = executable.parent().ok_or("Missing application directory")?;
if !directory.join("unins000.exe").is_file() {
return Err(
"This copy is portable. Use the download page to replace its package.".into()
);
}
Ok(directory.to_owned())
}
}
pub(crate) fn guard_base(executable: &Path) -> PathBuf {
#[cfg(target_os = "macos")]
if let Ok(bundle) = macos::bundle(executable) {
if let (Some(parent), Some(name)) = (bundle.parent(), bundle.file_name()) {
use sha2::{Digest as _, Sha256};
use std::os::unix::ffi::OsStrExt as _;
let identity: String =
Sha256::digest(name.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect();
return parent.join(format!(".pebrel-update-{identity}"));
}
}
executable.parent().expect("canonical executable parent").join(".pebrel-update")
}
/// Select and materialize the native helper; transaction authority stays with the caller.
pub(crate) fn spawn_prepared_helper(directory: &Path, plan: &Path) -> Result<Child, String> {
#[cfg(target_os = "macos")]
return macos::spawn(directory, plan);
#[cfg(not(target_os = "macos"))]
{
let helper = directory.join("handoff.ps1");
crate::atomic_file::write(&helper, include_bytes!("../update_download/handoff.ps1"))
.map_err(|error| error.to_string())?;
spawn_helper(&helper, plan).map_err(|error| error.to_string())
}
}
@@ -0,0 +1,293 @@
//! macOS bundle I/O. Transaction authorization stays in update_download::handoff.
use std::ffi::{CStr, CString};
use std::io;
use std::os::unix::ffi::OsStrExt as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
pub(crate) fn spawn(directory: &Path, plan: &Path) -> Result<Child, String> {
let helper = directory.join("handoff");
std::fs::copy(std::env::current_exe().map_err(|e| e.to_string())?, &helper)
.map_err(|e| e.to_string())?;
Command::new(helper)
.arg("--internal-macos-update")
.arg(plan)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| e.to_string())
}
pub(crate) fn run(command: &mut Command) -> Result<Output, String> {
// Regular files cannot fill a pipe while we poll for process completion.
let stdout = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
let stderr = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
let mut child = command
.stdin(Stdio::null())
.stdout(stdout.reopen().map_err(|e| e.to_string())?)
.stderr(stderr.reopen().map_err(|e| e.to_string())?)
.spawn()
.map_err(|e| e.to_string())?;
let deadline = Instant::now() + Duration::from_secs(120);
while child.try_wait().map_err(|e| e.to_string())?.is_none() {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err("macOS update command timed out".into());
}
std::thread::sleep(Duration::from_millis(25));
}
let read_output = |file: &tempfile::NamedTempFile| -> Result<Vec<u8>, String> {
if file.as_file().metadata().map_err(|e| e.to_string())?.len() > 2 * 1024 * 1024 {
return Err("macOS update command output exceeded its limit".into());
}
std::fs::read(file.path()).map_err(|e| e.to_string())
};
let output = Output {
status: child.wait().map_err(|e| e.to_string())?,
stdout: read_output(&stdout)?,
stderr: read_output(&stderr)?,
};
if !output.status.success() {
return Err(format!(
"macOS update command failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).chars().take(2048).collect::<String>()
));
}
Ok(output)
}
pub(crate) fn property(bundle: &Path, key: &str) -> Result<String, String> {
let result = run(Command::new("/usr/bin/plutil")
.args(["-extract", key, "raw", "-o", "-"])
.arg(bundle.join("Contents/Info.plist")))?;
String::from_utf8(result.stdout).map(|s| s.trim().to_owned()).map_err(|e| e.to_string())
}
pub(crate) fn bundle(executable: &Path) -> Result<PathBuf, String> {
let macos = executable.parent().ok_or("Missing executable directory")?;
let contents = macos.parent().ok_or("Missing bundle contents")?;
let bundle = contents.parent().ok_or("Missing application bundle")?;
if executable.file_name().is_none_or(|n| n != "pebrel")
|| macos.file_name().is_none_or(|n| n != "MacOS")
|| contents.file_name().is_none_or(|n| n != "Contents")
|| bundle.extension().is_none_or(|e| e != "app")
{
return Err("Automatic installation requires a packaged Pebrel .app. Install it from the Releases page first.".into());
}
if !matches!(
property(bundle, "CFBundleIdentifier")?.as_str(),
"io.github.kuddev.pebrel" | "io.github.kuddev.pebrel.preview"
) || property(bundle, "CFBundleExecutable")? != "pebrel"
{
return Err("This is not an official Pebrel application bundle".into());
}
if bundle.starts_with("/Volumes")
|| bundle.components().any(|p| p.as_os_str() == "AppTranslocation")
{
return Err(
"Move Pebrel to a writable Applications folder before installing updates".into()
);
}
Ok(bundle.to_owned())
}
pub(crate) fn process_created(pid: u32) -> io::Result<Option<u64>> {
// SAFETY: buffer is an initialized proc_bsdinfo of exactly the requested size.
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = std::mem::size_of_val(&info) as i32;
let read = unsafe {
libc::proc_pidinfo(
pid as i32,
libc::PROC_PIDTBSDINFO,
0,
(&mut info as *mut libc::proc_bsdinfo).cast(),
size,
)
};
if read == size {
// Zombies have already stopped using their application bundle.
if info.pbi_status == 5 {
return Ok(None);
}
return Ok(Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec));
}
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) { Ok(None) } else { Err(error) }
}
pub(crate) fn running_copies(executable: &Path) -> Result<Vec<u32>, String> {
// proc_listallpids returns a count, unlike proc_listpids' byte count.
let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return Err("Could not inspect running applications".into());
}
let mut pids = vec![0i32; count as usize + 1024];
let read = unsafe {
libc::proc_listallpids(
pids.as_mut_ptr().cast(),
(pids.len() * std::mem::size_of::<i32>()) as i32,
)
};
if read <= 0 || read as usize >= pids.len() {
return Err("Process snapshot was incomplete".into());
}
let mut result = Vec::new();
for pid in pids.into_iter().take(read as usize).filter(|pid| *pid > 0) {
let mut buffer = vec![0u8; 4096];
let read =
unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
if read > 0 {
let path = CStr::from_bytes_until_nul(&buffer).map_err(|e| e.to_string())?;
if Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())) == executable {
result.push(pid as u32);
}
}
}
Ok(result)
}
pub(crate) fn exchange(first: &Path, second: &Path) -> Result<(), String> {
let first = CString::new(first.as_os_str().as_bytes()).map_err(|e| e.to_string())?;
let second = CString::new(second.as_os_str().as_bytes()).map_err(|e| e.to_string())?;
// Same-volume atomic swap: the original app never disappears, even on crash.
let result = unsafe {
libc::renameatx_np(
libc::AT_FDCWD,
first.as_ptr(),
libc::AT_FDCWD,
second.as_ptr(),
libc::RENAME_SWAP,
)
};
if result != 0 {
return Err(io::Error::last_os_error().to_string());
}
Ok(())
}
pub(crate) fn verify_bundle(bundle: &Path, version: &str) -> Result<(), String> {
let id = property(bundle, "CFBundleIdentifier")?;
if !matches!(id.as_str(), "io.github.kuddev.pebrel" | "io.github.kuddev.pebrel.preview")
|| property(bundle, "CFBundleExecutable")? != "pebrel"
|| property(bundle, "CFBundleShortVersionString")? != version
{
return Err("The downloaded bundle identity or version does not match the release".into());
}
let executable = bundle.join("Contents/MacOS/pebrel");
if std::fs::canonicalize(&executable).map_err(|e| e.to_string())? != executable {
return Err("The bundle executable must not redirect outside the application".into());
}
run(Command::new("/usr/bin/codesign").args(["--verify", "--deep", "--strict"]).arg(bundle))?;
let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x86_64" };
let architectures = run(Command::new("/usr/bin/lipo").arg("-archs").arg(&executable))?;
if !String::from_utf8_lossy(&architectures.stdout).split_whitespace().any(|v| v == arch) {
return Err("The update does not contain this Mac's architecture".into());
}
let output = run(Command::new(&executable).arg("--version"))?;
let output = String::from_utf8_lossy(&output.stdout);
if output.split_whitespace().nth(1) != Some(version) {
return Err("The update executable reports a different version".into());
}
Ok(())
}
fn team(bundle: &Path) -> Result<Option<String>, String> {
let output = run(Command::new("/usr/bin/codesign").args(["-d", "--verbose=4"]).arg(bundle))?;
Ok(String::from_utf8_lossy(&output.stderr)
.lines()
.find_map(|line| line.strip_prefix("TeamIdentifier="))
.filter(|team| *team != "not set")
.map(str::to_owned))
}
pub(crate) fn stage(
installer: &Path,
original: &Path,
staged: &Path,
mount: &Path,
version: &str,
) -> Result<(), String> {
std::fs::create_dir(mount).map_err(|e| e.to_string())?;
let outcome: Result<(), String> = (|| {
run(Command::new("/usr/bin/hdiutil")
.args(["attach", "-readonly", "-nobrowse", "-noautoopen", "-verify", "-mountpoint"])
.arg(mount)
.arg(installer))?;
let bundles: Vec<_> = std::fs::read_dir(mount)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
.into_iter()
.map(|entry| entry.path())
.filter(|path| {
path.extension().is_some_and(|e| e == "app") && path.is_dir() && !path.is_symlink()
})
.collect();
if bundles.len() != 1 {
return Err("The disk image must contain exactly one application".into());
}
// Copy before checking/executing; all subsequent verification uses the local staged tree.
run(Command::new("/usr/bin/ditto").arg(&bundles[0]).arg(staged))?;
if let Some(expected) = team(original)?
&& team(staged)?.as_deref() != Some(expected.as_str())
{
return Err("The update's signing team differs from the installed application".into());
}
verify_bundle(staged, version)?;
Ok(())
})();
let detached = run(Command::new("/usr/bin/hdiutil").args(["detach"]).arg(mount));
let _ = std::fs::remove_dir(mount);
outcome?;
detached?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_output_larger_than_a_pipe_is_drained() {
let output = run(Command::new("/bin/sh")
.args(["-c", "head -c 262144 /dev/zero; head -c 262144 /dev/zero >&2"]))
.unwrap();
assert_eq!(output.stdout.len(), 262144);
assert_eq!(output.stderr.len(), 262144);
assert!(run(Command::new("/bin/sh").args(["-c", "exit 7"])).is_err());
}
#[test]
fn atomic_exchange_keeps_a_complete_original_and_can_roll_back() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("Pebrel.app");
let new = dir.path().join("staged.app");
std::fs::create_dir(&old).unwrap();
std::fs::create_dir(&new).unwrap();
std::fs::write(old.join("marker"), "old").unwrap();
std::fs::write(new.join("marker"), "new").unwrap();
exchange(&old, &new).unwrap();
assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "new");
assert_eq!(std::fs::read_to_string(new.join("marker")).unwrap(), "old");
exchange(&old, &new).unwrap();
assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "old");
assert!(exchange(&old, &dir.path().join("missing")).is_err());
assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "old");
}
#[test]
fn developer_executables_are_not_treated_as_installable_bundles() {
assert!(bundle(Path::new("/tmp/target/debug/pebrel")).is_err());
assert!(bundle(Path::new("/tmp/Pebrel.app/Contents/MacOS/not-pebrel")).is_err());
}
#[test]
fn process_identity_and_executable_inspection_find_this_process() {
let created = process_created(std::process::id()).unwrap().unwrap();
assert!(created > 0);
assert_eq!(process_created(std::process::id()).unwrap(), Some(created));
let executable = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap();
assert!(running_copies(&executable).unwrap().contains(&std::process::id()));
}
}
+32 -31
View File
@@ -22,6 +22,9 @@ pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/pebrel/releases";
const UPDATE_STATE_FILE: &str = "update_state.json";
const REMIND_LATER_SECS: u64 = 3 * 24 * 60 * 60;
pub(crate) mod assets;
mod fallback;
#[cfg(feature = "update-test-source")]
pub(crate) mod test_source;
@@ -291,18 +294,34 @@ fn fetch_latest_release() -> Result<LatestRelease, String> {
return fetch_release_with_agent(&agent, &url);
}
let agent = crate::update_proxy::agent(RELEASES_API, Duration::from_secs(10));
fetch_release_with_agent(&agent, RELEASES_API)
fetch_release_with_fallback(&agent, RELEASES_API, fallback::fetch_latest)
}
#[cfg(any(test, feature = "update-test-source"))]
fn fetch_release_with_agent(agent: &ureq::Agent, url: &str) -> Result<LatestRelease, String> {
let bytes = agent
// Explicit local rehearsals must never fall back to the public network.
fetch_release_with_fallback(agent, url, |status| Err(format!("GitHub HTTP {status}")))
}
fn fetch_release_with_fallback(
agent: &ureq::Agent,
url: &str,
on_rate_limit: impl FnOnce(u16) -> Result<LatestRelease, String>,
) -> Result<LatestRelease, String> {
let response = agent
.get(url)
.header("User-Agent", "pebrel")
.header("Accept", "application/vnd.github+json")
.call()
.and_then(|mut response| {
response.body_mut().with_config().limit(2 * 1024 * 1024).read_to_vec()
})
.call();
let mut response = match response {
Err(ureq::Error::StatusCode(status @ (403 | 429))) => return on_rate_limit(status),
other => other.map_err(|error| format!("GitHub 请求失败:{error}"))?,
};
let bytes = response
.body_mut()
.with_config()
.limit(2 * 1024 * 1024)
.read_to_vec()
.map_err(|error| format!("GitHub 请求失败:{error}"))?;
parse_latest_release(&bytes)
}
@@ -315,15 +334,12 @@ fn parse_latest_release(bytes: &[u8]) -> Result<LatestRelease, String> {
return Err("GitHub release 的版本号为空".to_owned());
}
let version = version.to_owned();
let asset = if cfg!(all(windows, target_arch = "x86_64")) {
select_windows_x64_installer(
&version,
release.body.as_deref().unwrap_or_default(),
release.assets,
)
} else {
None
};
let asset = assets::select(
&version,
release.body.as_deref().unwrap_or_default(),
&release.assets,
&assets::native_names(&version),
);
Ok(LatestRelease { version, asset })
}
@@ -340,22 +356,7 @@ fn select_windows_x64_installer(
release_body: &str,
assets: Vec<GitHubReleaseAsset>,
) -> Option<UpdateAsset> {
let selected = windows_x64_installer_names(version)
.iter()
.find_map(|name| assets.iter().position(|asset| asset.name == *name))?;
let asset = assets.into_iter().nth(selected)?;
let sha256 = asset
.digest
.as_deref()
.and_then(normalize_sha256)
.or_else(|| checksum_from_release_body(release_body, &asset.name));
Some(UpdateAsset {
version: version.to_owned(),
name: asset.name,
download_url: asset.browser_download_url,
size: (asset.size > 0).then_some(asset.size),
sha256,
})
assets::select(version, release_body, &assets, &windows_x64_installer_names(version))
}
fn normalize_sha256(value: &str) -> Option<String> {
+106
View File
@@ -0,0 +1,106 @@
//! Exact package names shared by release discovery and download validation.
use super::{GitHubReleaseAsset, UpdateAsset, checksum_from_release_body, normalize_sha256};
use crate::platform::Platform;
pub(crate) fn macos_names(version: &str, architecture: &str) -> Vec<String> {
let architecture = match architecture {
"aarch64" => "arm64",
"x86_64" => "x64",
_ => return Vec::new(),
};
vec![
format!("Pebrel-v{version}-macos-{architecture}.dmg"),
format!("Pebrel-v{version}-macos-{architecture}-preview.dmg"),
]
}
pub(crate) fn native_names(version: &str) -> Vec<String> {
match Platform::current() {
Platform::MacOS => macos_names(version, std::env::consts::ARCH),
Platform::Windows if std::env::consts::ARCH == "x86_64" => {
super::windows_x64_installer_names(version).to_vec()
},
_ => Vec::new(),
}
}
pub(super) fn select(
version: &str,
body: &str,
assets: &[GitHubReleaseAsset],
names: &[String],
) -> Option<UpdateAsset> {
let asset = names.iter().find_map(|name| assets.iter().find(|asset| asset.name == *name))?;
Some(UpdateAsset {
version: version.into(),
name: asset.name.clone(),
download_url: asset.browser_download_url.clone(),
size: (asset.size > 0).then_some(asset.size),
sha256: asset
.digest
.as_deref()
.and_then(normalize_sha256)
.or_else(|| checksum_from_release_body(body, &asset.name)),
})
}
pub(super) fn from_checksums(version: &str, text: &str) -> Option<UpdateAsset> {
native_names(version).iter().find_map(|name| {
let hashes: Vec<_> = text
.lines()
.filter_map(|line| {
let (hash, file) = line.split_once(char::is_whitespace)?;
(file.trim().trim_start_matches('*') == name)
.then(|| normalize_sha256(&format!("sha256:{hash}")))
.flatten()
})
.collect();
if hashes.len() != 1 {
return None;
}
Some(UpdateAsset {
version: version.into(),
name: name.clone(),
download_url: format!(
"https://github.com/Kuddev/pebrel/releases/download/v{version}/{name}"
),
size: None,
sha256: hashes.into_iter().next(),
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn macos_architectures_and_preference_are_exact() {
assert!(macos_names("1.9.0", "unknown").is_empty());
for (arch, expected) in [("aarch64", "arm64"), ("x86_64", "x64")] {
let names = macos_names("1.9.0", arch);
assert_eq!(names[0], format!("Pebrel-v1.9.0-macos-{expected}.dmg"));
assert_eq!(names[1], format!("Pebrel-v1.9.0-macos-{expected}-preview.dmg"));
let assets = names
.iter()
.rev()
.map(|name| GitHubReleaseAsset {
name: name.clone(),
browser_download_url: "https://example.invalid".into(),
size: 42,
digest: Some(format!("sha256:{}", "a".repeat(64))),
})
.collect::<Vec<_>>();
assert_eq!(select("1.9.0", "", &assets, &names).unwrap().name, names[0]);
}
}
#[test]
fn checksum_manifest_requires_one_exact_valid_entry() {
let Some(name) = native_names("1.9.0").into_iter().next() else { return };
let entry = format!("{} {name}\n", "a".repeat(64));
assert!(from_checksums("1.9.0", &entry).is_some());
assert!(from_checksums("1.9.0", &(entry.clone() + &entry)).is_none());
assert!(from_checksums("1.9.0", &format!("bad {name}")).is_none());
assert!(from_checksums("1.9.0", &entry.replace(&name, &(name.clone() + ".bak"))).is_none());
assert!(from_checksums("1.9.1", &entry).is_none());
}
}
+186
View File
@@ -0,0 +1,186 @@
//! GitHub's public latest-release redirect is independent of the REST API quota.
//! The redirect proves a version; the official SHA256SUMS asset can then supply
//! verified package metadata. If absent, retain manual download only.
use std::time::Duration;
use ureq::ResponseExt as _;
use super::LatestRelease;
use crate::i18n::{LanguagePreference, Message};
const LATEST_PAGE: &str = "https://github.com/Kuddev/pebrel/releases/latest";
const TAG_PREFIX: &str = "https://github.com/Kuddev/pebrel/releases/tag/";
pub(super) fn fetch_latest(status: u16) -> Result<LatestRelease, String> {
log::info!("update-check: API returned HTTP {status}; checking official latest-release page");
// Resolve proxy settings for github.com independently of api.github.com.
let agent = crate::update_proxy::agent(LATEST_PAGE, Duration::from_secs(10));
let language =
LanguagePreference::from(nebula_settings::RuntimeSettings::load().language).resolved();
let uri = redirected_uri(&agent, LATEST_PAGE).map_err(|error| {
language.format(
Message::UpdateCheckFallbackFailed,
&[("status", &status.to_string()), ("error", &error.to_string())],
)
})?;
let mut release = release_from_uri(&uri)
.ok_or_else(|| language.text(Message::UpdateCheckUnrecognizedRelease).to_owned())?;
// The official manifest restores verified download metadata during API limits.
// If it is unavailable, version discovery still works with manual download.
let url = format!(
"https://github.com/Kuddev/pebrel/releases/download/v{}/SHA256SUMS",
release.version
);
let agent = crate::update_proxy::agent(&url, Duration::from_secs(10));
if let Ok(mut response) = agent.get(&url).header("User-Agent", "pebrel").call()
&& let Ok(text) = response.body_mut().with_config().limit(64 * 1024).read_to_string()
{
release.asset = super::assets::from_checksums(&release.version, &text);
}
Ok(release)
}
fn redirected_uri(agent: &ureq::Agent, url: &str) -> Result<String, ureq::Error> {
let response = agent
.get(url)
.header("User-Agent", "pebrel")
.header("Accept", "text/html")
.config()
.max_redirects(5)
.build()
.call()?;
// The final response URI is supplied by the HTTP client, not by untrusted
// markup. No HTML body needs to be downloaded or parsed.
Ok(response.get_uri().to_string())
}
fn release_from_uri(uri: &str) -> Option<LatestRelease> {
let tag = uri.strip_prefix(TAG_PREFIX)?;
let version = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
// Official stable releases use major.minor.patch. Reject login pages,
// arbitrary tags, extra path/query/fragment components and preview tags.
let components: Vec<_> = version.split('.').collect();
if components.len() != 3
|| components.iter().any(|part| {
part.is_empty()
|| !part.bytes().all(|byte| byte.is_ascii_digit())
|| part.parse::<u64>().is_err()
|| (part.len() > 1 && part.starts_with('0'))
})
{
return None;
}
Some(LatestRelease { version: version.to_owned(), asset: None })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::update_proxy::test_support::{Server, response};
#[test]
fn rate_limited_api_falls_back_and_discovers_an_upgrade_without_install_authority() {
for status in ["403 Forbidden", "429 Too Many Requests"] {
let server = Server::start(vec![response(status, "", "API rate limit exceeded")]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|code| {
assert!(matches!(code, 403 | 429));
Ok(release_from_uri(&format!("{TAG_PREFIX}v1.9.0")).unwrap())
},
)
.unwrap();
assert!(super::super::is_newer(&result.version, "1.8.2"));
assert!(!super::super::is_newer(&result.version, "1.9.0"));
assert!(result.asset.is_none(), "a version redirect grants no install authority");
assert_eq!(server.finish().len(), 1);
}
}
#[test]
fn successful_api_and_other_errors_never_call_the_fallback() {
for (status, body, succeeds) in [
("200 OK", r#"{"tag_name":"v1.9.0","assets":[]}"#, true),
("200 OK", "not JSON", false),
("404 Not Found", "missing release", false),
("500 Internal Server Error", "server error", false),
] {
let server = Server::start(vec![response(status, "", body)]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|_| panic!("fallback is only for access/rate limiting"),
);
assert_eq!(result.is_ok(), succeeds);
server.finish();
}
}
#[test]
fn failed_fallback_stays_an_error_instead_of_claiming_up_to_date() {
let server = Server::start(vec![response("403 Forbidden", "", "rate limited")]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|_| Err("release page unavailable".into()),
);
assert_eq!(result.unwrap_err(), "release page unavailable");
server.finish();
}
#[test]
fn redirect_transport_uses_the_final_location_without_parsing_html() {
// The local CONNECT fixture uses HTTP; production agent requires HTTPS.
let destination = "http://github.com/Kuddev/pebrel/releases/tag/v1.9.0";
let server = Server::start(vec![
response("302 Found", &format!("Location: {destination}\r\n"), ""),
response("200 OK", "Content-Type: text/html\r\n", "<html>no metadata</html>"),
]);
let uri =
redirected_uri(&server.agent(&[]), "http://github.com/Kuddev/pebrel/releases/latest")
.unwrap();
assert_eq!(uri, destination);
let requests = server.finish();
assert_eq!(requests.len(), 2);
assert!(requests[0].1.contains("/releases/latest"));
assert!(requests[1].1.contains("/releases/tag/v1.9.0"));
}
#[test]
fn only_exact_official_stable_tag_locations_are_accepted() {
for tag in ["v1.9.0", "V1.9.0", "1.9.0"] {
assert_eq!(release_from_uri(&format!("{TAG_PREFIX}{tag}")).unwrap().version, "1.9.0");
}
for uri in [
LATEST_PAGE,
"https://github.com/login",
"https://github.com/Other/pebrel/releases/tag/v99.0.0",
"http://github.com/Kuddev/pebrel/releases/tag/v1.9.0",
"https://github.com.evil.invalid/Kuddev/pebrel/releases/tag/v1.9.0",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0?foo=bar",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0#fragment",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0/extra",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0-rc1",
"https://github.com/Kuddev/pebrel/releases/tag/relay-abcd",
"https://github.com/Kuddev/pebrel/releases/tag/v1..0",
"https://github.com/Kuddev/pebrel/releases/tag/v01.9.0",
"https://github.com/Kuddev/pebrel/releases/tag/v18446744073709551616.0.0",
] {
assert!(release_from_uri(uri).is_none(), "{uri}");
}
}
#[test]
#[ignore = "contacts public GitHub; run explicitly for native network acceptance"]
fn live_update_check_survives_api_rate_limiting() {
let release = super::super::check_now().expect("live public update check");
println!(
"current={} latest={} update_available={}",
release.current, release.latest, release.update_available
);
let fallback = fetch_latest(403).expect("live official release fallback");
assert_eq!(release.latest, fallback.version);
}
}
+66 -8
View File
@@ -1,12 +1,12 @@
//! GPUI 更新安装包的下载、校验与启动。
//!
//! 更新检查只负责提供 release 元数据;本模块再次收紧资产合同,并把大文件
//! 流式写入同目录 `.part` 文件。只有长度、PE 文件头与 SHA-256 全部通过后,
//! 流式写入同目录 `.part` 文件。只有长度、PE 文件头或 DMG 尾标记与 SHA-256 全部通过后,
//! 才原子替换为可启动的安装包,避免中断下载或错误响应变成可执行文件。
use std::fmt::Write as _;
use std::fs::{File, OpenOptions};
use std::io::{Read as _, Write as _};
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard};
@@ -299,6 +299,7 @@ fn download_with_job(
output.sync_all().map_err(|error| format!("同步更新临时文件失败:{error}"))?;
verify_download(downloaded, &pe_header, hasher.finalize(), asset)?;
verify_package_trailer(&mut File::open(partial_path).map_err(|e| e.to_string())?, asset)?;
Ok(downloaded)
}
@@ -365,6 +366,7 @@ fn verify_file(path: &Path, asset: &UpdateAsset) -> Result<u64, String> {
hasher.update(&buffer[..read]);
}
verify_download(bytes, &pe_header, hasher.finalize(), asset)?;
verify_package_trailer(&mut file, asset)?;
Ok(bytes)
}
@@ -377,7 +379,7 @@ fn verify_download(
if bytes == 0 || asset.size.is_some_and(|expected| expected != bytes) {
return Err(format!("安装包长度校验失败(实际 {bytes} 字节)"));
}
if pe_header != b"MZ" {
if !asset.name.ends_with(".dmg") && pe_header != b"MZ" {
return Err("下载内容不是 Windows PE 安装包".to_owned());
}
let expected = asset.sha256.as_deref().ok_or_else(|| "release 未提供 SHA-256".to_owned())?;
@@ -410,14 +412,32 @@ fn download_paths(asset: &UpdateAsset) -> Result<(PathBuf, PathBuf), String> {
Ok((partial_path, final_path))
}
fn validate_asset(asset: &UpdateAsset) -> Result<(), String> {
if !cfg!(all(windows, target_arch = "x86_64")) {
return Err("当前平台没有可用的自动更新安装包".to_owned());
fn verify_package_trailer(file: &mut File, asset: &UpdateAsset) -> Result<(), String> {
if asset.name.ends_with(".dmg") {
let mut signature = [0; 4];
file.seek(SeekFrom::End(-512))
.and_then(|_| file.read_exact(&mut signature))
.map_err(|_| "Invalid macOS disk image trailer".to_owned())?;
if &signature != b"koly" {
return Err("Invalid macOS disk image trailer".into());
}
}
validate_windows_asset_contract(asset)
Ok(())
}
fn validate_asset(asset: &UpdateAsset) -> Result<(), String> {
validate_asset_contract(asset, &crate::update_check::assets::native_names(&asset.version))
}
#[cfg(test)]
fn validate_windows_asset_contract(asset: &UpdateAsset) -> Result<(), String> {
validate_asset_contract(
asset,
&crate::update_check::windows_x64_installer_names(&asset.version),
)
}
fn validate_asset_contract(asset: &UpdateAsset, names: &[String]) -> Result<(), String> {
if asset.version.is_empty()
|| !asset
.version
@@ -426,7 +446,7 @@ fn validate_windows_asset_contract(asset: &UpdateAsset) -> Result<(), String> {
{
return Err("release 版本号不符合安装包命名规则".to_owned());
}
if !crate::update_check::windows_x64_installer_names(&asset.version).contains(&asset.name) {
if !names.contains(&asset.name) {
return Err("release 资产不是当前平台的精确安装包".to_owned());
}
let trusted_url = [RELEASE_DOWNLOAD_PREFIX, LEGACY_RELEASE_DOWNLOAD_PREFIX]
@@ -737,3 +757,41 @@ mod tests {
}
}
}
#[cfg(test)]
mod macos_package_tests {
use super::*;
#[test]
fn dmg_contract_checks_architecture_hash_and_udif_trailer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("image.part");
let mut bytes = vec![0; 1024];
bytes[512..516].copy_from_slice(b"koly");
std::fs::write(&path, &bytes).unwrap();
let names = crate::update_check::assets::macos_names("1.9.1", "aarch64");
let mut asset = UpdateAsset {
version: "1.9.1".into(),
name: names[0].clone(),
download_url: format!("{RELEASE_DOWNLOAD_PREFIX}v1.9.1/{}", names[0]),
size: Some(bytes.len() as u64),
sha256: Some(Sha256::digest(&bytes).iter().map(|byte| format!("{byte:02x}")).collect()),
};
validate_asset_contract(&asset, &names).unwrap();
verify_file(&path, &asset).unwrap();
assert!(
validate_asset_contract(
&asset,
&crate::update_check::assets::macos_names("1.9.1", "x86_64")
)
.is_err()
);
bytes[512] = b'x';
std::fs::write(&path, &bytes).unwrap();
assert!(verify_file(&path, &asset).is_err());
asset.sha256 =
Some(Sha256::digest(&bytes).iter().map(|byte| format!("{byte:02x}")).collect());
assert!(verify_file(&path, &asset).unwrap_err().contains("trailer"));
asset.download_url = "https://example.invalid/image.dmg".into();
assert!(validate_asset_contract(&asset, &names).is_err());
}
}
+28 -3
View File
@@ -67,6 +67,30 @@ function Check-UnpreparedProcesses {
}
}
function Wait-RuntimeHelperFiles {
# New helpers have a bounded lifetime, but can still be draining as the
# application exits. Check before setup copies any file. Old stuck helpers
# remain a visible failure; never terminate processes by their image name.
$deadline = [DateTime]::UtcNow.AddSeconds(5)
$helpers = @('runtime\pebrel-hook.exe', 'pebrel-hook.exe',
'runtime\nebula-hook.exe', 'nebula-hook.exe')
while ($true) {
$busy = $null
foreach ($relative in $helpers) {
$path = Join-Path $installation $relative
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
try {
$probe = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
} catch { $busy = $_.Exception }
}
if (-not $busy) { return }
if ([DateTime]::UtcNow -ge $deadline) { throw $busy }
Start-Sleep -Milliseconds 100
}
}
try {
if ((Get-Item -LiteralPath $PlanPath).Length -gt 1048576) { throw 'Update plan exceeds limit' }
$plan = Get-Content -LiteralPath $PlanPath -Raw -Encoding UTF8 | ConvertFrom-Json
@@ -133,6 +157,7 @@ try {
Check-UnpreparedProcesses
# No Restart Manager process-name shutdown: every participant has already
# saved and exited. DIR reuses this validated installation without a chooser.
Wait-RuntimeHelperFiles
$setupLog = Join-Path $transaction 'installer.log'
$arguments = '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NOCLOSEAPPLICATIONS /NORESTARTAPPLICATIONS' +
' /DIR="' + $installation + '" /LOG="' + $setupLog + '"'
@@ -144,9 +169,9 @@ try {
throw 'Installed application did not report the expected version'
}
$installedDigest = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash
if ($plan.version -eq $plan.original_version -and $installedDigest -eq $originalDigest) {
throw 'Same-version installation did not replace the application binary'
}
# A repair can replace a previously locked helper while leaving pebrel.exe
# byte-identical. Setup success plus the expected version is authoritative;
# a changed main-executable hash is not a same-version success requirement.
Write-State 'result.json' @{
transaction = $plan.transaction; success = $true; version = $plan.version
executable_sha256 = $installedDigest
+28 -13
View File
@@ -1,4 +1,4 @@
//! Two-phase Windows installation handoff and the resulting restore ticket.
//! Two-phase platform installation handoff and the resulting restore ticket.
//! Files under one transaction are durable evidence; only commit authorizes setup.
use std::io::{self, Read as _};
use std::path::{Path, PathBuf};
@@ -7,10 +7,13 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::platform::update_installation::{canonical, current_process_created, spawn_helper};
use crate::platform::update_installation::{canonical, current_process_created};
use crate::session::Session;
use crate::update_check::UpdateAsset;
#[cfg(target_os = "macos")]
pub(crate) mod macos;
struct ActiveRestore {
directory: PathBuf,
expected_windows: usize,
@@ -70,6 +73,9 @@ impl Drop for PreparedUpdate {
if !self.committed {
let _ = crate::atomic_file::write(&self.directory.join("cancel.json"), b"{}");
// This is our own uncommitted helper; it has no authority to install.
if crate::platform::Platform::current() == crate::platform::Platform::MacOS {
return;
}
let _ = self.child.kill();
let _ = self.child.wait();
}
@@ -80,7 +86,7 @@ fn guard_base(executable: &Path) -> PathBuf {
// All configurations of the same installed binary share this lock. A
// settings-directory override must not bypass an installation in progress.
// The installer never replaces or deletes this reserved sidecar.
executable.parent().expect("canonical executable has a parent").join(".pebrel-update")
crate::platform::update_installation::guard_base(executable)
}
/// Called before starting any resident resources. The lock belongs to the exact
@@ -104,10 +110,7 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result<PreparedUpdate, String> {
let installer = super::ready_path(asset)?;
let executable = canonical(&std::env::current_exe().map_err(|error| error.to_string())?)
.map_err(|error| error.to_string())?;
let installation = executable.parent().ok_or("Application directory is missing")?.to_owned();
if !installation.join("unins000.exe").is_file() {
return Err("This copy is portable. Use the download page to replace its package.".into());
}
let installation = crate::platform::update_installation::installation_directory(&executable)?;
let transaction = format!(
"{}-{}",
std::process::id(),
@@ -116,7 +119,13 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result<PreparedUpdate, String> {
let directory = nebula_settings::settings_dir().join("updates/handoffs").join(&transaction);
std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
// Detect an unwritable target while all windows are still available.
let probe = installation.join(format!(".pebrel-update-{transaction}"));
let probe_directory =
if crate::platform::Platform::current() == crate::platform::Platform::MacOS {
installation.parent().ok_or("Missing application parent")?
} else {
&installation
};
let probe = probe_directory.join(format!(".pebrel-update-{transaction}"));
let probe_file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
@@ -153,12 +162,15 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result<PreparedUpdate, String> {
&serde_json::to_vec(&plan_path).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let helper = directory.join("handoff.ps1");
crate::atomic_file::write(&helper, include_bytes!("handoff.ps1"))
.map_err(|error| error.to_string())?;
let child = spawn_helper(&helper, &plan_path).map_err(|error| error.to_string())?;
let child =
crate::platform::update_installation::spawn_prepared_helper(&directory, &plan_path)?;
let mut prepared = PreparedUpdate { directory, transaction, child, committed: false };
let deadline = Instant::now() + Duration::from_secs(30);
let timeout = if crate::platform::Platform::current() == crate::platform::Platform::MacOS {
600
} else {
30
};
let deadline = Instant::now() + Duration::from_secs(timeout);
loop {
if prepared.child.try_wait().map_err(|error| error.to_string())?.is_some() {
let result = read_json::<serde_json::Value>(&prepared.directory.join("result.json"))
@@ -335,3 +347,6 @@ pub(crate) fn apply_scheduled() -> bool {
},
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,247 @@
//! The macOS participant in the shared prepare/commit/restore transaction.
use super::*;
use crate::platform::update_installation::macos as native;
use std::process::{Command, Stdio};
pub(crate) fn run_helper_if_requested() -> Option<i32> {
let args: Vec<_> = std::env::args_os().collect();
if args.get(1).is_none_or(|arg| arg != "--internal-macos-update") {
return None;
}
if args.len() != 3 {
return Some(2);
}
Some(match run(&PathBuf::from(&args[2])) {
Ok(()) => 0,
Err(error) => {
eprintln!("macOS update: {error}");
1
},
})
}
fn run(path: &Path) -> Result<(), String> {
let path = canonical(path).map_err(|e| e.to_string())?;
let directory = path.parent().ok_or("Missing update directory")?;
let plan: Plan = read_json(&path).ok_or("Invalid update plan")?;
let root = canonical(&nebula_settings::settings_dir().join("updates/handoffs"))
.map_err(|e| e.to_string())?;
if path.file_name().is_none_or(|n| n != "plan.json")
|| directory.parent() != Some(root.as_path())
|| directory.file_name().is_none_or(|n| n != plan.transaction.as_str())
|| canonical(&std::env::current_exe().map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?
!= directory.join("handoff")
|| plan.schema != 1
|| plan.participants.len() != 1
|| plan.version != plan.asset.version
|| plan.sha256 != plan.asset.sha256.clone().unwrap_or_default()
|| canonical(&nebula_settings::settings_dir()).map_err(|e| e.to_string())?
!= plan.config_directory
|| plan.executable != plan.installation.join("Contents/MacOS/pebrel")
|| native::bundle(&plan.executable)? != plan.installation
|| plan.guard_path != guard_base(&plan.executable).with_extension("nebula-lock")
|| plan.installer != plan.config_directory.join("updates").join(&plan.asset.name)
|| !crate::update_check::can_install_version(&plan.version)?
{
return Err("The macOS update plan does not match this installation".into());
}
let _guard = crate::atomic_file::try_lifetime_lock(&guard_base(&plan.executable))
.map_err(|e| e.to_string())?
.ok_or("Another update owns this application")?;
let staged = plan
.installation
.parent()
.ok_or("Missing bundle parent")?
.join(format!(".pebrel-update-{}.app", plan.transaction));
if std::fs::symlink_metadata(&staged).is_ok() {
return Err("The staging bundle already exists".into());
}
let mut swapped = false;
let mut committed = false;
let result: Result<(), String> = (|| {
super::super::validate_asset(&plan.asset)?;
let bytes = super::super::verify_file(&plan.installer, &plan.asset)?;
if bytes != plan.bytes {
return Err("The installer size changed".into());
}
let participant = &plan.participants[0];
let created: u64 = participant.created.parse().map_err(|_| "Invalid process identity")?;
if native::process_created(participant.pid).map_err(|e| e.to_string())? != Some(created)
|| native::running_copies(&plan.executable)? != vec![participant.pid]
{
return Err("Close other copies of this Pebrel application before updating".into());
}
native::verify_bundle(&plan.installation, &plan.original_version)?;
let original_digest = file_digest(&plan.executable)?;
native::stage(
&plan.installer,
&plan.installation,
&staged,
&directory.join("mount"),
&plan.version,
)?;
if directory.join("cancel.json").exists() {
return Err("Update cancelled before commit".into());
}
write_value(
&directory.join("ready.json"),
&serde_json::json!({"transaction": plan.transaction}),
)?;
let deadline = Instant::now() + Duration::from_secs(180);
loop {
if directory.join("cancel.json").exists() {
return Err("Update cancelled before commit".into());
}
if let Some(commit) = read_json::<serde_json::Value>(&directory.join("commit.json")) {
if commit["transaction"] != plan.transaction {
return Err("Update commit does not match".into());
}
let _: Vec<Session> = read_json(&directory.join("workspace.json"))
.ok_or("Missing update workspace")?;
committed = true;
break;
}
if Instant::now() >= deadline
|| native::process_created(participant.pid).map_err(|e| e.to_string())?
!= Some(created)
{
return Err(
"The application exited or timed out without authorizing installation".into()
);
}
std::thread::sleep(Duration::from_millis(50));
}
let deadline = Instant::now() + Duration::from_secs(90);
while native::process_created(participant.pid).map_err(|e| e.to_string())? == Some(created)
{
if Instant::now() >= deadline {
return Err("Pebrel did not exit; its application was not replaced".into());
}
std::thread::sleep(Duration::from_millis(50));
}
if !native::running_copies(&plan.executable)?.is_empty()
|| file_digest(&plan.executable)? != original_digest
{
return Err(
"The installed application changed or another copy started during preparation"
.into(),
);
}
native::verify_bundle(&staged, &plan.version)?;
native::exchange(&plan.installation, &staged)?;
swapped = true;
// Persist success before launch so the new application can consume the restore ticket.
write_result(directory, &plan, true, false, None)?;
Ok(())
})();
// Startup checks this same installation lock. Release it only after the swap/result are durable.
drop(_guard);
let result = result.and_then(|_| relaunch(&plan, &path));
if let Err(mut error) = result {
let rollback_guard =
match crate::atomic_file::try_lifetime_lock(&guard_base(&plan.executable)) {
Ok(guard) => guard,
Err(lock_error) => {
error.push_str(&format!("; could not lock for rollback: {lock_error}"));
None
},
};
let recovery = (|| -> Result<bool, String> {
if rollback_guard.is_none() || !native::running_copies(&plan.executable)?.is_empty() {
return Ok(false);
}
if swapped {
native::exchange(&plan.installation, &staged)?;
Ok(true)
} else {
Ok(committed
&& native::verify_bundle(&plan.installation, &plan.original_version).is_ok())
}
})();
let recovered = match recovery {
Ok(recovered) => recovered,
Err(rollback) => {
error.push_str(&format!(
"; rollback failed: {rollback}; retained bundle at {}",
staged.display()
));
false
},
};
write_result(directory, &plan, false, recovered, Some(&error))?;
drop(rollback_guard);
if recovered {
let _ = relaunch(&plan, &path);
}
if !swapped {
let _ = std::fs::remove_dir_all(&staged);
}
return Err(error);
}
// Retain the original signed bundle at the unique sibling path for recovery.
Ok(())
}
fn relaunch(plan: &Plan, path: &Path) -> Result<(), String> {
let mut child = Command::new(&plan.executable)
.arg("--gpui")
.env("PEBREL_CONFIG_DIR", &plan.config_directory)
.env("NEBULA_CONFIG_DIR", &plan.config_directory)
.env("PEBREL_UPDATE_RESTORE", path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Could not relaunch Pebrel: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return if status.success() {
Ok(())
} else {
Err(format!("Updated Pebrel exited with {status}"))
};
}
std::thread::sleep(Duration::from_millis(50));
}
Ok(())
}
fn write_value(path: &Path, value: &serde_json::Value) -> Result<(), String> {
crate::atomic_file::write(path, &serde_json::to_vec(value).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())
}
fn write_result(
directory: &Path,
plan: &Plan,
success: bool,
recovered: bool,
error: Option<&str>,
) -> Result<(), String> {
write_value(
&directory.join("result.json"),
&serde_json::json!({
"transaction": plan.transaction, "success": success, "recovered_original": recovered, "error": error,
}),
)
}
fn file_digest(path: &Path) -> Result<Vec<u8>, String> {
use sha2::{Digest as _, Sha256};
let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).map_err(|e| e.to_string())?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher.finalize().to_vec())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,261 @@
//! Native acceptance uses disposable signed apps, never the user's installation.
use super::*;
use std::fs;
struct Fixture {
_root: tempfile::TempDir,
plan: Plan,
directory: PathBuf,
old: Child,
helper: Option<Child>,
}
impl Drop for Fixture {
fn drop(&mut self) {
if let Some(child) = &mut self.helper {
let _ = child.kill();
let _ = child.wait();
}
let _ = self.old.kill();
let _ = self.old.wait();
// Only processes executing this disposable fixture path are ours.
for pid in native::running_copies(&self.plan.executable).unwrap_or_default() {
unsafe { libc::kill(pid as i32, libc::SIGTERM) };
}
}
}
fn app(path: &Path, version: &str, fails: bool) {
fs::create_dir_all(path.join("Contents/MacOS")).unwrap();
let source = path.parent().unwrap().join("fixture.c");
fs::write(
&source,
format!(
r#"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char **argv) {{
if (argc > 1 && strcmp(argv[1], "--version") == 0) {{
puts("pebrel {version}"); return 0;
}}
char path[4096];
snprintf(path, sizeof(path), "%s/launched.txt", getenv("PEBREL_CONFIG_DIR"));
FILE *file = fopen(path, "a");
if (file) {{ fprintf(file, "{version}\n"); fclose(file); }}
if ({fails}) return 42;
sleep(600); return 0;
}}
"#,
fails = u8::from(fails)
),
)
.unwrap();
native::run(
Command::new("/usr/bin/cc").arg(&source).arg("-o").arg(path.join("Contents/MacOS/pebrel")),
)
.unwrap();
fs::remove_file(source).unwrap();
fs::write(path.join("Contents/Info.plist"), format!(r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleIdentifier</key><string>io.github.kuddev.pebrel</string>
<key>CFBundleExecutable</key><string>pebrel</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>{version}</string>
</dict></plist>"#)).unwrap();
native::run(Command::new("/usr/bin/codesign").args(["--force", "--sign", "-"]).arg(path))
.unwrap();
}
impl Fixture {
fn new(binary: &Path, fails: bool) -> Self {
let root = tempfile::tempdir().unwrap();
let path = fs::canonicalize(root.path()).unwrap();
let installation = path.join("Pebrel.app");
let config = path.join("config");
let transaction = "native-acceptance".to_owned();
let directory = config.join("updates/handoffs").join(&transaction);
fs::create_dir_all(&directory).unwrap();
let version = "99.0.0";
app(&installation, env!("CARGO_PKG_VERSION"), false);
let source = path.join("image");
fs::create_dir(&source).unwrap();
app(&source.join("Pebrel.app"), version, fails);
let name = crate::update_check::assets::native_names(version).remove(0);
let installer = config.join("updates").join(&name);
native::run(
Command::new("/usr/bin/hdiutil")
.args([
"create",
"-quiet",
"-fs",
"HFS+",
"-volname",
"PebrelUpdateTest",
"-srcfolder",
])
.arg(&source)
.args(["-format", "UDZO"])
.arg(&installer),
)
.unwrap();
let hash: String =
file_digest(&installer).unwrap().iter().map(|b| format!("{b:02x}")).collect();
let executable = installation.join("Contents/MacOS/pebrel");
let old = Command::new(&executable).env("PEBREL_CONFIG_DIR", &config).spawn().unwrap();
let created = native::process_created(old.id()).unwrap().unwrap();
let plan = Plan {
schema: 1,
asset: UpdateAsset {
version: version.into(),
name: name.clone(),
download_url: format!(
"https://github.com/Kuddev/pebrel/releases/download/v{version}/{name}"
),
size: Some(fs::metadata(&installer).unwrap().len()),
sha256: Some(hash.clone()),
},
transaction,
guard_path: guard_base(&executable).with_extension("nebula-lock"),
executable,
installation,
config_directory: config,
bytes: fs::metadata(&installer).unwrap().len(),
installer,
sha256: hash,
version: version.into(),
original_version: env!("CARGO_PKG_VERSION").into(),
participants: vec![Participant { pid: old.id(), created: created.to_string() }],
};
fs::copy(binary, directory.join("handoff")).unwrap();
Self { _root: root, plan, directory, old, helper: None }
}
fn start(&mut self) {
let path = self.directory.join("plan.json");
fs::write(&path, serde_json::to_vec(&self.plan).unwrap()).unwrap();
let log = fs::File::create(self.directory.join("helper.log")).unwrap();
self.helper = Some(
Command::new(self.directory.join("handoff"))
.arg("--internal-macos-update")
.arg(path)
.env("PEBREL_CONFIG_DIR", &self.plan.config_directory)
.env("NEBULA_CONFIG_DIR", &self.plan.config_directory)
.stdout(Stdio::null())
.stderr(log)
.spawn()
.unwrap(),
);
}
fn ready(&mut self) {
let deadline = Instant::now() + Duration::from_secs(60);
while !self.directory.join("ready.json").exists() {
assert!(self.helper.as_mut().unwrap().try_wait().unwrap().is_none(), "{}", self.log());
assert!(Instant::now() < deadline, "helper did not prepare: {}", self.log());
std::thread::sleep(Duration::from_millis(50));
}
assert_eq!(
native::property(&self.plan.installation, "CFBundleShortVersionString").unwrap(),
self.plan.original_version
);
assert!(
crate::atomic_file::try_lifetime_lock(&guard_base(&self.plan.executable))
.unwrap()
.is_none()
);
}
fn commit(&mut self) {
fs::write(self.directory.join("workspace.json"), b"[]").unwrap();
write_value(
&self.directory.join("commit.json"),
&serde_json::json!({"transaction": self.plan.transaction}),
)
.unwrap();
self.old.kill().unwrap();
self.old.wait().unwrap();
}
fn finish(&mut self, success: bool) -> serde_json::Value {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Some(status) = self.helper.as_mut().unwrap().try_wait().unwrap() {
assert_eq!(status.success(), success, "{}", self.log());
break;
}
assert!(Instant::now() < deadline, "helper timed out: {}", self.log());
std::thread::sleep(Duration::from_millis(50));
}
assert!(!self.directory.join("mount").exists(), "image must be detached");
read_json(&self.directory.join("result.json")).unwrap()
}
fn log(&self) -> String {
fs::read_to_string(self.directory.join("helper.log")).unwrap_or_default()
}
}
#[test]
#[ignore = "needs macOS signing/mount permissions and PEBREL_MACOS_TEST_EXECUTABLE from a fresh product build"]
fn native_macos_update_installs_cancels_rejects_and_rolls_back() {
let binary = PathBuf::from(
std::env::var_os("PEBREL_MACOS_TEST_EXECUTABLE").expect("fresh product executable"),
);
for case in ["success", "rollback", "cancel", "digest", "other-instance"] {
let mut fixture = Fixture::new(&binary, case == "rollback");
if case == "digest" {
fixture.plan.sha256 = "0".repeat(64);
fixture.plan.asset.sha256 = Some(fixture.plan.sha256.clone());
}
let mut other = (case == "other-instance").then(|| {
Command::new(&fixture.plan.executable)
.env("PEBREL_CONFIG_DIR", &fixture.plan.config_directory)
.spawn()
.unwrap()
});
fixture.start();
if case == "digest" || case == "other-instance" {
let result = fixture.finish(false);
assert_eq!(result["success"], false);
assert!(!fixture.directory.join("ready.json").exists());
} else {
fixture.ready();
if case == "cancel" {
fs::write(fixture.directory.join("cancel.json"), b"{}").unwrap();
} else {
fixture.commit();
}
let result = fixture.finish(case == "success");
assert_eq!(result["success"], case == "success");
assert_eq!(result["recovered_original"], case == "rollback");
}
if let Some(child) = &mut other {
let _ = child.kill();
let _ = child.wait();
}
let expected = if case == "success" { "99.0.0" } else { env!("CARGO_PKG_VERSION") };
assert_eq!(
native::property(&fixture.plan.installation, "CFBundleShortVersionString").unwrap(),
expected,
"{case}"
);
if case == "success" {
let backup = fixture
.plan
.installation
.parent()
.unwrap()
.join(".pebrel-update-native-acceptance.app");
native::verify_bundle(&backup, env!("CARGO_PKG_VERSION")).unwrap();
assert!(
fs::read_to_string(fixture.plan.config_directory.join("launched.txt"))
.unwrap()
.contains("99.0.0")
);
}
println!("native macOS handoff: {case} passed");
}
}
@@ -0,0 +1,85 @@
use super::*;
#[test]
fn update_restore_tickets_cover_success_rollback_and_acknowledgement() {
// Run in a separate process: changing the settings environment in the shared
// test runner would race unrelated tests and could touch real user settings.
if std::env::var_os("PEBREL_RESTORE_TEST_CHILD").is_none() {
let root = tempfile::tempdir().unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "update_download::handoff::tests::update_restore_tickets_cover_success_rollback_and_acknowledgement", "--nocapture"])
.env("PEBREL_RESTORE_TEST_CHILD", "1")
.env("PEBREL_CONFIG_DIR", root.path())
.env("NEBULA_CONFIG_DIR", root.path())
.env_remove("PEBREL_UPDATE_RESTORE")
.output().unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
return;
}
let config = canonical(&nebula_settings::settings_dir()).unwrap();
let directory = config.join("updates/handoffs/restore-test");
std::fs::create_dir_all(&directory).unwrap();
let executable = canonical(&std::env::current_exe().unwrap()).unwrap();
let version = env!("CARGO_PKG_VERSION").to_owned();
let mut plan = Plan {
schema: 1,
asset: UpdateAsset {
version: version.clone(),
name: "fixture".into(),
download_url: String::new(),
sha256: None,
size: None,
},
transaction: "restore-test".into(),
installation: executable.parent().unwrap().to_owned(),
executable,
config_directory: config.clone(),
installer: config.join("fixture"),
sha256: String::new(),
bytes: 1,
version: version.clone(),
original_version: version,
guard_path: config.join("guard"),
participants: Vec::new(),
};
let save = |name: &str, value: serde_json::Value| {
std::fs::write(directory.join(name), serde_json::to_vec(&value).unwrap()).unwrap();
};
std::fs::write(
config.join("updates/last-handoff.json"),
serde_json::to_vec(&directory.join("plan.json")).unwrap(),
)
.unwrap();
save("workspace.json", serde_json::to_value(vec![Session::new(0, Vec::new())]).unwrap());
for recovered in [false, true] {
plan.version = if recovered { "99.0.0".into() } else { env!("CARGO_PKG_VERSION").into() };
save("plan.json", serde_json::to_value(&plan).unwrap());
save(
"result.json",
serde_json::json!({"transaction": "wrong", "success": !recovered, "recovered_original": recovered}),
);
assert!(restore_ticket().is_none());
save(
"result.json",
serde_json::json!({"transaction": plan.transaction, "success": !recovered, "recovered_original": recovered}),
);
assert_eq!(restore_ticket().unwrap().len(), 1);
acknowledge_restore(2);
assert!(!directory.join("restored.json").exists());
acknowledge_restore(1);
assert!(directory.join("restored.json").exists());
assert!(restore_ticket().is_none(), "a completed restore must not replay");
std::fs::remove_file(directory.join("restored.json")).unwrap();
save("restore-attempts.json", serde_json::json!(3));
assert!(restore_ticket().is_none(), "crash-loop recovery is bounded");
save("restore-attempts.json", serde_json::json!(0));
}
plan.original_version = "0.0.0".into();
save("plan.json", serde_json::to_value(&plan).unwrap());
assert!(restore_ticket().is_none(), "rollback must match the running original version");
}
+4 -4
View File
@@ -10,8 +10,8 @@ APP_MANIFEST RT_MANIFEST nebula.manifest
#endif
1 VERSIONINFO
FILEVERSION 1,9,0,0
PRODUCTVERSION 1,9,0,0
FILEVERSION 1,9,1,0
PRODUCTVERSION 1,9,1,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS 0x40004L
@@ -24,11 +24,11 @@ BEGIN
BEGIN
VALUE "CompanyName", "Kuddev"
VALUE "FileDescription", "Pebrel"
VALUE "FileVersion", "1.9.0"
VALUE "FileVersion", "1.9.1"
VALUE "InternalName", "nebula"
VALUE "OriginalFilename", "pebrel.exe"
VALUE "ProductName", "Pebrel"
VALUE "ProductVersion", "1.9.0"
VALUE "ProductVersion", "1.9.1"
END
END
BLOCK "VarFileInfo"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula_config"
version = "1.9.0"
version = "1.9.1"
license = "GPL-3.0-or-later"
description = "Nebula configuration abstractions"
homepage = "https://github.com/Kuddev/pebrel"
@@ -14,5 +14,5 @@ serde = "1.0.163"
toml.workspace = true
[dev-dependencies]
nebula_config_derive = { version = "1.9.0", path = "../nebula_config_derive" }
nebula_config_derive = { version = "1.9.1", path = "../nebula_config_derive" }
serde = { version = "1.0.163", features = ["derive"] }
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula_config_derive"
version = "1.9.0"
version = "1.9.1"
license = "GPL-3.0-or-later"
description = "Failure resistant deserialization derive"
homepage = "https://github.com/Kuddev/pebrel"
@@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin
[dev-dependencies.nebula_config]
path = "../nebula_config"
version = "1.9.0"
version = "1.9.1"
[dev-dependencies]
log = "0.4.11"
+28 -6
View File
@@ -12,14 +12,15 @@
//! surfaces an error banner, 2 even blocks the turn), and kimi's `Stop` is
//! likewise a blockable event. Every path — including panic — must exit 0,
//! fast. Claude and kimi also write the payload to our stdin, so those modes
//! always drain stdin even when the message goes nowhere: an unread pipe
//! could surface as a hook write error.
//! drain stdin within a bounded invocation even when the message goes nowhere.
//! A caller retaining stdin or a stalled receiver must not leave this helper
//! running indefinitely and holding the installed executable open.
//! 2. SCOPED: the hook config is global (settings.json / kimi's config.toml),
//! but the effect must be Nebula-only. The scope guard is the environment:
//! NEBULA_NOTIFY_PIPE only exists for processes spawned inside Nebula.
//! Anywhere else this is an invisible ~10 ms no-op.
//! 3. FAST: pure std, no JSON handling (Nebula parses), one pipe write.
//! Keeps the whole claude→toast chain under ~50 ms.
//! Anywhere else it forwards nothing and exits without affecting the caller.
//! 3. BOUNDED: pure std, no JSON handling (Pebrel parses), one pipe write.
//! Forwarding has a deadline; startup and notification latency depend on the host.
//!
//! Usage (installed by `nebula setup-ai` / Nebula's boot self-heal):
//! ```text
@@ -42,6 +43,7 @@
use std::io::{Read, Write};
const MAX_PAYLOAD_BYTES: usize = 1 << 20;
const FORWARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// 其他 agent 的 hook runner 独有的环境变量。
///
@@ -167,7 +169,25 @@ fn log_outcome(source: &str, pane: &str, bytes: usize, outcome: &Outcome) {
fn main() {
// Constraint 1: never leak a failure to the calling CLI.
let args: Vec<String> = std::env::args().skip(1).collect();
let _ = std::panic::catch_unwind(|| run(&args));
let forwarding_args = args.clone();
let (done, finished) = std::sync::mpsc::sync_channel(1);
// Bound the entire forwarding operation, including stdin drain and pipe
// writes. A read-only timeout would still leave a blocked writer alive.
// This one-shot process never joins a stuck worker: returning from main
// retires all of its threads and handles. The provider sees exit code 0.
if std::thread::Builder::new()
.name("hook-forward".into())
.spawn(move || {
let _ = std::panic::catch_unwind(|| run(&forwarding_args));
let _ = done.send(());
})
.is_ok()
{
let _ = finished.recv_timeout(FORWARD_TIMEOUT);
}
// A user-owned notifier is independent of our best-effort transport. Never
// wait for it and do not suppress it when the Pebrel pipe is unavailable.
chain_notifier(&args);
// Cursor 的提交前 Hook 有响应合同;传输失败也不能阻止用户提交。
if args.first().is_some_and(|source| source == "cursor")
&& native_event(&args) == Some("prompt")
@@ -307,7 +327,9 @@ fn run(args: &[String]) {
}
}
log_outcome(source, &pane, payload.len(), &outcome);
}
fn chain_notifier(args: &[String]) {
// Chain mode: keep a pre-existing codex notifier working. Runs even
// outside Nebula — the original program must keep firing everywhere.
let strs: Vec<&str> = args.iter().map(String::as_str).collect();
+142
View File
@@ -0,0 +1,142 @@
//! Real-process lifetime regressions: a provider may retain its stdin handle,
//! and a pipe receiver may stop draining. Neither can pin the installed helper.
use std::io::Write as _;
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
struct Invocation(Option<Child>);
impl Invocation {
fn spawn(args: &[&str], pipe: Option<&str>) -> Self {
let mut command = Command::new(env!("CARGO_BIN_EXE_pebrel-hook"));
command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
for suffix in ["NOTIFY_PIPE", "REMOTE_HOOK_TOKEN", "HOOK_LOG"] {
command.env_remove(format!("PEBREL_{suffix}"));
command.env_remove(format!("NEBULA_{suffix}"));
}
if let Some(pipe) = pipe {
command.env("PEBREL_NOTIFY_PIPE", pipe);
}
Self(Some(command.spawn().unwrap()))
}
fn finish(mut self) -> Output {
let deadline = Instant::now() + Duration::from_secs(6);
let child = self.0.as_mut().unwrap();
loop {
if child.try_wait().unwrap().is_some() {
let output = self.0.take().unwrap().wait_with_output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
return output;
}
assert!(Instant::now() < deadline, "helper retained a blocked invocation");
std::thread::sleep(Duration::from_millis(10));
}
}
}
impl Drop for Invocation {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn stdin_kept_open_cannot_pin_a_native_hook_executable() {
for args in [&["codex", "--hooks=full"][..], &["claude"][..]] {
let mut invocation = Invocation::spawn(args, None);
let mut input = invocation.0.as_mut().unwrap().stdin.take().unwrap();
input.write_all(b"{\"hook_event_name\":\"Stop\"}").unwrap();
// Hold stdin open across finish: EOF must not be necessary for exit.
let output = invocation.finish();
assert!(output.stdout.is_empty());
drop(input);
}
}
#[test]
fn cursor_still_allows_submission_when_stdin_never_closes() {
let mut invocation = Invocation::spawn(&["cursor", "--event", "prompt"], None);
let input = invocation.0.as_mut().unwrap().stdin.take().unwrap();
let output = invocation.finish();
assert_eq!(output.stdout, b"{\"continue\":true}\n");
drop(input);
}
#[test]
fn ordinary_completed_payload_exits_silently() {
let mut invocation = Invocation::spawn(&["codex", "--hooks=full"], None);
invocation.0.as_mut().unwrap().stdin.take().unwrap().write_all(b"{}").unwrap();
let output = invocation.finish();
assert!(output.stdout.is_empty() && output.stderr.is_empty());
}
#[test]
fn legacy_notify_still_invokes_the_users_chained_program() {
let executable = std::env::current_exe().unwrap();
let invocation = Invocation::spawn(
&["codex", "--chain", executable.to_str().unwrap(), "--list", "--format=terse"],
None,
);
let output = invocation.finish();
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("ordinary_completed_payload_exits_silently")
);
}
#[cfg(windows)]
#[test]
fn a_receiver_that_never_reads_cannot_pin_the_helper() {
use std::ffi::c_void;
#[link(name = "kernel32")]
unsafe extern "system" {
fn CreateNamedPipeW(
name: *const u16,
mode: u32,
pipe_mode: u32,
instances: u32,
output: u32,
input: u32,
timeout: u32,
security: *const c_void,
) -> *mut c_void;
fn CloseHandle(handle: *mut c_void) -> i32;
fn GetNamedPipeClientProcessId(handle: *mut c_void, pid: *mut u32) -> i32;
}
struct Pipe(*mut c_void);
impl Drop for Pipe {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
let name = format!(r"\\.\pipe\pebrel-hook-lifetime-test-{}", std::process::id());
let wide: Vec<u16> = name.encode_utf16().chain(Some(0)).collect();
// The server owns an inbound byte pipe and deliberately never drains it.
let handle =
unsafe { CreateNamedPipeW(wide.as_ptr(), 1, 0, 1, 4096, 4096, 0, std::ptr::null()) };
assert_ne!(handle as isize, -1, "{}", std::io::Error::last_os_error());
let _pipe = Pipe(handle);
let payload = "x".repeat(16 * 1024);
let invocation = Invocation::spawn(&["codex", &payload], Some(&name));
let deadline = Instant::now() + Duration::from_secs(6);
loop {
let mut pid = 0;
if unsafe { GetNamedPipeClientProcessId(handle, &mut pid) } != 0 {
assert_eq!(pid, invocation.0.as_ref().unwrap().id());
break;
}
assert!(Instant::now() < deadline, "helper never connected to the blocked receiver");
std::thread::sleep(Duration::from_millis(10));
}
let output = invocation.finish();
assert!(output.stdout.is_empty() && output.stderr.is_empty());
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula-settings"
version = "1.9.0"
version = "1.9.1"
license = "GPL-3.0-or-later"
description = "Shared reader for Nebula runtime user settings (nebula_settings.txt) and theme terminal-color tables"
publish = false
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula-split"
version = "1.9.0"
version = "1.9.1"
license = "GPL-3.0-or-later"
description = "Shared split-pane rules: layout tree, geometry, divider drag and directional focus"
publish = false
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula_terminal"
version = "1.9.0"
version = "1.9.1"
license = "GPL-3.0-or-later"
description = "Nebula terminal emulation core"
readme = "../README.md"
+20 -2
View File
@@ -402,6 +402,22 @@ mod tests {
let event = Event::readable(KEY);
let mut reader = UnblockedReader::new(ScriptedSource { rx, pending: Vec::new() }, 4096);
// Readiness permits another poll; it does not guarantee bytes. piper
// can deliberately yield for fairness and wake us again, and a queued
// notification can outlive the data that originally triggered it.
// Retry only after a notification so a genuinely lost wake still fails.
let read_chunk = |reader: &mut UnblockedReader<ScriptedSource>, buf: &mut [u8]| {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let read = reader.try_read(buf);
if read > 0 {
return read;
}
assert!(Instant::now() < deadline, "readiness never produced data");
assert!(readable_arrived(&poller, KEY), "pending read was not woken");
}
};
// 首次 register 自带一次无条件投递;先消化掉,后面等到的事件就只可能
// 来自 try_read 的补投。
reader.register(&poller, event, PollMode::Level);
@@ -414,7 +430,8 @@ mod tests {
);
let mut buf = [0u8; 64];
assert_eq!(reader.try_read(&mut buf), 5);
assert_eq!(read_chunk(&mut reader, &mut buf), 5);
assert_eq!(&buf[..5], b"hello");
assert!(reader.pipe.is_empty(), "这一读须把管道读空,否则测不到 TOCTOU 的那一半");
assert!(
@@ -427,6 +444,7 @@ mod tests {
assert_eq!(reader.try_read(&mut buf), 0);
tx.send(b"world".to_vec()).unwrap();
assert!(readable_arrived(&poller, KEY), "重新注册的 waker 失效,后续输出会卡住");
assert_eq!(reader.try_read(&mut buf), 5);
assert_eq!(read_chunk(&mut reader, &mut buf), 5);
assert_eq!(&buf[..5], b"world");
}
}
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""拒绝把竞品名称写入新增内容、提交说明或待推送提交。"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
from typing import Iterable
PROHIBITED_PATTERNS = (
re.compile(r"(?<![A-Za-z0-9_])tty[-_ ]?7(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])otty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])netcaxx(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])netcat[-_ ]?ty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])arxxx(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])cmux(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])ghostty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])herdr(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])kaku(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])orca(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])tmux(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])wezterm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])windows[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])alacritty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])kitty(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])tabby(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])warp(?:[-_ ]?terminal)?(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])wave[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])electerm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])termius(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])xshell(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])windterm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])mobaxterm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])iterm2?(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])hyper[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])rio[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])contour(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])extraterm(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])finalshell(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])royal[-_ ]?ts(?![A-Za-z0-9_])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9_])securecrt(?![A-Za-z0-9_])", re.IGNORECASE),
)
PROHIBITED_EXAMPLES = (
"tty7",
"otty",
"netcaxx",
"netcatty",
"arxxx",
"cmux",
"ghostty",
"herdr",
"kaku",
"orca",
"tmux",
"wezterm",
"Windows Terminal",
"Alacritty",
"Kitty",
"Tabby",
"Warp",
)
# 门禁必须能维护自己的关键词表;除此之外没有源码或文档例外。
EXEMPT_PATHS = {
"scripts/check_prohibited_names.py",
# These fixtures intentionally contain both accepted and rejected names so
# the policy can test its own false-positive boundaries.
"scripts/tests/test_prohibited_names.py",
"scripts/tests/fixtures/prohibited_names/ordinary-comparison.md",
}
LEGAL_ATTRIBUTION_PATTERNS = (
re.compile(
r"^\s*(?:[#/;*=-]+\s*)?copyright\b[^;\n]*\b(?:project|contributors?|authors?)\b",
re.IGNORECASE,
),
re.compile(
r"^\s*(?:[#/;*=-]+\s*)?(?:third[- ]party|upstream)\s+(?:notice|attribution|project)\b[^;\n]*",
re.IGNORECASE,
),
)
DEPENDENCY_REFERENCE_PATTERNS = (
re.compile(r"\bgit\s*=\s*[\"']https?://[^\"']+[\"']", re.IGNORECASE),
re.compile(r"\bsource\s*=\s*[\"']git\+https?://[^\"']+[\"']", re.IGNORECASE),
)
PROTOCOL_COMPATIBILITY_PATTERNS = (
re.compile(r"\bKITTY_[A-Z0-9_]+\b"),
re.compile(r"\bkitty_(?:keyboard|seq|event|release|escape|input)[A-Za-z0-9_]*\b", re.IGNORECASE),
re.compile(r"\b(?:kitty|tmux)/(?:legacy|style|protocol)\b", re.IGNORECASE),
re.compile(r"\b(?:kitty|tmux)\s+(?:keyboard|protocol|escape|sequence)\b", re.IGNORECASE),
re.compile(r"\bThemeFormat::(?:WindowsTerminal|Kitty|Ghostty|WezTerm|Alacritty)\b"),
re.compile(
r"unsupported theme format; use Pebrel JSON, Windows Terminal JSON, Kitty, Ghostty, WezTerm or Alacritty",
re.IGNORECASE,
),
re.compile(r"\\x1bPtmux;"),
)
def git(*args: str) -> bytes:
return subprocess.check_output(["git", "-c", "i18n.logOutputEncoding=utf-8", *args], stderr=subprocess.DEVNULL)
def prohibited(text: str) -> bool:
return any(pattern.search(text) for pattern in PROHIBITED_PATTERNS)
def source_without_allowed_occurrences(path: str, text: str) -> str:
for pattern in LEGAL_ATTRIBUTION_PATTERNS:
text = pattern.sub("", text)
if Path(path).name in {"Cargo.toml", "Cargo.lock"} and any(
pattern.search(text) for pattern in DEPENDENCY_REFERENCE_PATTERNS
):
for pattern in DEPENDENCY_REFERENCE_PATTERNS:
text = pattern.sub("", text)
for pattern in PROTOCOL_COMPATIBILITY_PATTERNS:
text = pattern.sub("", text)
return text
def prohibited_source_line(path: str, text: str) -> bool:
return prohibited(source_without_allowed_occurrences(path, text))
def changed_paths(*revision_args: str) -> list[str]:
raw = git("diff", *revision_args, "--name-only", "-z", "--diff-filter=ACMR", "--", ".")
return [path.decode("utf-8") for path in raw.split(b"\0") if path]
def added_lines(path: str, *revision_args: str) -> Iterable[tuple[int, str]]:
patch = git("diff", *revision_args, "--no-ext-diff", "--unified=0", "--", path)
for line_no, raw_line in enumerate(patch.decode("utf-8").splitlines(), 1):
if raw_line.startswith("+") and not raw_line.startswith("+++"):
yield line_no, raw_line[1:]
def scan_added_lines(*revision_args: str) -> list[str]:
hits: list[str] = []
for path in changed_paths(*revision_args):
normalized = path.replace("\\", "/")
if normalized in EXEMPT_PATHS:
continue
for line_no, line in added_lines(path, *revision_args):
if prohibited_source_line(normalized, line):
hits.append(f"{normalized}:{line_no}:{line}")
return hits
def added_patch_lines(patch: bytes, merge_parent_count: int | None) -> Iterable[tuple[int, str]]:
lines = patch.decode("utf-8").splitlines()
if merge_parent_count is None:
for line_no, raw_line in enumerate(lines, 1):
if raw_line.startswith("+") and not raw_line.startswith("+++"):
yield line_no, raw_line[1:]
return
in_hunk = False
prefix = "+" * merge_parent_count
for line_no, raw_line in enumerate(lines, 1):
if raw_line.startswith("@@@"):
in_hunk = True
continue
# Combined diff headers (including `+++ `) occur before the hunk.
if in_hunk and raw_line.startswith(prefix):
yield line_no, raw_line[merge_parent_count:]
def scan_pending_commits(revision_range: str) -> list[str]:
hits: list[str] = []
commits = git("rev-list", "--reverse", revision_range).decode("ascii").splitlines()
for commit in commits:
parents = git("rev-list", "--parents", "-n", "1", commit).decode("ascii").split()
parent = parents[1] if len(parents) > 1 else None
merge_parent_count = len(parents) - 1
merge_commit = merge_parent_count > 1
if merge_commit:
# Combined diff reports only content resolved against all parents;
# comparing to one parent would replay the other branch's changes.
paths = git("diff-tree", "--cc", "--no-commit-id", "--name-only", "-z", "-r", commit)
elif parent:
# Comparing against the first parent includes a merge resolution's
# actual additions for ordinary commits.
paths = git("diff", "--name-only", "-z", "--diff-filter=ACMR", parent, commit, "--", ".")
else:
paths = git("diff-tree", "--root", "--no-commit-id", "--name-only", "-z", "-r", commit)
for raw_path in paths.split(b"\0"):
if not raw_path:
continue
path = raw_path.decode("utf-8")
normalized = path.replace("\\", "/")
if normalized in EXEMPT_PATHS:
continue
if merge_commit:
patch = git("show", "--format=", "--cc", "--no-ext-diff", "--unified=0", commit, "--", path)
elif parent:
patch = git("diff", "--no-ext-diff", "--unified=0", parent, commit, "--", path)
else:
patch = git("show", "--format=", "--no-ext-diff", "--unified=0", commit, "--", path)
for line_no, line in added_patch_lines(
patch, merge_parent_count if merge_commit else None
):
if prohibited_source_line(normalized, line):
hits.append(f"{commit}:{normalized}:{line_no}:{line}")
return hits
def scan_message(path: Path) -> list[str]:
return [
f"{line_no}:{line}"
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if prohibited(line)
]
def resolved_commit(revision: str) -> str:
if not revision or re.fullmatch(r"0+", revision):
raise ValueError(f"missing or zero revision: {revision!r}")
try:
return git("rev-parse", "--verify", f"{revision}^{{commit}}").decode("ascii").strip()
except (subprocess.CalledProcessError, UnicodeDecodeError) as exc:
raise ValueError(f"cannot resolve revision {revision!r}") from exc
def checked_range(base: str, head: str) -> tuple[str, str]:
base_commit = resolved_commit(base)
head_commit = resolved_commit(head)
try:
common = git("merge-base", base_commit, head_commit).decode("ascii").strip()
except (subprocess.CalledProcessError, UnicodeDecodeError) as exc:
raise ValueError(f"base {base_commit} and head {head_commit} are unrelated") from exc
if not common:
raise ValueError(f"base {base_commit} and head {head_commit} have no common ancestor")
return common, head_commit
def scan_commit_messages(revision_range: str) -> list[str]:
hits: list[str] = []
commits = git("rev-list", "--reverse", revision_range).decode("ascii").splitlines()
for commit in commits:
message = git("show", "-s", "--format=%B", commit).decode("utf-8")
for line_no, line in enumerate(message.splitlines(), 1):
if prohibited(line):
hits.append(f"commit-message:{commit}:{line_no}:{line}")
return hits
def scan_range(base: str, head: str) -> list[str]:
base_commit, head_commit = checked_range(base, head)
revision_range = f"{base_commit}..{head_commit}"
hits = scan_commit_messages(revision_range)
# Scan each commit's added lines so a name added and later deleted in the
# same range cannot disappear from the final two-commit diff.
hits.extend(scan_pending_commits(revision_range))
return hits
def pending_push_range() -> str | None:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
)
upstream = result.stdout.decode("utf-8").strip()
return f"{upstream}..HEAD" if result.returncode == 0 and upstream else None
def report(title: str, hits: list[str]) -> int:
if not hits:
return 0
print(f"ERROR: {title}\n" + "\n".join(hits), file=sys.stderr)
return 1
def main(argv: list[str]) -> int:
if len(argv) < 2:
print(
"usage: check_prohibited_names.py staged|message|push [message-file] | "
"range --base BASE --head HEAD",
file=sys.stderr,
)
return 2
mode = argv[1]
if mode == "staged":
return report("staged additions contain prohibited product names", scan_added_lines("--cached"))
if mode == "message":
if len(argv) != 3:
print("message mode requires the commit message path", file=sys.stderr)
return 2
return report("commit message contains prohibited product names", scan_message(Path(argv[2])))
if mode == "push":
revision_range = pending_push_range()
if revision_range is None:
print("No upstream branch; skipping pending-push name check")
return 0
message_text = git("log", "--format=%H:%s%n%b", revision_range).decode("utf-8")
hits = [
f"commit-message:{line_no}:{line}"
for line_no, line in enumerate(message_text.splitlines(), 1)
if prohibited(line)
]
# 逐提交看新增行,避免“先加入、后删除”在最终 range diff 中被抵消。
hits.extend(scan_pending_commits(revision_range))
return report("commits pending push contain prohibited product names", hits)
if mode == "range":
if len(argv) != 6 or argv[2] != "--base" or argv[4] != "--head":
print("range mode requires --base BASE --head HEAD", file=sys.stderr)
return 2
try:
hits = scan_range(argv[3], argv[5])
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
print(f"ERROR: cannot scan prohibited-name range: {exc}", file=sys.stderr)
return 1
return report("range contains prohibited product names", hits)
print(f"unknown mode: {mode}", file=sys.stderr)
return 2
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv))
except (OSError, UnicodeError, subprocess.CalledProcessError) as exc:
print(f"ERROR: cannot read naming-check input: {exc}", file=sys.stderr)
raise SystemExit(1)
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Plan the native CI matrices before GitHub creates runner jobs.
The output file is the path normally supplied as ``GITHUB_OUTPUT``. Planning
is deliberately separate from workflow steps: a draft pull request therefore
does not materialize jobs for the scarce platforms.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
@dataclass(frozen=True)
class Platform:
runner: str
draft: bool
release_check: bool = False
# One platform catalog owns both draft eligibility and release compilation.
PLATFORMS = (
Platform("ubuntu-24.04", draft=True),
Platform("windows-2022", draft=True),
Platform("macos-26", draft=True, release_check=True),
Platform("windows-11-arm", draft=False),
Platform("macos-26-intel", draft=False, release_check=True),
)
FULL_EVENTS = frozenset(
{"push", "merge_group", "workflow_dispatch", "workflow_call", "schedule"}
)
class PlanError(ValueError):
"""An event cannot be converted into a safe CI plan."""
def _matrices(draft: bool) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
selected = [platform for platform in PLATFORMS if not draft or platform.draft]
return (
[{"os": platform.runner} for platform in selected],
[{"os": platform.runner} for platform in selected if platform.release_check],
)
def _reject_json_constant(value: str) -> None:
raise ValueError(f"non-standard JSON constant {value}")
def _read_event(path: Path) -> dict[str, Any]:
try:
raw = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise PlanError(f"cannot read event payload {path}: {exc}") from exc
try:
payload = json.loads(raw, parse_constant=_reject_json_constant)
except (json.JSONDecodeError, ValueError) as exc:
raise PlanError(f"invalid event JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise PlanError("event payload must be a JSON object")
return payload
def plan_matrices(event_name: str, payload: dict[str, Any]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Return native and release matrices for one validated event."""
if event_name == "pull_request":
pull_request = payload.get("pull_request")
if not isinstance(pull_request, dict):
raise PlanError("pull_request event must contain a pull_request object")
draft = pull_request.get("draft")
if not isinstance(draft, bool):
raise PlanError("pull_request.draft must be a boolean")
return _matrices(draft)
if event_name in FULL_EVENTS:
return _matrices(draft=False)
supported = sorted(FULL_EVENTS | {"pull_request"})
raise PlanError(f"unsupported event name {event_name!r}; expected one of {', '.join(supported)}")
def _compact_output(name: str, value: list[dict[str, str]]) -> str:
return f"{name}={json.dumps(value, separators=(',', ':'), ensure_ascii=True)}"
def write_outputs(path: Path, native_matrix: list[dict[str, str]], release_matrix: list[dict[str, str]]) -> None:
"""Append both outputs in one write after planning has fully succeeded."""
content = "\n".join(
(
_compact_output("native_matrix", native_matrix),
_compact_output("release_matrix", release_matrix),
)
) + "\n"
try:
with path.open("a", encoding="utf-8", newline="\n") as output:
output.write(content)
except (OSError, UnicodeError) as exc:
raise PlanError(f"cannot append CI plan to {path}: {exc}") from exc
def _arguments(argv: Sequence[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--event-name", required=True)
parser.add_argument("--event-path", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
arguments = _arguments(argv)
try:
payload = _read_event(arguments.event_path)
native_matrix, release_matrix = plan_matrices(arguments.event_name, payload)
write_outputs(arguments.output, native_matrix, release_matrix)
except PlanError as exc:
print(f"ci_plan: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
+98 -48
View File
@@ -277,13 +277,13 @@ end;
{ —— 按 WSL 发行版注册的右键项 ——
「在 Pebrel 中打开(Ubuntu)」这类菜单项按**安装那一刻**机器上的发行版动态生成:
WSL 发行版子菜单按**安装那一刻**机器上的发行版动态生成:
`Lxss` 里的 `DistributionName` 是唯一真相,枚举口径与 Rust 侧
`shell_detect::find_wsl_distros` 一致(跳过 `docker-desktop*` 这类 plumbing 发行版)。
走 `[Code]` 而不是 `[Registry]` 的原因只有一个:键的条数取决于机器上有几个发行版。
代价是这些键没有 `uninsdeletekey`,卸载由 `RemoveOwnedWslContextMenus` 按名字前缀
加命令串里的可执行文件与 `--shell` 认领后删除。 }
加精确命令与级联菜单归属标记认领后删除。 }
const
LxssRoot = 'Software\Microsoft\Windows\CurrentVersion\Lxss';
@@ -308,67 +308,117 @@ begin
SetArrayLength(Result, Count);
end;
{ 删掉本安装器写下的 WSL 右键项:键名以 `PebrelWsl` 开头、命令串里同时有我们的
可执行文件和 `--shell`,两条都满足才算我们写的。发行版后来被移除也不会留下
点不动的死项。 }
{ WSL 右键项按精确可执行文件前缀归属;级联菜单还校验所有子项,保留用户编辑。
发行版移除后,下次安装重建菜单时不留下旧项。 }
function IsOwnedWslCommand(Command, Executable: string): Boolean;
var
Prefix: string;
begin
Prefix := '"' + Executable + '" --gpui --shell "wsl:';
Result := CompareText(Copy(Command, 1, Length(Prefix)), Prefix) = 0;
end;
function IsOwnedWslMenu(Key, Executable: string): Boolean;
var
Owner, Command: string;
Children, Subkeys: TArrayOfString;
Index: Integer;
begin
Result := False;
if not RegQueryStringValue(HKCU, Key, 'PebrelOwner', Owner) or
(CompareText(Owner, Executable) <> 0) then
Exit;
if not RegGetSubkeyNames(HKCU, Key, Subkeys) then
Exit;
for Index := 0 to GetArrayLength(Subkeys) - 1 do
if CompareText(Subkeys[Index], 'shell') <> 0 then
Exit;
if GetArrayLength(Subkeys) > 0 then begin
if not RegGetSubkeyNames(HKCU, Key + '\shell', Children) then
Exit;
for Index := 0 to GetArrayLength(Children) - 1 do begin
if not RegQueryStringValue(HKCU, Key + '\shell\' + Children[Index] + '\command', '', Command) or
not IsOwnedWslCommand(Command, Executable) then
Exit;
end;
end;
Result := True;
end;
procedure RemoveOwnedWslContextMenusAt(Root, Executable: string);
var
Key, Command: string;
Names: TArrayOfString;
NameIndex: Integer;
begin
if RegGetSubkeyNames(HKCU, Root, Names) then
for NameIndex := 0 to GetArrayLength(Names) - 1 do
if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin
Key := Root + '\' + Names[NameIndex];
Command := '';
if IsOwnedWslMenu(Key, Executable) or
(RegQueryStringValue(HKCU, Key + '\command', '', Command) and
IsOwnedWslCommand(Command, Executable)) then
if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then
RaiseException('Unable to remove an owned WSL context menu: ' + Key);
end;
end;
procedure RemoveOwnedWslContextMenus;
var
Roots: array[0..1] of string;
Root, Key, Command: string;
Names: TArrayOfString;
Index, NameIndex: Integer;
Index: Integer;
begin
Roots[0] := 'Software\Classes\Directory\shell';
Roots[1] := 'Software\Classes\Directory\Background\shell';
for Index := 0 to 1 do begin
Root := Roots[Index];
if RegGetSubkeyNames(HKCU, Root, Names) then
for NameIndex := 0 to GetArrayLength(Names) - 1 do
if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin
Key := Root + '\' + Names[NameIndex];
Command := '';
if RegQueryStringValue(HKCU, Key + '\command', '', Command) and
(Pos(ExpandConstant('{app}\pebrel.exe'), Command) > 0) and
(Pos('--shell', Command) > 0) then
if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then
RaiseException('Unable to remove an owned WSL context menu: ' + Key);
end;
for Index := 0 to 1 do
RemoveOwnedWslContextMenusAt(Roots[Index], ExpandConstant('{app}\pebrel.exe'));
end;
procedure RegisterWslContextMenuAt(Root, Executable, DirectoryArgument: string;
Distros: TArrayOfString);
var
Index: Integer;
Distro, Verb, Command, Key, Menu: string;
begin
Menu := Root + '\PebrelWslMenu';
{ Never overwrite an unknown installation or an edited submenu. }
if RegKeyExists(HKCU, Menu) and not IsOwnedWslMenu(Menu, Executable) then
RaiseException(FmtMessage(CustomMessage('WslMenuConflict'), [Menu]));
RemoveOwnedWslContextMenusAt(Root, Executable);
if GetArrayLength(Distros) = 0 then
Exit;
if not RegWriteStringValue(HKCU, Menu, 'MUIVerb', CustomMessage('OpenInPebrelWsl')) or
not RegWriteStringValue(HKCU, Menu, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Menu, 'SubCommands', '') or
not RegWriteStringValue(HKCU, Menu, 'PebrelOwner', Executable) then
RaiseException(CustomMessage('WslMenuRegistrationFailed'));
for Index := 0 to GetArrayLength(Distros) - 1 do begin
Distro := Distros[Index];
{ 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦,
何况我们靠前缀认领。 }
Verb := 'PebrelWsl' + IntToStr(Index);
{ `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾
反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面),
顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 }
Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory ';
Key := Menu + '\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', Distro) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"' + DirectoryArgument + '"') then
RaiseException('Unable to register the WSL context menu for ' + Distro + '.');
end;
end;
procedure RegisterWslContextMenus;
var
Distros: TArrayOfString;
Index: Integer;
Distro, Verb, LabelText, Executable, Command, Key: string;
Executable: string;
begin
{ 先清掉上一次写下的:发行版被移除后,残留的索引键会指向一个已经不存在的发行版。 }
RemoveOwnedWslContextMenus;
Distros := WslDistroNames;
if GetArrayLength(Distros) = 0 then
Exit;
Executable := ExpandConstant('{app}\pebrel.exe');
for Index := 0 to GetArrayLength(Distros) - 1 do begin
Distro := Distros[Index];
{ 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦,
何况我们靠前缀认领。 }
Verb := 'PebrelWsl' + IntToStr(Index);
LabelText := CustomMessage('OpenInPebrelWsl') + ' (' + Distro + ')';
{ `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾
反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面),
顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 }
Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory ';
Key := 'Software\Classes\Directory\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%1"') then
RaiseException('Unable to register the WSL context menu for ' + Distro + '.');
Key := 'Software\Classes\Directory\Background\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%V"') then
RaiseException('Unable to register the WSL background context menu for ' + Distro + '.');
end;
RegisterWslContextMenuAt('Software\Classes\Directory\shell', Executable, '%1', Distros);
RegisterWslContextMenuAt('Software\Classes\Directory\Background\shell', Executable, '%V', Distros);
end;
procedure MigrateLegacyIntegrations;
+8 -4
View File
@@ -1,9 +1,9 @@
#ifndef AppVersion
#define AppVersion "1.9.0"
#define AppVersion "1.9.1"
#endif
#ifndef NumericVersion
#define NumericVersion "1.9.0.0"
#define NumericVersion "1.9.1.0"
#endif
#ifndef Configuration
@@ -77,7 +77,9 @@ english.AutoStart=Start Pebrel when I sign in to Windows
english.InstallFont=Install Maple Mono font for the current user
english.AddToPath=Add Pebrel to the user PATH
english.OpenInPebrel=Open in Pebrel
english.OpenInPebrelWsl=Open in Pebrel
english.OpenInPebrelWsl=Open in Pebrel (WSL)
english.WslMenuConflict=An existing WSL submenu belongs to another installation or was edited, so it was preserved: %1.
english.WslMenuRegistrationFailed=Unable to register the WSL context submenu.
english.LaunchProgram=Launch Pebrel
english.UninstallProgram=Uninstall Pebrel
english.CloseLegacyProgram=Close the application at %1, then retry the installation.
@@ -89,7 +91,9 @@ chinesesimplified.AutoStart=登录 Windows 后启动 Pebrel
chinesesimplified.InstallFont=为当前用户安装 Maple Mono 字体
chinesesimplified.AddToPath=将 Pebrel 添加到当前用户 PATH
chinesesimplified.OpenInPebrel=在 Pebrel 中打开
chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开
chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开(WSL)
chinesesimplified.WslMenuConflict=现有 WSL 子菜单属于其他安装或已被修改,因此已保留:%1。
chinesesimplified.WslMenuRegistrationFailed=无法注册 WSL 右键子菜单。
chinesesimplified.LaunchProgram=启动 Pebrel
chinesesimplified.UninstallProgram=卸载 Pebrel
chinesesimplified.CloseLegacyProgram=请关闭 %1 中运行的程序,然后重试安装。
+1 -1
View File
@@ -1 +1 @@
484
481
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more