Merge remote-tracking branch 'origin/main' into ci/preflight-and-cache-evidence-20260923
This commit is contained in:
commit
efc0108c03
38 files changed
+1646
-181
No files matched your search
@@ -0,0 +1,64 @@
|
||||
# Color capabilities belong to the local child environment
|
||||
|
||||
## Status
|
||||
|
||||
Implemented; pending review.
|
||||
|
||||
## Context
|
||||
|
||||
Windows users report that Codex lacks its sparse composer stars and user-message
|
||||
backgrounds in Pebrel while they appear in the reference terminal. The same distinction
|
||||
must work across themes without identifying message roles from screen text.
|
||||
|
||||
## Evidence
|
||||
|
||||
GPUI `local_options` refreshes a complete Windows environment before `spawn`
|
||||
calls `tty::setup_env()`. The environment cache preserves its earlier snapshot;
|
||||
changing the parent environment afterward cannot insert `COLORTERM` into the
|
||||
already complete child environment. An Explorer launch need not have terminal
|
||||
capability variables inherited from another terminal.
|
||||
|
||||
Codex's `rust-v0.154.0` `tui/src/terminal_palette.rs` returns the default color
|
||||
for ANSI-16/unknown color levels. `style.rs` uses that result for user-message
|
||||
backgrounds. `bottom_pane/chat_composer/sparkle.rs` requires truecolor and queried
|
||||
foreground/background colors. This explains a shared failure path; settings,
|
||||
model choice and Codex version still control whether its animation runs.
|
||||
|
||||
## Decision
|
||||
|
||||
After refreshing a local Windows environment, fill missing `TERM` with
|
||||
`xterm-256color` and missing `COLORTERM` with `truecolor`. Preserve explicit
|
||||
case-insensitive overrides and `NO_COLOR`/`FORCE_COLOR`. Forward `COLORTERM` through the existing WSL environment allowlist, preserving
|
||||
explicit forwarding flags. Keep the actual Pebrel terminal identity. Existing OSC color-query and rendering paths remain authoritative.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Pretending to be the reference terminal through `WT_SESSION` misstates identity.
|
||||
- Recoloring arbitrary terminal rows as user messages would invent application
|
||||
semantics and damage ordinary command output.
|
||||
- Hardcoding a Nord-only fix leaves other themes and applications affected.
|
||||
|
||||
## Consequences
|
||||
|
||||
New local Windows panes advertise implemented capabilities independently of their
|
||||
launching parent. Already running shells retain their inherited environment.
|
||||
Providers can still intentionally suppress colors or animations. The two-second
|
||||
Hook lifetime fix is unrelated to this environment declaration.
|
||||
|
||||
## Validation
|
||||
|
||||
Eight platform-environment tests pass, including an actual child launched with
|
||||
an explicitly complete environment. VT replay preserves message surfaces and
|
||||
Braille stars for all fifteen built-in themes, plus all 225 theme-switch pairs.
|
||||
A native Debian control returned no `COLORTERM` without forwarding and
|
||||
`truecolor` with it. These are capability/rendering tests, not a full live Codex
|
||||
animation acceptance.
|
||||
|
||||
## Supersedes
|
||||
|
||||
None.
|
||||
|
||||
## Revisit when
|
||||
|
||||
Terminal capability defaults move into a shared per-child environment builder,
|
||||
or the Windows environment refresh no longer creates a complete snapshot.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Bound the lifetime of one-shot Hook forwarding
|
||||
|
||||
## Status
|
||||
|
||||
Implemented; pending review.
|
||||
|
||||
## Context
|
||||
|
||||
[Issue #258](https://github.com/Kuddev/pebrel/issues/258) reports an update that
|
||||
first failed because `pebrel-hook.exe` remained in use after the application
|
||||
closed, followed by a successful same-version repair incorrectly marked failed.
|
||||
The helper must finish even when its caller or notification receiver stalls.
|
||||
|
||||
## Evidence
|
||||
|
||||
`nebula_hook/src/main.rs` drained stdin through `read_to_end` and wrote the
|
||||
notification through synchronous `write_all`. Neither had a deadline. A caller
|
||||
retaining stdin can prevent EOF; a receiver that stops reading can block a write.
|
||||
Both conditions are reproduced by actual child processes in
|
||||
[`lifetime.rs`](../../../nebula_hook/tests/lifetime.rs).
|
||||
|
||||
## Decision
|
||||
|
||||
Run the existing forwarding operation on one process-owned worker. The main
|
||||
thread waits up to two seconds, then returns normally. Returning from this
|
||||
one-shot process releases its worker and OS handles without leaving a resident
|
||||
thread. This bounds stdin, oversize-input draining and notification I/O together.
|
||||
No new dependency, background application service or platform thread is added.
|
||||
|
||||
The user's chained notifier is launched independently after forwarding/waiting;
|
||||
it is neither joined nor terminated. Cursor's submission-allow response remains
|
||||
on the main path, including timeout. All provider-visible exits remain successful.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Timing only stdin leaves a blocked pipe write alive.
|
||||
- Joining the worker after timeout defeats the bound.
|
||||
- Terminating every process named `pebrel-hook.exe` can affect another installation
|
||||
or invocation. This change retires only the helper's own process.
|
||||
- Skipping stdin entirely breaks providers which stream their payload into it.
|
||||
|
||||
## Consequences
|
||||
|
||||
A stalled or unusually slow invocation can lose its best-effort notification.
|
||||
The forwarding wait is bounded; this is not a universal process startup latency
|
||||
guarantee. Already running binaries from an older installation do not acquire
|
||||
the new deadline and may still require the user to close the owning CLI/restart.
|
||||
|
||||
## Validation
|
||||
|
||||
`cargo test --locked -p nebula_hook`: seven unit tests and five actual-process
|
||||
tests passed on Windows, covering retained stdin, a connected non-reading named
|
||||
pipe, normal completion, Cursor continuation and the original chained notifier.
|
||||
Non-Windows integration paths still require their native CI run.
|
||||
|
||||
## Supersedes
|
||||
|
||||
None.
|
||||
|
||||
## Revisit when
|
||||
|
||||
Providers require a longer documented delivery window or the transport becomes
|
||||
cancellable without a per-invocation worker.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -104,6 +104,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 +174,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"
|
||||
}
|
||||
}
|
||||
@@ -903,5 +903,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"
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,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 +174,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"
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,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 +245,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"
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,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 +174,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"
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@
|
||||
"new_window": "新しいウィンドウ",
|
||||
"close_tab": "タブを閉じる",
|
||||
"duplicate_tab": "タブを複製",
|
||||
"copy_working_directory": "作業ディレクトリをコピー",
|
||||
"terminal": "ターミナル",
|
||||
"connect": "接続",
|
||||
"disconnect": "切断",
|
||||
@@ -173,5 +174,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": "タブの色"
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@
|
||||
"new_window": "새 창",
|
||||
"close_tab": "탭 닫기",
|
||||
"duplicate_tab": "탭 복제",
|
||||
"copy_working_directory": "작업 디렉터리 복사",
|
||||
"terminal": "터미널",
|
||||
"connect": "연결",
|
||||
"disconnect": "연결 해제",
|
||||
@@ -173,5 +174,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": "탭 색상"
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,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 +174,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"
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@
|
||||
"new_window": "Новое окно",
|
||||
"close_tab": "Закрыть вкладку",
|
||||
"duplicate_tab": "Дублировать вкладку",
|
||||
"copy_working_directory": "Копировать рабочий каталог",
|
||||
"terminal": "Терминал",
|
||||
"connect": "Подключиться",
|
||||
"disconnect": "Отключиться",
|
||||
@@ -173,5 +174,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": "Цвет вкладки"
|
||||
}
|
||||
}
|
||||
@@ -903,5 +903,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": "标签颜色"
|
||||
}
|
||||
}
|
||||
@@ -218,5 +218,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": "分頁顏色"
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,9 @@ const WSLENV_ENTRIES: &[&str] = &[
|
||||
crate::ai_hook::LEGACY_PANE_ENV,
|
||||
TERM_PROGRAM_ENV,
|
||||
TERM_PROGRAM_VERSION_ENV,
|
||||
// WSL otherwise falls back to a 256-color TERM despite the host supporting
|
||||
// truecolor. Keep the capability declaration; do not spoof another terminal.
|
||||
"COLORTERM",
|
||||
"PEBREL_CLI/p",
|
||||
"PEBREL_BIN_DIR/p",
|
||||
PROCESS_ENV,
|
||||
@@ -311,6 +314,27 @@ mod tests {
|
||||
assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}");
|
||||
assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}");
|
||||
assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV));
|
||||
assert!(names.contains(&"COLORTERM"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_receives_truecolor_without_replacing_an_explicit_forwarding_rule() {
|
||||
let mut env = HashMap::from([
|
||||
("COLORTERM".to_owned(), "truecolor".to_owned()),
|
||||
("WSLENV".to_owned(), "KEEP/p".to_owned()),
|
||||
]);
|
||||
apply(&mut env, 8);
|
||||
assert_eq!(env["COLORTERM"], "truecolor");
|
||||
assert!(env["WSLENV"].split(':').any(|entry| entry == "COLORTERM"));
|
||||
assert!(env["WSLENV"].split(':').any(|entry| entry == "KEEP/p"));
|
||||
env.insert("WSLENV".into(), "KEEP/p:COLORTERM/u".into());
|
||||
env.insert("COLORTERM".into(), "24bit".into());
|
||||
apply(&mut env, 8);
|
||||
assert_eq!(env["COLORTERM"], "24bit");
|
||||
let entries: Vec<_> =
|
||||
env["WSLENV"].split(':').filter(|entry| variable_name(entry) == "COLORTERM").collect();
|
||||
assert_eq!(entries, ["COLORTERM/u"]);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -111,8 +111,10 @@ pub(super) fn ensure_codex_hooks() -> bool {
|
||||
}
|
||||
|
||||
fn groups(helper: &str, mode: CodexHookMode) -> Value {
|
||||
// Codex 的 Windows runner 使用 COMSPEC /C;可执行文件路径自身仍需引号。
|
||||
let command = format!("\"{}\" codex {}", helper.replace('"', "\\\""), mode.argument());
|
||||
// Codex runs Windows hooks through PowerShell -Command. A quoted path is
|
||||
// a string expression until invoked with &, and single quotes keep $, `
|
||||
// and other path characters literal. PowerShell escapes ' by doubling it.
|
||||
let command = format!("& '{}' codex {}", helper.replace('\'', "''"), mode.argument());
|
||||
installation::codex_groups(&command, Some(&command), mode)
|
||||
}
|
||||
|
||||
@@ -260,6 +262,78 @@ fn remove(directory: &Path) -> io::Result<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn upgrades_cmd_style_owned_hooks_without_changing_user_entries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let helper = "C:/Program Files/Pebrel/runtime/pebrel-hook.exe";
|
||||
let old_command = format!("\"{helper}\" codex --hooks=full");
|
||||
let previous =
|
||||
installation::codex_groups(&old_command, Some(&old_command), CodexHookMode::Full);
|
||||
let foreign = json!({"hooks":[{"type":"command","command":"user-hook"}]});
|
||||
let mut hooks = json!({"hooks": previous});
|
||||
hooks["hooks"]["Stop"].as_array_mut().unwrap().push(foreign.clone());
|
||||
std::fs::write(root.path().join("hooks.json"), hooks.to_string()).unwrap();
|
||||
std::fs::write(
|
||||
root.path().join(MARKER),
|
||||
json!({"groups": previous, "enabled_feature": true}).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let config = "notify = ['user-notifier']\n[features]\nhooks = true\n";
|
||||
std::fs::write(root.path().join("config.toml"), config).unwrap();
|
||||
|
||||
assert!(!current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap());
|
||||
assert!(install(root.path(), helper, CodexHookMode::Full).unwrap());
|
||||
assert!(current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap());
|
||||
assert!(!install(root.path(), helper, CodexHookMode::Full).unwrap());
|
||||
let hooks: Value =
|
||||
serde_json::from_str(&read(&root.path().join("hooks.json")).unwrap().unwrap()).unwrap();
|
||||
let mut expected = json!({"hooks": groups(helper, CodexHookMode::Full)});
|
||||
expected["hooks"]["Stop"].as_array_mut().unwrap().insert(0, foreign);
|
||||
assert_eq!(hooks, expected);
|
||||
assert_eq!(read(&root.path().join("config.toml")).unwrap().unwrap(), config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_executes_generated_hooks_with_literal_paths_and_stdin() {
|
||||
use std::io::Write as _;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let helper = root.path().join("Pebrel's $data `hook.ps1");
|
||||
std::fs::write(
|
||||
&helper,
|
||||
"Write-Output ($args -join '|')\r\nWrite-Output ([Console]::In.ReadLine())\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let powershell = Path::new(&std::env::var_os("SystemRoot").unwrap())
|
||||
.join("System32/WindowsPowerShell/v1.0/powershell.exe");
|
||||
for mode in [CodexHookMode::Turns, CodexHookMode::Full] {
|
||||
let groups = groups(&helper.to_string_lossy().replace('\\', "/"), mode);
|
||||
let command = groups["Stop"][0]["hooks"][0]["command"].as_str().unwrap();
|
||||
let mut child = Command::new(&powershell)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
command,
|
||||
])
|
||||
.creation_flags(0x0800_0000)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child.stdin.take().unwrap().write_all(b"{\"session_id\":\"fixture\"}\n").unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(stdout.contains(&format!("codex|{}", mode.argument())), "{stdout}");
|
||||
assert!(stdout.contains(r#"{"session_id":"fixture"}"#), "{stdout}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_upgrade_remove_preserves_notify_and_user_hooks() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -37,6 +37,9 @@ use crate::config::window::WindowConfig;
|
||||
/// Regex used for default terminal hints (Markdown links, URLs, and local paths).
|
||||
#[rustfmt::skip]
|
||||
pub const DEFAULT_HINT_REGEX: &str = "(?:\
|
||||
(?m:^[a-z0-9_.-]+@[a-z0-9_.-]+:/[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s`]*[$#])|\
|
||||
\"(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}\"\\r\\n]+\"|\
|
||||
'(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}'\\r\\n]+'|\
|
||||
\\[[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n\\]]+\\]\\((?:[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]|\\([^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]*\\))+\\)|\
|
||||
(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\]+|\
|
||||
(?:(?-u:\\b)[a-z]:[/\\\\]|\\\\\\\\)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+|\
|
||||
|
||||
@@ -585,9 +585,45 @@ impl<'a, T> HintPostProcessor<'a, T> {
|
||||
let mut iter = self.term.grid().iter_from(*regex_match.start());
|
||||
|
||||
let mut c = iter.cell().c;
|
||||
let mut start = *regex_match.start();
|
||||
let end = *regex_match.end();
|
||||
if start == self.term.line_search_left(start) && c.is_ascii_alphanumeric() {
|
||||
let mut prefix = self.term.grid().iter_from(start);
|
||||
let mut at = false;
|
||||
loop {
|
||||
let ch = prefix.cell().c;
|
||||
if ch == ':' && at {
|
||||
if let Some(next) = prefix.next()
|
||||
&& next.c == '/'
|
||||
{
|
||||
start = next.point;
|
||||
iter = self.term.grid().iter_from(start);
|
||||
c = iter.cell().c;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '@'))
|
||||
|| prefix.point() >= end
|
||||
{
|
||||
break;
|
||||
}
|
||||
at |= ch == '@';
|
||||
if prefix.next().is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Quoted local paths have an explicit boundary and may contain spaces,
|
||||
// punctuation and parentheses. Keep the target, not its shell quotes.
|
||||
if matches!(c, '\'' | '"') && start < end && self.term.grid()[end].c == c {
|
||||
let inner_start = start.add(self.term, Boundary::Grid, 1);
|
||||
let inner_end = end.sub(self.term, Boundary::Grid, 1);
|
||||
if inner_start <= inner_end {
|
||||
return Some(inner_start..=inner_end);
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate uneven number of brackets.
|
||||
let end = *regex_match.end();
|
||||
let mut open_parents = 0;
|
||||
let mut open_brackets = 0;
|
||||
loop {
|
||||
@@ -624,7 +660,14 @@ impl<'a, T> HintPostProcessor<'a, T> {
|
||||
}
|
||||
|
||||
// Truncate trailing characters which are likely to be delimiters.
|
||||
let start = *regex_match.start();
|
||||
// A default Bash/WSL prompt has no space between its cwd and $/#.
|
||||
// Only trim that suffix in a user@host:~/... prompt; '$' is otherwise
|
||||
// legal in paths and URLs, and OSC 8 targets bypass these heuristics.
|
||||
if matches!(c, '$' | '#') && self.is_shell_prompt_path(start, iter.point()) {
|
||||
if let Some(indexed) = iter.prev() {
|
||||
c = indexed.cell.c;
|
||||
}
|
||||
}
|
||||
while iter.point() != start {
|
||||
if !matches!(
|
||||
c,
|
||||
@@ -660,6 +703,32 @@ impl<'a, T> HintPostProcessor<'a, T> {
|
||||
if start > iter.point() { None } else { Some(start..=iter.point()) }
|
||||
}
|
||||
|
||||
fn is_shell_prompt_path(&self, start: Point, end: Point) -> bool {
|
||||
if !matches!(self.term.grid()[start].c, '~' | '/') {
|
||||
return false;
|
||||
}
|
||||
let line_start = self.term.line_search_left(start);
|
||||
if start == line_start {
|
||||
return false;
|
||||
}
|
||||
let mut after = self.term.grid().iter_from(end);
|
||||
if after.next().is_some_and(|cell| !cell.c.is_whitespace()) {
|
||||
return false;
|
||||
}
|
||||
let before = start.sub(self.term, Boundary::Grid, 1);
|
||||
let prefix = self.term.bounds_to_string(line_start, before);
|
||||
let Some(prefix) = prefix.split_whitespace().next_back() else { return false };
|
||||
let Some((user, host)) = prefix.strip_suffix(':').and_then(|s| s.rsplit_once('@')) else {
|
||||
return false;
|
||||
};
|
||||
!user.is_empty()
|
||||
&& !host.is_empty()
|
||||
&& user
|
||||
.chars()
|
||||
.chain(host.chars())
|
||||
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
/// Loop over submatches until a non-empty post-processed match is found.
|
||||
fn next_processed_match(&mut self, mut regex_match: Match) {
|
||||
self.next_match = loop {
|
||||
@@ -705,6 +774,65 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shell_prompt_symbols_are_not_part_of_clickable_home_paths() {
|
||||
for (text, expected) in [
|
||||
("alice@workstation:/mnt/d/project$ ", "/mnt/d/project"),
|
||||
("root@host:/root# ", "/root"),
|
||||
("user@host:/# ", "/"),
|
||||
("alice@workstation:~/.codex$ ", "~/.codex"),
|
||||
("root@host:~/work# ", "~/work"),
|
||||
("alice@workstation:~/工作文档$ echo test", "~/工作文档"),
|
||||
("~/cost$ ", "~/cost$"),
|
||||
("~/repo# ", "~/repo#"),
|
||||
("https://example.com/cost$ ", "https://example.com/cost$"),
|
||||
("user@host:~/price$usd ", "~/price$usd"),
|
||||
] {
|
||||
let term = mock_term(text);
|
||||
let config = UiConfig::default();
|
||||
let matches = visible_clickable_matches(&term, &config);
|
||||
assert_eq!(matches.len(), 1, "{text}: {matches:?}");
|
||||
assert_eq!(term.bounds_to_string(*matches[0].start(), *matches[0].end()), expected);
|
||||
let end = *matches[0].end();
|
||||
let hit = highlighted_at(&term, &config, end, ModifiersState::CONTROL).unwrap();
|
||||
assert_eq!(hit.text(&term).unwrap().as_ref(), expected);
|
||||
assert!(
|
||||
highlighted_at(
|
||||
&term,
|
||||
&config,
|
||||
end.add(&term, Boundary::Grid, 1),
|
||||
ModifiersState::CONTROL
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_paths_keep_spaces_and_literal_punctuation_in_hover_and_click_targets() {
|
||||
for (text, expected) in [
|
||||
(
|
||||
r#"& "C:\Program Files\Example Tools\probe.exe" pr view"#,
|
||||
r"C:\Program Files\Example Tools\probe.exe",
|
||||
),
|
||||
("open 'D:/My Documents/开始 菜单 (1).md' now", "D:/My Documents/开始 菜单 (1).md"),
|
||||
("cat '~/cost$'", "~/cost$"),
|
||||
(r#"open "\\server\my share\report.txt" now"#, r"\\server\my share\report.txt"),
|
||||
] {
|
||||
let term = mock_term(text);
|
||||
let config = UiConfig::default();
|
||||
let matches = visible_clickable_matches(&term, &config);
|
||||
assert_eq!(matches.len(), 1, "{text}: {matches:?}");
|
||||
let start = *matches[0].start();
|
||||
let end = *matches[0].end();
|
||||
assert_eq!(term.bounds_to_string(start, end), expected);
|
||||
for point in [start, end] {
|
||||
let hit = highlighted_at(&term, &config, point, ModifiersState::CONTROL).unwrap();
|
||||
assert_eq!(hit.text(&term).unwrap().as_ref(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_label_generation() {
|
||||
let mut generator = HintLabels::new("0123", 0.5);
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
//! 共同塑形,再由 `ligatures` 把字形簇映射回固定列号。其余字符逐格
|
||||
//! 绘制,组合字符跟随基字;编辑、光标和选区不能改变无关格的原点。
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ContentMask, Corners, CursorStyle, DispatchPhase, Element, ElementId,
|
||||
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, MouseButton,
|
||||
@@ -28,6 +26,10 @@ use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor};
|
||||
use super::colors::Palette;
|
||||
use super::view::TerminalView;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "element/color_tests.rs"]
|
||||
mod color_tests;
|
||||
|
||||
pub struct TerminalElement {
|
||||
view: gpui::Entity<TerminalView>,
|
||||
}
|
||||
@@ -84,7 +86,8 @@ impl TerminalElement {
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
cx: &App,
|
||||
) -> Option<(RenderSnapshot, Option<String>, usize, HashSet<(u16, u16)>, usize, i64)> {
|
||||
) -> Option<(RenderSnapshot, Option<String>, usize, super::osc_links::LinkCells, usize, i64)>
|
||||
{
|
||||
let view = self.view.read(cx);
|
||||
let session = view.session.as_ref()?;
|
||||
let hint_config = view.hint_config.clone();
|
||||
@@ -139,12 +142,23 @@ impl TerminalElement {
|
||||
fn resolve_app_colors(
|
||||
&self,
|
||||
snap: &mut RenderSnapshot,
|
||||
links: &mut super::osc_links::LinkCells,
|
||||
theme: &Palette,
|
||||
overrides: &Colors,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.view.update(cx, |view, _| {
|
||||
resolve_app_colors_into(snap, theme, overrides, &mut view.color_resolver);
|
||||
for cell in links.values_mut() {
|
||||
cell.fg = resolve_text_foreground(
|
||||
cell.fg,
|
||||
cell.bg,
|
||||
cell.bold,
|
||||
theme,
|
||||
overrides,
|
||||
&mut view.color_resolver,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -281,7 +295,7 @@ impl Element for TerminalElement {
|
||||
let focused = focus_handle.is_focused(window);
|
||||
// 旧壳只让光标本身参与闪烁;ghost、弹窗补齐和 IME 仍复用同一个坐标锚点。
|
||||
let cursor_visible = self.view.read(cx).cursor_visible();
|
||||
let Some((mut snap, prompt_line, history, dashed, scrollback_floor, viewport_top_abs)) =
|
||||
let Some((mut snap, prompt_line, history, mut dashed, scrollback_floor, viewport_top_abs)) =
|
||||
self.snapshot(layout.rows, layout.cols, cx)
|
||||
else {
|
||||
return;
|
||||
@@ -295,7 +309,7 @@ impl Element for TerminalElement {
|
||||
view.drive_pending_remote_dir(cx);
|
||||
});
|
||||
let overrides = snap.color_overrides;
|
||||
self.resolve_app_colors(&mut snap, &theme, &overrides, cx);
|
||||
self.resolve_app_colors(&mut snap, &mut dashed, &theme, &overrides, cx);
|
||||
let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx);
|
||||
let host_cursor_follows_theme = is_default_host_cursor(&theme);
|
||||
let app_cursor = snap.cursor.as_ref().filter(|cursor| {
|
||||
@@ -606,7 +620,7 @@ impl Element for TerminalElement {
|
||||
} else {
|
||||
theme.resolve(cell.fg, &overrides, cell.bold).into()
|
||||
};
|
||||
let dashed_link = dashed.contains(&(seg.row, cell.col));
|
||||
let dashed_link = dashed.contains_key(&(seg.row, cell.col));
|
||||
let underline = (cell.underline && !dashed_link).then(|| UnderlineStyle {
|
||||
thickness: px(1.0),
|
||||
color: Some(fg),
|
||||
@@ -639,7 +653,7 @@ impl Element for TerminalElement {
|
||||
== cursor_inverts(seg.row, cell.col)
|
||||
&& selected_foreground(seg.row, next.col)
|
||||
== selected_foreground(seg.row, cell.col)
|
||||
&& !dashed.contains(&(seg.row, next.col))
|
||||
&& !dashed.contains_key(&(seg.row, next.col))
|
||||
})
|
||||
} else {
|
||||
1
|
||||
@@ -674,18 +688,35 @@ impl Element for TerminalElement {
|
||||
layout.line_height,
|
||||
);
|
||||
}
|
||||
if dashed_link {
|
||||
paint_dashed_underline(
|
||||
window,
|
||||
origin,
|
||||
layout.cell_width,
|
||||
layout.line_height,
|
||||
fg,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Link decoration follows grid columns, including wide-character spacer
|
||||
// cells and spaces omitted by text shaping. Every cell shares one dash
|
||||
// phase, so font fallback and ASCII/CJK boundaries cannot restart it.
|
||||
for (&(row, col), cell) in &dashed {
|
||||
if math_frame.covers(row as usize, col as usize) {
|
||||
continue;
|
||||
}
|
||||
let Some(visual_col) = math_frame.project_cell(row as usize, col as usize, layout.cols)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let color = if cursor_inverts(row, col) {
|
||||
theme.cursor_text.unwrap_or(theme.background)
|
||||
} else if let Some(foreground) = selected_foreground(row, col) {
|
||||
foreground
|
||||
} else {
|
||||
theme.resolve(cell.fg, &overrides, cell.bold)
|
||||
};
|
||||
super::link_underline::paint(
|
||||
window,
|
||||
cell_rect(row as usize, visual_col, 1),
|
||||
bounds.origin.x,
|
||||
color.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前,
|
||||
// 与旧壳 draw_rects → draw_overlays 的次序一致。
|
||||
if !math_frame.is_empty() {
|
||||
@@ -945,27 +976,6 @@ impl Element for TerminalElement {
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_dashed_underline(
|
||||
window: &mut Window,
|
||||
origin: gpui::Point<Pixels>,
|
||||
cell_width: Pixels,
|
||||
line_height: Pixels,
|
||||
color: Hsla,
|
||||
) {
|
||||
let y = origin.y + line_height - px(1.0);
|
||||
let end: f32 = origin.x.as_f32() + cell_width.as_f32();
|
||||
let mut x: f32 = origin.x.as_f32();
|
||||
let dash: f32 = 3.0;
|
||||
let gap: f32 = 2.0;
|
||||
while x < end {
|
||||
let width = f32::min(dash, end - x);
|
||||
if width > 0.0 {
|
||||
window.paint_quad(fill(Bounds::new(point(px(x), y), size(px(width), px(1.0))), color));
|
||||
}
|
||||
x += dash + gap;
|
||||
}
|
||||
}
|
||||
|
||||
/// Align geometry-only terminal primitives to the device pixel grid. Cell
|
||||
/// widths/heights are already measured in device pixels and converted back to
|
||||
/// logical units, but a pane origin can still be fractional after a split or
|
||||
@@ -1642,9 +1652,6 @@ fn resolve_app_colors_into(
|
||||
use crate::display::content::is_terminal_graphic;
|
||||
use crate::display::terminal_color::is_fixed_color;
|
||||
|
||||
let theme_fg = rgb_from_rgba(theme.foreground);
|
||||
let theme_bg = rgb_from_rgba(theme.background);
|
||||
|
||||
for run in &mut snap.bg_runs {
|
||||
let base = rgb_from_rgba(theme.resolve(run.color, overrides, false));
|
||||
let resolved = resolver.resolve_background(base, is_fixed_color(run.color, overrides));
|
||||
@@ -1662,18 +1669,33 @@ fn resolve_app_colors_into(
|
||||
// 对比度是一对颜色的属性:这个前景可不可读,取决于它**这一格**底下是
|
||||
// 什么,而不是主题底色。默认底色的格子没有 bg run,所以 `SnapCell::bg`
|
||||
// 单独带着这个值。
|
||||
let bg_base = rgb_from_rgba(theme.resolve(cell.bg, overrides, false));
|
||||
let bg = resolver.resolve_background(bg_base, is_fixed_color(cell.bg, overrides));
|
||||
// bold 提亮(0-7 → 8-15)必须发生在矫正**之前**,否则写回的 `Spec` 会把
|
||||
// 提亮吃掉。
|
||||
let base = rgb_from_rgba(theme.resolve(cell.fg, overrides, cell.bold));
|
||||
let resolved = resolver.resolve_foreground(base, bg, true, theme_fg, theme_bg);
|
||||
if resolved != base {
|
||||
cell.fg = Color::Spec(resolved.0);
|
||||
}
|
||||
cell.fg = resolve_text_foreground(cell.fg, cell.bg, cell.bold, theme, overrides, resolver);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_text_foreground(
|
||||
fg: Color,
|
||||
bg: Color,
|
||||
bold: bool,
|
||||
theme: &Palette,
|
||||
overrides: &Colors,
|
||||
resolver: &mut crate::display::terminal_color::TerminalColorResolver,
|
||||
) -> Color {
|
||||
use crate::display::terminal_color::is_fixed_color;
|
||||
let bg_base = rgb_from_rgba(theme.resolve(bg, overrides, false));
|
||||
let bg = resolver.resolve_background(bg_base, is_fixed_color(bg, overrides));
|
||||
// Resolve bold before contrast adjustment; Spec must retain that brightening.
|
||||
let base = rgb_from_rgba(theme.resolve(fg, overrides, bold));
|
||||
let resolved = resolver.resolve_foreground(
|
||||
base,
|
||||
bg,
|
||||
true,
|
||||
rgb_from_rgba(theme.foreground),
|
||||
rgb_from_rgba(theme.background),
|
||||
);
|
||||
if resolved != base { Color::Spec(resolved.0) } else { fg }
|
||||
}
|
||||
|
||||
pub(super) fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba {
|
||||
Rgba {
|
||||
r: f32::from(color.r) / 255.0,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Replay the terminal features required by Codex message surfaces and stars.
|
||||
//! Fixture colors follow codex rust-v0.154.0 tui/src/style.rs and
|
||||
//! bottom_pane/chat_composer/sparkle.rs; this does not emulate a live Codex session.
|
||||
|
||||
use nebula_terminal::event::VoidListener;
|
||||
use nebula_terminal::render::{RenderSnapshot, SnapshotConfig};
|
||||
use nebula_terminal::term::color::Colors;
|
||||
use nebula_terminal::term::test::TermSize;
|
||||
use nebula_terminal::term::{Config, Term};
|
||||
use nebula_terminal::vte::ansi::{self, Color, NamedColor, Rgb};
|
||||
|
||||
use super::{Palette, resolve_app_colors_into, rgb_from_rgba};
|
||||
use crate::display::terminal_color::TerminalColorResolver;
|
||||
use crate::gpui_shell::terminal::colors::from_ansi_rgb;
|
||||
use crate::gpui_shell::theme::ResolvedTheme;
|
||||
|
||||
fn palette(name: nebula_settings::ThemeName) -> Palette {
|
||||
let resolved = ResolvedTheme::builtin(name, None);
|
||||
let color = |[r, g, b]: [u8; 3]| from_ansi_rgb(Rgb { r, g, b });
|
||||
Palette {
|
||||
background: color(resolved.terminal_background()),
|
||||
foreground: color(resolved.terminal_foreground()),
|
||||
..Palette::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn capture(palette: &Palette) -> (RenderSnapshot, Rgb) {
|
||||
let bg = palette.query_reply(NamedColor::Background as usize, &Colors::default());
|
||||
let fg = palette.query_reply(NamedColor::Foreground as usize, &Colors::default());
|
||||
let (top, alpha) = if palette.is_dark() { (255.0, 0.12) } else { (0.0, 0.04) };
|
||||
let blend = |channel: u8| (channel as f32 * (1.0 - alpha) + top * alpha).round() as u8;
|
||||
let user = Rgb { r: blend(bg.r), g: blend(bg.g), b: blend(bg.b) };
|
||||
let bytes = format!(
|
||||
"\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m用户 message\x1b[K\r\n⠁⠂⠄⠈⠐⠠⡀⢀\x1b[K\r\n\x1b[0mAI reply",
|
||||
user.r, user.g, user.b, fg.r, fg.g, fg.b,
|
||||
);
|
||||
let mut term = Term::new(Config::default(), &TermSize::new(40, 4), VoidListener);
|
||||
let mut parser: ansi::Processor = ansi::Processor::new();
|
||||
parser.advance(&mut term, bytes.as_bytes());
|
||||
(RenderSnapshot::capture(&term, &SnapshotConfig { rows: 4, cols: 40 }), user)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_builtin_theme_keeps_message_surfaces_and_braille_stars() {
|
||||
for name in nebula_settings::ThemeName::BUILTIN {
|
||||
let palette = palette(name);
|
||||
let (mut snapshot, user) = capture(&palette);
|
||||
assert_ne!(from_ansi_rgb(user), palette.background, "{name:?}");
|
||||
let star_colors: Vec<_> = snapshot
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|seg| seg.row == 1)
|
||||
.flat_map(|seg| seg.cells.iter().map(|cell| cell.fg))
|
||||
.collect();
|
||||
let mut resolver = TerminalColorResolver::default();
|
||||
resolve_app_colors_into(&mut snapshot, &palette, &Colors::default(), &mut resolver);
|
||||
for row in [0, 1] {
|
||||
let run = snapshot.bg_runs.iter().find(|run| run.row == row).unwrap();
|
||||
assert_eq!((run.start, run.end, run.color), (0, 40, Color::Spec(user)), "{name:?}");
|
||||
}
|
||||
assert!(
|
||||
snapshot.bg_runs.iter().all(|run| run.row != 2),
|
||||
"{name:?}: AI reply acquired a message background"
|
||||
);
|
||||
let stars: String = snapshot
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|seg| seg.row == 1)
|
||||
.flat_map(|seg| seg.cells.iter().map(|cell| cell.text.as_str()))
|
||||
.collect();
|
||||
assert_eq!(stars, "⠁⠂⠄⠈⠐⠠⡀⢀");
|
||||
let after: Vec<_> = snapshot
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|seg| seg.row == 1)
|
||||
.flat_map(|seg| seg.cells.iter().map(|cell| cell.fg))
|
||||
.collect();
|
||||
assert_eq!(star_colors, after, "{name:?}: star colors were changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_background_remains_distinct_after_every_builtin_theme_switch() {
|
||||
for from in nebula_settings::ThemeName::BUILTIN {
|
||||
for to in nebula_settings::ThemeName::BUILTIN {
|
||||
let original = palette(from);
|
||||
let next = palette(to);
|
||||
let (mut snapshot, _) = capture(&original);
|
||||
let mut resolver = TerminalColorResolver::default();
|
||||
resolver
|
||||
.theme_changed(rgb_from_rgba(original.background), rgb_from_rgba(next.background));
|
||||
resolve_app_colors_into(&mut snapshot, &next, &Colors::default(), &mut resolver);
|
||||
let user = snapshot.bg_runs.iter().find(|run| run.row == 0).unwrap();
|
||||
let background = next.resolve(user.color, &Colors::default(), false);
|
||||
assert_ne!(background, next.background, "{from:?} -> {to:?}");
|
||||
assert!(snapshot.bg_runs.iter().all(|run| run.row != 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Link dashes in physical grid coordinates, independent of glyph width/shaping.
|
||||
|
||||
use gpui::{Bounds, Hsla, Pixels, Window, fill, point, px, size};
|
||||
|
||||
pub(super) fn paint(window: &mut Window, cell: Bounds<Pixels>, grid_left: Pixels, color: Hsla) {
|
||||
let scale = window.scale_factor().max(0.5);
|
||||
let y = (cell.bottom().as_f32() * scale).round();
|
||||
let thickness = scale.round().max(1.0);
|
||||
for (left, right) in
|
||||
dash_segments(cell.left().as_f32(), cell.right().as_f32(), grid_left.as_f32(), scale)
|
||||
{
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(
|
||||
point(px(left / scale), px((y - thickness) / scale)),
|
||||
size(px((right - left) / scale), px(thickness / scale)),
|
||||
),
|
||||
color,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn dash_segments(
|
||||
left: f32,
|
||||
right: f32,
|
||||
anchor: f32,
|
||||
scale: f32,
|
||||
) -> impl Iterator<Item = (f32, f32)> {
|
||||
let left = (left * scale).round();
|
||||
let right = (right * scale).round();
|
||||
let anchor = (anchor * scale).round();
|
||||
let dash = (3.0 * scale).round().max(1.0);
|
||||
let period = dash + (2.0 * scale).round().max(1.0);
|
||||
let mut x = anchor + ((left - anchor) / period).floor() * period;
|
||||
std::iter::from_fn(move || {
|
||||
while x < right {
|
||||
let start = x.max(left);
|
||||
let end = (x + dash).min(right);
|
||||
x += period;
|
||||
if start < end {
|
||||
return Some((start, end));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::*;
|
||||
use crate::config::UiConfig;
|
||||
use crate::gpui_shell::terminal::osc_links::dashed_cells;
|
||||
use nebula_terminal::event::VoidListener;
|
||||
use nebula_terminal::term::test::TermSize;
|
||||
use nebula_terminal::term::{Config, Term};
|
||||
use nebula_terminal::vte::ansi;
|
||||
|
||||
#[test]
|
||||
#[ignore = "informational hot-path measurement; no machine-specific timing gate"]
|
||||
fn measure_visible_link_decoration_work() {
|
||||
for (name, linked, text) in
|
||||
[("plain", false, "x"), ("ascii", true, "x"), ("cjk", true, "中")]
|
||||
{
|
||||
let mut term = Term::new(Config::default(), &TermSize::new(120, 40), VoidListener);
|
||||
let mut parser: ansi::Processor = ansi::Processor::new();
|
||||
let text = text.repeat(if name == "cjk" { 50 } else { 100 });
|
||||
let row = if linked {
|
||||
format!("\x1b]8;;file:///fixture\x1b\\{text}\x1b]8;;\x1b\\\r\n")
|
||||
} else {
|
||||
format!("{text}\r\n")
|
||||
};
|
||||
parser.advance(&mut term, row.repeat(35).as_bytes());
|
||||
let config = UiConfig::default();
|
||||
let started = std::time::Instant::now();
|
||||
let mut last_cells = 0;
|
||||
for _ in 0..200 {
|
||||
let cells = dashed_cells(&term, &config, 40, 120);
|
||||
last_cells = cells.len();
|
||||
let segments: usize = cells
|
||||
.keys()
|
||||
.map(|&(_, col)| {
|
||||
dash_segments(
|
||||
2.3 + f32::from(col) * 8.4,
|
||||
2.3 + f32::from(col + 1) * 8.4,
|
||||
2.3,
|
||||
1.25,
|
||||
)
|
||||
.count()
|
||||
})
|
||||
.sum();
|
||||
std::hint::black_box((cells, segments));
|
||||
}
|
||||
crate::gpui_shell::try_write_stderr(format_args!(
|
||||
"{name}: 120x40 grid, {last_cells} linked columns, {:.1} us/pass (capture + dash geometry, no GPU)",
|
||||
started.elapsed().as_secs_f64() * 1_000_000.0 / 200.0
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_osc8_grid_covers_cjk_spacers_and_spaces_with_one_dash_phase() {
|
||||
let mut term = Term::new(Config::default(), &TermSize::new(40, 2), VoidListener);
|
||||
let mut parser: ansi::Processor = ansi::Processor::new();
|
||||
parser.advance(
|
||||
&mut term,
|
||||
"\x1b]8;;file:///tmp/example\x1b\\A开始 菜单Z\x1b]8;;\x1b\\".as_bytes(),
|
||||
);
|
||||
let cells = dashed_cells(&term, &UiConfig::default(), 2, 40);
|
||||
assert_eq!(cells.len(), 11);
|
||||
assert!((0..11).all(|col| cells.contains_key(&(0, col))));
|
||||
for scale in [1.0, 1.25, 1.5, 2.0] {
|
||||
for width in [7.0, 8.4, 9.5, 12.0] {
|
||||
let anchor = 2.3;
|
||||
let pixels = |left, right| {
|
||||
dash_segments(left, right, anchor, scale)
|
||||
.flat_map(|(left, right)| left as i32..right as i32)
|
||||
};
|
||||
let actual: BTreeSet<_> = cells
|
||||
.keys()
|
||||
.flat_map(|&(_, col)| {
|
||||
pixels(anchor + f32::from(col) * width, anchor + f32::from(col + 1) * width)
|
||||
})
|
||||
.collect();
|
||||
let expected: BTreeSet<_> = pixels(anchor, anchor + 11.0 * width).collect();
|
||||
assert_eq!(actual, expected, "scale={scale}, width={width}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ mod event_mailbox;
|
||||
mod inline_image;
|
||||
pub mod keymap;
|
||||
mod ligatures;
|
||||
mod link_underline;
|
||||
pub mod math_overlay;
|
||||
mod molecule_overlay;
|
||||
pub mod mouse_protocol;
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
//! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做
|
||||
//! 视口坐标、GPUI 修饰键和打开入口。
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{App, ClipboardItem, Window};
|
||||
use nebula_terminal::event::EventListener;
|
||||
use nebula_terminal::index::Point;
|
||||
use nebula_terminal::term::cell::Flags;
|
||||
use nebula_terminal::term::{Term, point_to_viewport_from};
|
||||
use nebula_terminal::vte::ansi::Color;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use winit::keyboard::ModifiersState;
|
||||
|
||||
@@ -48,25 +50,42 @@ pub(super) fn winit_mouse_mods(mods: &gpui::Modifiers) -> ModifiersState {
|
||||
}
|
||||
|
||||
/// 可见可点范围(OSC 8 + 正则 URL)映射到当前视口格子,供虚线下划线使用。
|
||||
pub(super) struct LinkCell {
|
||||
pub fg: Color,
|
||||
pub bg: Color,
|
||||
pub bold: bool,
|
||||
}
|
||||
|
||||
pub(super) type LinkCells = HashMap<(u16, u16), LinkCell>;
|
||||
|
||||
pub(super) fn dashed_cells<T: EventListener>(
|
||||
term: &Term<T>,
|
||||
config: &UiConfig,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
) -> HashSet<(u16, u16)> {
|
||||
) -> LinkCells {
|
||||
let matches = hint::visible_clickable_matches(term, config);
|
||||
if matches.is_empty() {
|
||||
return HashSet::new();
|
||||
return HashMap::new();
|
||||
}
|
||||
let origin = term.viewport_origin_for(rows);
|
||||
let mut cells = HashSet::new();
|
||||
let mut cells = HashMap::new();
|
||||
for indexed in term.grid().display_iter() {
|
||||
if !matches.iter().any(|bounds| bounds.contains(&indexed.point)) {
|
||||
if indexed.flags.intersects(Flags::HIDDEN | Flags::LEADING_WIDE_CHAR_SPACER)
|
||||
|| !matches.iter().any(|bounds| bounds.contains(&indexed.point))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(vp) = point_to_viewport_from(origin, indexed.point) else { continue };
|
||||
if vp.line < rows && vp.column.0 < cols {
|
||||
cells.insert((vp.line as u16, vp.column.0 as u16));
|
||||
let (mut fg, mut bg) = (indexed.fg, indexed.bg);
|
||||
if indexed.flags.contains(Flags::INVERSE) {
|
||||
std::mem::swap(&mut fg, &mut bg);
|
||||
}
|
||||
cells.insert(
|
||||
(vp.line as u16, vp.column.0 as u16),
|
||||
LinkCell { fg, bg, bold: indexed.flags.contains(Flags::BOLD) },
|
||||
);
|
||||
}
|
||||
}
|
||||
cells
|
||||
@@ -110,12 +129,61 @@ pub(super) fn open_hint_match(
|
||||
hint: &HintMatch,
|
||||
text: &str,
|
||||
cwd: Option<&std::path::Path>,
|
||||
launch: &crate::session::LaunchSession,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let target = (hint.hyperlink().is_none()
|
||||
&& hint.action() == &HintAction::Command(default_hint_command()))
|
||||
.then(|| wsl_absolute_target(text, launch))
|
||||
.flatten();
|
||||
let text = target.as_deref().unwrap_or(text);
|
||||
dispatch_hint_action(hint.action(), hint.hyperlink().is_some(), text, cwd, window, cx);
|
||||
}
|
||||
|
||||
fn wsl_absolute_target(text: &str, launch: &crate::session::LaunchSession) -> Option<String> {
|
||||
if !text.starts_with('/') || text.starts_with("//") {
|
||||
return None;
|
||||
}
|
||||
let (program, args) = match launch {
|
||||
crate::session::LaunchSession::Shell { program, args, .. } => (program, args),
|
||||
crate::session::LaunchSession::Profile { command, args, .. } => (command, args),
|
||||
_ => return None,
|
||||
};
|
||||
let distro = crate::shell_detect::wsl_launch_distro(program, args)?;
|
||||
Some(crate::shell_detect::wsl_unc_path(distro, text).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absolute_prompt_paths_use_the_owning_wsl_distribution() {
|
||||
let launch = crate::session::LaunchSession::Shell {
|
||||
name: "Debian".into(),
|
||||
program: "wsl.exe".into(),
|
||||
args: vec!["-d".into(), "Debian".into()],
|
||||
};
|
||||
assert_eq!(
|
||||
wsl_absolute_target("/mnt/d/project", &launch).as_deref(),
|
||||
Some(r"\\wsl.localhost\Debian\mnt\d\project")
|
||||
);
|
||||
assert!(wsl_absolute_target("//example.com/file", &launch).is_none());
|
||||
assert!(wsl_absolute_target("https://example.com", &launch).is_none());
|
||||
assert!(
|
||||
wsl_absolute_target(
|
||||
"/home/user",
|
||||
&crate::session::LaunchSession::Ssh { host: "remote".into() }
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
wsl_absolute_target("/home/user", &crate::session::LaunchSession::Default).is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_hint_action(
|
||||
action: &HintAction,
|
||||
hyperlink: bool,
|
||||
|
||||
@@ -178,7 +178,14 @@ impl TerminalView {
|
||||
};
|
||||
let Some(text) = text else { return };
|
||||
let cwd = self.local_cwd();
|
||||
super::super::osc_links::open_hint_match(&hover.hint, &text, cwd.as_deref(), window, cx);
|
||||
super::super::osc_links::open_hint_match(
|
||||
&hover.hint,
|
||||
&text,
|
||||
cwd.as_deref(),
|
||||
&self.session_launch,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// 应用是否接管了鼠标(vim/htop 等)。Shift 按住时强制旁路——这是
|
||||
|
||||
@@ -27,8 +27,10 @@ use gpui::{
|
||||
};
|
||||
use gpui_component::menu::PopupMenuItem;
|
||||
|
||||
use crate::display::UiLanguage;
|
||||
use crate::display::color::Rgb;
|
||||
use crate::gpui_shell::prelude::*;
|
||||
use crate::i18n::Message;
|
||||
use crate::session::LaunchSession;
|
||||
use nebula_split::SplitDirection;
|
||||
|
||||
@@ -73,7 +75,7 @@ pub(super) fn copy_working_directory_item(
|
||||
let available = source.read(cx).working_directory().is_some();
|
||||
let source = source.downgrade();
|
||||
PopupMenuItem::new(
|
||||
super::workspace_ui_language().text(crate::i18n::Message::CommonCopyWorkingDirectory),
|
||||
crate::gpui_shell::config::ui_language(cx).text(Message::CommonCopyWorkingDirectory),
|
||||
)
|
||||
.icon(IconName::Folder)
|
||||
.disabled(!available)
|
||||
@@ -101,6 +103,7 @@ impl NebulaWorkspace {
|
||||
}
|
||||
self.activate_tab(ix, window, cx);
|
||||
let terminal = self.tabs.get(ix).is_some_and(WorkspaceTab::is_terminal);
|
||||
let language = crate::gpui_shell::config::ui_language(cx);
|
||||
let ai_fork = tab_ai_fork_enabled(self, ix, cx);
|
||||
let color = self.meta(ix).color;
|
||||
let tab_count = self.tabs.len();
|
||||
@@ -116,25 +119,18 @@ impl NebulaWorkspace {
|
||||
let menu = PopupMenu::build(window, cx, move |mut menu, _window, _cx| {
|
||||
if let Some(view) = choose_session {
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(
|
||||
super::workspace_ui_language()
|
||||
.text(crate::i18n::Message::SessionChooseConversation),
|
||||
)
|
||||
.on_click(move |_, _, cx| {
|
||||
view.update(cx, |view, cx| view.choose_recovery_session(cx))
|
||||
}),
|
||||
PopupMenuItem::new(language.text(Message::SessionChooseConversation)).on_click(
|
||||
move |_, _, cx| {
|
||||
view.update(cx, |view, cx| view.choose_recovery_session(cx))
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(view) = retry {
|
||||
menu = menu
|
||||
.item(
|
||||
PopupMenuItem::new(
|
||||
super::workspace_ui_language().text(crate::i18n::Message::SessionRetry),
|
||||
)
|
||||
.on_click(move |_, _, cx| {
|
||||
view.update(cx, |view, cx| view.retry_recovery(cx))
|
||||
}),
|
||||
)
|
||||
.item(PopupMenuItem::new(language.text(Message::SessionRetry)).on_click(
|
||||
move |_, _, cx| view.update(cx, |view, cx| view.retry_recovery(cx)),
|
||||
))
|
||||
.separator();
|
||||
}
|
||||
if let Some(copy_cwd) = copy_cwd {
|
||||
@@ -148,6 +144,7 @@ impl NebulaWorkspace {
|
||||
ai_fork,
|
||||
color,
|
||||
tab_count,
|
||||
language,
|
||||
)
|
||||
});
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
@@ -187,19 +184,22 @@ impl NebulaWorkspace {
|
||||
ai_fork: bool,
|
||||
color: Option<Rgb>,
|
||||
tab_count: usize,
|
||||
language: UiLanguage,
|
||||
) -> PopupMenu {
|
||||
if ai_fork {
|
||||
let target = workspace.clone();
|
||||
menu = menu
|
||||
.item(PopupMenuItem::new("分叉 AI 会话").icon(IconName::Bot).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(workspace) = target.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.fork_ai_session(ix, window, cx);
|
||||
});
|
||||
}
|
||||
},
|
||||
))
|
||||
.item(
|
||||
PopupMenuItem::new(language.text(Message::TabMenuForkAiSession))
|
||||
.icon(IconName::Bot)
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Some(workspace) = target.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.fork_ai_session(ix, window, cx);
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
.separator();
|
||||
}
|
||||
if terminal {
|
||||
@@ -209,7 +209,7 @@ impl NebulaWorkspace {
|
||||
let split_right = workspace.clone();
|
||||
let split_down = workspace.clone();
|
||||
menu = menu
|
||||
.item(PopupMenuItem::new("复制标签页").icon(IconName::Copy).on_click(
|
||||
.item(PopupMenuItem::new(language.text(Message::CommonDuplicateTab)).icon(IconName::Copy).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(workspace) = duplicate.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
@@ -219,7 +219,7 @@ impl NebulaWorkspace {
|
||||
},
|
||||
))
|
||||
.item(
|
||||
PopupMenuItem::new("移到新窗口")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuMoveToNewWindow))
|
||||
.icon(IconName::ExternalLink)
|
||||
.on_click(move |_, _, cx| {
|
||||
if let Some(workspace) = move_to_window.upgrade() {
|
||||
@@ -229,7 +229,7 @@ impl NebulaWorkspace {
|
||||
}
|
||||
}),
|
||||
)
|
||||
.item(PopupMenuItem::new("导出为工作区…").icon(IconName::Inbox).on_click(
|
||||
.item(PopupMenuItem::new(language.text(Message::TabMenuExportAsWorkspace)).icon(IconName::Inbox).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(workspace) = export.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
@@ -242,7 +242,7 @@ impl NebulaWorkspace {
|
||||
// `action` 只用来渲染键帽:handler 存在时组件不会 dispatch
|
||||
// 它(见 PopupMenu::confirm),所以命令仍然作用在 `ix` 上。
|
||||
.item(
|
||||
PopupMenuItem::new("左右分屏")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuSplitLeftRight))
|
||||
.icon(IconName::PanelRight)
|
||||
.action(Box::new(SplitRight))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -259,7 +259,7 @@ impl NebulaWorkspace {
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new("上下分屏")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuSplitTopBottom))
|
||||
.icon(IconName::PanelBottom)
|
||||
.action(Box::new(SplitDown))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -286,7 +286,7 @@ impl NebulaWorkspace {
|
||||
// 分屏两项)。首/末位灰掉而不是隐藏——菜单条目忽隐忽现比灰掉
|
||||
// 更难认。
|
||||
.item(
|
||||
PopupMenuItem::new("向左移动")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuMoveLeft))
|
||||
.icon(IconName::ArrowLeft)
|
||||
.action(Box::new(MoveTabLeft))
|
||||
.disabled(ix == 0)
|
||||
@@ -300,7 +300,7 @@ impl NebulaWorkspace {
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new("向右移动")
|
||||
PopupMenuItem::new(language.text(Message::TabMenuMoveRight))
|
||||
.icon(IconName::ArrowRight)
|
||||
.action(Box::new(MoveTabRight))
|
||||
.disabled(ix + 1 >= tab_count)
|
||||
@@ -315,7 +315,7 @@ impl NebulaWorkspace {
|
||||
)
|
||||
.separator()
|
||||
.item(
|
||||
PopupMenuItem::new("重命名")
|
||||
PopupMenuItem::new(language.text(Message::CommonRename))
|
||||
.icon(IconName::ALargeSmall)
|
||||
.action(Box::new(RenameActiveTab))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -327,7 +327,7 @@ impl NebulaWorkspace {
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new("关闭")
|
||||
PopupMenuItem::new(language.text(Message::CommonClose))
|
||||
.icon(IconName::Close)
|
||||
.action(Box::new(CloseActiveTerminal))
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -338,7 +338,7 @@ impl NebulaWorkspace {
|
||||
}
|
||||
}),
|
||||
);
|
||||
Self::tab_color_items(menu, workspace, ix, color)
|
||||
Self::tab_color_items(menu, workspace, ix, color, language)
|
||||
}
|
||||
|
||||
/// 标签颜色行(旧壳菜单尾部的色板):首槽 `A` = 无色,其后是 7 枚品牌
|
||||
@@ -348,9 +348,10 @@ impl NebulaWorkspace {
|
||||
workspace: gpui::WeakEntity<Self>,
|
||||
ix: usize,
|
||||
current: Option<Rgb>,
|
||||
language: UiLanguage,
|
||||
) -> PopupMenu {
|
||||
menu.separator().item(PopupMenuItem::label("标签颜色")).item(PopupMenuItem::element(
|
||||
move |_, cx| {
|
||||
menu.separator().item(PopupMenuItem::label(language.text(Message::TabMenuTabColor))).item(
|
||||
PopupMenuItem::element(move |_, cx| {
|
||||
let swatches = std::iter::once(None)
|
||||
.chain(crate::display::context_menu::TAB_COLORS.into_iter().map(Some));
|
||||
let mut row = h_flex().gap_1().py_1();
|
||||
@@ -395,8 +396,8 @@ impl NebulaWorkspace {
|
||||
);
|
||||
}
|
||||
row
|
||||
},
|
||||
))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,39 @@ fn typed_and_compatibility_lookups_agree() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_context_menu_has_translations_for_every_supported_language() {
|
||||
let items = [
|
||||
Message::CommonCopyWorkingDirectory,
|
||||
Message::CommonDuplicateTab,
|
||||
Message::TabMenuForkAiSession,
|
||||
Message::TabMenuMoveToNewWindow,
|
||||
Message::TabMenuExportAsWorkspace,
|
||||
Message::TabMenuSplitLeftRight,
|
||||
Message::TabMenuSplitTopBottom,
|
||||
Message::TabMenuMoveLeft,
|
||||
Message::TabMenuMoveRight,
|
||||
Message::CommonRename,
|
||||
Message::CommonClose,
|
||||
Message::TabMenuTabColor,
|
||||
];
|
||||
for language in UiLanguage::ALL {
|
||||
for item in items {
|
||||
assert!(!language.text(item).is_empty(), "{}: {item:?}", language.code());
|
||||
if *language != UiLanguage::EnUs {
|
||||
assert_ne!(
|
||||
language.text(item),
|
||||
UiLanguage::EnUs.text(item),
|
||||
"{}: {item:?}",
|
||||
language.code()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(UiLanguage::KoKr.text(Message::CommonCopyWorkingDirectory), "작업 디렉터리 복사");
|
||||
assert_eq!(UiLanguage::KoKr.text(Message::TabMenuTabColor), "탭 색상");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_migration_preserves_bilingual_text_and_english_fallback() {
|
||||
assert_eq!(UiLanguage::ZhCn.pick("网络", "Network"), "网络");
|
||||
|
||||
@@ -9,7 +9,7 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) {
|
||||
}
|
||||
let inherited_override =
|
||||
!options.env_is_complete && std::env::var_os(GROK_LEGACY_CONSOLE).is_some();
|
||||
apply_windows_terminal_defaults(options, inherited_override);
|
||||
apply_local_console_defaults(options, inherited_override);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = options;
|
||||
@@ -19,10 +19,20 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) {
|
||||
const GROK_LEGACY_CONSOLE: &str = "GROK_FORCE_LEGACY_CONSOLE";
|
||||
|
||||
#[cfg(windows)]
|
||||
fn apply_windows_terminal_defaults(
|
||||
fn apply_local_console_defaults(
|
||||
options: &mut nebula_terminal::tty::Options,
|
||||
inherited_override: bool,
|
||||
) {
|
||||
// The refreshed, complete environment is captured before the GPUI spawn
|
||||
// calls tty::setup_env(). Updating the parent later cannot change that
|
||||
// snapshot. Advertise our actual capabilities in the child environment,
|
||||
// including Explorer launches with no parent terminal. Keep explicit values.
|
||||
for (name, value) in [("TERM", "xterm-256color"), ("COLORTERM", "truecolor")] {
|
||||
if !options.env.keys().any(|key| key.eq_ignore_ascii_case(name)) {
|
||||
let inherited = if options.env_is_complete { None } else { std::env::var(name).ok() };
|
||||
options.env.insert(name.to_owned(), inherited.unwrap_or_else(|| value.to_owned()));
|
||||
}
|
||||
}
|
||||
// Grok 1.0.25 treats unknown Windows terminal names as legacy consoles and
|
||||
// omits its Braille logo. Keep our real identity and use its capability override.
|
||||
// Remove this default when Grok recognizes Pebrel's terminal capabilities.
|
||||
@@ -94,13 +104,13 @@ mod tests {
|
||||
env_is_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
super::apply_windows_terminal_defaults(&mut options, false);
|
||||
super::apply_local_console_defaults(&mut options, false);
|
||||
assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0");
|
||||
assert_eq!(options.env["TERM_PROGRAM"], "pebrel");
|
||||
assert_eq!(options.env["WSLENV"], "KEEP/p");
|
||||
assert!(!options.env.contains_key("WT_SESSION"));
|
||||
let once = options.clone();
|
||||
super::apply_windows_terminal_defaults(&mut options, false);
|
||||
super::apply_local_console_defaults(&mut options, false);
|
||||
assert_eq!(options, once);
|
||||
}
|
||||
|
||||
@@ -110,12 +120,18 @@ mod tests {
|
||||
for name in [super::GROK_LEGACY_CONSOLE, "grok_force_legacy_console"] {
|
||||
for value in ["1", "true", "0", "false", ""] {
|
||||
let mut options = nebula_terminal::tty::Options {
|
||||
env: [(name.to_owned(), value.to_owned())].into_iter().collect(),
|
||||
env: [
|
||||
(name.to_owned(), value.to_owned()),
|
||||
("TERM".to_owned(), "xterm-256color".to_owned()),
|
||||
("COLORTERM".to_owned(), "truecolor".to_owned()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
env_is_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let original = options.clone();
|
||||
super::apply_windows_terminal_defaults(&mut options, false);
|
||||
super::apply_local_console_defaults(&mut options, false);
|
||||
assert_eq!(options, original);
|
||||
}
|
||||
}
|
||||
@@ -125,21 +141,92 @@ mod tests {
|
||||
#[test]
|
||||
fn inherited_grok_override_only_applies_to_incomplete_environments() {
|
||||
let mut options = nebula_terminal::tty::Options::default();
|
||||
super::apply_windows_terminal_defaults(&mut options, true);
|
||||
super::apply_local_console_defaults(&mut options, true);
|
||||
assert!(!options.env.contains_key(super::GROK_LEGACY_CONSOLE));
|
||||
|
||||
// A successful refresh can remove a stale value from the parent's registry
|
||||
// snapshot. Do not resurrect it when the new complete environment omits it.
|
||||
options.env_is_complete = true;
|
||||
super::apply_windows_terminal_defaults(&mut options, true);
|
||||
super::apply_local_console_defaults(&mut options, true);
|
||||
assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0");
|
||||
|
||||
let mut fallback = nebula_terminal::tty::Options::default();
|
||||
super::apply_windows_terminal_defaults(&mut fallback, false);
|
||||
super::apply_local_console_defaults(&mut fallback, false);
|
||||
assert_eq!(fallback.env[super::GROK_LEGACY_CONSOLE], "0");
|
||||
assert!(!fallback.env_is_complete);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn complete_explorer_environment_advertises_truecolor_to_the_child_process() {
|
||||
let mut options = nebula_terminal::tty::Options {
|
||||
env: std::env::vars()
|
||||
.filter(|(name, _)| {
|
||||
!["TERM", "COLORTERM", "WT_SESSION", "TERM_PROGRAM"]
|
||||
.iter()
|
||||
.any(|key| name.eq_ignore_ascii_case(key))
|
||||
})
|
||||
.collect(),
|
||||
env_is_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
options.env.insert("TERM_PROGRAM".into(), "pebrel".into());
|
||||
super::apply_local_console_defaults(&mut options, false);
|
||||
assert_eq!(options.env["TERM"], "xterm-256color");
|
||||
assert_eq!(options.env["COLORTERM"], "truecolor");
|
||||
assert!(!options.env.contains_key("WT_SESSION"));
|
||||
// Match the complete-environment child launch, independent of the test
|
||||
// runner's terminal. Read the variables from the actual child.
|
||||
let system = std::env::var_os("SystemRoot").unwrap();
|
||||
let command =
|
||||
std::path::Path::new(&system).join("System32/WindowsPowerShell/v1.0/powershell.exe");
|
||||
let output = std::process::Command::new(command)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::WriteLine(\"$env:TERM $env:COLORTERM $env:TERM_PROGRAM\")",
|
||||
])
|
||||
.env_clear()
|
||||
.env("SystemRoot", system)
|
||||
.envs(&options.env)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"status={}, stderr={}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(output.stdout).unwrap().trim(),
|
||||
"xterm-256color truecolor pebrel"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn color_capability_defaults_preserve_explicit_overrides() {
|
||||
let mut options = nebula_terminal::tty::Options {
|
||||
env: [
|
||||
("term", "dumb"),
|
||||
("ColorTerm", "24bit"),
|
||||
("NO_COLOR", "1"),
|
||||
("FORCE_COLOR", "0"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.into(), value.into()))
|
||||
.collect(),
|
||||
env_is_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
super::apply_local_console_defaults(&mut options, false);
|
||||
assert_eq!(options.env["term"], "dumb");
|
||||
assert_eq!(options.env["ColorTerm"], "24bit");
|
||||
assert_eq!(options.env["NO_COLOR"], "1");
|
||||
assert_eq!(options.env["FORCE_COLOR"], "0");
|
||||
assert!(!options.env.contains_key("TERM") && !options.env.contains_key("COLORTERM"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn refreshed_local_terminal_preserves_the_pane_grok_override() {
|
||||
|
||||
@@ -67,6 +67,30 @@ function Check-UnpreparedProcesses {
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-RuntimeHelperFiles {
|
||||
# New helpers have a bounded lifetime, but can still be draining as the
|
||||
# application exits. Check before setup copies any file. Old stuck helpers
|
||||
# remain a visible failure; never terminate processes by their image name.
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(5)
|
||||
$helpers = @('runtime\pebrel-hook.exe', 'pebrel-hook.exe',
|
||||
'runtime\nebula-hook.exe', 'nebula-hook.exe')
|
||||
while ($true) {
|
||||
$busy = $null
|
||||
foreach ($relative in $helpers) {
|
||||
$path = Join-Path $installation $relative
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
|
||||
try {
|
||||
$probe = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open,
|
||||
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
||||
$probe.Dispose()
|
||||
} catch { $busy = $_.Exception }
|
||||
}
|
||||
if (-not $busy) { return }
|
||||
if ([DateTime]::UtcNow -ge $deadline) { throw $busy }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ((Get-Item -LiteralPath $PlanPath).Length -gt 1048576) { throw 'Update plan exceeds limit' }
|
||||
$plan = Get-Content -LiteralPath $PlanPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
@@ -133,6 +157,7 @@ try {
|
||||
Check-UnpreparedProcesses
|
||||
# No Restart Manager process-name shutdown: every participant has already
|
||||
# saved and exited. DIR reuses this validated installation without a chooser.
|
||||
Wait-RuntimeHelperFiles
|
||||
$setupLog = Join-Path $transaction 'installer.log'
|
||||
$arguments = '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NOCLOSEAPPLICATIONS /NORESTARTAPPLICATIONS' +
|
||||
' /DIR="' + $installation + '" /LOG="' + $setupLog + '"'
|
||||
@@ -144,9 +169,9 @@ try {
|
||||
throw 'Installed application did not report the expected version'
|
||||
}
|
||||
$installedDigest = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash
|
||||
if ($plan.version -eq $plan.original_version -and $installedDigest -eq $originalDigest) {
|
||||
throw 'Same-version installation did not replace the application binary'
|
||||
}
|
||||
# A repair can replace a previously locked helper while leaving pebrel.exe
|
||||
# byte-identical. Setup success plus the expected version is authoritative;
|
||||
# a changed main-executable hash is not a same-version success requirement.
|
||||
Write-State 'result.json' @{
|
||||
transaction = $plan.transaction; success = $true; version = $plan.version
|
||||
executable_sha256 = $installedDigest
|
||||
|
||||
+28
-6
@@ -12,14 +12,15 @@
|
||||
//! surfaces an error banner, 2 even blocks the turn), and kimi's `Stop` is
|
||||
//! likewise a blockable event. Every path — including panic — must exit 0,
|
||||
//! fast. Claude and kimi also write the payload to our stdin, so those modes
|
||||
//! always drain stdin even when the message goes nowhere: an unread pipe
|
||||
//! could surface as a hook write error.
|
||||
//! drain stdin within a bounded invocation even when the message goes nowhere.
|
||||
//! A caller retaining stdin or a stalled receiver must not leave this helper
|
||||
//! running indefinitely and holding the installed executable open.
|
||||
//! 2. SCOPED: the hook config is global (settings.json / kimi's config.toml),
|
||||
//! but the effect must be Nebula-only. The scope guard is the environment:
|
||||
//! NEBULA_NOTIFY_PIPE only exists for processes spawned inside Nebula.
|
||||
//! Anywhere else this is an invisible ~10 ms no-op.
|
||||
//! 3. FAST: pure std, no JSON handling (Nebula parses), one pipe write.
|
||||
//! Keeps the whole claude→toast chain under ~50 ms.
|
||||
//! Anywhere else it forwards nothing and exits without affecting the caller.
|
||||
//! 3. BOUNDED: pure std, no JSON handling (Pebrel parses), one pipe write.
|
||||
//! Forwarding has a deadline; startup and notification latency depend on the host.
|
||||
//!
|
||||
//! Usage (installed by `nebula setup-ai` / Nebula's boot self-heal):
|
||||
//! ```text
|
||||
@@ -42,6 +43,7 @@
|
||||
use std::io::{Read, Write};
|
||||
|
||||
const MAX_PAYLOAD_BYTES: usize = 1 << 20;
|
||||
const FORWARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// 其他 agent 的 hook runner 独有的环境变量。
|
||||
///
|
||||
@@ -167,7 +169,25 @@ fn log_outcome(source: &str, pane: &str, bytes: usize, outcome: &Outcome) {
|
||||
fn main() {
|
||||
// Constraint 1: never leak a failure to the calling CLI.
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let _ = std::panic::catch_unwind(|| run(&args));
|
||||
let forwarding_args = args.clone();
|
||||
let (done, finished) = std::sync::mpsc::sync_channel(1);
|
||||
// Bound the entire forwarding operation, including stdin drain and pipe
|
||||
// writes. A read-only timeout would still leave a blocked writer alive.
|
||||
// This one-shot process never joins a stuck worker: returning from main
|
||||
// retires all of its threads and handles. The provider sees exit code 0.
|
||||
if std::thread::Builder::new()
|
||||
.name("hook-forward".into())
|
||||
.spawn(move || {
|
||||
let _ = std::panic::catch_unwind(|| run(&forwarding_args));
|
||||
let _ = done.send(());
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
let _ = finished.recv_timeout(FORWARD_TIMEOUT);
|
||||
}
|
||||
// A user-owned notifier is independent of our best-effort transport. Never
|
||||
// wait for it and do not suppress it when the Pebrel pipe is unavailable.
|
||||
chain_notifier(&args);
|
||||
// Cursor 的提交前 Hook 有响应合同;传输失败也不能阻止用户提交。
|
||||
if args.first().is_some_and(|source| source == "cursor")
|
||||
&& native_event(&args) == Some("prompt")
|
||||
@@ -307,7 +327,9 @@ fn run(args: &[String]) {
|
||||
}
|
||||
}
|
||||
log_outcome(source, &pane, payload.len(), &outcome);
|
||||
}
|
||||
|
||||
fn chain_notifier(args: &[String]) {
|
||||
// Chain mode: keep a pre-existing codex notifier working. Runs even
|
||||
// outside Nebula — the original program must keep firing everywhere.
|
||||
let strs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Real-process lifetime regressions: a provider may retain its stdin handle,
|
||||
//! and a pipe receiver may stop draining. Neither can pin the installed helper.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Invocation(Option<Child>);
|
||||
|
||||
impl Invocation {
|
||||
fn spawn(args: &[&str], pipe: Option<&str>) -> Self {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_pebrel-hook"));
|
||||
command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
for suffix in ["NOTIFY_PIPE", "REMOTE_HOOK_TOKEN", "HOOK_LOG"] {
|
||||
command.env_remove(format!("PEBREL_{suffix}"));
|
||||
command.env_remove(format!("NEBULA_{suffix}"));
|
||||
}
|
||||
if let Some(pipe) = pipe {
|
||||
command.env("PEBREL_NOTIFY_PIPE", pipe);
|
||||
}
|
||||
Self(Some(command.spawn().unwrap()))
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Output {
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
let child = self.0.as_mut().unwrap();
|
||||
loop {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
let output = self.0.take().unwrap().wait_with_output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
return output;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "helper retained a blocked invocation");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Invocation {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = &mut self.0 {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_kept_open_cannot_pin_a_native_hook_executable() {
|
||||
for args in [&["codex", "--hooks=full"][..], &["claude"][..]] {
|
||||
let mut invocation = Invocation::spawn(args, None);
|
||||
let mut input = invocation.0.as_mut().unwrap().stdin.take().unwrap();
|
||||
input.write_all(b"{\"hook_event_name\":\"Stop\"}").unwrap();
|
||||
// Hold stdin open across finish: EOF must not be necessary for exit.
|
||||
let output = invocation.finish();
|
||||
assert!(output.stdout.is_empty());
|
||||
drop(input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_still_allows_submission_when_stdin_never_closes() {
|
||||
let mut invocation = Invocation::spawn(&["cursor", "--event", "prompt"], None);
|
||||
let input = invocation.0.as_mut().unwrap().stdin.take().unwrap();
|
||||
let output = invocation.finish();
|
||||
assert_eq!(output.stdout, b"{\"continue\":true}\n");
|
||||
drop(input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_completed_payload_exits_silently() {
|
||||
let mut invocation = Invocation::spawn(&["codex", "--hooks=full"], None);
|
||||
invocation.0.as_mut().unwrap().stdin.take().unwrap().write_all(b"{}").unwrap();
|
||||
let output = invocation.finish();
|
||||
assert!(output.stdout.is_empty() && output.stderr.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_notify_still_invokes_the_users_chained_program() {
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let invocation = Invocation::spawn(
|
||||
&["codex", "--chain", executable.to_str().unwrap(), "--list", "--format=terse"],
|
||||
None,
|
||||
);
|
||||
let output = invocation.finish();
|
||||
assert!(
|
||||
String::from_utf8(output.stdout)
|
||||
.unwrap()
|
||||
.contains("ordinary_completed_payload_exits_silently")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_receiver_that_never_reads_cannot_pin_the_helper() {
|
||||
use std::ffi::c_void;
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn CreateNamedPipeW(
|
||||
name: *const u16,
|
||||
mode: u32,
|
||||
pipe_mode: u32,
|
||||
instances: u32,
|
||||
output: u32,
|
||||
input: u32,
|
||||
timeout: u32,
|
||||
security: *const c_void,
|
||||
) -> *mut c_void;
|
||||
fn CloseHandle(handle: *mut c_void) -> i32;
|
||||
fn GetNamedPipeClientProcessId(handle: *mut c_void, pid: *mut u32) -> i32;
|
||||
}
|
||||
struct Pipe(*mut c_void);
|
||||
impl Drop for Pipe {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = format!(r"\\.\pipe\pebrel-hook-lifetime-test-{}", std::process::id());
|
||||
let wide: Vec<u16> = name.encode_utf16().chain(Some(0)).collect();
|
||||
// The server owns an inbound byte pipe and deliberately never drains it.
|
||||
let handle =
|
||||
unsafe { CreateNamedPipeW(wide.as_ptr(), 1, 0, 1, 4096, 4096, 0, std::ptr::null()) };
|
||||
assert_ne!(handle as isize, -1, "{}", std::io::Error::last_os_error());
|
||||
let _pipe = Pipe(handle);
|
||||
let payload = "x".repeat(16 * 1024);
|
||||
let invocation = Invocation::spawn(&["codex", &payload], Some(&name));
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
loop {
|
||||
let mut pid = 0;
|
||||
if unsafe { GetNamedPipeClientProcessId(handle, &mut pid) } != 0 {
|
||||
assert_eq!(pid, invocation.0.as_ref().unwrap().id());
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "helper never connected to the blocked receiver");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let output = invocation.finish();
|
||||
assert!(output.stdout.is_empty() && output.stderr.is_empty());
|
||||
}
|
||||
@@ -402,6 +402,22 @@ mod tests {
|
||||
let event = Event::readable(KEY);
|
||||
let mut reader = UnblockedReader::new(ScriptedSource { rx, pending: Vec::new() }, 4096);
|
||||
|
||||
// Readiness permits another poll; it does not guarantee bytes. piper
|
||||
// can deliberately yield for fairness and wake us again, and a queued
|
||||
// notification can outlive the data that originally triggered it.
|
||||
// Retry only after a notification so a genuinely lost wake still fails.
|
||||
let read_chunk = |reader: &mut UnblockedReader<ScriptedSource>, buf: &mut [u8]| {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let read = reader.try_read(buf);
|
||||
if read > 0 {
|
||||
return read;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "readiness never produced data");
|
||||
assert!(readable_arrived(&poller, KEY), "pending read was not woken");
|
||||
}
|
||||
};
|
||||
|
||||
// 首次 register 自带一次无条件投递;先消化掉,后面等到的事件就只可能
|
||||
// 来自 try_read 的补投。
|
||||
reader.register(&poller, event, PollMode::Level);
|
||||
@@ -414,7 +430,8 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut buf = [0u8; 64];
|
||||
assert_eq!(reader.try_read(&mut buf), 5);
|
||||
assert_eq!(read_chunk(&mut reader, &mut buf), 5);
|
||||
assert_eq!(&buf[..5], b"hello");
|
||||
assert!(reader.pipe.is_empty(), "这一读须把管道读空,否则测不到 TOCTOU 的那一半");
|
||||
|
||||
assert!(
|
||||
@@ -427,6 +444,7 @@ mod tests {
|
||||
assert_eq!(reader.try_read(&mut buf), 0);
|
||||
tx.send(b"world".to_vec()).unwrap();
|
||||
assert!(readable_arrived(&poller, KEY), "重新注册的 waker 失效,后续输出会卡住");
|
||||
assert_eq!(reader.try_read(&mut buf), 5);
|
||||
assert_eq!(read_chunk(&mut reader, &mut buf), 5);
|
||||
assert_eq!(&buf[..5], b"world");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 中运行的程序,然后重试安装。
|
||||
|
||||
+5
-1
@@ -17,7 +17,11 @@ class UpdateProcess {
|
||||
if (!File.Exists(Path.Combine(directory, "parent-exited"))) return 3;
|
||||
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_FAIL_INSTALL") == "1") return 17;
|
||||
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_NOOP_INSTALL") == "1") return 0;
|
||||
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
|
||||
try {
|
||||
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
|
||||
Directory.CreateDirectory(Path.Combine(target, "runtime"));
|
||||
File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture");
|
||||
} catch (IOException) { return 5; }
|
||||
return 0;
|
||||
#else
|
||||
if (args.Length > 0 && args[0] == "--version") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#ifndef FixtureRoot
|
||||
#ifndef FixtureRoot
|
||||
#error FixtureRoot must point at an isolated directory under the workspace tmp directory.
|
||||
#endif
|
||||
#define MigrationFixture
|
||||
@@ -15,6 +15,11 @@ OutputBaseFilename=installer-migration-fixture
|
||||
Compression=none
|
||||
SetupLogging=no
|
||||
|
||||
[CustomMessages]
|
||||
OpenInPebrelWsl=Open in Pebrel (WSL)
|
||||
WslMenuConflict=Preserved an edited or foreign submenu: %1.
|
||||
WslMenuRegistrationFailed=Unable to register the WSL context submenu.
|
||||
|
||||
[Code]
|
||||
#include "..\installer-migration.iss"
|
||||
|
||||
@@ -180,6 +185,69 @@ begin
|
||||
Check(FileExists(NewLink), 'shortcut pointing at another program is preserved');
|
||||
end;
|
||||
|
||||
procedure CheckContextMenus(Root: string);
|
||||
var
|
||||
RegistryRoot, ShellRoot, Executable, Value, Menu, ForeignCommand: string;
|
||||
Distros, Names: TArrayOfString;
|
||||
Index: Integer;
|
||||
Failed: Boolean;
|
||||
begin
|
||||
RegistryRoot := 'Software\PebrelTestFixtures\' + ExtractFileName(Root);
|
||||
Executable := Root + '\Program Files\pebrel.exe';
|
||||
ForeignCommand := '"D:\other\pebrel.exe" --gpui --shell "wsl:Other" --working-directory "%1"';
|
||||
try
|
||||
for Index := 0 to 1 do begin
|
||||
ShellRoot := RegistryRoot + '\root' + IntToStr(Index);
|
||||
Menu := ShellRoot + '\PebrelWslMenu';
|
||||
Check(RegWriteStringValue(HKCU, ShellRoot + '\Pebrel\command', '', 'ordinary-open'), 'seed ordinary entry');
|
||||
Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', ForeignCommand), 'seed foreign entry');
|
||||
Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWsl0\command', '',
|
||||
'"' + Executable + '" --gpui --shell "wsl:Old" --working-directory "%1"'), 'seed old flat entry');
|
||||
SetArrayLength(Distros, 3);
|
||||
Distros[0] := 'Debian';
|
||||
Distros[1] := 'Ubuntu Test';
|
||||
Distros[2] := '开发环境';
|
||||
if Index = 0 then Value := '%1' else Value := '%V';
|
||||
RegisterWslContextMenuAt(ShellRoot, Executable, Value, Distros);
|
||||
Check(not RegKeyExists(HKCU, ShellRoot + '\PebrelWsl0'), 'migrate old flat entry');
|
||||
Check(RegGetSubkeyNames(HKCU, ShellRoot, Names) and (GetArrayLength(Names) = 3), 'only normal, cascade and foreign roots remain');
|
||||
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'three distros live inside cascade');
|
||||
Check(RegQueryStringValue(HKCU, Menu, 'SubCommands', Value) and (Value = ''), 'enable static cascade');
|
||||
Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl1\command', '', Value), 'child command exists');
|
||||
if Index = 0 then
|
||||
Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%1"', 'selected directory argv')
|
||||
else
|
||||
Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%V"', 'background directory argv');
|
||||
Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl2', 'MUIVerb', Value) and (Value = '开发环境'), 'Unicode label retained');
|
||||
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
|
||||
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'repeat registration has no duplicates');
|
||||
SetArrayLength(Distros, 1);
|
||||
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
|
||||
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 1), 'removed distros leave no stale entries');
|
||||
Check(RegWriteStringValue(HKCU, Menu + '\shell\Custom\command', '', ForeignCommand), 'seed edited submenu');
|
||||
Failed := False;
|
||||
try
|
||||
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
|
||||
except
|
||||
Failed := True;
|
||||
end;
|
||||
Check(Failed and RegKeyExists(HKCU, Menu + '\shell\Custom'), 'edited submenu is not overwritten');
|
||||
RemoveOwnedWslContextMenusAt(ShellRoot, Executable);
|
||||
Check(RegKeyExists(HKCU, Menu + '\shell\Custom'), 'uninstall preserves edited subtree');
|
||||
Check(RegDeleteKeyIncludingSubkeys(HKCU, Menu + '\shell\Custom'), 'remove fixture edit');
|
||||
RemoveOwnedWslContextMenusAt(ShellRoot, 'D:\other\install.exe');
|
||||
Check(RegKeyExists(HKCU, Menu), 'other installation cannot remove this menu');
|
||||
SetArrayLength(Distros, 0);
|
||||
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
|
||||
Check(not RegKeyExists(HKCU, Menu), 'zero distros removes empty cascade');
|
||||
Check(RegQueryStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', Value) and (Value = ForeignCommand), 'foreign entry preserved');
|
||||
Check(RegQueryStringValue(HKCU, ShellRoot + '\Pebrel\command', '', Value) and (Value = 'ordinary-open'), 'ordinary entry preserved');
|
||||
end;
|
||||
finally
|
||||
RegDeleteKeyIncludingSubkeys(HKCU, RegistryRoot);
|
||||
end;
|
||||
end;
|
||||
|
||||
function InitializeSetup: Boolean;
|
||||
var
|
||||
Root, Report: string;
|
||||
@@ -190,6 +258,7 @@ begin
|
||||
CheckPayloadMigration(Root);
|
||||
CheckFailures(Root);
|
||||
CheckShortcuts(Root);
|
||||
CheckContextMenus(Root);
|
||||
Report := 'PASS: ' + IntToStr(Checks) + ' migration checks';
|
||||
except
|
||||
Report := 'FAIL: ' + GetExceptionMessage;
|
||||
|
||||
@@ -36,7 +36,7 @@ function Write-Json([string]$Path, $Value) {
|
||||
}
|
||||
|
||||
$results = @()
|
||||
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'reinstall-noop')) {
|
||||
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
|
||||
$directory = Join-Path $OutputRoot $scenario
|
||||
$installation = Join-Path $directory 'installed app'
|
||||
$transaction = Join-Path $directory 'transaction'
|
||||
@@ -44,17 +44,25 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
|
||||
$null = New-Item -ItemType Directory -Path $installation, $transaction, $config -Force
|
||||
$executable = Join-Path $installation 'pebrel.exe'
|
||||
Copy-Item -LiteralPath $old -Destination $executable
|
||||
$payload = if ($scenario -eq 'reinstall') { $reinstall } else { $candidate }
|
||||
$payload = if ($scenario -eq 'reinstall') { $reinstall } elseif ($scenario -eq 'repair-identical') { $old } else { $candidate }
|
||||
Copy-Item -LiteralPath $payload -Destination (Join-Path $directory 'candidate.exe')
|
||||
[System.IO.File]::WriteAllText((Join-Path $installation 'unins000.exe'), 'fixture marker')
|
||||
$env:PEBREL_HANDOFF_FIXTURE = $directory
|
||||
$env:PEBREL_HANDOFF_FAIL_INSTALL = if ($scenario -eq 'installer-failure') { '1' } else { '0' }
|
||||
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'reinstall-noop') { '1' } else { '0' }
|
||||
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'upgrade-noop') { '1' } else { '0' }
|
||||
$parent = Start-Process -FilePath $executable -ArgumentList 'wait' -PassThru
|
||||
$null = $parent.Handle
|
||||
$runner = $null
|
||||
$other = $null
|
||||
$helperLock = $null
|
||||
try {
|
||||
if ($scenario -in @('helper-unlocked', 'helper-held')) {
|
||||
$null = New-Item -ItemType Directory -Path (Join-Path $installation 'runtime') -Force
|
||||
$helperPath = Join-Path $installation 'runtime\pebrel-hook.exe'
|
||||
[System.IO.File]::WriteAllText($helperPath, 'old helper fixture')
|
||||
$helperLock = [System.IO.FileStream]::new($helperPath, [System.IO.FileMode]::Open,
|
||||
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
||||
}
|
||||
$plan = @{
|
||||
schema = 1; transaction = $scenario; executable = $executable; installation = $installation
|
||||
config_directory = $config; installer = $installer
|
||||
@@ -64,7 +72,7 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
|
||||
participants = @(@{ pid = $parent.Id; created = $parent.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() })
|
||||
}
|
||||
if ($scenario -eq 'checksum') { $plan.sha256 = '0' * 64 }
|
||||
if ($scenario -in @('reinstall', 'reinstall-noop')) { $plan.version = '1.8.0' }
|
||||
if ($scenario -in @('reinstall', 'repair-identical')) { $plan.version = '1.8.0' }
|
||||
if ($scenario -eq 'creation-time') { $plan.participants[0].created = '1' }
|
||||
if ($scenario -eq 'other-process') {
|
||||
$other = Start-Process -FilePath $executable -ArgumentList 'wait-other' -PassThru
|
||||
@@ -104,8 +112,14 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
|
||||
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran while the old process was alive'
|
||||
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
|
||||
Assert ($parent.WaitForExit(5000)) 'Fixture parent did not exit'
|
||||
if ($scenario -eq 'helper-unlocked') {
|
||||
Start-Sleep -Milliseconds 350
|
||||
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup started with the helper still locked'
|
||||
$helperLock.Dispose()
|
||||
$helperLock = $null
|
||||
}
|
||||
Wait-File $resultPath
|
||||
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'reinstall-noop')) {
|
||||
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
|
||||
Wait-File (Join-Path $directory 'new-launch')
|
||||
$launch = [System.IO.File]::ReadAllLines((Join-Path $directory 'new-launch'))
|
||||
Assert ($launch[0] -eq $executable) 'Relaunch selected another installation'
|
||||
@@ -117,24 +131,31 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
|
||||
}
|
||||
Assert ($runner.WaitForExit(5000)) 'Helper did not finish'
|
||||
$result = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
Assert ($result.success -eq ($scenario -in @('success', 'reinstall'))) 'Unexpected helper outcome'
|
||||
Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical', 'helper-unlocked'))) 'Unexpected helper outcome'
|
||||
if ($result.success) {
|
||||
$expected = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash
|
||||
Assert ($result.executable_sha256 -eq $expected) 'Installed binary differs from candidate'
|
||||
Assert (Test-Path -LiteralPath (Join-Path $installation 'runtime\pebrel-hook.exe')) 'Successful setup did not repair the helper'
|
||||
Assert ([System.IO.File]::ReadAllText((Join-Path $installation 'runtime\pebrel-hook.exe')) -eq 'repaired helper fixture') 'Helper still contains the original payload'
|
||||
}
|
||||
if ($scenario -in @('other-process', 'late-process')) {
|
||||
Assert (-not $other.HasExited) 'Update stopped an unprepared process'
|
||||
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran with an unprepared process'
|
||||
}
|
||||
if ($scenario -in @('installer-failure', 'reinstall-noop')) {
|
||||
if ($scenario -in @('installer-failure', 'upgrade-noop', 'helper-held')) {
|
||||
Assert $result.recovered_original 'Untouched old executable was not recovered'
|
||||
}
|
||||
if ($scenario -eq 'helper-held') {
|
||||
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'A blocked helper allowed a partial installation'
|
||||
Assert ($result.error -match 'pebrel-hook.exe') 'The blocked file is missing from the failure details'
|
||||
}
|
||||
$probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::OpenOrCreate,
|
||||
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
||||
$probe.Dispose()
|
||||
$results += @{ scenario = $scenario; passed = $true }
|
||||
Write-Output "$scenario : passed"
|
||||
} finally {
|
||||
if ($helperLock) { $helperLock.Dispose() }
|
||||
if ($runner -and -not $runner.HasExited) { $runner.Kill(); $runner.WaitForExit() }
|
||||
if (-not $parent.HasExited) {
|
||||
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 用户反馈回归测试清单
|
||||
|
||||
本清单覆盖 2026-09-23 汇总的 Hook、更新、窗口材质、链接、Codex 显示与
|
||||
Windows 右键菜单反馈。它是验收合同,不表示下列功能已经全部修复。
|
||||
`待验证`、`等待日志`、`部分覆盖` 均不得作为 Issue 已解决的依据。
|
||||
|
||||
## 执行与证据
|
||||
|
||||
- 每轮记录源码提交、未提交补丁、产品版本、Windows build、DPI、主题、字体、
|
||||
Shell 与 Codex 版本。记录实际命令、通过/失败数量及截图或日志位置。
|
||||
- 自动回归使用实际生产函数、真实终端网格或进程夹具;不能只检查源码中出现
|
||||
某个字符串。原生截图不替代行为测试,编译成功也不替代视觉验收。
|
||||
- 对修复先验证旧实现失败,再验证新实现通过;保留相邻功能的反例。
|
||||
- 浅色/深色、100%/125%/150%/200% DPI、宽/窄窗口、分屏、滚动和主题热切换
|
||||
按条目覆盖。平台条件导致无法执行的项必须注明,不计入通过数量。
|
||||
- 实机操作使用隔离配置与测试窗口。安装/注册表夹具只修改其临时测试根,
|
||||
不关闭用户会话,不覆盖正式安装,不触碰用户 Hook、凭据和发行版配置。
|
||||
- 仅关闭已有代码与验收证据覆盖的问题单;部分修复说明剩余范围。
|
||||
发布版本尚未包含修复时必须说明交付状态,不把本地通过写成已发布。
|
||||
|
||||
## 回归项目
|
||||
|
||||
| ID | 问题与来源 | 自动回归 / 通过条件 | 实机验收 / 反例 | 当前证据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| HOOK-255 | [#255](https://github.com/Kuddev/pebrel/issues/255):Windows Codex Hook 被 PowerShell 解析为字符串而未执行 | 生成命令实际执行;`codex`、`--hooks=full/turns` 和 stdin JSON 完整传入;带空格、单引号、`$`、反引号的路径保持字面值 | Windows PowerShell 5.1 与 PowerShell 7;Codex 0.155.1/0.156.0;新会话、工具调用、提问、停止都无 ParserError | Windows 5.1 进程夹具通过;完整 Codex/PowerShell 7 会话待验收 |
|
||||
| HOOK-LIFETIME | #258 前置故障:关闭 Pebrel 后 Hook 仍占用安装文件 | 保持 stdin 打开、连接后不读取命名管道时,真实 Hook 子进程仍在期限内以 0 退出;Cursor 继续允许提交;用户原有 notifier 仍执行 | 已运行的旧版 Hook 不会自动获得新期限;分别验证新启动的 native Hook 与旧进程恢复步骤 | 7 项单元 + 5 项真实进程回归通过 |
|
||||
| HOOK-MIGRATE | 更新后旧 `hooks.json` / 归属标记仍含失效命令 | 旧受管命令迁移;重复安装幂等;保留 notify、第三方 Hook、显式禁用及用户编辑;卸载只移除自有条目 | 重启 Pebrel 后自动修复;用户修改过的冲突条目不能静默覆盖 | `ai_hook::` 120 通过、1 项既有忽略;后续改动需重跑相关项 |
|
||||
| UPDATE-403 | 手动检查更新返回 HTTP 403 | 公共 Release 无需用户 token;覆盖 403/429、正常 JSON、其他错误、代理、受限响应;下载仍须官方 URL、精确资产名和 SHA-256 | 同一网络直连/代理、启动检查/手动检查;离线不得假报最新或成功 | **等待日志,按用户要求暂停**;未验收、不结项 |
|
||||
| UPDATE-EXIT5 | 安装器 `exit code 5`,当前/最新版本显示相同 | 失败不得标成功;保留原快照与安装日志;已退出进程、安装失败、部分替换、相同版本、校验失败分别验证 | 基于反馈者 `installer.log` 重现;核对原版本、目标版本、实际 EXE;不能把 Inno 退出码 5 当作 Win32 拒绝访问码 | 后续 #258 已提供修复安装成功日志;无限等待的 Hook 已补期限;安装前等待短暂占用释放,持续占用时在启动安装器前失败,避免部分替换;旧版本已有挂起进程仍需另行处理 |
|
||||
| UPDATE-258 | [#258](https://github.com/Kuddev/pebrel/issues/258):同版本修复成功却因主程序哈希不变报错 | 同版本主程序字节不变、Hook 文件修复后应成功;安装器失败或目标版本仍错误必须失败 | 同版本重装、首次升级部分成功后的修复;日志、实际版本和结果提示一致;原会话快照保留 | 12 项原生 handoff 场景通过,包含 `repair-identical` 与 `upgrade-noop` 正反例 |
|
||||
| BLUR-236-A | [#236](https://github.com/Kuddev/pebrel/issues/236):Acrylic 在屏幕边缘透出背景内容 | 切换材质/透明度不重复创建资源;新窗口、关闭、失焦、最大化/还原、队列退出及运行库失败路径 | 0% 与非零不透明度;明显文字/色块背景;左右/上下屏幕边缘、最大化、任务栏恢复、混合 DPI;失焦不出现不透明灰底 | [PR #250](https://github.com/Kuddev/pebrel/pull/250) 尚未合并;其跨平台 CI 通过不等于本轮实机验收 |
|
||||
| BLUR-236-B | #236 的 Aero 与 Acrylic 兼容回退范围 | Windows 10、Win11 22H2+、缺失/不匹配运行库与失败挂接均可正常启动/切换/退出 | Aero 需单独验收;缺少受支持 Windows App Runtime 时旧边缘现象不能标为已解决 | #250 仅覆盖受支持 Acrylic;**Aero 和回退范围仍开放** |
|
||||
| LINK-CJK | OSC 8 中文/全角字符的虚线只画半字、错位 | 从真实 VT 输出构建网格,中文两格、空格、ASCII、组合字符均按列覆盖;逐格绘制与连续区间的物理像素集合相同 | `A开始 菜单Z`、纯中文、混排;DPI/分屏/窄窗/换行;不随字体回补或字形分段重置虚线 | 真实 OSC 8 网格与 16 种 DPI/格宽组合像素覆盖回归通过;原生 Nord / Paper 混排截图已核对 |
|
||||
| LINK-PATH | 英文路径下划线断开,带空格路径只匹配前半段 | 连续虚线相位;引号包围的盘符/UNC/`~/` 路径完整匹配;hover、点击使用同一边界;普通正文不误连 | `"C:\Program Files\Example Tools\probe.exe"`、中文与括号目录、相邻命令参数、路径换行 | 8 项 hint 测试与 5 项配置解析测试通过;原生带空格路径截图已核对 |
|
||||
| LINK-PROMPT | WSL `user@host:~/.codex$` 吞入 `$`;绝对路径提示符没有下划线 | `~/` 与 `/mnt/d/...` 提示符中的 `$`/`#` 不可点;真实文件名 `~/cost$`、URL 尾部 `$`、路径内部 `$` 保留;下划线与命中边界一致 | Bash/WSL 彩色提示符、root 提示符、输入命令后、换行;OSC 8 显式 URI 不被启发式改写 | 正反匹配/命中回归通过;绝对路径打开应使用所属 WSL 发行版,不得套用宿主目录或 SSH 目标 |
|
||||
| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex 可执行文件/模型/设置,对照 WT;分别覆盖 low/high/xhigh/max/ultra、Max→xhigh 回退、持续星点与一次性等级动画;以 CLI 实际输出为准,不能由终端自行按等级添加/过滤星点;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归、10 项身份/WSL 透传回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;已观察到 Codex 0.154.0 在 xhigh 持续输出变化的星点;其源码持续星点与 Max/Ultra 一次性动画分别控制;与对照终端的等级切换差异仍待验收 |
|
||||
| CODEX-MESSAGE | **所有主题**的 Codex 用户消息与 AI 回复背景缺少区分 | 保留/正确映射应用给出的用户消息背景;未提供背景时不得凭屏幕文字猜测角色;消息文本对比度不退化 | 遍历主题注册表中的全部内置主题;输入框与已发送的单/多行、中英混排消息;深浅切换、滚动回看;用户消息应有可辨认浅色背景区 | 15 个内置主题 + 225 种主题切换的背景保留回归通过;真实产品 Nord / Paper 的背景和星点截图已核对;其余主题有自动回放覆盖,仍需原生视觉抽查 |
|
||||
| SHELL-MENU | 多个 WSL 发行版把资源管理器右键菜单铺满 | 保留一个普通“在 Pebrel 中打开”;其余发行版集中到一个级联菜单;零/一/多个发行版、重复安装、移除发行版、卸载与旧项迁移 | 文件夹对象/文件夹空白处;中文/空格/盘根路径;点击普通项与每个子项验证 Shell 和 cwd;保留第三方/其他安装拥有的键 | 已实现;真实 Inno 夹具累计 117 项通过,完整安装器编译通过;正式 Explorer 点击验收仍需进行 |
|
||||
|
||||
## 修复与评审入口
|
||||
|
||||
- Hook 命令及退出期限:[PR #260](https://github.com/Kuddev/pebrel/pull/260),关联 #254 / #255 及 #258 的前置占用故障。
|
||||
- 同版本修复判定:[PR #261](https://github.com/Kuddev/pebrel/pull/261),关联 #258。
|
||||
- 下划线与路径边界:[PR #262](https://github.com/Kuddev/pebrel/pull/262)。
|
||||
- Windows / WSL 真彩色能力:[PR #263](https://github.com/Kuddev/pebrel/pull/263),关联 #136。
|
||||
- WSL 级联菜单:[PR #264](https://github.com/Kuddev/pebrel/pull/264)。
|
||||
|
||||
本地组合回归与各 PR 的 CI、Code Owner 评审是不同证据;未合并、未发布不能写成已经交付。
|
||||
|
||||
## 当前测试入口
|
||||
|
||||
```powershell
|
||||
cargo test --locked -p nebula_hook
|
||||
cargo test --locked -p nebula --bin pebrel --features gpui-shell platform::environment::tests
|
||||
cargo test --locked -p nebula --bin pebrel --features gpui-shell ai_hook::
|
||||
cargo test --locked -p nebula --bin pebrel --features gpui-shell display::hint::tests
|
||||
cargo test --locked -p nebula --bin pebrel --features gpui-shell config::ui_config::tests
|
||||
cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::link_underline::tests
|
||||
cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::element::tests
|
||||
cargo test --locked -p nebula --bin pebrel --features gpui-shell display::terminal_color::tests
|
||||
```
|
||||
|
||||
安装器既有入口为 `scripts/tests/installer.tests.ps1`、
|
||||
`scripts/tests/installer-migration.tests.ps1`、`scripts/tests/test_update_handoff.ps1`。
|
||||
菜单夹具已扩展零/一/多个发行版及所有权保护;handoff 已扩展同版本修复正例与错误版本反例。它们不代替正式安装或 Explorer 点击验收。
|
||||
|
||||
修改完成后还需按 `CONTRIBUTING.md` 执行架构、格式、受影响模块与真实产品检查;
|
||||
架构门禁使用实际目标分支基线。新增门禁或 GitHub 强制保护需单独验证,
|
||||
本清单不宣称启用了服务端保护。
|
||||
Reference in new issue
Block a user