diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ce98a4..36127b79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -349,16 +349,31 @@ jobs: run: cargo clippy --locked -p nocterm-vault -p nocterm-vault-store -p nocterm-device-unlock --all-targets -- -D warnings - name: Test portable envelope and native metadata without authentication prompts run: cargo test --locked -p nocterm-vault -p nocterm-vault-store -p nocterm-device-unlock + - name: Test held filesystem capabilities on Windows + if: runner.os == 'Windows' + run: cargo test --locked -p nocterm-localfs + - name: Test bounded native file reads on Windows + if: runner.os == 'Windows' + run: cargo test --locked -p nocterm-core --lib fs::tests::bounded_read + - name: Test SSH native file reads on Windows + if: runner.os == 'Windows' + run: cargo test --locked -p nocterm-ssh --lib file::tests + - name: Test native chat history on Windows + if: runner.os == 'Windows' + run: cargo test --locked -p nocterm --test native_history + - name: Test authenticated MCP bridge after idle and refresh on Windows + if: runner.os == 'Windows' + run: cargo test --locked -p nocterm-acp --lib bridge::tests - name: Check the application builds for Windows if: runner.os == 'Windows' run: cargo check --locked -p nocterm # Held directories, chat history and agent launch have Windows-only code paths: # run them against the real filesystem and process APIs there. - - name: Test Windows held directories, chat history and agent launch + - name: Lint Windows history adapters and test agent environment if: runner.os == 'Windows' run: | cargo clippy --locked -p nocterm-localfs -p nocterm-agent-storage -p nocterm-agent-runtime --all-targets -- -D warnings - cargo test --locked -p nocterm-localfs -p nocterm-ai -p nocterm-agent-storage + cargo test --locked -p nocterm-ai -p nocterm-agent-storage cargo test --locked -p nocterm --test native_chat_history # The one check to require in branch protection: it passes only when every diff --git a/Cargo.lock b/Cargo.lock index 3ad6fcfd..d25a7e48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "aligned" version = "0.4.3" @@ -265,6 +271,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -1011,6 +1023,24 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix 1.1.5", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1690,6 +1720,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "cvt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" +dependencies = [ + "cfg-if", +] + [[package]] name = "darling" version = "0.24.1" @@ -2515,6 +2554,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix 1.1.5", + "windows-sys 0.59.0", +] + [[package]] name = "fs2" version = "0.4.3" @@ -2525,6 +2575,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs_at" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036" +dependencies = [ + "aligned", + "cfg-if", + "cvt", + "libc", + "nix 0.29.0", + "windows-sys 0.52.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -4161,6 +4225,28 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "io-surface" version = "0.16.1" @@ -4750,6 +4836,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -5000,6 +5092,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -5138,6 +5242,7 @@ dependencies = [ "subtle", "tempfile", "thiserror 2.0.21", + "tracing", ] [[package]] @@ -5749,14 +5854,19 @@ name = "nocterm-localfs" version = "0.0.0" dependencies = [ "atomicwrites", + "cap-primitives", "directories", + "fs_at", "getrandom 0.4.3", "nix 0.28.0", "nocterm-session", + "ouroboros", + "prikk-ffi", "rustix 1.1.5", "tempfile", - "winapi-util", + "tracing", "windows-sys 0.61.2", + "winx", ] [[package]] @@ -7144,6 +7254,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "p256" version = "0.14.0" @@ -7696,6 +7830,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prikk-ffi" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb845fabe07b7f7e2071302f0868e4cdbb9b973e48a9127df87d511f55cedd14" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "primefield" version = "0.14.0" @@ -7741,6 +7884,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + [[package]] name = "process-wrap" version = "9.1.1" @@ -8730,6 +8886,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.5", +] + [[package]] name = "rustix-openpty" version = "0.2.0" @@ -12189,6 +12355,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.2", + "windows-sys 0.59.0", +] + [[package]] name = "wio" version = "0.2.2" @@ -12384,6 +12560,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.2.1" diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index e2f378d4..562e0c7f 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -2,9 +2,9 @@ Nocterm third-party licenses and notices ======================================== Generated by scripts/generate-license-notices.py using cargo-about 0.9.2. -Content SHA-256: 2f360cfb2a7cd01c7ce3ef0e3dd344f3a2ca39dbfdf737e4d17724bd52fe913b -Input fingerprint: d1693647fcfff0284baa3b8a794e4462e07e469e5dd606539116650db970e01b -Cargo.lock SHA-256 (release version masked): 3636147a9a00b37439d037e23d79a100118c4ed220e768415a1da6fadf46a302 +Content SHA-256: 4536e416d08a476065d40732bd53803885df66157f1241e698c4a44f95a08940 +Input fingerprint: d41268d9912392fcbb1660baaf43a364f1134c4c9ea22d56895995475b4a400e +Cargo.lock SHA-256 (release version masked): efe961a6a4b84e2fbff7e2afb64c5f4dd94c9cf43cb33860cf52881a5469d189 Scope: runtime and build dependencies of Nocterm and its optional vault broker for Linux x86_64, Windows x86_64, macOS x86_64 and macOS arm64. @@ -118,11 +118,25 @@ Authors: Christian Duerr ; Joe Wilm Source: vendor/alacritty_terminal Repository: https://github.com/alacritty/alacritty +aliasable 0.1.3: MIT +Authors: avitex +Source: https://crates.io/api/v1/crates/aliasable/0.1.3/download +Repository: https://github.com/avitex/rust-aliasable + +aligned 0.4.3: MIT OR Apache-2.0 +Source: https://crates.io/api/v1/crates/aligned/0.4.3/download +Repository: https://github.com/rust-embedded-community/aligned + allocator-api2 0.2.21: MIT OR Apache-2.0 Authors: Zakarum Source: https://crates.io/api/v1/crates/allocator-api2/0.2.21/download Repository: https://github.com/zakarumych/allocator-api2 +ambient-authority 0.0.2: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT +Authors: Dan Gohman +Source: https://crates.io/api/v1/crates/ambient-authority/0.0.2/download +Repository: https://github.com/sunfishcode/ambient-authority + annotate-snippets 0.12.16: MIT OR Apache-2.0 Source: https://crates.io/api/v1/crates/annotate-snippets/0.12.16/download Repository: https://github.com/rust-lang/annotate-snippets-rs @@ -165,6 +179,11 @@ as-raw-xcb-connection 1.0.1: MIT OR Apache-2.0 Source: https://crates.io/api/v1/crates/as-raw-xcb-connection/1.0.1/download Repository: https://github.com/psychon/as-raw-xcb-connection +as-slice 0.2.1: MIT OR Apache-2.0 +Authors: Jorge Aparicio ; Emil Fresk +Source: https://crates.io/api/v1/crates/as-slice/0.2.1/download +Repository: https://github.com/japaric/as-slice + ash 0.38.0+1.3.281: MIT OR Apache-2.0 Authors: Maik Klein ; Benjamin Saunders ; Marijn Suijten Source: https://crates.io/api/v1/crates/ash/0.38.0+1.3.281/download @@ -425,6 +444,11 @@ Authors: Kirill Chibisov Source: https://crates.io/api/v1/crates/calloop-wayland-source/0.4.1/download Repository: https://github.com/smithay/calloop-wayland-source +cap-primitives 4.0.3: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT +Authors: Dan Gohman ; Jakub Konka +Source: https://crates.io/api/v1/crates/cap-primitives/4.0.3/download +Repository: https://github.com/bytecodealliance/cap-std + cbc 0.1.2: MIT OR Apache-2.0 Authors: RustCrypto Developers Source: https://crates.io/api/v1/crates/cbc/0.1.2/download @@ -702,6 +726,11 @@ curve25519-dalek-derive 0.1.1: MIT OR Apache-2.0 Source: https://crates.io/api/v1/crates/curve25519-dalek-derive/0.1.1/download Repository: https://github.com/dalek-cryptography/curve25519-dalek +cvt 0.1.2: Apache-2.0 +Authors: Marcin Mielniczuk +Source: https://crates.io/api/v1/crates/cvt/0.1.2/download +Repository: https://github.com/marmistrz/cvt + darling 0.24.1: MIT Authors: Ted Driggs Source: https://crates.io/api/v1/crates/darling/0.24.1/download @@ -1070,11 +1099,21 @@ Authors: Coeuvre Source: https://crates.io/api/v1/crates/freetype-sys/0.20.1/download Repository: https://github.com/PistonDevelopers/freetype-sys.git +fs-set-times 0.20.3: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT +Authors: Dan Gohman +Source: https://crates.io/api/v1/crates/fs-set-times/0.20.3/download +Repository: https://github.com/bytecodealliance/fs-set-times + fs2 0.4.3: MIT OR Apache-2.0 Authors: Dan Burkert Source: https://crates.io/api/v1/crates/fs2/0.4.3/download Repository: https://github.com/danburkert/fs2-rs +fs_at 0.2.1: Apache-2.0 +Authors: Robert Collins +Source: https://crates.io/api/v1/crates/fs_at/0.2.1/download +Repository: https://github.com/rbtcollins/fs_at.git + fsevent-sys 4.1.0: MIT Authors: Pierre Baillet Source: https://crates.io/api/v1/crates/fsevent-sys/4.1.0/download @@ -1603,6 +1642,21 @@ Authors: David Tolnay Source: https://crates.io/api/v1/crates/inventory/0.3.24/download Repository: https://github.com/dtolnay/inventory +io-extras 0.19.0: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT +Authors: Dan Gohman +Source: https://crates.io/api/v1/crates/io-extras/0.19.0/download +Repository: https://github.com/sunfishcode/io-extras + +io-lifetimes 2.0.4: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT +Authors: Dan Gohman +Source: https://crates.io/api/v1/crates/io-lifetimes/2.0.4/download +Repository: https://github.com/sunfishcode/io-lifetimes + +io-lifetimes 3.0.1: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT +Authors: Dan Gohman +Source: https://crates.io/api/v1/crates/io-lifetimes/3.0.1/download +Repository: https://github.com/sunfishcode/io-lifetimes + io-surface 0.16.1: MIT OR Apache-2.0 Authors: The Servo Project Developers Source: https://crates.io/api/v1/crates/io-surface/0.16.1/download @@ -1838,6 +1892,11 @@ Authors: Eliza Weisman Source: https://crates.io/api/v1/crates/matchers/0.2.0/download Repository: https://github.com/hawkw/matchers +maybe-owned 0.3.4: MIT OR Apache-2.0 +Authors: Philipp korber ; darkstalker ; daboross ; Zac Burns (That3Percent) +Source: https://crates.io/api/v1/crates/maybe-owned/0.3.4/download +Repository: https://github.com/rustonaut/maybe-owned + md-5 0.10.6: MIT OR Apache-2.0 Authors: RustCrypto Developers Source: https://crates.io/api/v1/crates/md-5/0.10.6/download @@ -1951,6 +2010,11 @@ Authors: The nix-rust Project Developers Source: https://crates.io/api/v1/crates/nix/0.28.0/download Repository: https://github.com/nix-rust/nix +nix 0.29.0: MIT +Authors: The nix-rust Project Developers +Source: https://crates.io/api/v1/crates/nix/0.29.0/download +Repository: https://github.com/nix-rust/nix + nix 0.31.3: MIT Source: https://crates.io/api/v1/crates/nix/0.31.3/download Repository: https://github.com/nix-rust/nix @@ -2176,6 +2240,16 @@ Authors: Daniel De Graaf ; Zeeshan Ali Khan +Source: https://crates.io/api/v1/crates/ouroboros/0.18.5/download +Repository: https://github.com/someguynamedjosh/ouroboros + +ouroboros_macro 0.18.5: MIT OR Apache-2.0 +Authors: Josh +Source: https://crates.io/api/v1/crates/ouroboros_macro/0.18.5/download +Repository: https://github.com/someguynamedjosh/ouroboros + p256 0.14.0: Apache-2.0 OR MIT Authors: RustCrypto Developers Source: https://crates.io/api/v1/crates/p256/0.14.0/download @@ -2427,6 +2501,11 @@ Authors: David Tolnay Source: https://crates.io/api/v1/crates/prettyplease/0.2.37/download Repository: https://github.com/dtolnay/prettyplease +prikk-ffi 0.56.0: Apache-2.0 +Authors: nabbisen +Source: https://crates.io/api/v1/crates/prikk-ffi/0.56.0/download +Repository: https://github.com/prikk-vcs/prikk + primefield 0.14.0: Apache-2.0 OR MIT Authors: RustCrypto Developers Source: https://crates.io/api/v1/crates/primefield/0.14.0/download @@ -2447,6 +2526,11 @@ Authors: David Tolnay ; Alex Crichton Source: https://crates.io/api/v1/crates/proc-macro2/1.0.107/download Repository: https://github.com/dtolnay/proc-macro2 +proc-macro2-diagnostics 0.10.1: MIT OR Apache-2.0 +Authors: Sergio Benitez +Source: https://crates.io/api/v1/crates/proc-macro2-diagnostics/0.10.1/download +Repository: https://github.com/SergioBenitez/proc-macro2-diagnostics + process-wrap 9.1.1: Apache-2.0 OR MIT Authors: FĂ©lix Saparelli Source: https://crates.io/api/v1/crates/process-wrap/9.1.1/download @@ -2768,6 +2852,11 @@ Authors: Dan Gohman ; Jakub Konka +Source: https://crates.io/api/v1/crates/rustix-linux-procfs/0.1.1/download +Repository: https://github.com/sunfishcode/rustix-linux-procfs + rustix-openpty 0.2.0: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT Authors: Dan Gohman Source: https://crates.io/api/v1/crates/rustix-openpty/0.2.0/download @@ -4033,6 +4122,11 @@ Authors: Max Resch Source: https://crates.io/api/v1/crates/winresource/0.1.31/download Repository: https://github.com/BenjaminRi/winresource +winx 0.36.4: Apache-2.0 WITH LLVM-exception +Authors: Jakub Konka ; Dan Gohman +Source: https://crates.io/api/v1/crates/winx/0.36.4/download +Repository: https://github.com/sunfishcode/winx + wio 0.2.2: MIT OR Apache-2.0 Authors: Peter Atashian Source: https://crates.io/api/v1/crates/wio/0.2.2/download @@ -4108,6 +4202,11 @@ Authors: Evgeniy Reizner Source: https://crates.io/api/v1/crates/xmlwriter/0.1.0/download Repository: https://github.com/RazrFalcon/xmlwriter +yansi 1.0.1: MIT OR Apache-2.0 +Authors: Sergio Benitez +Source: https://crates.io/api/v1/crates/yansi/1.0.1/download +Repository: https://github.com/SergioBenitez/yansi + yazi 0.2.1: Apache-2.0 OR MIT Authors: Chad Brokaw Source: https://crates.io/api/v1/crates/yazi/0.2.1/download @@ -8087,7 +8186,8 @@ Apache License limitations under the License. ======================================================================== -Original notice: xkeysym 0.2.1 / LICENSE-APACHE +Original notice: ouroboros 0.18.5 / LICENSE_APACHE +Original notice: ouroboros_macro 0.18.5 / LICENSE_APACHE Apache License Version 2.0, January 2004 @@ -8277,7 +8377,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2022-2023 John Nunley + Copyright 2021 Josh Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -8292,7 +8392,7 @@ Apache License limitations under the License. ======================================================================== -Original notice: powerfmt 0.2.0 / LICENSE-Apache +Original notice: xkeysym 0.2.1 / LICENSE-APACHE Apache License Version 2.0, January 2004 @@ -8482,7 +8582,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 Jacob Pratt et al. + Copyright 2022-2023 John Nunley Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -8497,7 +8597,7 @@ Apache License limitations under the License. ======================================================================== -Original notice: cursor-icon 1.2.0 / LICENSE-APACHE +Original notice: powerfmt 0.2.0 / LICENSE-Apache Apache License Version 2.0, January 2004 @@ -8687,7 +8787,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 Kirill Chibisov + Copyright 2023 Jacob Pratt et al. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -8702,8 +8802,7 @@ Apache License limitations under the License. ======================================================================== -Original notice: zerocopy 0.8.59 / LICENSE-APACHE -Original notice: zerocopy-derive 0.8.59 / LICENSE-APACHE +Original notice: cursor-icon 1.2.0 / LICENSE-APACHE Apache License Version 2.0, January 2004 @@ -8893,7 +8992,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 The Fuchsia Authors + Copyright 2023 Kirill Chibisov Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -8908,7 +9007,8 @@ Apache License limitations under the License. ======================================================================== -Original notice: web-time 1.1.0 / LICENSE-APACHE +Original notice: zerocopy 0.8.59 / LICENSE-APACHE +Original notice: zerocopy-derive 0.8.59 / LICENSE-APACHE Apache License Version 2.0, January 2004 @@ -9098,7 +9198,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 dAxpeDDa + Copyright 2023 The Fuchsia Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -9113,7 +9213,7 @@ Apache License limitations under the License. ======================================================================== -Original notice: deranged 0.5.8 / LICENSE-Apache +Original notice: web-time 1.1.0 / LICENSE-APACHE Apache License Version 2.0, January 2004 @@ -9303,7 +9403,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2024 Jacob Pratt et al. + Copyright 2023 dAxpeDDa Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -9318,10 +9418,7 @@ Apache License limitations under the License. ======================================================================== -License: Apache-2.0 -Used by: moxcms 0.8.1, pxfm 0.1.30 -Original notice: moxcms 0.8.1 / LICENSE-APACHE.md -Original notice: pxfm 0.1.30 / LICENSE-APACHE.md +Original notice: deranged 0.5.8 / LICENSE-Apache Apache License Version 2.0, January 2004 @@ -9511,7 +9608,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2024 Radzivon Bartoshyk + Copyright 2024 Jacob Pratt et al. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -9527,56 +9624,9 @@ Apache License ======================================================================== License: Apache-2.0 -Used by: clang-sys 1.9.1, codespan-reporting 0.13.1, miette 7.6.0, self_cell 1.3.0, unicode-linebreak 0.1.5 -License: Apache-2.0 -Used by: russh-sftp 3.0.1 -Original notice: as-raw-xcb-connection 1.0.1 / LICENSE-APACHE -Original notice: atspi 0.29.0 / LICENSE-APACHE2.txt -Original notice: atspi-common 0.13.0 / LICENSE-APACHE2.txt -Original notice: atspi-proxies 0.13.0 / LICENSE-APACHE2.txt -Original notice: clang-sys 1.9.1 / LICENSE.txt -Original notice: cmov 0.5.4 / LICENSE-APACHE -Original notice: codespan-reporting 0.13.1 / LICENSE -Original notice: crypto-primes 0.7.2 / LICENSE-APACHE -Original notice: ctutils 0.4.8 / LICENSE-APACHE -Original notice: encoding_rs 0.8.42 / LICENSE-APACHE -Original notice: flume 0.12.0 / LICENSE-APACHE -Original notice: gl_generator@0.14.0 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt -Source: https://raw.githubusercontent.com/brendanzab/gl-rs/ea503e8d5fb6d73c6030e6191ce738cd3bf3433e/LICENSE -Original notice: khronos_api@3.1.0 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt -Source: https://raw.githubusercontent.com/brendanzab/gl-rs/f150967b1c44ae888e6676f93f639ebc82771bdc/LICENSE -Original notice: kurbo 0.11.3 / LICENSE-APACHE -Original notice: kurbo 0.13.1 / LICENSE-APACHE -Original notice: miette 7.6.0 / LICENSE -Original notice: multiversion_no_op 1.0.0 / LICENSE-APACHE -Original notice: nohash-hasher 0.2.0 / LICENSE-APACHE -Original notice: pageant@0.2.3 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt -Source: https://raw.githubusercontent.com/warp-tech/russh/8a51a0dca4f6fbdcc3ce98c627254026b25b3252/LICENSE.txt -Original notice: polycool 0.4.0 / LICENSE-APACHE -Original notice: raw-window-metal 1.1.0 / LICENSE-APACHE -Original notice: russh-cryptovec@0.62.0 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt -Source: https://raw.githubusercontent.com/warp-tech/russh/907c732562c5732aad6c140dba958eafddc41ba0/LICENSE.txt -Original notice: russh-sftp 3.0.1 / LICENSE -Original notice: russh-util@0.52.0 / upstream/russh-apache-cfc7749b96f6.txt -Source: https://raw.githubusercontent.com/warp-tech/russh/3d09c20c52a542ffa327723d3a44cb1edd9b0ca4/LICENSE-2.0.txt -Original notice: russh@0.63.3 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt -Source: https://raw.githubusercontent.com/warp-tech/russh/f33baf439be8c59c49cb6cb2ae5976c579495bdf/LICENSE.txt -Original notice: secrecy 0.10.3 / LICENSE-APACHE -Original notice: self_cell 1.3.0 / LICENSE-APACHE -Original notice: siphasher 1.0.4 / LICENSE-APACHE -Original notice: spirv@0.4.0+sdk-1.4.341.0 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt -Source: https://raw.githubusercontent.com/gfx-rs/rspirv/8afc3d0ac8e158128cd1410bb2e4b4c26ab11bb4/LICENSE -Original notice: static_assertions 1.1.0 / LICENSE-APACHE -Original notice: sys-locale 0.3.2 / LICENSE-APACHE -Original notice: tinyvec 1.13.3 / LICENSE-APACHE.md -Original notice: unicode-linebreak 0.1.5 / LICENSE -Original notice: utf8_iter 1.0.4 / LICENSE-APACHE -Original notice: x11rb 0.13.2 / LICENSE-APACHE -Original notice: x11rb-protocol 0.13.2 / LICENSE-APACHE -Original notice: zeroize 1.9.0 / LICENSE-APACHE -Original notice: zeroize_derive 1.5.0 / LICENSE-APACHE -Original notice: zune-core 0.5.3 / LICENSE-APACHE -Original notice: zune-jpeg 0.5.15 / LICENSE-APACHE +Used by: moxcms 0.8.1, pxfm 0.1.30 +Original notice: moxcms 0.8.1 / LICENSE-APACHE.md +Original notice: pxfm 0.1.30 / LICENSE-APACHE.md Apache License Version 2.0, January 2004 @@ -9766,7 +9816,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2024 Radzivon Bartoshyk Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -9781,13 +9831,8 @@ Apache License limitations under the License. ======================================================================== -Original notice: linux-raw-sys 0.12.1 / LICENSE-Apache-2.0_WITH_LLVM-exception -Original notice: linux-raw-sys 0.4.15 / LICENSE-Apache-2.0_WITH_LLVM-exception -Original notice: rustix 0.38.44 / LICENSE-Apache-2.0_WITH_LLVM-exception -Original notice: rustix 1.1.5 / LICENSE-Apache-2.0_WITH_LLVM-exception -Original notice: rustix-openpty 0.2.0 / LICENSE-Apache-2.0_WITH_LLVM-exception -Original notice: wasmparser@0.228.0 / upstream/bytecodealliance-wasm-tools-license-apache-llvm-exception-268872b9816f.txt -Source: https://raw.githubusercontent.com/bytecodealliance/wasm-tools/e66235859a6ec0502bf6f9dcc358953eda4cafcc/LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: prikk-ffi@0.56.0 / upstream/prikk-ffi-0.56.0-LICENSE +Source: https://raw.githubusercontent.com/prikk-vcs/prikk/808126bd207edb37142a1a59131656cf555cc30e/LICENSE Apache License Version 2.0, January 2004 @@ -9977,7 +10022,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 nabbisen Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -9991,27 +10036,59 @@ Apache License See the License for the specific language governing permissions and limitations under the License. - ---- LLVM Exceptions to the Apache 2.0 License ---- - -As an exception, if, as a result of your compiling your source code, portions -of this Software are embedded into an Object form of such source code, you -may redistribute such embedded portions in such Object form without complying -with the conditions of Sections 4(a), 4(b) and 4(d) of the License. - -In addition, if you combine or link compiled forms of this Software with -software that is licensed under the GPLv2 ("Combined Software") and if a -court of competent jurisdiction determines that the patent provision (Section -3), the indemnity provision (Section 9) or other Section of the License -conflicts with the conditions of the GPLv2, you may retroactively and -prospectively choose to deem waived or otherwise exclude such Section(s) of -the License, but only in their entirety and only with respect to the Combined -Software. - ======================================================================== License: Apache-2.0 -Used by: ring 0.17.14 -Original notice: ring 0.17.14 / LICENSE-BoringSSL +Used by: clang-sys 1.9.1, codespan-reporting 0.13.1, cvt 0.1.2, miette 7.6.0, self_cell 1.3.0, unicode-linebreak 0.1.5 +License: Apache-2.0 +Used by: russh-sftp 3.0.1 +Original notice: as-raw-xcb-connection 1.0.1 / LICENSE-APACHE +Original notice: atspi 0.29.0 / LICENSE-APACHE2.txt +Original notice: atspi-common 0.13.0 / LICENSE-APACHE2.txt +Original notice: atspi-proxies 0.13.0 / LICENSE-APACHE2.txt +Original notice: clang-sys 1.9.1 / LICENSE.txt +Original notice: cmov 0.5.4 / LICENSE-APACHE +Original notice: codespan-reporting 0.13.1 / LICENSE +Original notice: crypto-primes 0.7.2 / LICENSE-APACHE +Original notice: ctutils 0.4.8 / LICENSE-APACHE +Original notice: cvt 0.1.2 / LICENSE +Original notice: encoding_rs 0.8.42 / LICENSE-APACHE +Original notice: flume 0.12.0 / LICENSE-APACHE +Original notice: gl_generator@0.14.0 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt +Source: https://raw.githubusercontent.com/brendanzab/gl-rs/ea503e8d5fb6d73c6030e6191ce738cd3bf3433e/LICENSE +Original notice: khronos_api@3.1.0 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt +Source: https://raw.githubusercontent.com/brendanzab/gl-rs/f150967b1c44ae888e6676f93f639ebc82771bdc/LICENSE +Original notice: kurbo 0.11.3 / LICENSE-APACHE +Original notice: kurbo 0.13.1 / LICENSE-APACHE +Original notice: miette 7.6.0 / LICENSE +Original notice: multiversion_no_op 1.0.0 / LICENSE-APACHE +Original notice: nohash-hasher 0.2.0 / LICENSE-APACHE +Original notice: pageant@0.2.3 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt +Source: https://raw.githubusercontent.com/warp-tech/russh/8a51a0dca4f6fbdcc3ce98c627254026b25b3252/LICENSE.txt +Original notice: polycool 0.4.0 / LICENSE-APACHE +Original notice: raw-window-metal 1.1.0 / LICENSE-APACHE +Original notice: russh-cryptovec@0.62.0 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt +Source: https://raw.githubusercontent.com/warp-tech/russh/907c732562c5732aad6c140dba958eafddc41ba0/LICENSE.txt +Original notice: russh-sftp 3.0.1 / LICENSE +Original notice: russh-util@0.52.0 / upstream/russh-apache-cfc7749b96f6.txt +Source: https://raw.githubusercontent.com/warp-tech/russh/3d09c20c52a542ffa327723d3a44cb1edd9b0ca4/LICENSE-2.0.txt +Original notice: russh@0.63.3 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt +Source: https://raw.githubusercontent.com/warp-tech/russh/f33baf439be8c59c49cb6cb2ae5976c579495bdf/LICENSE.txt +Original notice: secrecy 0.10.3 / LICENSE-APACHE +Original notice: self_cell 1.3.0 / LICENSE-APACHE +Original notice: siphasher 1.0.4 / LICENSE-APACHE +Original notice: spirv@0.4.0+sdk-1.4.341.0 / upstream/brendanzab-gl-rs-license-cfc7749b96f6.txt +Source: https://raw.githubusercontent.com/gfx-rs/rspirv/8afc3d0ac8e158128cd1410bb2e4b4c26ab11bb4/LICENSE +Original notice: static_assertions 1.1.0 / LICENSE-APACHE +Original notice: sys-locale 0.3.2 / LICENSE-APACHE +Original notice: tinyvec 1.13.3 / LICENSE-APACHE.md +Original notice: unicode-linebreak 0.1.5 / LICENSE +Original notice: utf8_iter 1.0.4 / LICENSE-APACHE +Original notice: x11rb 0.13.2 / LICENSE-APACHE +Original notice: x11rb-protocol 0.13.2 / LICENSE-APACHE +Original notice: zeroize 1.9.0 / LICENSE-APACHE +Original notice: zeroize_derive 1.5.0 / LICENSE-APACHE +Original notice: zune-core 0.5.3 / LICENSE-APACHE +Original notice: zune-jpeg 0.5.15 / LICENSE-APACHE Apache License Version 2.0, January 2004 @@ -10215,78 +10292,24 @@ Apache License See the License for the specific language governing permissions and limitations under the License. - -Licenses for support code -------------------------- - -Parts of the TLS test suite are under the Go license. This code is not included -in BoringSSL (i.e. libcrypto and libssl) when compiled, however, so -distributing code linked against BoringSSL does not trigger this license: - -Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -BoringSSL uses the Chromium test infrastructure to run a continuous build, -trybots etc. The scripts which manage this, and the script for generating build -metadata, are under the Chromium license. Distributing code linked against -BoringSSL does not trigger this license. - -Copyright 2015 The Chromium Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ======================================================================== -Original notice: ipnet 2.12.2 / LICENSE-APACHE +License: Apache-2.0 +Used by: winx 0.36.4 +Original notice: ambient-authority 0.0.2 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: cap-primitives 4.0.3 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: fs-set-times 0.20.3 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: io-extras 0.19.0 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: io-lifetimes 2.0.4 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: io-lifetimes 3.0.1 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: linux-raw-sys 0.12.1 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: linux-raw-sys 0.4.15 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: rustix 0.38.44 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: rustix 1.1.5 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: rustix-linux-procfs 0.1.1 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: rustix-openpty 0.2.0 / LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: wasmparser@0.228.0 / upstream/bytecodealliance-wasm-tools-license-apache-llvm-exception-268872b9816f.txt +Source: https://raw.githubusercontent.com/bytecodealliance/wasm-tools/e66235859a6ec0502bf6f9dcc358953eda4cafcc/LICENSE-Apache-2.0_WITH_LLVM-exception +Original notice: winx 0.36.4 / LICENSE Apache License Version 2.0, January 2004 @@ -10468,7 +10491,7 @@ Apache License APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" + boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -10476,7 +10499,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2017 Juniper Networks, Inc. + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -10490,10 +10513,27 @@ Apache License See the License for the specific language governing permissions and limitations under the License. + +--- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + ======================================================================== License: Apache-2.0 -Used by: glutin_wgl_sys 0.6.1 -Original notice: glutin_wgl_sys 0.6.1 / LICENSE +Used by: ring 0.17.14 +Original notice: ring 0.17.14 / LICENSE-BoringSSL Apache License Version 2.0, January 2004 @@ -10675,7 +10715,7 @@ Apache License APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" + boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -10683,7 +10723,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2022 Kirill Chibisov + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -10697,246 +10737,78 @@ Apache License See the License for the specific language governing permissions and limitations under the License. -======================================================================== -Original notice: cosmic-text 0.19.0 / LICENSE-APACHE - -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. +Licenses for support code +------------------------- - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. +Parts of the TLS test suite are under the Go license. This code is not included +in BoringSSL (i.e. libcrypto and libssl) when compiled, however, so +distributing code linked against BoringSSL does not trigger this license: - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. +Copyright (c) 2009 The Go Authors. All rights reserved. - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - END OF TERMS AND CONDITIONS +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - APPENDIX: How to apply the Apache License to your work. - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. +BoringSSL uses the Chromium test infrastructure to run a continuous build, +trybots etc. The scripts which manage this, and the script for generating build +metadata, are under the Chromium license. Distributing code linked against +BoringSSL does not trigger this license. - Copyright [yyyy] [name of copyright owner] +Copyright 2015 The Chromium Authors. All rights reserved. - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - http://www.apache.org/licenses/LICENSE-2.0 + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ======================================================================== -License: Apache-2.0 -Used by: unicode-general-category 1.0.0 -Original notice: annotate-snippets 0.12.16 / LICENSE-APACHE -Original notice: anstyle 1.0.14 / LICENSE-APACHE -Original notice: bit_field 0.10.3 / LICENSE-APACHE -Original notice: cobs 0.3.0 / LICENSE-APACHE -Original notice: crc32fast 1.5.2 / LICENSE-APACHE -Original notice: ctor 1.0.13 / LICENSE-APACHE -Original notice: delegate@0.13.5 / upstream/kobzol-rust-delegate-license-apache-b40930bbcf80.txt -Source: https://raw.githubusercontent.com/kobzol/rust-delegate/b129d4cbf4cc499eff7743365dcb0fe5d8d2e820/LICENSE-APACHE -Original notice: float-ord 0.3.2 / LICENSE-APACHE -Original notice: foreign-types 0.5.0 / LICENSE-APACHE -Original notice: foreign-types-macros 0.2.4 / LICENSE-APACHE -Original notice: foreign-types-shared 0.3.1 / LICENSE-APACHE -Original notice: hex 0.4.3 / LICENSE-APACHE -Original notice: link-section 0.19.3 / LICENSE-APACHE -Original notice: linktime-proc-macro 0.2.3 / LICENSE-APACHE -Original notice: notify-rust 4.18.1 / LICENSE-Apache -Original notice: quick-error 2.0.1 / LICENSE-APACHE -Original notice: serde_spanned 0.6.9 / LICENSE-APACHE -Original notice: serde_spanned 1.1.1 / LICENSE-APACHE -Original notice: streaming-iterator 0.1.9 / LICENSE-APACHE -Original notice: toml 0.8.23 / LICENSE-APACHE -Original notice: toml 1.1.6+spec-1.1.0 / LICENSE-APACHE -Original notice: toml_datetime 0.6.11 / LICENSE-APACHE -Original notice: toml_datetime 1.1.1+spec-1.1.0 / LICENSE-APACHE -Original notice: toml_edit 0.22.27 / LICENSE-APACHE -Original notice: toml_edit 0.25.15+spec-1.1.0 / LICENSE-APACHE -Original notice: toml_parser 1.1.3+spec-1.1.0 / LICENSE-APACHE -Original notice: toml_write 0.1.2 / LICENSE-APACHE -Original notice: toml_writer 1.1.2+spec-1.1.0 / LICENSE-APACHE -Original notice: unicode-general-category 1.0.0 / LICENSE -Original notice: winapi 0.3.9 / LICENSE-APACHE -Original notice: wio 0.2.2 / LICENSE-APACHE +Original notice: ipnet 2.12.2 / LICENSE-APACHE Apache License Version 2.0, January 2004 @@ -11126,7 +10998,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2017 Juniper Networks, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -11141,7 +11013,657 @@ Apache License limitations under the License. ======================================================================== -Original notice: rangemap 1.8.0 / LICENSE-APACHE +License: Apache-2.0 +Used by: glutin_wgl_sys 0.6.1 +Original notice: glutin_wgl_sys 0.6.1 / LICENSE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Kirill Chibisov + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +======================================================================== +Original notice: cosmic-text 0.19.0 / LICENSE-APACHE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +======================================================================== +License: Apache-2.0 +Used by: unicode-general-category 1.0.0 +Original notice: annotate-snippets 0.12.16 / LICENSE-APACHE +Original notice: anstyle 1.0.14 / LICENSE-APACHE +Original notice: bit_field 0.10.3 / LICENSE-APACHE +Original notice: cobs 0.3.0 / LICENSE-APACHE +Original notice: crc32fast 1.5.2 / LICENSE-APACHE +Original notice: ctor 1.0.13 / LICENSE-APACHE +Original notice: delegate@0.13.5 / upstream/kobzol-rust-delegate-license-apache-b40930bbcf80.txt +Source: https://raw.githubusercontent.com/kobzol/rust-delegate/b129d4cbf4cc499eff7743365dcb0fe5d8d2e820/LICENSE-APACHE +Original notice: float-ord 0.3.2 / LICENSE-APACHE +Original notice: foreign-types 0.5.0 / LICENSE-APACHE +Original notice: foreign-types-macros 0.2.4 / LICENSE-APACHE +Original notice: foreign-types-shared 0.3.1 / LICENSE-APACHE +Original notice: hex 0.4.3 / LICENSE-APACHE +Original notice: link-section 0.19.3 / LICENSE-APACHE +Original notice: linktime-proc-macro 0.2.3 / LICENSE-APACHE +Original notice: notify-rust 4.18.1 / LICENSE-Apache +Original notice: quick-error 2.0.1 / LICENSE-APACHE +Original notice: serde_spanned 0.6.9 / LICENSE-APACHE +Original notice: serde_spanned 1.1.1 / LICENSE-APACHE +Original notice: streaming-iterator 0.1.9 / LICENSE-APACHE +Original notice: toml 0.8.23 / LICENSE-APACHE +Original notice: toml 1.1.6+spec-1.1.0 / LICENSE-APACHE +Original notice: toml_datetime 0.6.11 / LICENSE-APACHE +Original notice: toml_datetime 1.1.1+spec-1.1.0 / LICENSE-APACHE +Original notice: toml_edit 0.22.27 / LICENSE-APACHE +Original notice: toml_edit 0.25.15+spec-1.1.0 / LICENSE-APACHE +Original notice: toml_parser 1.1.3+spec-1.1.0 / LICENSE-APACHE +Original notice: toml_write 0.1.2 / LICENSE-APACHE +Original notice: toml_writer 1.1.2+spec-1.1.0 / LICENSE-APACHE +Original notice: unicode-general-category 1.0.0 / LICENSE +Original notice: winapi 0.3.9 / LICENSE-APACHE +Original notice: wio 0.2.2 / LICENSE-APACHE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +======================================================================== +Original notice: rangemap 1.8.0 / LICENSE-APACHE Apache License Version 2.0, January 2004 @@ -12132,6 +12654,7 @@ Original notice: mach2 0.5.0 / LICENSE-APACHE Original notice: paste 1.0.15 / LICENSE-APACHE Original notice: prettyplease 0.2.37 / LICENSE-APACHE Original notice: proc-macro2 1.0.107 / LICENSE-APACHE +Original notice: proc-macro2-diagnostics 0.10.1 / LICENSE-APACHE Original notice: process-wrap 9.1.1 / LICENSE-APACHE Original notice: quote 1.0.47 / LICENSE-APACHE Original notice: ref-cast 1.0.27 / LICENSE-APACHE @@ -12744,7 +13267,212 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2018-2024 Robin Freyler +Copyright (c) 2018-2024 Robin Freyler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +======================================================================== +Original notice: typenum 1.20.1 / LICENSE-APACHE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2014 Paho Lurie-Gregg Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -12759,7 +13487,7 @@ See the License for the specific language governing permissions and limitations under the License. ======================================================================== -Original notice: typenum 1.20.1 / LICENSE-APACHE +Original notice: maybe-owned 0.3.4 / LICENSE-APACHE Apache License Version 2.0, January 2004 @@ -12949,7 +13677,7 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2014 Paho Lurie-Gregg +Copyright 2016 Philipp Korber Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13168,6 +13896,211 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +======================================================================== +Original notice: yansi 1.0.1 / LICENSE-APACHE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 Sergio Benitez + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + ======================================================================== Original notice: http 1.5.0 / LICENSE-APACHE @@ -15639,8 +16572,11 @@ License: Apache-2.0 Used by: gethostname 1.1.0, prost 0.14.4, prost-build 0.14.4, prost-derive 0.14.4, prost-types 0.14.4, ring 0.17.14 Original notice: addr2line 0.25.1 / LICENSE-APACHE Original notice: ahash 0.8.12 / LICENSE-APACHE +Original notice: aligned 0.4.3 / LICENSE-APACHE +Original notice: ambient-authority 0.0.2 / LICENSE-APACHE Original notice: arc-swap 1.9.2 / LICENSE-APACHE Original notice: arrayvec 0.7.8 / LICENSE-APACHE +Original notice: as-slice 0.2.1 / LICENSE-APACHE Original notice: async-channel 2.5.0 / LICENSE-APACHE Original notice: async-compression 0.4.50 / LICENSE-APACHE Original notice: async-executor 1.14.1 / LICENSE-APACHE @@ -15664,6 +16600,7 @@ Original notice: bitflags 2.13.2 / LICENSE-APACHE Original notice: blocking 1.7.0 / LICENSE-APACHE Original notice: bstr 1.13.1 / LICENSE-APACHE Original notice: bzip2 0.6.1 / LICENSE-APACHE +Original notice: cap-primitives 4.0.3 / LICENSE-APACHE Original notice: cc 1.5.1 / LICENSE-APACHE Original notice: cexpr 0.6.0 / LICENSE-APACHE Original notice: cfg-if 1.0.5 / LICENSE-APACHE @@ -15705,6 +16642,7 @@ Original notice: fixedbitset 0.5.7 / LICENSE-APACHE Original notice: flate2 1.1.10 / LICENSE-APACHE Original notice: fnv 1.0.7 / LICENSE-APACHE Original notice: form_urlencoded 1.2.2 / LICENSE-APACHE +Original notice: fs-set-times 0.20.3 / LICENSE-APACHE Original notice: fs2 0.4.3 / LICENSE-APACHE Original notice: futf 0.1.5 / LICENSE-APACHE Original notice: futures-lite 2.6.1 / LICENSE-APACHE @@ -15730,6 +16668,9 @@ Original notice: hyper-timeout 0.5.2 / LICENSE-APACHE Original notice: idna 1.1.0 / LICENSE-APACHE Original notice: idna_adapter 1.2.2 / LICENSE-APACHE Original notice: indexmap 2.14.2 / LICENSE-APACHE +Original notice: io-extras 0.19.0 / LICENSE-APACHE +Original notice: io-lifetimes 2.0.4 / LICENSE-APACHE +Original notice: io-lifetimes 3.0.1 / LICENSE-APACHE Original notice: io-surface 0.16.1 / LICENSE-APACHE Original notice: itertools 0.11.0 / LICENSE-APACHE Original notice: itertools 0.13.0 / LICENSE-APACHE @@ -15799,6 +16740,7 @@ Original notice: rustc-hash 1.1.0 / LICENSE-APACHE Original notice: rustc_version 0.4.1 / LICENSE-APACHE Original notice: rustix 0.38.44 / LICENSE-APACHE Original notice: rustix 1.1.5 / LICENSE-APACHE +Original notice: rustix-linux-procfs 0.1.1 / LICENSE-APACHE Original notice: rustix-openpty 0.2.0 / LICENSE-APACHE Original notice: rustls 0.23.46 / LICENSE-APACHE Original notice: rustls-pemfile 2.2.0 / LICENSE-APACHE @@ -18022,7 +18964,7 @@ limitations under the License. ======================================================================== Original notice: document-features 0.2.12 / LICENSE-APACHE SPDX reference license terms: Apache-2.0 -Used by: agent-client-protocol 2.2.0, agent-client-protocol-derive 2.2.0, agent-client-protocol-schema 1.9.1, dunce 1.0.5, gl_generator 0.14.0, gpui-kit 0.7.0, gpui-kit-assets 0.7.0, khronos_api 3.1.0, pageant 0.2.3, russh 0.63.3, russh-cryptovec 0.62.0, russh-util 0.52.0, ryu 1.0.23, serial2 0.2.38, spirv 0.4.0+sdk-1.4.341.0, sync_wrapper 1.0.2 +Used by: agent-client-protocol 2.2.0, agent-client-protocol-derive 2.2.0, agent-client-protocol-schema 1.9.1, dunce 1.0.5, fs_at 0.2.1, gl_generator 0.14.0, gpui-kit 0.7.0, gpui-kit-assets 0.7.0, khronos_api 3.1.0, pageant 0.2.3, prikk-ffi 0.56.0, russh 0.63.3, russh-cryptovec 0.62.0, russh-util 0.52.0, ryu 1.0.23, serial2 0.2.38, spirv 0.4.0+sdk-1.4.341.0, sync_wrapper 1.0.2 Apache License Version 2.0, January 2004 @@ -20545,6 +21487,37 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +======================================================================== +License: MIT +Used by: maybe-owned 0.3.4 +Original notice: maybe-owned 0.3.4 / LICENSE-MIT + +Copyright (c) 2016 Philipp Korber + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ======================================================================== License: MIT Used by: httpdate 1.0.3 @@ -21196,7 +22169,8 @@ SOFTWARE. ======================================================================== License: MIT -Used by: heapless 0.9.3 +Used by: aligned 0.4.3, heapless 0.9.3 +Original notice: aligned 0.4.3 / LICENSE-MIT Original notice: heapless 0.9.3 / LICENSE-MIT Copyright (c) 2017 Jorge Aparicio @@ -21726,7 +22700,8 @@ DEALINGS IN THE SOFTWARE. ======================================================================== License: MIT -Used by: hash32 0.3.1 +Used by: as-slice 0.2.1, hash32 0.3.1 +Original notice: as-slice 0.2.1 / LICENSE-MIT Original notice: hash32 0.3.1 / LICENSE-MIT Copyright (c) 2018 Jorge Aparicio @@ -29875,6 +30850,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +======================================================================== +Original notice: ouroboros 0.18.5 / LICENSE_MIT +Original notice: ouroboros_macro 0.18.5 / LICENSE_MIT + +MIT License + +Copyright (c) 2021 Josh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ======================================================================== License: MIT Used by: rust-i18n 4.2.4 @@ -30318,7 +31319,7 @@ SOFTWARE. ======================================================================== SPDX reference license terms: MIT -Used by: accesskit 0.24.1, accesskit_atspi_common 0.19.1, accesskit_consumer 0.38.0, accesskit_macos 0.26.3, accesskit_unix 0.22.1, accesskit_windows 0.34.0, block 0.1.6, block2 0.6.2, chrono 0.4.45, delegate 0.13.5, dispatch2 0.3.1, gpu-descriptor 0.3.2, gpu-descriptor-types 0.2.0, granit-parser 1.3.0, harfrust 0.5.2, leak 0.1.2, leaky-cow 0.1.1, libm 0.2.16, lyon 1.0.19, lyon_algorithms 1.0.21, lyon_geom 1.0.19, lyon_path 1.0.19, lyon_tessellation 1.0.22, mac 0.1.1, mac-notification-sys 0.6.15, malloc_buf 0.0.6, md5 0.8.1, objc-sys 0.3.5, objc2 0.5.2, objc2 0.6.4, objc2-app-kit 0.2.2, objc2-app-kit 0.3.2, objc2-cloud-kit 0.3.2, objc2-core-data 0.3.2, objc2-core-foundation 0.3.2, objc2-core-graphics 0.3.2, objc2-core-image 0.3.2, objc2-core-location 0.3.2, objc2-core-media 0.3.2, objc2-core-text 0.3.2, objc2-core-video 0.3.2, objc2-encode 4.1.0, objc2-foundation 0.2.2, objc2-foundation 0.3.2, objc2-metal 0.3.2, objc2-quartz-core 0.3.2, objc2-screen-capture-kit 0.3.2, objc2-user-notifications 0.3.2, pathfinder_geometry 0.5.1, pathfinder_simd 0.5.6, profiling 1.0.18, profiling-procmacros 1.0.18, pulp-wasm-simd-flag 0.1.1, rust-i18n-macro 4.2.4, rust-i18n-support 4.2.4, seahash 4.1.0, svg_fmt 0.4.5, taffy 0.13.0, tauri-winrt-notification 0.8.1, tonic-prost-build 0.14.7, tree-sitter-bash 0.23.3, tree-sitter-cpp 0.23.4, tree-sitter-css 0.23.2, tree-sitter-diff 0.1.0, tree-sitter-go 0.23.4, tree-sitter-html 0.23.2, tree-sitter-java 0.23.5, tree-sitter-javascript 0.23.1, tree-sitter-json 0.24.8, tree-sitter-python 0.23.6, tree-sitter-toml-ng 0.7.0, tree-sitter-typescript 0.23.2, wasmi 2.0.0, wasmi_collections 2.0.0, wasmi_core 2.0.0, wasmi_ir 2.0.0, wasmparser 0.228.0, windows 0.58.0, windows 0.62.2, windows-collections 0.3.2, windows-core 0.58.0, windows-core 0.62.2, windows-future 0.3.2, windows-implement 0.58.0, windows-implement 0.60.2, windows-interface 0.58.0, windows-interface 0.59.3, windows-link 0.1.3, windows-link 0.2.1, windows-numerics 0.3.1, windows-registry 0.4.0, windows-registry 0.6.1, windows-result 0.2.0, windows-result 0.3.4, windows-result 0.4.1, windows-strings 0.1.0, windows-strings 0.3.1, windows-strings 0.5.1, windows-sys 0.48.0, windows-sys 0.52.0, windows-sys 0.59.0, windows-sys 0.61.2, windows-targets 0.48.5, windows-targets 0.52.6, windows-targets 0.53.5, windows-threading 0.2.1, windows-version 0.1.7, windows_x86_64_gnu 0.48.5, windows_x86_64_gnu 0.52.6, windows_x86_64_gnu 0.53.1, windows_x86_64_msvc 0.48.5, windows_x86_64_msvc 0.52.6, windows_x86_64_msvc 0.53.1, xim-ctext 0.3.0, xim-parser 0.2.2, zune-core 0.4.12, zune-inflate 0.2.54, zune-jpeg 0.4.21 +Used by: accesskit 0.24.1, accesskit_atspi_common 0.19.1, accesskit_consumer 0.38.0, accesskit_macos 0.26.3, accesskit_unix 0.22.1, accesskit_windows 0.34.0, block 0.1.6, block2 0.6.2, chrono 0.4.45, delegate 0.13.5, dispatch2 0.3.1, gpu-descriptor 0.3.2, gpu-descriptor-types 0.2.0, granit-parser 1.3.0, harfrust 0.5.2, leak 0.1.2, leaky-cow 0.1.1, libm 0.2.16, lyon 1.0.19, lyon_algorithms 1.0.21, lyon_geom 1.0.19, lyon_path 1.0.19, lyon_tessellation 1.0.22, mac 0.1.1, mac-notification-sys 0.6.15, malloc_buf 0.0.6, md5 0.8.1, objc-sys 0.3.5, objc2 0.5.2, objc2 0.6.4, objc2-app-kit 0.2.2, objc2-app-kit 0.3.2, objc2-cloud-kit 0.3.2, objc2-core-data 0.3.2, objc2-core-foundation 0.3.2, objc2-core-graphics 0.3.2, objc2-core-image 0.3.2, objc2-core-location 0.3.2, objc2-core-media 0.3.2, objc2-core-text 0.3.2, objc2-core-video 0.3.2, objc2-encode 4.1.0, objc2-foundation 0.2.2, objc2-foundation 0.3.2, objc2-metal 0.3.2, objc2-quartz-core 0.3.2, objc2-screen-capture-kit 0.3.2, objc2-user-notifications 0.3.2, ouroboros 0.18.5, ouroboros_macro 0.18.5, pathfinder_geometry 0.5.1, pathfinder_simd 0.5.6, profiling 1.0.18, profiling-procmacros 1.0.18, pulp-wasm-simd-flag 0.1.1, rust-i18n-macro 4.2.4, rust-i18n-support 4.2.4, seahash 4.1.0, svg_fmt 0.4.5, taffy 0.13.0, tauri-winrt-notification 0.8.1, tonic-prost-build 0.14.7, tree-sitter-bash 0.23.3, tree-sitter-cpp 0.23.4, tree-sitter-css 0.23.2, tree-sitter-diff 0.1.0, tree-sitter-go 0.23.4, tree-sitter-html 0.23.2, tree-sitter-java 0.23.5, tree-sitter-javascript 0.23.1, tree-sitter-json 0.24.8, tree-sitter-python 0.23.6, tree-sitter-toml-ng 0.7.0, tree-sitter-typescript 0.23.2, wasmi 2.0.0, wasmi_collections 2.0.0, wasmi_core 2.0.0, wasmi_ir 2.0.0, wasmparser 0.228.0, windows 0.58.0, windows 0.62.2, windows-collections 0.3.2, windows-core 0.58.0, windows-core 0.62.2, windows-future 0.3.2, windows-implement 0.58.0, windows-implement 0.60.2, windows-interface 0.58.0, windows-interface 0.59.3, windows-link 0.1.3, windows-link 0.2.1, windows-numerics 0.3.1, windows-registry 0.4.0, windows-registry 0.6.1, windows-result 0.2.0, windows-result 0.3.4, windows-result 0.4.1, windows-strings 0.1.0, windows-strings 0.3.1, windows-strings 0.5.1, windows-sys 0.48.0, windows-sys 0.52.0, windows-sys 0.59.0, windows-sys 0.61.2, windows-targets 0.48.5, windows-targets 0.52.6, windows-targets 0.53.5, windows-threading 0.2.1, windows-version 0.1.7, windows_x86_64_gnu 0.48.5, windows_x86_64_gnu 0.52.6, windows_x86_64_gnu 0.53.1, windows_x86_64_msvc 0.48.5, windows_x86_64_msvc 0.52.6, windows_x86_64_msvc 0.53.1, xim-ctext 0.3.0, xim-parser 0.2.2, zune-core 0.4.12, zune-inflate 0.2.54, zune-jpeg 0.4.21 MIT License @@ -31422,6 +32423,76 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. +======================================================================== +Original licensing declaration: fs_at@0.2.1 / upstream/fs_at-0.2.1-declaration.txt +Source: https://docs.rs/crate/fs_at/0.2.1/source/Cargo.toml.orig + +Original licensing declaration from the published crate archive. +No standalone primary license file is included in this archive or its pinned repository. +The declared SPDX terms are reproduced separately in THIRD_PARTY_NOTICES.txt. + +Cargo.toml.orig (unmodified): + +[package] +authors = ["Robert Collins "] +categories = ["filesystem", "os"] +description = "Implementation of 'at' functions for various platforms" +edition = "2021" +license = "Apache-2.0" +name = "fs_at" +readme = "README.md" +repository = "https://github.com/rbtcollins/fs_at.git" +rust-version = "1.71.0" + +# Auto-publishes when changed. +version = "0.2.1" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +default = [] +log = ["dep:log"] +"workaround-procmon" = ["dep:once_cell"] + +[dependencies] +cfg-if = "1.0.0" +cvt = "0.1.1" +log = { version = "0.4.21", optional = true } + +[dev-dependencies] +env_logger = "0.11.3" +fs-set-times = "0.20.1" +rayon = "1.10.0" +tempfile = "3.10.1" +test-log = "0.2.16" + +[target.'cfg(not(windows))'.dependencies] +libc = "0.2.153" +# Saves nontrivial unsafe and platform specific code (Darwin vs other Unixes, +# MAX_PATH and more : consider it weak and something we can remove if expedient +# later. +nix = { version = "0.29.0", default-features = false, features = ["dir"] } + +[target.'cfg(windows)'.dependencies] +aligned = "0.4.1" +once_cell = { optional = true, version = "1.19.0" } + +[target.'cfg(windows)'.dependencies.windows-sys] +features = [ + "Wdk_Foundation", + "Wdk_Storage_FileSystem", + "Wdk_System_SystemServices", + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Ioctl", + "Win32_System_Kernel", + "Win32_System_SystemServices", + "Win32_System_WindowsProgramming", +] +version = "0.52.0" + ======================================================================== Original licensing declaration: hexf-parse@0.2.1 / upstream/hexf-parse-0.2.1-declaration.txt Source: https://docs.rs/crate/hexf-parse/0.2.1/source/Cargo.toml @@ -31897,7 +32968,7 @@ DEALINGS IN THE SOFTWARE. ======================================================================== License: MIT -Used by: adler2 2.0.1, anyhow 1.0.104, async-channel 2.5.0, async-executor 1.14.1, async-fs 2.2.0, async-io 2.6.0, async-lock 3.4.2, async-net 2.0.0, async-process 2.5.0, async-recursion 1.1.1, async-signal 0.2.14, async-task 4.7.1, async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0, curve25519-dalek-derive 0.1.1, displaydoc 0.2.7, dyn-clone 1.0.20, endi 1.1.1, enumn 0.1.14, erased-serde 0.4.10, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flume 0.12.0, futures-lite 2.6.1, group 0.14.0, home 0.5.12, inventory 0.3.24, itoa 1.0.18, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, minimal-lexical 0.2.1, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, paste 1.0.15, pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, prettyplease 0.2.37, proc-macro-crate 3.5.0, proc-macro2 1.0.107, process-wrap 9.1.1, prost-reflect 0.16.5, protox 0.10.0, protox-parse 0.10.0, quote 1.0.47, ref-cast 1.0.27, ref-cast-impl 1.0.27, rsa 0.10.0-rc.18, rustc-hash 1.1.0, rustc-hash 2.1.3, rustix 0.38.44, rustix 1.1.5, rustix-openpty 0.2.0, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde-saphyr 1.3.0, serde_bytes 0.11.19, serde_core 1.0.229, serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json 1.0.151, serde_json_lenient 0.2.4, serde_repr 0.1.21, smol 2.0.2, smol_str 0.3.6, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typeid 1.0.3, unicode-ident 1.0.26, utf-8 0.7.6, waker-fn 1.2.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zed-font-kit 0.14.1-zed, zmij 1.0.23, zvariant_utils 4.2.0 +Used by: adler2 2.0.1, ambient-authority 0.0.2, anyhow 1.0.104, async-channel 2.5.0, async-executor 1.14.1, async-fs 2.2.0, async-io 2.6.0, async-lock 3.4.2, async-net 2.0.0, async-process 2.5.0, async-recursion 1.1.1, async-signal 0.2.14, async-task 4.7.1, async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, cap-primitives 4.0.3, concurrent-queue 2.5.0, curve25519-dalek-derive 0.1.1, displaydoc 0.2.7, dyn-clone 1.0.20, endi 1.1.1, enumn 0.1.14, erased-serde 0.4.10, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flume 0.12.0, fs-set-times 0.20.3, futures-lite 2.6.1, group 0.14.0, home 0.5.12, inventory 0.3.24, io-extras 0.19.0, io-lifetimes 2.0.4, io-lifetimes 3.0.1, itoa 1.0.18, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, minimal-lexical 0.2.1, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, paste 1.0.15, pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, prettyplease 0.2.37, proc-macro-crate 3.5.0, proc-macro2 1.0.107, process-wrap 9.1.1, prost-reflect 0.16.5, protox 0.10.0, protox-parse 0.10.0, quote 1.0.47, ref-cast 1.0.27, ref-cast-impl 1.0.27, rsa 0.10.0-rc.18, rustc-hash 1.1.0, rustc-hash 2.1.3, rustix 0.38.44, rustix 1.1.5, rustix-linux-procfs 0.1.1, rustix-openpty 0.2.0, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde-saphyr 1.3.0, serde_bytes 0.11.19, serde_core 1.0.229, serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json 1.0.151, serde_json_lenient 0.2.4, serde_repr 0.1.21, smol 2.0.2, smol_str 0.3.6, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typeid 1.0.3, unicode-ident 1.0.26, utf-8 0.7.6, waker-fn 1.2.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zed-font-kit 0.14.1-zed, zmij 1.0.23, zvariant_utils 4.2.0 License: MIT Used by: allocator-api2 0.2.21, glow 0.17.0 Original notice: accesskit@0.24.1 / upstream/AccessKit-accesskit-license-mit-23f18e03dc49.txt @@ -31906,6 +32977,7 @@ Original notice: accesskit_atspi_common@0.19.1, accesskit_consumer@0.38.0, acces Source: https://raw.githubusercontent.com/AccessKit/accesskit/c88605b96d04431f9c3c792464a0f2f253480e94/LICENSE-MIT Original notice: adler2 2.0.1 / LICENSE-MIT Original notice: allocator-api2 0.2.21 / LICENSE-MIT +Original notice: ambient-authority 0.0.2 / LICENSE-MIT Original notice: anyhow 1.0.104 / LICENSE-MIT Original notice: async-channel 2.5.0 / LICENSE-MIT Original notice: async-executor 1.14.1 / LICENSE-MIT @@ -31920,6 +32992,7 @@ Original notice: async-task 4.7.1 / LICENSE-MIT Original notice: async-trait 0.1.92 / LICENSE-MIT Original notice: atomic-waker 1.1.2 / LICENSE-MIT Original notice: blocking 1.7.0 / LICENSE-MIT +Original notice: cap-primitives 4.0.3 / LICENSE-MIT Original notice: concurrent-queue 2.5.0 / LICENSE-MIT Original notice: curve25519-dalek-derive 0.1.1 / LICENSE-MIT Original notice: displaydoc 0.2.7 / LICENSE-MIT @@ -31931,11 +33004,15 @@ Original notice: event-listener 5.4.2 / LICENSE-MIT Original notice: event-listener-strategy 0.5.4 / LICENSE-MIT Original notice: fastrand 2.5.0 / LICENSE-MIT Original notice: flume 0.12.0 / LICENSE-MIT +Original notice: fs-set-times 0.20.3 / LICENSE-MIT Original notice: futures-lite 2.6.1 / LICENSE-MIT Original notice: glow 0.17.0 / LICENSE-MIT Original notice: group 0.14.0 / LICENSE-MIT Original notice: home 0.5.12 / LICENSE-MIT Original notice: inventory 0.3.24 / LICENSE-MIT +Original notice: io-extras 0.19.0 / LICENSE-MIT +Original notice: io-lifetimes 2.0.4 / LICENSE-MIT +Original notice: io-lifetimes 3.0.1 / LICENSE-MIT Original notice: itoa 1.0.18 / LICENSE-MIT Original notice: khronos-egl 6.0.0 / LICENSE-MIT Original notice: lazy_static 1.5.1 / LICENSE-MIT @@ -31966,6 +33043,7 @@ Original notice: rustc-hash 1.1.0 / LICENSE-MIT Original notice: rustc-hash 2.1.3 / LICENSE-MIT Original notice: rustix 0.38.44 / LICENSE-MIT Original notice: rustix 1.1.5 / LICENSE-MIT +Original notice: rustix-linux-procfs 0.1.1 / LICENSE-MIT Original notice: rustix-openpty 0.2.0 / LICENSE-MIT Original notice: rustversion 1.0.23 / LICENSE-MIT Original notice: semver 1.0.28 / LICENSE-MIT @@ -32086,6 +33164,15 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +======================================================================== +Original notice: prikk-ffi@0.56.0 / upstream/prikk-ffi-0.56.0-NOTICE +Source: https://raw.githubusercontent.com/prikk-vcs/prikk/808126bd207edb37142a1a59131656cf555cc30e/NOTICE + +Prikk +Copyright 2026 nabbisen + +This product is licensed under the Apache License, Version 2.0. + ======================================================================== Bundled component: rust/COPYRIGHT-library.txt Original notice: / supplemental/rust/COPYRIGHT-library.txt @@ -60665,6 +61752,172 @@ adapted from the Windows™ version of the new() method in watchexec at src/process.rs, copyright Matt Green, licensed under Apache 2.0 only. +======================================================================== +Original notice: ambient-authority 0.0.2 / COPYRIGHT + +Short version for non-lawyers: + +`ambient-authority` is triple-licensed under Apache 2.0 with the LLVM Exception, +Apache 2.0, and MIT terms. + + +Longer version: + +Copyrights in the `ambient-authority` project are retained by their contributors. +No copyright assignment is required to contribute to the `ambient-authority` +project. + +Some files include code derived from Rust's `libstd`; see the comments in +the code for details. + +Except as otherwise noted (below and/or in individual files), `ambient-authority` +is licensed under: + + - the Apache License, Version 2.0, with the LLVM Exception + or + + - the Apache License, Version 2.0 + or + , + - or the MIT license + or + , + +at your option. + +======================================================================== +Original notice: cap-primitives 4.0.3 / COPYRIGHT + +Short version for non-lawyers: + +`cap-primitives` is triple-licensed under Apache 2.0 with the LLVM Exception, +Apache 2.0, and MIT terms. + + +Longer version: + +Copyrights in the `cap-primitives` project are retained by their contributors. +No copyright assignment is required to contribute to the `cap-primitives` +project. + +Some files include code derived from Rust's `libstd`; see the comments in +the code for details. + +Except as otherwise noted (below and/or in individual files), `cap-primitives` +is licensed under: + + - the Apache License, Version 2.0, with the LLVM Exception + or + + - the Apache License, Version 2.0 + or + , + - or the MIT license + or + , + +at your option. + +======================================================================== +Original notice: fs-set-times 0.20.3 / COPYRIGHT + +Short version for non-lawyers: + +`fs-set-times` is triple-licensed under Apache 2.0 with the LLVM Exception, +Apache 2.0, and MIT terms. + + +Longer version: + +Copyrights in the `fs-set-times` project are retained by their contributors. +No copyright assignment is required to contribute to the `fs-set-times` +project. + +Some files include code derived from Rust's `libstd`; see the comments in +the code for details. + +Except as otherwise noted (below and/or in individual files), `fs-set-times` +is licensed under: + + - the Apache License, Version 2.0, with the LLVM Exception + or + + - the Apache License, Version 2.0 + or + , + - or the MIT license + or + , + +at your option. + +======================================================================== +Original notice: io-extras 0.19.0 / COPYRIGHT + +Short version for non-lawyers: + +`io-extras` is triple-licensed under Apache 2.0 with the LLVM Exception, +Apache 2.0, and MIT terms. + + +Longer version: + +Copyrights in the `io-extras` project are retained by their contributors. +No copyright assignment is required to contribute to the `io-extras` +project. + +Some files include code derived from Rust's `libstd`; see the comments in +the code for details. + +Except as otherwise noted (below and/or in individual files), `io-extras` +is licensed under: + + - the Apache License, Version 2.0, with the LLVM Exception + or + + - the Apache License, Version 2.0 + or + , + - or the MIT license + or + , + +at your option. + +======================================================================== +Original notice: io-lifetimes 2.0.4 / COPYRIGHT +Original notice: io-lifetimes 3.0.1 / COPYRIGHT + +Short version for non-lawyers: + +`io-lifetimes` is triple-licensed under Apache 2.0 with the LLVM Exception, +Apache 2.0, and MIT terms. + + +Longer version: + +Copyrights in the `io-lifetimes` project are retained by their contributors. +No copyright assignment is required to contribute to the `io-lifetimes` +project. + +Some files include code derived from Rust's `libstd`; see the comments in +the code for details. + +Except as otherwise noted (below and/or in individual files), `io-lifetimes` +is licensed under: + + - the Apache License, Version 2.0, with the LLVM Exception + or + + - the Apache License, Version 2.0 + or + , + - or the MIT license + or + , + +at your option. + ======================================================================== Original notice: linux-raw-sys 0.12.1 / COPYRIGHT Original notice: linux-raw-sys 0.4.15 / COPYRIGHT @@ -60699,6 +61952,39 @@ is licensed under: at your option. +======================================================================== +Original notice: rustix-linux-procfs 0.1.1 / COPYRIGHT + +Short version for non-lawyers: + +`rustix-linux-procfs` is triple-licensed under Apache 2.0 with the LLVM Exception, +Apache 2.0, and MIT terms. + + +Longer version: + +Copyrights in the `rustix-linux-procfs` project are retained by their contributors. +No copyright assignment is required to contribute to the `rustix-linux-procfs` +project. + +Some files include code derived from Rust's `libstd`; see the comments in +the code for details. + +Except as otherwise noted (below and/or in individual files), `rustix-linux-procfs` +is licensed under: + + - the Apache License, Version 2.0, with the LLVM Exception + or + + - the Apache License, Version 2.0 + or + , + - or the MIT license + or + , + +at your option. + ======================================================================== Original notice: rustix-openpty 0.2.0 / COPYRIGHT @@ -61343,8 +62629,9 @@ SOFTWARE. ======================================================================== License: MIT -Used by: nix 0.28.0, nix 0.31.3 +Used by: nix 0.28.0, nix 0.29.0, nix 0.31.3 Original notice: nix 0.28.0 / LICENSE +Original notice: nix 0.29.0 / LICENSE Original notice: nix 0.31.3 / LICENSE The MIT License (MIT) @@ -62258,6 +63545,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +======================================================================== +License: MIT +Used by: aliasable 0.1.3 +Original notice: aliasable 0.1.3 / LICENSE + +The MIT License (MIT) + +Copyright (c) 2020 James Dyson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ======================================================================== License: MIT Used by: etagere 0.2.15 @@ -62591,6 +63905,56 @@ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +======================================================================== +License: MIT +Used by: proc-macro2-diagnostics 0.10.1 +Original notice: proc-macro2-diagnostics 0.10.1 / LICENSE-MIT + +The MIT License (MIT) +Copyright (c) 2016-2020 Sergio Benitez + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +======================================================================== +License: MIT +Used by: yansi 1.0.1 +Original notice: yansi 1.0.1 / LICENSE-MIT + +The MIT License (MIT) +Copyright (c) 2017 Sergio Benitez + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ======================================================================== License: MIT Used by: version_check 0.9.5 diff --git a/about.toml b/about.toml index 4acc8c70..a611786e 100644 --- a/about.toml +++ b/about.toml @@ -1,6 +1,7 @@ # cargo-about 0.9.2: licenses selected for the shipped application and vault broker. # Native component and asset notices are preserved by scripts/generate-license-notices.py. -accepted = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "BSD-1-Clause", "ISC", "0BSD", "CC0-1.0", "Unicode-3.0", "Zlib", "MPL-2.0", "CDLA-Permissive-2.0", "NCSA", "bzip2-1.0.6", "BSL-1.0", "Unlicense", "OpenSSL"] +# winx uses the Apache license with the additional LLVM exception. +accepted = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "BSD-1-Clause", "ISC", "0BSD", "CC0-1.0", "Unicode-3.0", "Zlib", "MPL-2.0", "CDLA-Permissive-2.0", "NCSA", "bzip2-1.0.6", "BSL-1.0", "Unlicense", "OpenSSL"] targets = ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc", "x86_64-apple-darwin", "aarch64-apple-darwin"] ignore-dev-dependencies = true private = { ignore = true } diff --git a/crates/acp/Cargo.toml b/crates/acp/Cargo.toml index 07a1b8c3..231f5377 100644 --- a/crates/acp/Cargo.toml +++ b/crates/acp/Cargo.toml @@ -22,6 +22,7 @@ serde_json.workspace = true getrandom.workspace = true thiserror.workspace = true subtle = "2" +tracing.workspace = true [target.'cfg(unix)'.dependencies] nix = { version = "0.28", features = ["signal", "process"] } diff --git a/crates/acp/src/bridge.rs b/crates/acp/src/bridge.rs index 12647bd9..96a0ddf3 100644 --- a/crates/acp/src/bridge.rs +++ b/crates/acp/src/bridge.rs @@ -169,6 +169,11 @@ impl ToolBridge for BridgeServer { fn stop(&self) { self.inner.generation.fetch_add(1, Ordering::AcqRel); let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner()); + tracing::debug!( + registrations = state.registrations.len(), + connections = state.sockets.len(), + "Stopping MCP bridge" + ); for (_, registration) in std::mem::take(&mut state.registrations) { close_registration(registration); } @@ -263,7 +268,18 @@ fn start_listening(inner: &Arc, state: &mut State) -> Result tracing::debug!( + connection_id, + "MCP bridge connection closed" + ), + Err(error) => tracing::warn!( + connection_id, + kind = ?error.kind(), + os_code = error.raw_os_error(), + "MCP bridge connection failed" + ), + } }); } Err(e) if e.kind() == io::ErrorKind::WouldBlock => { @@ -372,6 +388,11 @@ fn revoke(inner: &Inner, id: u64) { .registrations .remove(&id); if let Some(registration) = registration { + tracing::debug!( + registration_id = id, + connections = registration.sockets.len(), + "Revoking MCP bridge registration" + ); close_registration(registration); } } @@ -419,6 +440,9 @@ pub(crate) fn bounded_line(reader: &mut R, limit: usize) -> io::Resu } fn serve(socket: Socket, inner: Arc, epoch: u64, connection_id: u64) -> io::Result<()> { + // Windows accepted sockets inherit the listener's nonblocking mode. This + // worker uses blocking reads, including while an authenticated client idles. + socket.set_nonblocking(false)?; socket.set_read_timeout(Some(Duration::from_secs(5)))?; socket.set_write_timeout(Some(Duration::from_secs(5)))?; let mut reader = BufReader::new(socket.try_clone()?); @@ -473,7 +497,15 @@ fn serve(socket: Socket, inner: Arc, epoch: u64, connection_id: u64) -> i registration.writers.insert(connection_id, writer.clone()); (*id, registration.cancelled.clone(), writer) }; - let result = socket + tracing::debug!( + connection_id, + registration_id = id, + "MCP bridge authenticated" + ); + // The authentication deadline belongs to the handle doing the reads. + // Clear it there, without discarding any pipelined bytes in this buffer. + let result = reader + .get_ref() .set_read_timeout(None) .and_then(|()| serve_authenticated(&writer, &mut reader, &inner, id, cancel)); let mut state = inner.state.lock().unwrap_or_else(|e| e.into_inner()); @@ -562,5 +594,5 @@ fn serve_authenticated( Ok(()) } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests; diff --git a/crates/acp/src/bridge/tests.rs b/crates/acp/src/bridge/tests.rs index ccf65df4..d9509ff2 100644 --- a/crates/acp/src/bridge/tests.rs +++ b/crates/acp/src/bridge/tests.rs @@ -1,8 +1,10 @@ use super::*; +#[cfg(unix)] use std::io::Read; /// Reads like `Read::read`, retrying when a signal meant for another /// test (a reaped child, say) interrupts the timed socket read. +#[cfg(unix)] fn read_uninterrupted(socket: &mut Socket, buf: &mut [u8]) -> io::Result { loop { match socket.read(buf) { @@ -71,12 +73,20 @@ fn refuses_a_registration_whose_relay_program_is_gone() { assert!(error.contains("nocterm (deleted)"), "{error}"); assert!(error.contains("Restart nocterm"), "{error}"); } -fn authenticated(registration: &BridgeRegistration) -> (Socket, BufReader) { - let mut socket = - Socket::connect(endpoint(registration).strip_prefix("unix:").unwrap()).unwrap(); +fn connected(registration: &BridgeRegistration) -> Socket { + let endpoint = endpoint(registration); + let address = endpoint + .strip_prefix("unix:") + .or_else(|| endpoint.strip_prefix("tcp:")) + .unwrap(); + let socket = Socket::connect(address).unwrap(); socket .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); + socket +} +fn authenticated(registration: &BridgeRegistration) -> (Socket, BufReader) { + let mut socket = connected(registration); writeln!( socket, "{}", @@ -86,7 +96,7 @@ fn authenticated(registration: &BridgeRegistration) -> (Socket, BufReader) { +fn initialize(socket: &mut impl Write, reader: &mut impl BufRead) { writeln!(socket,"{}",serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1"}}})).unwrap(); let response: Value = serde_json::from_str(&bounded_line(reader, 1024 * 1024).unwrap().unwrap()).unwrap(); @@ -95,16 +105,19 @@ fn initialize(socket: &mut Socket, reader: &mut BufReader) { } #[test] fn authenticates_routes_and_revokes_pending_calls() { - use std::os::unix::fs::PermissionsExt as _; let dir = tempfile::tempdir().unwrap(); let server = bridge(&dir); let mut registration = server.register().unwrap(); - let endpoint = endpoint(®istration); - let path = endpoint.strip_prefix("unix:").unwrap(); - assert_eq!( - std::fs::metadata(path).unwrap().permissions().mode() & 0o777, - 0o600 - ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let endpoint = endpoint(®istration); + let path = endpoint.strip_prefix("unix:").unwrap(); + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } let (mut socket, mut reader) = authenticated(®istration); initialize(&mut socket, &mut reader); writeln!(socket,"{}",serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_terminals","arguments":{}}})).unwrap(); @@ -138,7 +151,7 @@ fn authenticates_routes_and_revokes_pending_calls() { } assert!(pending.respond.is_canceled()); } -fn listed(socket: &mut Socket, reader: &mut BufReader, id: u64) -> Vec { +fn listed(socket: &mut impl Write, reader: &mut impl BufRead, id: u64) -> Vec { writeln!( socket, "{}", @@ -147,6 +160,7 @@ fn listed(socket: &mut Socket, reader: &mut BufReader, id: u64) -> Vec, id: u64) -> Vec ( + std::net::TcpStream, + BufReader, + std::thread::JoinHandle>, +) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let socket = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (pipe, _) = listener.accept().unwrap(); + let endpoint = endpoint(registration); + let token = token(registration); + let relay = + std::thread::spawn(move || crate::run_relay(pipe.try_clone()?, pipe, &endpoint, &token)); + let reader = BufReader::new(socket.try_clone().unwrap()); + (socket, reader, relay) +} + +fn next_call(server: &BridgeServer) -> BridgeCall { + futures::executor::block_on(async { + let calls = server.calls(); + match select( + Box::pin(calls.recv()), + Box::pin(async_io::Timer::after(Duration::from_secs(2))), + ) + .await + { + Either::Left((call, _)) => call.expect("bridge routes the tool call"), + Either::Right(_) => panic!("bridge did not route the tool call before the deadline"), + } + }) +} + +#[test] +fn stdio_tools_survive_idle_beyond_authentication_timeout_and_refresh() { + let dir = tempfile::tempdir().unwrap(); + let server = bridge(&dir); + let registration = server.register().unwrap(); + for _ in 0..2 { + let (mut socket, mut reader, relay) = relayed(®istration); + initialize(&mut socket, &mut reader); + assert!(listed(&mut socket, &mut reader, 2).contains(&"list_terminals".to_owned())); + // Authentication's five-second deadline must not limit an established session. + std::thread::sleep(Duration::from_secs(6)); + writeln!( + socket, + "{}", + serde_json::json!({"jsonrpc":"2.0","id":3,"method":"ping"}) + ) + .unwrap(); + let ping: Value = serde_json::from_str( + &bounded_line(&mut reader, 1024) + .unwrap() + .expect("idle relay stays connected"), + ) + .unwrap(); + assert_eq!(ping["id"], 3); + assert_eq!(ping["result"], serde_json::json!({})); + assert!(listed(&mut socket, &mut reader, 4).contains(&"list_terminals".to_owned())); + writeln!(socket, "{}", serde_json::json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"list_terminals","arguments":{}}})).unwrap(); + let call = next_call(&server); + assert_eq!(call.registration_id, registration.id); + assert!(matches!(call.call, nocterm_ai::TerminalCall::ListTerminals)); + call.respond + .send(Ok(serde_json::json!([{"id":"t1"}]))) + .unwrap(); + let reply: Value = + serde_json::from_str(&bounded_line(&mut reader, 4096).unwrap().unwrap()).unwrap(); + assert_eq!(reply["id"], 5); + assert!( + reply["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("t1") + ); + socket.shutdown(std::net::Shutdown::Write).unwrap(); + assert!(bounded_line(&mut reader, 4096).unwrap().is_none()); + relay.join().unwrap().unwrap(); + } +} + +#[test] +fn pipelined_authentication_keeps_the_buffered_initialize_request() { + let dir = tempfile::tempdir().unwrap(); + let server = bridge(&dir); + let registration = server.register().unwrap(); + let mut socket = connected(®istration); + let input = format!( + "{}\n{}\n", + serde_json::json!({"token":token(®istration)}), + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}) + ); + socket.write_all(input.as_bytes()).unwrap(); + let mut reader = BufReader::new(socket.try_clone().unwrap()); + let reply: Value = + serde_json::from_str(&bounded_line(&mut reader, 4096).unwrap().unwrap()).unwrap(); + assert_eq!(reply["id"], 1); + assert_eq!(reply["result"]["serverInfo"]["name"], "nocterm"); + assert!(listed(&mut socket, &mut reader, 2).contains(&"list_terminals".to_owned())); +} + +#[test] +fn unauthenticated_connections_still_expire() { + let dir = tempfile::tempdir().unwrap(); + let server = bridge(&dir); + let registration = server.register().unwrap(); + let socket = connected(®istration); + socket + .set_read_timeout(Some(Duration::from_secs(8))) + .unwrap(); + let mut reader = BufReader::new(socket); + assert!(bounded_line(&mut reader, 4096).unwrap().is_none()); +} diff --git a/crates/acp/src/client.rs b/crates/acp/src/client.rs index 528461a3..d6eb3e3c 100644 --- a/crates/acp/src/client.rs +++ b/crates/acp/src/client.rs @@ -13,6 +13,12 @@ mod remote_errors; mod startup; mod stderr_tail; +#[cfg(all(test, unix))] +use nocterm_ai::model::{ + controls::{ConfigKind, ConfigSetting}, + ids::{ConfigChoiceId, ConfigId, SessionId}, + session::{PromptRequest, RestoreSession, SessionOpen, SetConfigOption}, +}; /// Names the behavior tests exercise through `use super::*`. #[cfg(test)] use { @@ -23,12 +29,7 @@ use { handlers::client_builder, nocterm_ai::{ AgentEvent, - model::{ - auth::PermissionOutcome, - controls::{ConfigKind, ConfigSetting}, - ids::{ConfigChoiceId, ConfigId, SessionId}, - session::{PromptRequest, RestoreSession, SessionOpen, SessionUpdate, SetConfigOption}, - }, + model::{auth::PermissionOutcome, session::SessionUpdate}, }, remote_errors::{map_error, map_error_with, restore_error}, std::{sync::Arc, time::Duration}, diff --git a/crates/acp/src/client/tests.rs b/crates/acp/src/client/tests.rs index 25fe9227..3d7d11cb 100644 --- a/crates/acp/src/client/tests.rs +++ b/crates/acp/src/client/tests.rs @@ -2,6 +2,7 @@ use super::*; use agent_client_protocol::{Channel, schema::ProtocolVersion}; use serde_json::json; +#[cfg(unix)] fn setting(session: SessionId, config: ConfigId, value: &str) -> SetConfigOption { SetConfigOption { session, diff --git a/crates/acp/src/lib.rs b/crates/acp/src/lib.rs index bc230dcd..f5c60d4d 100644 --- a/crates/acp/src/lib.rs +++ b/crates/acp/src/lib.rs @@ -10,6 +10,8 @@ //! Invariants: SDK types never leave this crate; relay registrations authenticate callers; revocation closes owned sockets. //! The bridge lists and admits only the tools a registration's chat offers, and tells its //! connected agents when the offer changes without blocking the chat. +//! Authentication deadlines are cleared on the retained reader before serving MCP; +//! connection diagnostics contain only opaque IDs and native error classifications. //! Windows agents and taskkill start without a console window; bare commands fall back to //! the usual Node.js locations after PATH (ADR 0163). //! Native taskkill diagnostics retain the original I/O error through shared typed causes; diff --git a/crates/acp/src/workspace/tests.rs b/crates/acp/src/workspace/tests.rs index ff63c1a6..c28e61a8 100644 --- a/crates/acp/src/workspace/tests.rs +++ b/crates/acp/src/workspace/tests.rs @@ -1,9 +1,8 @@ use super::*; use futures::executor::block_on; -use nocterm_ai::{ - sandbox::{PathKind, SandboxLaunchError}, - workspace::IsolationPaths, -}; +#[cfg(unix)] +use nocterm_ai::sandbox::{PathKind, SandboxLaunchError}; +use nocterm_ai::workspace::IsolationPaths; fn request(path: &std::path::Path, private: bool, isolated: bool) -> WorkdirRequest { WorkdirRequest { candidate: path.to_owned(), diff --git a/crates/agent-chat/src/thread/client.rs b/crates/agent-chat/src/thread/client.rs index 07d5965a..382e6af4 100644 --- a/crates/agent-chat/src/thread/client.rs +++ b/crates/agent-chat/src/thread/client.rs @@ -702,9 +702,11 @@ impl AgentThread { cx: &mut Context, ) { let message = super::save_failure::message(&error); + let diagnostic = nocterm_ai::redact::redact(&format!("{error:?}")); if !self.state.failed(revision, error) { return; } + tracing::warn!(error = %diagnostic, "could not save chat"); self.notice.present(message); self.composer.pause(); self.notice.mark_error(); diff --git a/crates/agent-chat/src/thread/document/tests.rs b/crates/agent-chat/src/thread/document/tests.rs index 11ff966b..97956b4d 100644 --- a/crates/agent-chat/src/thread/document/tests.rs +++ b/crates/agent-chat/src/thread/document/tests.rs @@ -121,12 +121,13 @@ fn empty_matching_chunks_identical_tool_patches_and_view_updates_are_clean() { } #[test] -fn a_new_empty_row_is_durable_but_a_patch_outside_the_saved_window_is_not() { +fn a_patch_beyond_the_old_saved_window_and_a_new_empty_row_are_durable() { let mut entries = vec![Entry::Tool(ToolRecord::new("old", "Old"))]; entries.extend((0..2000).map(|_| Entry::Agent("saved".into()))); let mut document = imported(entries); document.apply_transcript(update(json!({"sessionUpdate":"tool_call_update","toolCallId":"old","title":"Changed old row"})),80).unwrap(); - assert_eq!(document.desired_revision(), 0); + assert_eq!(document.desired_revision(), 1); + assert_eq!(document.updated(), 80); document .apply_transcript( update( @@ -135,7 +136,7 @@ fn a_new_empty_row_is_durable_but_a_patch_outside_the_saved_window_is_not() { 81, ) .unwrap(); - assert_eq!(document.desired_revision(), 1); + assert_eq!(document.desired_revision(), 2); assert_eq!(document.updated(), 81); } diff --git a/crates/agent-chat/src/thread/save_failure.rs b/crates/agent-chat/src/thread/save_failure.rs index ce5a4cab..f5cb447a 100644 --- a/crates/agent-chat/src/thread/save_failure.rs +++ b/crates/agent-chat/src/thread/save_failure.rs @@ -1,26 +1,44 @@ -//! Guidance shown when a chat snapshot could not be saved. Queued messages stay -//! queued in every case; only the advice depends on why the save failed. +//! Recovery guidance for typed chat-save and history-load failures. use crate::runtime::{AgentPersistenceError, PersistenceFailure}; +use nocterm_ai::storage::AgentStorageError; pub(super) fn message(error: &AgentPersistenceError) -> String { - match PersistenceFailure::of(error) { - PersistenceFailure::Unsupported => format!( - "Could not save chat: {error}. This is a limitation of the history storage on this \ - system, not a disk or agent problem. Queued messages are retained and are not sent \ - until the chat can be saved." - ), - PersistenceFailure::StorageFull => format!( - "Could not save chat: the disk holding chat history is full ({error}). Queued \ - messages are retained; free disk space before continuing." - ), - PersistenceFailure::AccessDenied => format!( - "Could not save chat: access to the chat history folder was denied ({error}). Queued \ - messages are retained; check the folder's permissions before continuing." - ), - PersistenceFailure::Write => format!( - "Could not save chat: {error}. Queued messages are retained and are sent once the \ - chat has been saved." - ), + let failure = PersistenceFailure::of(error); + if let Some(guidance) = history_guidance(failure) { + return format!("Could not save chat: {guidance} Queued messages are retained."); + } + let recovery = match failure { + PersistenceFailure::Unsupported => { + "this is a limitation of the history storage: this build lacks the native storage support needed for this chat. Update Nocterm before retrying." + } + PersistenceFailure::StorageFull => "free disk space before retrying.", + PersistenceFailure::AccessDenied => { + "allow access to the chat storage directory before retrying." + } + _ => "resolve the storage error before retrying.", + }; + format!("Could not save chat: {error}. Queued messages are retained; {recovery}") +} + +pub(super) fn load_failure(error: &AgentStorageError) -> String { + if let Some(guidance) = history_guidance(PersistenceFailure::of_storage(error)) { + return format!("Could not open saved chat: {guidance}"); + } + format!("Could not open saved chat: {error}") +} + +fn history_guidance(failure: PersistenceFailure) -> Option<&'static str> { + match failure { + PersistenceFailure::HistoryMemory => { + Some("history needs more memory. Close an unused chat and try again.") + } + PersistenceFailure::HistoryBusy => { + Some("history is busy. Close an unused chat or try again shortly.") + } + PersistenceFailure::ChatLimit => { + Some("this conversation is too large. Start a new chat to continue.") + } + _ => None, } } @@ -52,3 +70,6 @@ mod tests { } } } + +#[cfg(test)] +mod guidance_tests; diff --git a/crates/agent-chat/src/thread/save_failure/guidance_tests.rs b/crates/agent-chat/src/thread/save_failure/guidance_tests.rs new file mode 100644 index 00000000..cd14428d --- /dev/null +++ b/crates/agent-chat/src/thread/save_failure/guidance_tests.rs @@ -0,0 +1,100 @@ +use super::message as persistence_failure; +use super::*; +use nocterm_ai::{history::ContextId, local_environment::LocalEnvironment}; +use nocterm_session::local_fs::LocalFsError; +use std::{error::Error, io, path::PathBuf, sync::Arc}; + +fn snapshot_failure(error: impl Error + Send + Sync + 'static) -> AgentPersistenceError { + nocterm_store::StoreError::backend(error).into() +} + +#[test] +fn unsupported_storage_reports_missing_capability_instead_of_disk_space() { + for error in [ + AgentStorageError::EnvironmentUnsupported(Arc::new(LocalEnvironment::ThisComputer)), + AgentStorageError::ScopeUnsupported(ContextId::General), + ] { + for failure in [error.clone().into(), snapshot_failure(error)] { + let message = persistence_failure(&failure); + assert!( + message.contains("lacks the native storage support"), + "{message}" + ); + assert!( + message.contains("Queued messages are retained"), + "{message}" + ); + assert!(!message.contains("disk space"), "{message}"); + } + } +} + +#[test] +fn full_storage_reports_disk_space_from_the_preserved_native_cause() { + for kind in [io::ErrorKind::StorageFull, io::ErrorKind::QuotaExceeded] { + let native = LocalFsError::from_io("chat", io::Error::from(kind)); + let message = persistence_failure(&snapshot_failure(AgentStorageError::from(native))); + assert!( + message.contains("free disk space before retrying"), + "{message}" + ); + assert!( + message.contains("Queued messages are retained"), + "{message}" + ); + } +} + +#[test] +fn denied_storage_reports_access_and_other_write_failures_keep_the_cause() { + let denied = AgentStorageError::from(LocalFsError::PermissionDenied(PathBuf::from("chat"))); + let message = persistence_failure(&snapshot_failure(denied)); + assert!( + message.contains("allow access to the chat storage directory"), + "{message}" + ); + assert!(!message.contains("disk space"), "{message}"); + + let native = LocalFsError::from_io("chat", io::Error::other("snapshot interrupted")); + let message = persistence_failure(&snapshot_failure(AgentStorageError::from(native))); + assert!(message.contains("snapshot interrupted"), "{message}"); + assert!( + message.contains("resolve the storage error before retrying"), + "{message}" + ); + assert!(!message.contains("disk space"), "{message}"); +} + +#[test] +fn history_pressure_gives_recovery_without_internal_byte_counts() { + let cases = [ + ( + AgentStorageError::ResidentCapacity { + retained: 65_410_245, + incoming: 14_188_544, + limit: 66_060_288, + }, + "history needs more memory. Close an unused chat and try again.", + ), + ( + AgentStorageError::PayloadCapacity, + "history is busy. Close an unused chat or try again shortly.", + ), + ( + nocterm_ai::history::HistoryCodecError::StructuralLimit.into(), + "this conversation is too large. Start a new chat to continue.", + ), + ]; + for (error, guidance) in cases { + assert_eq!( + load_failure(&error), + format!("Could not open saved chat: {guidance}") + ); + for failure in [error.clone().into(), snapshot_failure(error)] { + assert_eq!( + persistence_failure(&failure), + format!("Could not save chat: {guidance} Queued messages are retained.") + ); + } + } +} diff --git a/crates/agent-chat/src/thread/saved.rs b/crates/agent-chat/src/thread/saved.rs index be2c6eba..5accaacb 100644 --- a/crates/agent-chat/src/thread/saved.rs +++ b/crates/agent-chat/src/thread/saved.rs @@ -440,8 +440,8 @@ impl AgentThread { match self.archive.accept(load_id, current, result, permit) { archive::LoadOutcome::Stale => return, archive::LoadOutcome::Failed(error) => { - self.notice - .present(format!("Could not load saved chat: {error}")); + tracing::warn!(error = %nocterm_ai::redact::redact(&format!("{error:?}")), "could not load saved chat"); + self.notice.present(super::save_failure::load_failure(&error)); self.notice.mark_error(); } archive::LoadOutcome::Hydrate { diff --git a/crates/agent-runtime/src/catalog.rs b/crates/agent-runtime/src/catalog.rs index db338793..42f71c64 100644 --- a/crates/agent-runtime/src/catalog.rs +++ b/crates/agent-runtime/src/catalog.rs @@ -21,11 +21,9 @@ use prepared::PreparedRecords; pub enum CatalogLoadError { #[error("The original account source is unavailable: {0}")] AccountSource(#[from] nocterm_hubs_model::source_identity::LocalAccountSourceError), - #[error("All normal installation history payload slots are retained or loading.")] + #[error("Chat history is busy. Close an unused chat or try again shortly.")] Capacity, - #[error( - "Catalog metadata needs {incoming} bytes with {retained} retained; installation limit is {limit}." - )] + #[error("Chat history needs more memory. Close an unused chat and try again.")] ResidentCapacity { retained: usize, incoming: usize, @@ -493,10 +491,21 @@ mod tests { } #[test] fn prepared_records_reject_aggregate_structural_memory_without_trimming() { - let chats = (1..=33) + let entries = vec![ + nocterm_ai::thread::Entry::Agent(String::new()); + nocterm_ai::history::MAX_SAVED_ENTRIES + ]; + let mut first = chat(1); + first.entries = entries.clone(); + let resident = nocterm_ai::history::prepare_owned(first) + .unwrap() + .resident_bytes(); + let count = crate::history_admission::MAX_NORMAL_HISTORY_BYTES / resident + 1; + assert!(count <= nocterm_ai::history::MAX_RESTORED); + let chats = (1..=count) .map(|index| { let mut chat = chat(index); - chat.draft = Some("x".repeat(1024 * 1024)); + chat.entries = entries.clone(); chat }) .collect(); @@ -511,7 +520,7 @@ mod tests { #[test] fn prepared_records_reject_unbounded_rows_without_serialization_trimming() { let mut oversized = chat(1); - oversized.entries = (0..2001) + oversized.entries = (0..nocterm_ai::history::MAX_SAVED_ENTRIES + 1) .map(|_| nocterm_ai::thread::Entry::Agent("row".into())) .collect(); assert!(matches!( @@ -519,7 +528,7 @@ mod tests { Err(AgentStorageError::Codec(HistoryCodecError::CollectionLimit)) )); let mut oversized = chat(2); - oversized.times = vec![1; 2001]; + oversized.times = vec![1; nocterm_ai::history::MAX_SAVED_ENTRIES + 1]; assert!(matches!( PreparedHistory::new(vec![oversized]), Err(AgentStorageError::Codec(HistoryCodecError::CollectionLimit)) diff --git a/crates/agent-runtime/src/documents/history_tests/contexts/catalog_capacity.rs b/crates/agent-runtime/src/documents/history_tests/contexts/catalog_capacity.rs index f42ba662..dea9bcbe 100644 --- a/crates/agent-runtime/src/documents/history_tests/contexts/catalog_capacity.rs +++ b/crates/agent-runtime/src/documents/history_tests/contexts/catalog_capacity.rs @@ -1,6 +1,30 @@ //! Catalog generations, shared slots and retained metadata allocation. use super::*; -use crate::history_admission::HistoryAdmissionError; +use crate::history_admission::{HistoryAdmissionError, MAX_NORMAL_HISTORY_BYTES}; + +#[gpui::test] +fn catalogs_and_saved_chats_remain_available_with_a_busy_installation(cx: &mut TestAppContext) { + let (general, _directory, _, _, _) = scoped_fixture(cx); + cx.run_until_parked(); + let before = cx.update(|cx| general.catalog_snapshot(cx).unwrap()); + let admission = cx.update(|cx| general.history_admission(cx)); + // The reported failure had 57,307,727 bytes retained before a 14 MiB scan. + let retained = cx.update(|cx| admission.reserve_unowned_bytes(57_307_727, cx).unwrap()); + cx.update(|cx| general.try_reload_history(cx).unwrap()); + cx.run_until_parked(); + cx.update(|cx| { + let after = general.catalog_snapshot(cx).unwrap(); + assert!(after.generation() > before.generation()); + assert_eq!(after.rows().len(), before.rows().len()); + let selected_chat = admission.reserve_unowned_bytes(22_192_522, cx).unwrap(); + // Other open windows still have generous headroom, without allocating a buffer. + let other_windows = admission + .reserve_unowned_bytes(500 * 1024 * 1024, cx) + .unwrap(); + assert_eq!(retained.bytes(), 57_307_727); + drop((selected_chat, other_windows)); + }); +} #[gpui::test] fn retained_catalog_generations_consume_the_installation_limit_until_dropped( @@ -42,6 +66,15 @@ fn retained_catalog_generations_consume_the_installation_limit_until_dropped( #[gpui::test] fn pending_catalog_requests_share_the_same_installation_byte_capacity(cx: &mut TestAppContext) { let (general, _directory, repository, _, _) = scoped_fixture(cx); + let admission = cx.update(|cx| general.history_admission(cx)); + let _other_work = cx.update(|cx| { + admission + .reserve_unowned_bytes( + MAX_NORMAL_HISTORY_BYTES - 4 * crate::catalog::CATALOG_WORK_BYTES, + cx, + ) + .unwrap() + }); // General's initial scan already owns its worst-case metadata allocation, // including before the background work's first poll. let mut admitted = 1; @@ -162,7 +195,10 @@ fn replacing_a_loading_generation_keeps_its_native_read_budget_until_actual_comp let generation = latest.generation(); cx.update(|cx| { assert!(matches!( - admission.reserve_unowned_bytes(56 * 1024 * 1024, cx), + admission.reserve_unowned_bytes( + MAX_NORMAL_HISTORY_BYTES - crate::catalog::CATALOG_WORK_BYTES / 2, + cx + ), Err(HistoryAdmissionError::ResidentCapacity { .. }) )) }); @@ -175,7 +211,10 @@ fn replacing_a_loading_generation_keeps_its_native_read_budget_until_actual_comp cx.update(|cx| { drop( admission - .reserve_unowned_bytes(56 * 1024 * 1024, cx) + .reserve_unowned_bytes( + MAX_NORMAL_HISTORY_BYTES - crate::catalog::CATALOG_WORK_BYTES / 2, + cx, + ) .unwrap(), ) }); @@ -190,7 +229,10 @@ fn catalog_work_observes_the_same_retained_remote_bytes_before_native_start( let admission = cx.update(|cx| general.history_admission(cx)); let pending = cx.update(|cx| { admission - .reserve_unowned_bytes(56 * 1024 * 1024, cx) + .reserve_unowned_bytes( + MAX_NORMAL_HISTORY_BYTES - crate::catalog::CATALOG_WORK_BYTES / 2, + cx, + ) .unwrap() }); let attempts = repository.operations().len(); @@ -289,3 +331,35 @@ fn byte_pressure_reclaims_every_cold_scope_offered_by_one_catalog_owner(cx: &mut assert!(cx.update(|cx| project.catalog_snapshot(cx)).is_none()); drop(incoming); } +#[test] +fn memory_pressure_explains_recovery_and_preserves_technical_details() { + let errors: Vec> = vec![ + Box::new(crate::CatalogLoadError::ResidentCapacity { + retained: 65_410_245, + incoming: 14_188_544, + limit: 66_060_288, + }), + Box::new( + crate::history_admission::HistoryAdmissionError::ResidentCapacity { + retained: 65_410_245, + incoming: 14_188_544, + limit: 66_060_288, + }, + ), + Box::new(nocterm_ai::storage::AgentStorageError::ResidentCapacity { + retained: 65_410_245, + incoming: 14_188_544, + limit: 66_060_288, + }), + ]; + for error in errors { + assert_eq!( + error.to_string(), + "Chat history needs more memory. Close an unused chat and try again." + ); + let diagnostic = format!("{error:?}"); + for count in ["65410245", "14188544", "66060288"] { + assert!(diagnostic.contains(count), "{diagnostic}"); + } + } +} diff --git a/crates/agent-runtime/src/documents/history_tests/environments.rs b/crates/agent-runtime/src/documents/history_tests/environments.rs index 9adfe6b9..428eca36 100644 --- a/crates/agent-runtime/src/documents/history_tests/environments.rs +++ b/crates/agent-runtime/src/documents/history_tests/environments.rs @@ -188,7 +188,7 @@ fn all_account_context_views_consume_one_installation_catalog_budget(cx: &mut Te #[gpui::test] fn resident_history_budget_is_shared_across_accounts_and_windows(cx: &mut TestAppContext) { - const MIB: usize = 1024 * 1024; + let share = crate::history_admission::MAX_NORMAL_HISTORY_BYTES / 3; let (base, _directory, _, _, _) = contexts::scoped_fixture(cx); let a_factory = ScriptedLocalAccountFactory::default(); let b_factory = ScriptedLocalAccountFactory::default(); @@ -200,7 +200,7 @@ fn resident_history_budget_is_shared_across_accounts_and_windows(cx: &mut TestAp let requester = ProbeClient::new(cx, chat(&b, "B")); first .history - .set(Some(crate::HistoryUsage::new(48 * MIB, 1, true))); + .set(Some(crate::HistoryUsage::new(share * 2, 1, true))); first.allow_history_eviction.set(true); requester .history @@ -209,13 +209,13 @@ fn resident_history_budget_is_shared_across_accounts_and_windows(cx: &mut TestAp ownership::register_bound(&a, first.clone(), cx).unwrap(); ownership::register_bound(&b, requester.clone(), cx).unwrap(); assert!(matches!( - b.admit_history(requester.id(), 24 * MIB, cx), + b.admit_history(requester.id(), share * 2, cx), Err(AgentStorageError::ResidentCapacity { .. }) )); first .history - .set(Some(crate::HistoryUsage::new(48 * MIB, 1, false))); - b.admit_history(requester.id(), 24 * MIB, cx).unwrap(); + .set(Some(crate::HistoryUsage::new(share * 2, 1, false))); + b.admit_history(requester.id(), share * 2, cx).unwrap(); assert!(first.history.get().is_none()); }); } diff --git a/crates/agent-runtime/src/documents/history_tests/residency.rs b/crates/agent-runtime/src/documents/history_tests/residency.rs index 97bee38c..5b2a164a 100644 --- a/crates/agent-runtime/src/documents/history_tests/residency.rs +++ b/crates/agent-runtime/src/documents/history_tests/residency.rs @@ -1,13 +1,17 @@ //! One installation admits hydration across original contexts and windows. use super::{contexts::scoped_fixture, *}; use nocterm_ai::{ - history::{ContextId, MAX_RESIDENT_BYTES, SavedChat}, + history::{ContextId, SavedChat}, storage::AgentStorageError, }; const MIB: usize = 1024 * 1024; +// Logical charges exercise the quota without allocating enormous test buffers. +const SMALL_BYTES: usize = MAX_NORMAL_HISTORY_BYTES * 3 / 8; +const HALF_BYTES: usize = MAX_NORMAL_HISTORY_BYTES / 2; +const LARGE_BYTES: usize = MAX_NORMAL_HISTORY_BYTES * 5 / 8; use crate::{ HistoryUsage, - history_admission::{HistoryAdmissionError, testing::HistoryProbe}, + history_admission::{HistoryAdmissionError, MAX_NORMAL_HISTORY_BYTES, testing::HistoryProbe}, }; fn client_in(scope: ContextId, cx: &mut TestAppContext) -> Rc { @@ -26,10 +30,16 @@ fn client_in(scope: ContextId, cx: &mut TestAppContext) -> Rc { #[gpui::test] fn transferring_a_unique_buffer_releases_only_its_unused_reservation(cx: &mut TestAppContext) { let admission = HistoryProbe::installation(); - let reserved = cx.update(|cx| admission.reserve_unowned_bytes(32 * MIB, cx).unwrap()); + let reserved = cx.update(|cx| admission.reserve_unowned_bytes(HALF_BYTES, cx).unwrap()); let retained = HistoryProbe::transfer_buffer(&admission, reserved, MIB).unwrap(); assert_eq!(retained.bytes(), MIB); - cx.update(|cx| drop(admission.reserve_unowned_bytes(60 * MIB, cx).unwrap())); + cx.update(|cx| { + drop( + admission + .reserve_unowned_bytes(MAX_NORMAL_HISTORY_BYTES - 3 * MIB, cx) + .unwrap(), + ) + }); } #[gpui::test] @@ -37,20 +47,20 @@ fn a_shared_buffer_keeps_its_original_charge_until_shared_owners_release_it( cx: &mut TestAppContext, ) { let admission = HistoryProbe::installation(); - let reserved = cx.update(|cx| admission.reserve_unowned_bytes(32 * MIB, cx).unwrap()); + let reserved = cx.update(|cx| admission.reserve_unowned_bytes(HALF_BYTES, cx).unwrap()); let original = reserved.clone(); let retained = HistoryProbe::transfer_buffer(&admission, reserved, MIB).unwrap(); - assert_eq!(retained.bytes(), 32 * MIB); + assert_eq!(retained.bytes(), HALF_BYTES); cx.update(|cx| { assert!(matches!( - admission.reserve_unowned_bytes(40 * MIB, cx), + admission.reserve_unowned_bytes(LARGE_BYTES, cx), Err(HistoryAdmissionError::ResidentCapacity { .. }) )) }); drop(original); let retained = HistoryProbe::transfer_buffer(&admission, retained, MIB).unwrap(); assert_eq!(retained.bytes(), MIB); - cx.update(|cx| drop(admission.reserve_unowned_bytes(40 * MIB, cx).unwrap())); + cx.update(|cx| drop(admission.reserve_unowned_bytes(LARGE_BYTES, cx).unwrap())); } #[gpui::test] @@ -101,11 +111,11 @@ fn hydration_capacity_is_shared_by_contexts_and_shown_chats_in_other_windows_are let requester = client_in(c.scope(), cx); first .history - .set(Some(crate::HistoryUsage::new(24 * MIB, 1, true))); + .set(Some(crate::HistoryUsage::new(SMALL_BYTES, 1, true))); first.allow_history_eviction.set(true); second .history - .set(Some(crate::HistoryUsage::new(24 * MIB, 2, false))); + .set(Some(crate::HistoryUsage::new(SMALL_BYTES, 2, false))); requester .history .set(Some(crate::HistoryUsage::new(0, 3, false))); @@ -114,7 +124,7 @@ fn hydration_capacity_is_shared_by_contexts_and_shown_chats_in_other_windows_are ownership::register_bound(&b, second.clone(), cx).unwrap(); ownership::register_bound(&c, requester.clone(), cx).unwrap(); assert!(matches!( - c.admit_history(requester.id(), 24 * MIB, cx), + c.admit_history(requester.id(), SMALL_BYTES, cx), Err(AgentStorageError::ResidentCapacity { .. }) )); assert_eq!( @@ -125,7 +135,7 @@ fn hydration_capacity_is_shared_by_contexts_and_shown_chats_in_other_windows_are assert!(first.history.get().is_some()); assert!(second.history.get().is_some()); second.allow_history_eviction.set(true); - c.admit_history(requester.id(), 24 * MIB, cx).unwrap(); + c.admit_history(requester.id(), SMALL_BYTES, cx).unwrap(); assert!(first.history.get().is_some()); assert!(second.history.get().is_none()); assert!(matches!( @@ -156,7 +166,7 @@ fn remaining_prepared_payloads_count_until_their_claimed_handoff(cx: &mut TestAp ); cx.update(|cx| { ownership::register_bound(&general, client.clone(), cx).unwrap(); - let incoming = MAX_RESIDENT_BYTES - retained + 64 * 1024; + let incoming = MAX_NORMAL_HISTORY_BYTES - retained + 64 * 1024; assert!(matches!( general.admit_history(client.id(), incoming, cx), Err(AgentStorageError::ResidentCapacity { .. }) @@ -216,7 +226,7 @@ fn detached_active_and_latest_writer_payloads_remain_in_hydration_admission( assert!(matches!( project.admit_history( requester.id(), - MAX_RESIDENT_BYTES - retained + 64 * 1024, + MAX_NORMAL_HISTORY_BYTES - retained + 64 * 1024, cx ), Err(AgentStorageError::ResidentCapacity { .. }) @@ -230,7 +240,7 @@ fn detached_active_and_latest_writer_payloads_remain_in_hydration_admission( project .admit_history( requester.id(), - MAX_RESIDENT_BYTES - retained + 64 * 1024, + MAX_NORMAL_HISTORY_BYTES - retained + 64 * 1024, cx, ) .unwrap() @@ -248,10 +258,10 @@ fn remote_admission_evicts_hidden_local_history_and_local_admission_evicts_remot let hidden = client_in(hidden_scope.scope(), cx); protected .history - .set(Some(HistoryUsage::new(24 * MIB, 1, true))); + .set(Some(HistoryUsage::new(SMALL_BYTES, 1, true))); hidden .history - .set(Some(HistoryUsage::new(24 * MIB, 2, false))); + .set(Some(HistoryUsage::new(SMALL_BYTES, 2, false))); hidden.allow_history_eviction.set(true); let remote = HistoryProbe::default(); cx.update(|cx| { @@ -260,14 +270,14 @@ fn remote_admission_evicts_hidden_local_history_and_local_admission_evicts_remot let admission = general.history_admission(cx); let registration = admission.register(remote.participant()).unwrap(); let buffer = admission - .reserve_bytes(®istration, 24 * MIB, cx) + .reserve_bytes(®istration, SMALL_BYTES, cx) .unwrap(); assert!(hidden.history.get().is_none()); assert!(protected.history.get().is_some()); - remote.set_history(HistoryUsage::new(24 * MIB, 3, false), true); + remote.set_history(HistoryUsage::new(SMALL_BYTES, 3, false), true); drop(buffer); hidden_scope - .admit_history(hidden.id(), 24 * MIB, cx) + .admit_history(hidden.id(), SMALL_BYTES, cx) .unwrap(); assert!(remote.history().is_none()); assert_eq!(remote.evictions(), 1); @@ -282,15 +292,15 @@ fn pending_remote_buffer_clones_stay_charged_until_the_last_owner_drops(cx: &mut cx.update(|cx| { ownership::register_bound(&general, client.clone(), cx).unwrap(); let admission = general.history_admission(cx); - let buffer = admission.reserve_unowned_bytes(40 * MIB, cx).unwrap(); + let buffer = admission.reserve_unowned_bytes(LARGE_BYTES, cx).unwrap(); let pending = buffer.clone(); drop(buffer); assert!(matches!( - general.admit_history(client.id(), 32 * MIB, cx), + general.admit_history(client.id(), HALF_BYTES, cx), Err(AgentStorageError::ResidentCapacity { .. }) )); drop(pending); - general.admit_history(client.id(), 32 * MIB, cx).unwrap(); + general.admit_history(client.id(), HALF_BYTES, cx).unwrap(); }); } @@ -335,7 +345,7 @@ fn history_eviction_callbacks_run_without_a_budget_borrow_and_nested_admission_r ) { let (general, _directory, _, _, _) = scoped_fixture(cx); let remote = HistoryProbe::default(); - remote.set_history(HistoryUsage::new(40 * MIB, 1, false), true); + remote.set_history(HistoryUsage::new(LARGE_BYTES, 1, false), true); cx.update(|cx| { let admission = general.history_admission(cx); let _registration = admission.register(remote.participant()).unwrap(); @@ -353,7 +363,7 @@ fn history_eviction_callbacks_run_without_a_budget_borrow_and_nested_admission_r )); observed.set(true); })); - let _buffer = admission.reserve_unowned_bytes(40 * MIB, cx).unwrap(); + let _buffer = admission.reserve_unowned_bytes(LARGE_BYTES, cx).unwrap(); assert!(called.get()); }); } @@ -362,7 +372,6 @@ fn history_eviction_callbacks_run_without_a_budget_borrow_and_nested_admission_r fn unchanged_payload_offers_stop_reclamation_even_when_the_callback_reports_success( cx: &mut TestAppContext, ) { - use crate::history_admission::{HistoryAdmissionError, testing::HistoryProbe}; let admission = HistoryProbe::installation(); let neighbor = HistoryProbe::default(); neighbor.set_history(crate::HistoryUsage::new(0, 0, false), true); diff --git a/crates/agent-runtime/src/history_admission.rs b/crates/agent-runtime/src/history_admission.rs index 75e44515..fb893ef8 100644 --- a/crates/agent-runtime/src/history_admission.rs +++ b/crates/agent-runtime/src/history_admission.rs @@ -5,9 +5,7 @@ //! borrow is held while a participant is observed or asked to evict. use crate::HistoryUsage; use gpui::{App, EntityId}; -use nocterm_ai::{ - execution_origin::ChatKey, history::MAX_RESIDENT_BYTES, storage::AgentStorageError, -}; +use nocterm_ai::{execution_origin::ChatKey, storage::AgentStorageError}; use std::{ cell::{Cell, RefCell}, collections::HashSet, @@ -24,12 +22,13 @@ use eviction::EvictionCandidate; pub mod testing; pub const MAX_HISTORY_PAYLOADS: usize = 32; +/// Aggregate history capacity across windows/accounts, independent of a document's cap. +pub const MAX_HISTORY_BYTES: usize = 1024 * 1024 * 1024; /// The final two payloads and 1MiB remain available to original urgent controls. pub const MAX_NORMAL_HISTORY_PAYLOADS: usize = MAX_HISTORY_PAYLOADS - 2; // Guarded fresh-head receipts and upload buffers share this reserved lane. -// This repartitions the same installation total; normal capacity decreases. pub const URGENT_HISTORY_BYTES: usize = 1024 * 1024; -pub const MAX_NORMAL_HISTORY_BYTES: usize = MAX_RESIDENT_BYTES - URGENT_HISTORY_BYTES; +pub const MAX_NORMAL_HISTORY_BYTES: usize = MAX_HISTORY_BYTES - URGENT_HISTORY_BYTES; #[derive(Debug, thiserror::Error)] pub enum HistoryAdmissionError { @@ -43,11 +42,9 @@ pub enum HistoryAdmissionError { ForeignAllocation, #[error("A retained history allocation cannot grow from {reserved} to {requested} bytes.")] AllocationGrowth { reserved: usize, requested: usize }, - #[error("All normal installation history payload slots are retained or loading.")] + #[error("Chat history is busy. Close an unused chat or try again shortly.")] PayloadCapacity, - #[error( - "History needs {incoming} bytes with {retained} retained; the installation limit is {limit}." - )] + #[error("Chat history needs more memory. Close an unused chat and try again.")] ResidentCapacity { retained: usize, incoming: usize, @@ -592,7 +589,7 @@ impl HistoryAdmissionHandle { self.decide_to( Requester::Registration(Rc::as_ptr(&requester.0)), bytes, - MAX_RESIDENT_BYTES, + MAX_HISTORY_BYTES, cx, )?; let proof = self.charge(bytes)?; diff --git a/crates/agent-runtime/src/lib.rs b/crates/agent-runtime/src/lib.rs index adeb4ffd..dcbf2e66 100644 --- a/crates/agent-runtime/src/lib.rs +++ b/crates/agent-runtime/src/lib.rs @@ -1,7 +1,8 @@ //! Agent lifecycle and durable chat orchestration. //! //! Owns: pure admission policy, connection/lease owners, document identity, bounded -//! keyed write admission, lazy history hydration and usage projections. +//! keyed write admission, typed persistence-failure classification, lazy history +//! hydration and usage projections. //! Does not own: settings storage, history/favorites files, ACP subprocesses or views. //! Ports used: AgentConnector, ToolBridge, AgentStateRepository, ChatRepository, //! WorkdirResolver, CommandJobsFactory, explicit SettingsHandle and host-supplied ShutdownCoordinator diff --git a/crates/agent-runtime/src/storage/failure.rs b/crates/agent-runtime/src/storage/failure.rs index 79c74220..a2bb0148 100644 --- a/crates/agent-runtime/src/storage/failure.rs +++ b/crates/agent-runtime/src/storage/failure.rs @@ -1,6 +1,6 @@ //! Why a chat save failed, read from its typed cause chain for user guidance. use super::AgentPersistenceError; -use nocterm_ai::storage::AgentStorageError; +use nocterm_ai::{history::HistoryCodecError, storage::AgentStorageError}; use nocterm_session::local_fs::LocalFsError; use std::error::Error; @@ -9,6 +9,12 @@ use std::error::Error; /// retrying at all. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PersistenceFailure { + /// Retained histories and work have filled the installation's memory allowance. + HistoryMemory, + /// Retained or loading histories have filled the shared payload allowance. + HistoryBusy, + /// The conversation exceeds its own encoded, structural or row allowance. + ChatLimit, /// The history storage cannot provide the guarantees this chat's location needs. Unsupported, /// The volume holding the history is out of space or quota. @@ -25,12 +31,39 @@ impl PersistenceFailure { AgentPersistenceError::Worker(error) => classify(error), } } + pub fn of_storage(error: &AgentStorageError) -> Self { + classify(error) + } } fn classify(error: &(dyn Error + 'static)) -> PersistenceFailure { let mut frame = Some(error); let mut denied = false; while let Some(current) = frame { + if let Some(storage) = current.downcast_ref::() { + match storage { + AgentStorageError::ResidentCapacity { .. } => { + return PersistenceFailure::HistoryMemory; + } + AgentStorageError::PayloadCapacity => return PersistenceFailure::HistoryBusy, + AgentStorageError::Codec( + HistoryCodecError::EncodedLimit + | HistoryCodecError::StructuralLimit + | HistoryCodecError::CollectionLimit, + ) => return PersistenceFailure::ChatLimit, + _ => {} + } + } + if matches!( + current.downcast_ref::(), + Some( + HistoryCodecError::EncodedLimit + | HistoryCodecError::StructuralLimit + | HistoryCodecError::CollectionLimit + ) + ) { + return PersistenceFailure::ChatLimit; + } if let Some( AgentStorageError::EnvironmentUnsupported(_) | AgentStorageError::ScopeUnsupported(_), ) = current.downcast_ref::() @@ -71,6 +104,42 @@ mod tests { nocterm_store::StoreError::Backend(SharedCause::new(error)).into() } + #[test] + fn history_limits_remain_distinct_through_the_writer_cause_chain() { + use nocterm_ai::history::HistoryCodecError; + let cases = [ + ( + AgentStorageError::ResidentCapacity { + retained: 65_410_245, + incoming: 14_188_544, + limit: 66_060_288, + }, + PersistenceFailure::HistoryMemory, + ), + ( + AgentStorageError::PayloadCapacity, + PersistenceFailure::HistoryBusy, + ), + ( + HistoryCodecError::EncodedLimit.into(), + PersistenceFailure::ChatLimit, + ), + ( + HistoryCodecError::StructuralLimit.into(), + PersistenceFailure::ChatLimit, + ), + ( + HistoryCodecError::CollectionLimit.into(), + PersistenceFailure::ChatLimit, + ), + ]; + for (error, expected) in cases { + assert_eq!(PersistenceFailure::of_storage(&error), expected); + assert_eq!(PersistenceFailure::of(&error.clone().into()), expected); + assert_eq!(PersistenceFailure::of(&worker(error)), expected); + } + } + #[test] fn a_missing_capability_is_unsupported_directly_and_behind_the_writer() { let error = AgentStorageError::EnvironmentUnsupported(Arc::new(Default::default())); diff --git a/crates/agent/src/panel.rs b/crates/agent/src/panel.rs index 5a5811b4..ca9bbace 100644 --- a/crates/agent/src/panel.rs +++ b/crates/agent/src/panel.rs @@ -17,6 +17,7 @@ mod attach_files; mod attachments; mod bindings; mod chat; +mod chat_actions; mod chat_servers; pub(crate) mod commands; mod composer; @@ -31,6 +32,7 @@ mod image_preparation; mod images; mod layout; mod lifecycle; +mod local_attachment; mod menu; mod menu_view; mod message_actions; @@ -207,6 +209,7 @@ impl AgentPanel { /// account notice, any other the error line. fn load_history(&mut self, cx: &mut Context) { if let Err(error) = self.chats.host().runtime().ensure_history(cx) { + tracing::warn!(error = %nocterm_ai::redact::redact(&format!("{error:?}")), "could not load chat history"); match AccountNotice::from_catalog(&error) { Some(notice) => self.notices.set_account(Some(notice)), None => self.notices.set_error(Some(error.to_string())), @@ -575,6 +578,9 @@ impl Render for AgentPanel { let header = shell::ShellRenderer::header( agent, self.title(cx), + current + .clone() + .map(|thread| chat_actions::ChatActions::button(thread, cx)), self.layout.history(), self.layout.maximized(), &self.menus, @@ -589,7 +595,7 @@ impl Render for AgentPanel { .min_w_0() .min_h_0() .child(self.layout.parts().transcript()) - .child(self.layout.parts().footer()) + .child(self.layout.parts().footer(self.menus.current().is_some())) .into_any_element(); if self.layout.history() { body.child(self.render_split(chat, window, cx)) diff --git a/crates/agent/src/panel/attach.rs b/crates/agent/src/panel/attach.rs index cf393e26..872ed2d7 100644 --- a/crates/agent/src/panel/attach.rs +++ b/crates/agent/src/panel/attach.rs @@ -1,11 +1,13 @@ //! The attach menu: what a chat's agent may work with. //! -//! Two parts. *Active sessions* are terminals that are open now: the user's -//! tabs, the bottom shell, and sessions agents opened in the background. -//! *Saved servers* are connections, laid out as in the sidebar; attaching -//! one (or its folder) lets the agent use its open sessions, or open one in -//! the background when there is none. -use std::rc::Rc; +//! The permanent local-terminal action comes first. Saved servers appear once, +//! regardless of their tab count, and retain their sidebar folders. Attaching +//! a server covers its sessions and permits background connection when offline. +//! Ad-hoc sessions have separate rows; background sessions can be shown in tabs. +use std::{ + collections::{HashMap, HashSet}, + rc::Rc, +}; use gpui_kit::{ AnyElement, Context, Entity, SharedString, TestSupportExt as _, @@ -61,16 +63,6 @@ fn heading(text: &'static str, cx: &Context) -> AnyElement { .into_any_element() } -fn empty(text: &'static str, cx: &Context) -> AnyElement { - div() - .px_2() - .py_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(text) - .into_any_element() -} - impl AttachmentMenuRenderer { pub(super) fn render_attach_menu( thread: Option>, @@ -97,36 +89,43 @@ impl AttachmentMenuRenderer { seat.opens_connections(cx) .then(|| SavedServerOpen::Workspace(seat.clone())) }; - let mut menu = v_flex() - .w_full() - .gap(px(2.)) - .child(heading("Active sessions", cx)); - if terminals.is_empty() { - menu = menu.child(empty("No open terminals.", cx)); - } + let rows = Self::active_rows(&terminals, &summaries, &thread, cx); + let mut sessions: HashMap> = HashMap::new(); for entry in &terminals { - menu = menu.child(Self::terminal_row( - entry, - &summaries, - &thread, - seat.clone(), - cx, - )); + if let Some(profile) = entry + .capabilities + .state() + .info(cx) + .and_then(|info| info.profile) + { + sessions.entry(profile).or_default().push(entry); + } + } + let mut menu = + v_flex() + .w_full() + .gap(px(2.)) + .child(super::local_attachment::LocalAttachment::render( + &thread, + workspace.downgrade(), + cx, + )); + if !rows.is_empty() { + menu = menu.child(heading("Active sessions", cx)).children(rows); } if !summaries.is_empty() { menu = menu.child(heading("Saved servers", cx)); for row in servers::server_rows(&summaries) { menu = menu.child(match row { servers::ServerRow::Folder(group) => Self::folder_row(group, &thread, cx), - servers::ServerRow::Server { summary, nested } => { - let online = terminals.iter().any(|entry| { - entry.capabilities.state().info(cx).is_some_and(|info| { - info.profile.as_ref() == Some(&summary.id) - && info.status != TerminalStatus::Closed - }) - }); - Self::server_row(summary, nested, online, &thread, opening.clone(), cx) - } + servers::ServerRow::Server { summary, nested } => Self::server_row( + summary, + nested, + sessions.get(&summary.id).map_or(&[], Vec::as_slice), + &thread, + opening.clone(), + cx, + ), }); } } @@ -135,10 +134,38 @@ impl AttachmentMenuRenderer { } impl AttachmentMenuRenderer { + fn active_rows( + terminals: &[TerminalEntry], + summaries: &[ConnectionSummary], + thread: &Entity, + cx: &mut Context, + ) -> Vec { + let seat = thread.read(cx).workspace_seat().clone(); + let known: HashSet<_> = summaries.iter().map(|summary| &summary.id).collect(); + let mut rows = Vec::new(); + let mut seen = HashSet::new(); + for entry in terminals { + let Some(info) = entry.capabilities.state().info(cx) else { + continue; + }; + if info.local && info.profile.is_none() { + continue; + } + if let Some(profile) = &info.profile { + if !seen.insert(profile.clone()) { + continue; + } + if known.contains(profile) { + continue; + } + } + rows.push(Self::terminal_row(entry, thread, seat.clone(), cx)); + } + rows + } /// An open terminal. A background one can be brought into a tab. fn terminal_row( entry: &TerminalEntry, - summaries: &[ConnectionSummary], thread: &Entity, seat: Seat, cx: &mut Context, @@ -146,58 +173,31 @@ impl AttachmentMenuRenderer { let origin = thread.entity_id(); let attachment = Attachment::Terminal(entry.item); let info = entry.capabilities.state().info(cx); - // A saved server's session is attached through the server as well. - let summary = info - .as_ref() - .and_then(|info| info.profile.as_ref()) - .and_then(|profile| summaries.iter().find(|summary| summary.id == *profile)); let explicit = thread .read(cx) .composer() .attachments() .contains(&attachment); - let covering = summary.and_then(|summary| covering(thread.read(cx), summary, &attachment)); - let selected = explicit || covering.is_some(); - let icon = summary.and_then(|summary| summary.icon.clone()); let connected = info .as_ref() .is_some_and(|info| info.status == TerminalStatus::Connected); - let item = entry.item; let row = menu_row_with( SharedString::from(format!("attach-{:?}", entry.item)), entry.title.clone(), - icon.clone().map(server_image), + None, cx, ) - .when(icon.is_none(), |row| row.icon(attachment_icon(&attachment))) + .icon(attachment_icon(&attachment)) .when(connected, |row| row.child(running_dot(cx))) .flex_1() .min_w_0() - .selected(selected) - .when_some(covering.clone(), |row, through| { - row.tooltip(format!("{} · attached with {through}", entry.title)) - }) - .when(explicit || covering.is_none(), |row| { - row.on_click(cx.listener(move |this, _, _, cx| { - this.on_attachment_selected(origin, attachment.clone(), cx); - })) - }); + .selected(explicit) + .on_click(cx.listener(move |panel, _, _, cx| { + panel.on_attachment_selected(origin, attachment.clone(), cx); + })); let mut line = h_flex().w_full().gap_1().child(row); if entry.background { - let seat = seat.clone(); - line = line.child( - Button::new(SharedString::from(format!("show-background-{item:?}"))) - .custom(menu_variant(cx)) - .small() - .label("Show") - .tooltip("Opened by an agent in the background. Show it in a tab.") - .on_click(cx.listener(move |this, _, window, cx| { - this.close_menus(cx); - let seat = seat.clone(); - window.defer(cx, move |window, cx| seat.show_background(item, window, cx)); - cx.notify(); - })), - ); + line = line.child(Self::show_background(entry.item, seat, cx)); } line.into_any_element() } @@ -238,18 +238,33 @@ impl AttachmentMenuRenderer { fn server_row( summary: &ConnectionSummary, nested: bool, - online: bool, + sessions: &[&TerminalEntry], thread: &Entity, opening: Option, cx: &mut Context, ) -> AnyElement { let origin = thread.entity_id(); let attachment = Attachment::Connection(summary.id.to_string()); - let explicit = thread - .read(cx) - .composer() - .attachments() - .contains(&attachment); + let attachments = thread.read(cx).composer().attachments(); + let mut toggles: Vec<_> = sessions + .iter() + .map(|entry| Attachment::Terminal(entry.item)) + .filter(|attachment| attachments.contains(attachment)) + .collect(); + if attachments.contains(&attachment) || toggles.is_empty() { + toggles.push(attachment.clone()); + } + let explicit = toggles + .iter() + .any(|attachment| attachments.contains(attachment)); + let background = sessions.iter().find(|entry| entry.background).copied(); + let online = sessions.iter().any(|entry| { + entry + .capabilities + .state() + .info(cx) + .is_some_and(|info| info.status != TerminalStatus::Closed) + }); let covering = covering(thread.read(cx), summary, &attachment); let selected = explicit || covering.is_some(); let id = summary.id.to_string(); @@ -288,39 +303,47 @@ impl AttachmentMenuRenderer { .selected(selected) .when(explicit || covering.is_none(), |row| { row.on_click(cx.listener(move |this, _, _, cx| { - this.on_attachment_selected(origin, attachment.clone(), cx); + for attachment in &toggles { + this.on_attachment_selected(origin, attachment.clone(), cx); + } })) }), ) - .child( - Button::new(SharedString::from(format!("open-connection-{id}"))) - .custom(menu_variant(cx)) - .small() - .flex_shrink_0() - .disabled(opening.is_none()) - .icon(IconName::ArrowUpRight) - .accessibility_label(format!("Open {} in a new tab", summary.name)) - .tooltip("Open in a new tab") - .on_click(cx.listener(move |this, _, window, cx| { - this.close_menus(cx); - let opening = opening.clone(); - let id = id.clone(); - // Opening updates the workspace, which may be - // rendering this panel: do it once that is over. - window.defer(cx, move |window, cx| { - if let Some(opening) = opening { - opening.open(&id, window, cx); - } - }); - cx.notify(); - })), - ); + .when_some(background, |line, entry| { + line.child(Self::show_background( + entry.item, + thread.read(cx).workspace_seat().clone(), + cx, + )) + }) + .when(background.is_none(), |line| { + line.child(SavedServerOpen::button(summary, opening, cx)) + }); h_flex() .w_full() .when(nested, |row| row.pl(px(14.))) .child(line) .into_any_element() } + + fn show_background( + item: gpui_kit::EntityId, + seat: Seat, + cx: &mut Context, + ) -> Button { + Button::new(SharedString::from(format!("show-background-{item:?}"))) + .custom(menu_variant(cx)) + .small() + .flex_shrink_0() + .label("Show") + .tooltip("Opened by an agent in the background. Show it in a tab.") + .on_click(cx.listener(move |panel, _, window, cx| { + panel.close_menus(cx); + let seat = seat.clone(); + window.defer(cx, move |window, cx| seat.show_background(item, window, cx)); + cx.notify(); + })) + } } pub(super) struct AttachmentMenuRenderer; @@ -335,6 +358,34 @@ enum SavedServerOpen { Workspace(Seat), } impl SavedServerOpen { + fn button( + summary: &ConnectionSummary, + opening: Option, + cx: &mut Context, + ) -> Button { + let id = summary.id.to_string(); + Button::new(SharedString::from(format!("open-connection-{id}"))) + .custom(menu_variant(cx)) + .small() + .flex_shrink_0() + .disabled(opening.is_none()) + .icon(IconName::ArrowUpRight) + .accessibility_label(format!("Open {} in a new tab", summary.name)) + .tooltip("Open in a new tab") + .on_click(cx.listener(move |panel, _, window, cx| { + panel.close_menus(cx); + let opening = opening.clone(); + let id = id.clone(); + // Opening updates the workspace; wait until panel rendering is over. + window.defer(cx, move |window, cx| { + if let Some(opening) = opening { + opening.open(&id, window, cx); + } + }); + cx.notify(); + })) + } + fn open(&self, profile: &str, window: &mut gpui_kit::Window, cx: &mut gpui_kit::App) { match self { Self::General { diff --git a/crates/agent/src/panel/chat_actions.rs b/crates/agent/src/panel/chat_actions.rs new file mode 100644 index 00000000..4ce1b102 --- /dev/null +++ b/crates/agent/src/panel/chat_actions.rs @@ -0,0 +1,99 @@ +//! Actions bind to the chat shown when its title menu opens, not a later selection. +use super::AgentPanel; +use crate::thread::AgentThread; +use gpui_kit::{ + Anchor, AnyElement, App, ClipboardItem, Context, Entity, IntoElement, WeakEntity, + component::{ + Sizable as _, + button::{Button, ButtonVariants as _}, + menu::{DropdownMenu as _, PopupMenuItem}, + }, + div, + prelude::*, +}; +use nocterm_ui::IconName; + +pub(super) struct ChatActions; +impl ChatActions { + pub(super) fn button(thread: Entity, cx: &mut Context) -> AnyElement { + let panel = cx.weak_entity(); + div() + .id(("chat-actions-origin", thread.entity_id().as_u64())) + .flex_shrink_0() + .child( + Button::new("agent-chat-actions") + .ghost() + .small() + .icon(IconName::Ellipsis) + .tooltip("Chat actions") + .accessibility_label("Chat actions") + .dropdown_menu_with_anchor(Anchor::TopLeft, move |menu, _, cx| { + let id = thread.read(cx).chat_id().to_owned(); + let weak = panel.clone(); + let thread = thread.clone(); + let disabled = thread.read(cx).is_archived() + || panel + .upgrade() + .is_none_or(|panel| panel.read(cx).menus.copying()); + menu.item( + PopupMenuItem::new("Copy history ID") + .icon(IconName::Copy) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(id.clone())); + }), + ) + .item( + PopupMenuItem::new("Copy conversation") + .icon(IconName::Copy) + .disabled(disabled) + .on_click(move |_, _, cx| { + Self::copy(weak.clone(), thread.clone(), cx); + }), + ) + }), + ) + .into_any_element() + } + + fn copy(panel: WeakEntity, thread: Entity, cx: &mut App) { + let _ = panel.update(cx, |panel, cx| { + if panel.menus.copying() || thread.read(cx).is_archived() { + return; + } + let snapshot = match thread.update(cx, |thread, cx| thread.shared_snapshot(cx)) { + Ok(snapshot) => snapshot, + Err(error) => { + tracing::warn!(error = %nocterm_ai::redact::redact(&format!("{error:?}")), "could not copy conversation"); + use nocterm_agent_runtime::PersistenceFailure; + let message = match PersistenceFailure::of_storage(&error) { + PersistenceFailure::HistoryMemory | PersistenceFailure::HistoryBusy => + "History is busy. Close another conversation and try copying again.", + _ => "Could not copy this conversation. Please try again.", + }; + panel.notices.set_error(Some(message.into())); + cx.notify(); + return; + } + }; + let title = thread.read(cx).title(); + let task = cx.spawn(async move |panel, cx| { + let text = cx + .background_executor() + .spawn(async move { + let entries = snapshot + .as_ref() + .map_or(&[][..], |snapshot| snapshot.metadata.entries.as_slice()); + nocterm_ai::thread::export::conversation_text(&title, entries) + }) + .await; + let _ = panel.update(cx, |panel, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + panel.menus.finish_copy(); + cx.notify(); + }); + }); + panel.menus.retain_copy(task); + cx.notify(); + }); + } +} diff --git a/crates/agent/src/panel/entries.rs b/crates/agent/src/panel/entries.rs index 5dc6bfc7..79987f41 100644 --- a/crates/agent/src/panel/entries.rs +++ b/crates/agent/src/panel/entries.rs @@ -26,6 +26,7 @@ pub(super) enum PreviewError { #[error("{0}")] Codec(#[from] nocterm_ai::images::ImageCodecError), } +mod literal_viewport; pub(super) mod tool_input; pub(super) mod tool_output; diff --git a/crates/agent/src/panel/entries/literal_viewport.rs b/crates/agent/src/panel/entries/literal_viewport.rs new file mode 100644 index 00000000..76825242 --- /dev/null +++ b/crates/agent/src/panel/entries/literal_viewport.rs @@ -0,0 +1,57 @@ +//! Wheel routing and native scroll state for tool text inside the transcript. +use gpui_kit::{ + AnyElement, App, Axis, ElementId, IntoElement, RenderOnce, ScrollHandle, TestSupportExt as _, + Window, component::scroll::ScrollableMask, div, prelude::*, px, +}; + +#[derive(IntoElement)] +pub(super) struct LiteralViewport { + id: ElementId, + content: AnyElement, +} + +impl LiteralViewport { + pub(super) fn new(id: impl Into, content: impl IntoElement) -> Self { + Self { + id: id.into(), + content: content.into_any_element(), + } + } +} + +impl RenderOnce for LiteralViewport { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + // GPUI releases this state when the section leaves the rendered tree. + // Keep the same native offset through redraws, without a second owner + // in the transcript or a handle recreated on every frame. + let scroll = window + .use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::new()) + .read(cx) + .clone(); + div() + .id(self.id.clone()) + .test_support() + .relative() + .w_full() + .min_w_0() + .max_w_full() + .overflow_hidden() + .child( + div() + .id((self.id.clone(), "content")) + .w_full() + .min_w_0() + .max_h(px(240.)) + .overflow_hidden() + .track_scroll(&scroll) + .flex() + .items_start() + .child(self.content), + ) + // Inner masks run before the transcript's capture-phase mask. + // Vertical input chains to the transcript only at a text boundary; + // horizontal input stays here instead of becoming vertical input. + .child(ScrollableMask::new(Axis::Horizontal, &scroll).id(self.id.clone())) + .child(ScrollableMask::new(Axis::Vertical, &scroll).id(self.id)) + } +} diff --git a/crates/agent/src/panel/entries/tool_input.rs b/crates/agent/src/panel/entries/tool_input.rs index 11de88ee..f3c21dca 100644 --- a/crates/agent/src/panel/entries/tool_input.rs +++ b/crates/agent/src/panel/entries/tool_input.rs @@ -379,7 +379,6 @@ fn section( ) } -#[expect(clippy::too_many_lines, reason = "predates the limit")] pub(super) fn literal_section( index: usize, part: Option, @@ -444,29 +443,18 @@ pub(super) fn literal_section( }), ), ) - .child( + .child(super::literal_viewport::LiteralViewport::new( + scroll_id, div() - .id(scroll_id) + .id(code_id) .test_support() - .w_full() - .min_w_0() - .max_w_full() - .max_h(px(240.)) - .overflow_scroll() - .flex() - .items_start() - .child( - div() - .id(code_id) - .test_support() - .flex_shrink_0() - .font_family(cx.theme().mono_font_family.clone()) - .text_color(cx.theme().foreground) - .text_xs() - .whitespace_nowrap() - .child(StyledText::new(text).with_highlights(highlights)), - ), - ) + .flex_shrink_0() + .font_family(cx.theme().mono_font_family.clone()) + .text_color(cx.theme().foreground) + .text_xs() + .whitespace_nowrap() + .child(StyledText::new(text).with_highlights(highlights)), + )) .when(truncated, |section| { section.child("Preview limited to 200 KiB. Copy includes the full text.") }) diff --git a/crates/agent/src/panel/local_attachment.rs b/crates/agent/src/panel/local_attachment.rs new file mode 100644 index 00000000..5f8eb695 --- /dev/null +++ b/crates/agent/src/panel/local_attachment.rs @@ -0,0 +1,128 @@ +//! A permanent local-shell choice; only an explicit click may create a terminal. +use super::{ + AgentPanel, + widgets::{menu_row, running_dot}, +}; +use crate::thread::{AgentThread, Attachment}; +use gpui_kit::{ + AnyElement, App, Context, Entity, WeakEntity, Window, + component::{Disableable as _, Selectable as _}, + prelude::*, +}; +use nocterm_ui::IconName; +use nocterm_workspace::{TerminalEntry, TerminalStatus, Workspace}; + +pub(super) struct LocalAttachment; +impl LocalAttachment { + fn terminal(thread: &AgentThread, cx: &gpui_kit::App) -> Option { + thread + .workspace_seat() + .terminals(cx)? + .into_iter() + .filter(|entry| { + entry.capabilities.state().info(cx).is_some_and(|info| { + info.local && info.profile.is_none() && info.status != TerminalStatus::Closed + }) + }) + .min_by_key(|entry| { + ( + !thread + .composer() + .attachments() + .contains(&Attachment::Terminal(entry.item)), + !entry.active, + ) + }) + } + pub(super) fn render( + thread: &Entity, + workspace: WeakEntity, + cx: &mut Context, + ) -> AnyElement { + let entry = Self::terminal(thread.read(cx), cx); + let selected = entry.as_ref().is_some_and(|entry| { + thread + .read(cx) + .composer() + .attachments() + .contains(&Attachment::Terminal(entry.item)) + }); + let connected = entry.as_ref().is_some_and(|entry| { + entry + .capabilities + .state() + .info(cx) + .is_some_and(|info| info.status == TerminalStatus::Connected) + }); + let can_create = thread.read(cx).document().key().scope() + == nocterm_ai::history::ContextId::General + && thread.read(cx).document().key().environment() + == &nocterm_ai::local_environment::LocalEnvironment::ThisComputer; + let origin = thread.entity_id(); + let original = thread.clone(); + let tooltip = if entry.is_some() { + "Attach the local terminal" + } else if can_create { + "Open and attach a local terminal" + } else { + "Open a terminal in this workspace first" + }; + menu_row("attach-local-terminal", "Local terminal", cx) + .icon(IconName::Terminal) + .selected(selected) + .disabled(entry.is_none() && !can_create) + .when(connected, |row| row.child(running_dot(cx))) + .tooltip(tooltip) + .on_click(cx.listener(move |panel, _, window, cx| { + if let Some(entry) = &entry { + panel.on_attachment_selected(origin, Attachment::Terminal(entry.item), cx); + return; + } + panel.close_menus(cx); + let panel = cx.weak_entity(); + let workspace = workspace.clone(); + let original = original.clone(); + window.defer(cx, move |window, cx| { + Self::create_and_attach(panel, original, workspace, window, cx); + }); + })) + .into_any_element() + } + + fn create_and_attach( + panel: WeakEntity, + original: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + let Some(owner) = panel.upgrade() else { return }; + let origin = original.entity_id(); + let is_current = |cx: &App| { + owner + .read(cx) + .current() + .is_some_and(|thread| thread.entity_id() == origin) + }; + if !is_current(cx) || original.read(cx).workspace_seat().terminals(cx).is_none() { + return; + } + let _ = workspace.update(cx, |workspace, cx| workspace.new_local_terminal(window, cx)); + if !is_current(cx) { + return; + } + if let Some(entry) = Self::terminal(original.read(cx), cx) { + owner.update(cx, |panel, cx| { + panel.on_attachment_selected(origin, Attachment::Terminal(entry.item), cx); + }); + } else { + owner.update(cx, |panel, cx| { + panel.notices.set_error(Some( + "Could not open a local terminal. Try opening it from the terminal menu." + .into(), + )); + cx.notify(); + }); + } + } +} diff --git a/crates/agent/src/panel/menu_view.rs b/crates/agent/src/panel/menu_view.rs index 63a370fd..2eaa2ee5 100644 --- a/crates/agent/src/panel/menu_view.rs +++ b/crates/agent/src/panel/menu_view.rs @@ -1,4 +1,4 @@ -//! Disposable configuration-search and usage-card presentation lifetimes. +//! Configuration/search controls, usage-card lifetimes and one cancellable clipboard export. use super::{AgentPanel, MenuKind}; use gpui_kit::{ App, AppContext as _, Context, Entity, FocusHandle, SharedString, Subscription, Window, @@ -13,6 +13,7 @@ pub(super) struct MenuView { usage_focus: FocusHandle, search: Entity, _bindings: Vec, + copy: Option>, } impl MenuView { pub(super) fn new( @@ -40,11 +41,21 @@ impl MenuView { usage_focus, search, _bindings: bindings, + copy: None, } } pub(super) fn current(&self) -> Option<&MenuKind> { self.menu.as_ref() } + pub(super) fn copying(&self) -> bool { + self.copy.is_some() + } + pub(super) fn retain_copy(&mut self, task: gpui_kit::Task<()>) { + self.copy = Some(task); + } + pub(super) fn finish_copy(&mut self) { + self.copy = None; + } pub(super) fn is_open(&self, kind: &MenuKind) -> bool { self.menu.as_ref() == Some(kind) } diff --git a/crates/agent/src/panel/parts.rs b/crates/agent/src/panel/parts.rs index 1f26ceca..ddb4066c 100644 --- a/crates/agent/src/panel/parts.rs +++ b/crates/agent/src/panel/parts.rs @@ -1,11 +1,11 @@ //! The panel's regions as views, so a change redraws only the regions it touches. //! -//! Every region is cached: the panel's own redraws reuse them unless they are -//! notified. Any notify of the panel invalidates all of them, so state the panel -//! changes stays correct by default; thread changes, typing and queue disclosure can -//! target a single region. +//! Cached regions are reused unless they are notified. Any notify of the panel +//! invalidates all of them, so state the panel changes stays correct by default; +//! thread changes, typing and queue disclosure can target a single region. //! The footer (approvals, status, queue, commands and composer) is cached to its -//! content: reused, it keeps the height its content last took. +//! content while its pickers are closed. Open pickers keep their interaction state +//! live instead of reusing a content-sized cache's deferred popup. use super::AgentPanel; use gpui_kit::{ App, AppContext as _, Context, Entity, IntoElement, Render, StyleRefinement, Styled, @@ -75,11 +75,16 @@ impl PanelParts { .min_w_0(), ) } - /// The footer spans the chat column below the transcript, as tall as its content. - pub(super) fn footer(&self) -> impl IntoElement + use<> { - self.footer - .clone() - .cached_to_content(StyleRefinement::default().w_full()) + /// Open pickers must retain their keyed popup, hover and scroll state across frames. + pub(super) fn footer(&self, picker_open: bool) -> impl IntoElement + use<> { + if picker_open { + self.footer.clone().into_any_element() + } else { + self.footer + .clone() + .cached_to_content(StyleRefinement::default().w_full()) + .into_any_element() + } } /// Redraws the cached regions: the panel's state changed in a way only it knows. pub(super) fn invalidate(&self, cx: &mut App) { diff --git a/crates/agent/src/panel/shell.rs b/crates/agent/src/panel/shell.rs index e4ff8f18..9248837b 100644 --- a/crates/agent/src/panel/shell.rs +++ b/crates/agent/src/panel/shell.rs @@ -96,6 +96,7 @@ impl ShellRenderer { pub(super) fn header( agent: Option, title: SharedString, + actions: Option, history: bool, maximized: bool, menus: &MenuView, @@ -121,7 +122,14 @@ impl ShellRenderer { .text_color(cx.theme().muted_foreground), } }) - .child(Self::title(title, cx)) + .child( + h_flex() + .min_w_0() + .gap_1() + .child(Self::title(title, cx)) + .children(actions), + ) + .child(div().flex_1()) .child( Button::new("agent-history") .ghost() @@ -168,7 +176,6 @@ impl ShellRenderer { div() .id("agent-chat-title") .test_support() - .flex_1() .min_w_0() .truncate() .tooltip({ diff --git a/crates/agent/src/panel/tests/archive.rs b/crates/agent/src/panel/tests/archive.rs index 2b95e478..9682317d 100644 --- a/crates/agent/src/panel/tests/archive.rs +++ b/crates/agent/src/panel/tests/archive.rs @@ -1,10 +1,26 @@ //! Lazy history, authority and metadata operations cross the runtime/UI boundary. use super::*; +use nocterm_agent_runtime::history_admission::{HistoryBytesPermit, MAX_NORMAL_HISTORY_BYTES}; use nocterm_ai::{ history::{SavedChat, SavedPrompt}, metadata::ChatMetadataSource as _, thread::Entry, }; + +fn leave_history_capacity( + f: &Fixture, + bytes: usize, + cx: &mut TestAppContext, +) -> HistoryBytesPermit { + cx.update(|cx| { + f.installation + .installed + .runtime() + .history_admission(cx) + .reserve_unowned_bytes(MAX_NORMAL_HISTORY_BYTES - bytes, cx) + .unwrap() + }) +} pub(super) fn archived( f: &Fixture, chat: &SavedChat, @@ -203,6 +219,8 @@ fn restored_root_controls_process_sandbox_restore_and_fresh_fallback(cx: &mut Te fn hydration_waits_for_shared_permit_and_clean_archives_are_evicted(cx: &mut TestAppContext) { let f = fixture(cx); cx.run_until_parked(); + // Keep the workload small while exercising eviction at the actual installation cap. + let _other_windows = leave_history_capacity(&f, 63 * 1024 * 1024, cx); let mut chats = Vec::new(); for index in 0..15 { let mut chat = saved(&f); @@ -653,6 +671,7 @@ fn installation_capacity_refuses_restore_before_mutation_and_keeps_shown_project let f = fixture(cx); // Capacity refusal is independent of warm-start connections. lazy_start(&f, cx); + let _other_windows = leave_history_capacity(&f, 63 * 1024 * 1024, cx); let mut retained = Vec::new(); let mut threads = Vec::new(); cx.update_window(f.handle, |_, window, cx| { diff --git a/crates/agent/src/panel/tests/chat_actions.rs b/crates/agent/src/panel/tests/chat_actions.rs new file mode 100644 index 00000000..2fdc53fa --- /dev/null +++ b/crates/agent/src/panel/tests/chat_actions.rs @@ -0,0 +1,95 @@ +//! The title menu copies the original conversation, including offscreen messages. +use super::*; + +#[gpui_kit::test] +fn the_title_menu_copies_the_history_id_and_complete_conversation(cx: &mut TestAppContext) { + let f = fixture(cx); + new_chat(&f, cx); + let answer = format!("{}\nlast line", "complete answer ".repeat(500)); + exchange(&f, "my question", &answer, cx); + let thread = cx.update(|cx| f.panel.read(cx).current().unwrap()); + let id = cx.update(|cx| thread.read(cx).chat_id().to_owned()); + cx.update_window(f.handle, |_, window, cx| { + window.render_frame(cx); + window.click("agent-chat-actions", cx); + assert!(window.try_find("popup-menu").is_some(), "first opening"); + window.within("popup-menu").click(0usize, cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(id.as_str()) + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(f.handle, |_, window, cx| { + window.click("agent-chat-actions", cx); + assert!(window.try_find("popup-menu").is_some(), "second opening"); + window.within("popup-menu").click(1usize, cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + let copied = cx.read_from_clipboard().unwrap().text().unwrap(); + assert!(copied.contains("## User\n\nmy question"), "{copied}"); + assert!(copied.contains(&answer)); + }); +} + +#[gpui_kit::test] +fn the_chat_actions_button_sits_after_a_short_title_and_fits_a_narrow_header( + cx: &mut TestAppContext, +) { + let f = fixture_with_width(cx, 18., workspace_resolver(), image_deps()); + new_chat(&f, cx); + let thread = cx.update(|cx| f.panel.read(cx).current().unwrap()); + thread.update(cx, |thread, cx| thread.rename(Some("Audit".into()), cx)); + cx.update_window(f.handle, |_, window, cx| { + window.render_frame(cx); + let title = window.find("agent-chat-title").bounds(); + let actions = window.find("agent-chat-actions").bounds(); + assert!(actions.origin.x >= title.right()); + assert!(actions.origin.x - title.right() <= gpui_kit::px(8.)); + assert!( + window.find("agent-maximize").bounds().right() + <= window.find("agent-header").bounds().right() + ); + }) + .unwrap(); + thread.update(cx, |thread, cx| { + thread.rename(Some("Very long conversation title ".repeat(50)), cx) + }); + cx.update_window(f.handle, |_, window, cx| { + window.render_frame(cx); + assert!(window.find("agent-chat-actions").visible()); + assert!( + window.find("agent-maximize").bounds().right() + <= window.find("agent-header").bounds().right() + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn changing_chats_dismisses_the_title_menu_and_copies_the_new_history_id(cx: &mut TestAppContext) { + let f = fixture(cx); + new_chat(&f, cx); + exchange(&f, "first question", "first answer", cx); + let first = cx.update(|cx| f.panel.read(cx).current().unwrap()); + let first_id = cx.update(|cx| first.read(cx).chat_id().to_owned()); + new_chat(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + window.click("agent-chat-actions", cx); + assert!(window.try_find("popup-menu").is_some()); + f.panel + .update(cx, |panel, cx| panel.select_thread(first.entity_id(), cx)); + window.render_frame(cx); + assert!(window.try_find("popup-menu").is_none()); + window.click("agent-chat-actions", cx); + window.within("popup-menu").click(0usize, cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(first_id.as_str()) + ); + }) + .unwrap(); +} diff --git a/crates/agent/src/panel/tests/composer.rs b/crates/agent/src/panel/tests/composer.rs index 72ccb795..90db8739 100644 --- a/crates/agent/src/panel/tests/composer.rs +++ b/crates/agent/src/panel/tests/composer.rs @@ -5,6 +5,7 @@ use nocterm_ai::model::session::{PromptOutcome, SessionNotice, SessionUpdate, St use nocterm_ai::model::usage::{ContextUsage, TokenTotals}; mod context_usage; +mod menu_stability; #[gpui_kit::test] fn attach_menu_lists_servers_under_their_folders(cx: &mut TestAppContext) { @@ -199,8 +200,7 @@ fn composer_popups_select_config_modes_and_context(cx: &mut TestAppContext) { window.render_frame(cx); assert_eq!(window.find("mode-picker").label(), Some("Code")); window.click("agent-attach-context", cx); - let attachment = gpui_kit::SharedString::from(format!("attach-{:?}", f.terminal)); - window.click(attachment, cx); + window.click("attach-local-terminal", cx); assert!( f.panel .read(cx) diff --git a/crates/agent/src/panel/tests/composer/menu_stability.rs b/crates/agent/src/panel/tests/composer/menu_stability.rs new file mode 100644 index 00000000..0e437976 --- /dev/null +++ b/crates/agent/src/panel/tests/composer/menu_stability.rs @@ -0,0 +1,192 @@ +use super::*; +use gpui_kit::{ + Bounds, InputEvent, MouseButton, MouseDownEvent, MouseUpEvent, Pixels, ScrollDelta, point, px, +}; +use nocterm_workspace::WorkspaceEvent; + +struct PanelHost(Entity); + +impl gpui_kit::Render for PanelHost { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui_kit::div().size_full().child(self.0.clone()) + } +} + +fn many_servers(f: &Fixture, cx: &mut TestAppContext) -> Rc { + let directory = Rc::new(Directory(std::cell::RefCell::new( + (0..35) + .map(|index| super::super::servers::summary(&format!("server-{index:02}"), None)) + .collect(), + ))); + cx.update(|cx| { + f.workspace.update(cx, |workspace, _| { + workspace.set_connection_directory(directory.clone()) + }); + }); + directory +} + +const ROW: &str = "attach-connection-server-10"; + +fn scrolled_picker(f: &Fixture, cx: &mut TestAppContext) -> Bounds { + cx.update_window(f.handle, |_, window, cx| { + window.render_frame(cx); + window.click("agent-attach-context", cx); + let initial = window.find(ROW).bounds(); + window.scroll( + "agent-picker", + ScrollDelta::Pixels(point(px(0.), px(-240.))), + cx, + ); + let scrolled = window.find(ROW); + assert!(scrolled.visible()); + assert!(scrolled.bounds().origin.y < initial.origin.y); + window.hover(ROW, cx); + scrolled.bounds() + }) + .unwrap() +} + +#[gpui_kit::test] +fn a_scrolled_attachment_picker_keeps_its_rows_and_accepts_selection_after_refresh( + cx: &mut TestAppContext, +) { + let f = fixture(cx); + let directory = many_servers(&f, cx); + new_chat(&f, cx); + // Exercise the footer's own cache without a workspace ancestor retaining its state. + cx.update_window(f.handle, |_, window, cx| { + window.replace_root(cx, |_, _| PanelHost(f.panel.clone())); + }) + .unwrap(); + let before = scrolled_picker(&f, cx); + for event in [ + WorkspaceEvent::ItemsChanged, + WorkspaceEvent::ActiveSessionChanged, + WorkspaceEvent::ConnectionsChanged, + ] { + for _ in 0..3 { + cx.update_window(f.handle, |_, window, cx| { + window.draw(cx).clear(cx); + assert_eq!(window.find(ROW).bounds(), before); + }) + .unwrap(); + } + cx.update(|cx| f.workspace.update(cx, |_, cx| cx.emit(event))); + cx.run_until_parked(); + cx.update_window(f.handle, |_, window, cx| { + window.draw(cx).clear(cx); + assert_eq!(window.find(ROW).bounds(), before); + }) + .unwrap(); + } + directory + .0 + .borrow_mut() + .push(super::super::servers::summary("server-zz", None)); + cx.update(|cx| { + f.workspace + .update(cx, |_, cx| cx.emit(WorkspaceEvent::ConnectionsChanged)) + }); + cx.run_until_parked(); + cx.update_window(f.handle, |_, window, cx| { + window.draw(cx).clear(cx); + assert_eq!(window.find(ROW).bounds(), before); + assert_eq!( + window.find("attach-connection-server-zz").label(), + Some("server-zz") + ); + window.click(ROW, cx); + let thread = f.panel.read(cx).current().unwrap(); + assert!( + thread + .read(cx) + .composer() + .attachments() + .contains(&Attachment::Connection("server-10".into())) + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn an_attachment_click_survives_a_session_update_between_press_and_release( + cx: &mut TestAppContext, +) { + let f = fixture(cx); + many_servers(&f, cx); + new_chat(&f, cx); + let position = scrolled_picker(&f, cx).center(); + cx.update_window(f.handle, |_, window, cx| { + window.dispatch_event( + MouseDownEvent { + position, + button: MouseButton::Left, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + } + .to_platform_input(), + cx, + ); + window.draw(cx).clear(cx); + }) + .unwrap(); + cx.update(|cx| { + f.workspace + .update(cx, |_, cx| cx.emit(WorkspaceEvent::ActiveSessionChanged)) + }); + cx.run_until_parked(); + cx.update_window(f.handle, |_, window, cx| { + window.draw(cx).clear(cx); + assert_eq!(window.find(ROW).bounds().center(), position); + window.dispatch_event( + MouseUpEvent { + position, + button: MouseButton::Left, + modifiers: Default::default(), + click_count: 1, + } + .to_platform_input(), + cx, + ); + window.draw(cx).clear(cx); + let thread = f.panel.read(cx).current().unwrap(); + assert!( + thread + .read(cx) + .composer() + .attachments() + .contains(&Attachment::Connection("server-10".into())) + ); + window.press("escape", cx); + assert!(window.try_find("agent-picker").is_none()); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn reopening_the_attachment_picker_starts_at_the_top(cx: &mut TestAppContext) { + let f = fixture_with_width(cx, 18., workspace_resolver(), image_deps()); + many_servers(&f, cx); + new_chat(&f, cx); + scrolled_picker(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + window.press("escape", cx); + window.draw(cx).clear(cx); + window.click("agent-attach-context", cx); + let heading = window.find("attach-local-terminal"); + assert!(heading.visible()); + assert!(heading.bounds().origin.y >= window.find("agent-picker").bounds().origin.y); + window.click("attach-connection-server-00", cx); + let thread = f.panel.read(cx).current().unwrap(); + assert!( + thread + .read(cx) + .composer() + .attachments() + .contains(&Attachment::Connection("server-00".into())) + ); + }) + .unwrap(); +} diff --git a/crates/agent/src/panel/tests/drafts.rs b/crates/agent/src/panel/tests/drafts.rs index 470bbcfd..9e14c98c 100644 --- a/crates/agent/src/panel/tests/drafts.rs +++ b/crates/agent/src/panel/tests/drafts.rs @@ -321,7 +321,8 @@ fn sending_a_large_draft_counts_its_text_once_and_rejection_keeps_it(cx: &mut Te let f = fixture(cx); new_chat(&f, cx); let thread = cx.update(|cx| f.panel.read(cx).current().unwrap()); - let text = "x".repeat(17 * 1024 * 1024); + let length = nocterm_ai::history::MAX_FILE_BYTES as usize / 2 + 1024 * 1024; + let text = "x".repeat(length); thread.update(cx, |thread, cx| thread.send("active prompt".into(), cx)); cx.run_until_parked(); thread.update(cx, |thread, cx| { @@ -331,7 +332,7 @@ fn sending_a_large_draft_counts_its_text_once_and_rejection_keeps_it(cx: &mut Te assert_eq!(thread.composer().queue().len(), 1); // The accepted queued payload already uses over half of the budget. // A second payload must fail without removing its composer draft. - let next = "y".repeat(17 * 1024 * 1024); + let next = "y".repeat(length); thread.set_draft(next.clone(), cx); thread.begin_composer_edit( Vec::new(), diff --git a/crates/agent/src/panel/tests/mod.rs b/crates/agent/src/panel/tests/mod.rs index 24d20dc2..ee7b3010 100644 --- a/crates/agent/src/panel/tests/mod.rs +++ b/crates/agent/src/panel/tests/mod.rs @@ -56,6 +56,7 @@ mod process_commands; mod scripted_agent; use scripted_agent::{Bridge, Commands, Connector}; +mod chat_actions; mod terminal_fixture; use terminal_fixture::{Access, FakeTerminal, SignIn}; @@ -511,6 +512,7 @@ mod registration; mod resources; mod restoration; mod routing; +mod scroll_geometry; mod seat_binding; mod servers; mod tool_corpus; diff --git a/crates/agent/src/panel/tests/navigation.rs b/crates/agent/src/panel/tests/navigation.rs index d7dd12f6..e443b7cc 100644 --- a/crates/agent/src/panel/tests/navigation.rs +++ b/crates/agent/src/panel/tests/navigation.rs @@ -1,6 +1,6 @@ //! Chat controls use the existing virtual scroller instead of a second viewport. use super::*; -use gpui_kit::{ListOffset, point, px}; +use gpui_kit::{InputEvent as _, ListOffset, point, px}; use nocterm_ai::model::session::{SessionNotice, SessionUpdate}; use nocterm_ai::model::tools::ToolRecord; use nocterm_ai::thread::Entry; @@ -542,6 +542,40 @@ fn streaming_keeps_reader_scroll_until_jump_to_latest(cx: &mut TestAppContext) { complete_active(&f, cx); } +#[gpui_kit::test] +fn upward_wheel_bursts_move_a_tall_reply_by_the_requested_pixels(cx: &mut TestAppContext) { + let f = fixture(cx); + transcript(&f, cx); + scroll_to(&f, 7, cx); + cx.update_window(f.handle, |_, window, cx| { + window.scroll( + "agent-transcript-area", + gpui_kit::ScrollDelta::Pixels(point(px(0.), px(-160.))), + cx, + ); + window.hover("agent-transcript-area", cx); + let before = window.find(("agent-entry", 7usize)).bounds(); + let position = window.find("agent-transcript-area").bounds().center(); + for _ in 0..3 { + window.dispatch_event( + gpui_kit::ScrollWheelEvent { + position, + delta: gpui_kit::ScrollDelta::Pixels(point(px(0.), px(20.))), + ..Default::default() + } + .to_platform_input(), + cx, + ); + } + window.render_frame(cx); + assert_eq!( + window.find(("agent-entry", 7usize)).bounds().top(), + before.top() + px(60.) + ); + }) + .unwrap(); +} + #[gpui_kit::test] fn a_code_block_streamed_in_chunks_copies_its_whole_code(cx: &mut TestAppContext) { let f = fixture(cx); diff --git a/crates/agent/src/panel/tests/persistence.rs b/crates/agent/src/panel/tests/persistence.rs index 545859b3..156241af 100644 --- a/crates/agent/src/panel/tests/persistence.rs +++ b/crates/agent/src/panel/tests/persistence.rs @@ -215,6 +215,45 @@ fn shutdown_prefers_the_latest_queued_snapshot_over_the_extracted_older_snapshot pub(super) fn save_count(f: &Fixture, id: &str) -> usize { f.repository.operations().iter().filter(|operation|matches!(operation,nocterm_ai::testing::StorageOperation::Save(saved) if saved==id)).count() } + +#[gpui_kit::test] +fn a_failed_chat_save_shows_readable_memory_guidance_and_keeps_the_queued_message( + cx: &mut TestAppContext, +) { + let f = fixture(cx); + new_chat(&f, cx); + let thread = cx.update(|cx| f.panel.read(cx).current().unwrap()); + thread.update(cx, |thread, cx| thread.send("active request".into(), cx)); + cx.run_until_parked(); + let id = cx.update(|cx| thread.read(cx).chat_id().to_owned()); + let release = f + .repository + .block_next(nocterm_ai::testing::StorageOperation::Save(id)); + thread.update(cx, |thread, cx| { + thread.send("keep this queued request".into(), cx) + }); + cx.run_until_parked(); + release + .send(Err( + nocterm_ai::storage::AgentStorageError::ResidentCapacity { + retained: 65_410_245, + incoming: 14_188_544, + limit: 66_060_288, + }, + )) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + let thread = thread.read(cx); + assert_eq!( + thread.status(), + "Could not save chat: history needs more memory. Close an unused chat and try again. Queued messages are retained." + ); + assert!(thread.composer().paused()); + assert_eq!(thread.composer().queue().len(), 1); + assert_eq!(thread.composer().queue()[0].saved().text, "keep this queued request"); + }); +} #[gpui_kit::test] fn loaded_browse_noop_actions_detach_and_quit_preserve_the_original_v1_bytes( cx: &mut TestAppContext, diff --git a/crates/agent/src/panel/tests/scroll_geometry.rs b/crates/agent/src/panel/tests/scroll_geometry.rs new file mode 100644 index 00000000..accad027 --- /dev/null +++ b/crates/agent/src/panel/tests/scroll_geometry.rs @@ -0,0 +1,493 @@ +//! Transcript wheel geometry with cold and changing row measurements. +use super::*; +use gpui_kit::{ + MouseMoveEvent, ScrollDelta, ScrollWheelEvent, StyleRefinement, TestSupportExt as _, + VisualTestContext, + component::message_scroller::{MessageScroller, MessageScrollerState}, + div, point, px, size, +}; +use std::cell::RefCell; + +struct Rows { + state: Entity, + heights: Rc>>, + rendered: Rc>, +} +impl Render for Rows { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let heights = self.heights.clone(); + let rendered = self.rendered.clone(); + MessageScroller::new("geometry", self.state.clone(), move |index, _, _| { + rendered.set(rendered.get() + 1); + div().w_full().h(px(heights.borrow()[index])) + }) + .with_row_style(StyleRefinement::default().p_0()) + .with_list_style(StyleRefinement::default().p_0()) + .jump_button(false) + .scrollbar(false) + } +} +fn paint(cx: &mut VisualTestContext, view: &Entity, width: f32) { + cx.draw(point(px(0.), px(0.)), size(px(width), px(100.)), |_, _| { + view.clone().into_any_element() + }); +} +fn wheel(cx: &mut VisualTestContext, delta: f32) { + let position = point(px(50.), px(50.)); + cx.simulate_event(MouseMoveEvent { + position, + ..Default::default() + }); + cx.simulate_event(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(0.), px(delta))), + ..Default::default() + }); +} +fn position(state: &Entity, heights: &[f32], cx: &VisualTestContext) -> f32 { + let top = state.read_with(cx, |state, _| state.logical_scroll_top()); + heights[..top.item_ix].iter().sum::() + f32::from(top.offset_in_item) +} + +#[gpui_kit::test] +fn a_cold_history_preserves_upward_wheel_distance_across_unseen_rows(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 512])); + let state = cx.new(|cx| MessageScrollerState::new(512, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + let mut expected = 512. * 20. - 100.; + assert_eq!(position(&state, &heights.borrow(), cx), expected); + for distance in [40., 240., 800., 1600.] { + wheel(cx, distance); + paint(cx, &view, 120.); + expected -= distance; + assert_eq!( + position(&state, &heights.borrow(), cx), + expected, + "a {distance}px upward wheel event must traverse that distance in cold history" + ); + } +} + +#[gpui_kit::test] +fn upward_wheel_enters_the_bottom_of_a_row_that_grew_offscreen(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 512])); + let state = cx.new(|cx| MessageScrollerState::new(512, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + // A previously visited history has cached heights for the older rows. + for index in (0..512).step_by(20) { + state.update(cx, |state, cx| { + state.scroll_to_item(index, cx); + }); + paint(cx, &view, 120.); + } + state.update(cx, |state, cx| state.scroll_to_end(cx)); + paint(cx, &view, 120.); + heights.borrow_mut()[470] = 3000.; + state.update(cx, |state, cx| { + state.remeasure_items(470..471, cx); + }); + paint(cx, &view, 120.); + let before = position(&state, &heights.borrow(), cx); + wheel(cx, 800.); + paint(cx, &view, 120.); + assert_eq!( + position(&state, &heights.borrow(), cx), + before - 800., + "wheel must enter the grown row from its bottom, not jump past its new content" + ); +} + +#[gpui_kit::test] +fn growing_a_row_below_the_viewport_preserves_downward_wheel_distance(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 512])); + let state = cx.new(|cx| MessageScrollerState::new(512, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + for index in (0..512).step_by(20) { + state.update(cx, |state, cx| { + state.scroll_to_item(index, cx); + }); + paint(cx, &view, 120.); + } + state.update(cx, |state, cx| { + state.scroll_to_item(0, cx); + }); + paint(cx, &view, 120.); + heights.borrow_mut()[40] = 3000.; + state.update(cx, |state, cx| { + state.remeasure_items(40..41, cx); + }); + paint(cx, &view, 120.); + wheel(cx, -1000.); + paint(cx, &view, 120.); + assert_eq!(position(&state, &heights.borrow(), cx), 1000.); +} + +#[gpui_kit::test] +fn width_invalidation_does_not_make_upward_wheel_skip_newly_wrapped_text(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 512])); + let state = cx.new(|cx| MessageScrollerState::new(512, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + for index in (0..512).step_by(20) { + state.update(cx, |state, cx| { + state.scroll_to_item(index, cx); + }); + paint(cx, &view, 240.); + } + state.update(cx, |state, cx| state.scroll_to_end(cx)); + paint(cx, &view, 240.); + heights.borrow_mut()[470] = 3000.; + paint(cx, &view, 120.); + let before = position(&state, &heights.borrow(), cx); + wheel(cx, 800.); + paint(cx, &view, 120.); + assert_eq!(position(&state, &heights.borrow(), cx), before - 800.); +} + +#[gpui_kit::test] +fn opposite_wheel_events_before_paint_coalesce_without_losing_distance(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 512])); + let state = cx.new(|cx| MessageScrollerState::new(512, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + let before = position(&state, &heights.borrow(), cx); + wheel(cx, 800.); + wheel(cx, -400.); + paint(cx, &view, 120.); + assert_eq!(position(&state, &heights.borrow(), cx), before - 400.); +} + +#[gpui_kit::test] +fn explicit_navigation_cancels_wheel_continuation(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 1024])); + let state = cx.new(|cx| MessageScrollerState::new(1024, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + wheel(cx, 100_000.); + paint(cx, &view, 120.); + assert!( + position(&state, &heights.borrow(), cx) > 0., + "a large gesture must spread missing measurements across frames" + ); + state.update(cx, |state, cx| { + state.scroll_to_item(400, cx); + }); + for _ in 0..3 { + paint(cx, &view, 120.); + } + assert_eq!(position(&state, &heights.borrow(), cx), 8000.); +} + +struct NativeRows { + state: gpui_kit::ListState, + heights: Rc>>, +} +impl Render for NativeRows { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let heights = self.heights.clone(); + gpui_kit::list(self.state.clone(), move |index, _, _| { + div() + .id(("native-row", index)) + .test_support() + .w_full() + .h(px(heights.borrow()[index])) + .into_any_element() + }) + .size_full() + } +} + +#[gpui_kit::test] +fn a_bottom_aligned_native_list_stays_pinned_after_wheel_at_its_edge(cx: &mut TestAppContext) { + let heights = Rc::new(RefCell::new(vec![20.; 10])); + let state = gpui_kit::ListState::new(10, gpui_kit::ListAlignment::Bottom, px(400.)); + let view = cx.new(|_| NativeRows { + state: state.clone(), + heights: heights.clone(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + wheel(cx, 20.); + paint(cx, &view, 120.); + assert_eq!(state.bounds_for_item(9).unwrap().bottom(), px(120.)); + wheel(cx, -20.); + paint(cx, &view, 120.); + wheel(cx, -20.); + heights.borrow_mut().push(20.); + state.splice(10..10, 1); + paint(cx, &view, 120.); + assert_eq!( + cx.update(|window, _| window.find(("native-row", 10usize)).bounds().bottom()), + px(100.) + ); +} + +#[gpui_kit::test] +fn a_fitting_chat_keeps_following_when_upward_wheel_cannot_move_it(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 3])); + let state = cx.new(|cx| MessageScrollerState::new(3, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + state.update(cx, |state, cx| state.scroll_to_end(cx)); + wheel(cx, 20.); + assert!(state.read_with(cx, |state, _| state.is_following_tail())); + heights.borrow_mut().push(300.); + state.update(cx, |state, cx| { + state.append(1, cx); + }); + paint(cx, &view, 120.); + assert_eq!(position(&state, &heights.borrow(), cx), 260.); +} + +#[gpui_kit::test] +fn a_deferred_native_wheel_landing_at_the_end_keeps_bottom_pinning(cx: &mut TestAppContext) { + let heights = Rc::new(RefCell::new(vec![20.; 10])); + let state = gpui_kit::ListState::new(10, gpui_kit::ListAlignment::Bottom, px(400.)); + let view = cx.new(|_| NativeRows { + state: state.clone(), + heights: heights.clone(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + state.scroll_to(gpui_kit::ListOffset { + item_ix: 0, + offset_in_item: px(0.), + }); + paint(cx, &view, 120.); + state.remeasure_items(4..5); + wheel(cx, -100.); + paint(cx, &view, 120.); + heights.borrow_mut().push(20.); + state.splice(10..10, 1); + paint(cx, &view, 120.); + assert_eq!( + cx.update(|window, _| window.find(("native-row", 10usize)).bounds().bottom()), + px(100.) + ); +} + +#[gpui_kit::test] +fn a_large_wheel_schedules_bounded_measurement_batches_and_then_goes_idle(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 1024])); + let rendered = Rc::new(std::cell::Cell::new(0usize)); + let state = cx.new(|cx| MessageScrollerState::new(1024, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: rendered.clone(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + wheel(cx, 100_000.); + for _ in 0..12 { + rendered.set(0); + paint(cx, &view, 120.); + assert!( + rendered.get() <= 160, + "normal positive-height rows must not trigger eager history rendering: {}", + rendered.get() + ); + assert!( + cx.update(|window, cx| window.simulate_next_frame(cx)) > 0, + "missing-height continuation and final controls refresh need a real frame request" + ); + if position(&state, &heights.borrow(), cx) == 0. { + paint(cx, &view, 120.); + assert_eq!(cx.update(|window, cx| window.simulate_next_frame(cx)), 0); + return; + } + } + panic!("queued movement did not finish"); +} + +#[gpui_kit::test] +fn reversing_at_the_tail_cancels_an_unmeasured_upward_step(cx: &mut TestAppContext) { + let _f = fixture(cx); + let heights = Rc::new(RefCell::new(vec![20.; 512])); + let state = cx.new(|cx| MessageScrollerState::new(512, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + let before = position(&state, &heights.borrow(), cx); + state.update(cx, |state, cx| { + state.remeasure_items(506..507, cx); + }); + wheel(cx, 20.); + wheel(cx, -40.); + paint(cx, &view, 120.); + assert_eq!(position(&state, &heights.borrow(), cx), before); +} + +#[gpui_kit::test] +fn zero_height_rows_do_not_reengage_tail_follow_before_a_wheel_finishes(cx: &mut TestAppContext) { + let _f = fixture(cx); + let mut initial = vec![20.; 402]; + initial[0] = 1000.; + initial[401] = 100.; + let heights = Rc::new(RefCell::new(initial)); + let state = cx.new(|cx| MessageScrollerState::new(402, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + heights.borrow_mut()[1..401].fill(0.); + state.update(cx, |state, cx| { + state.remeasure_items(1..401, cx); + }); + wheel(cx, 20.); + for _ in 0..4 { + paint(cx, &view, 120.); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + } + assert_eq!(position(&state, &heights.borrow(), cx), 980.); + assert!(!state.read_with(cx, |state, _| state.is_following_tail())); +} + +#[gpui_kit::test] +fn explicit_list_replacement_and_thumb_drag_cancel_deferred_wheel(cx: &mut TestAppContext) { + for action in ["reset", "replace anchor", "drag thumb"] { + let heights = Rc::new(RefCell::new(vec![20.; 1024])); + let state = gpui_kit::ListState::new(1024, gpui_kit::ListAlignment::Top, px(400.)); + let view = cx.new(|_| NativeRows { + state: state.clone(), + heights: heights.clone(), + }); + let cx = cx.add_empty_window(); + state.scroll_to_end(); + paint(cx, &view, 120.); + wheel(cx, 100_000.); + let anchor = state.logical_scroll_top(); + let expected = match action { + "reset" => { + state.reset(1024); + 0 + } + "replace anchor" => { + state.splice(anchor.item_ix..anchor.item_ix + 1, 1); + anchor.item_ix + } + "drag thumb" => { + state.scrollbar_drag_started(); + anchor.item_ix + } + _ => unreachable!(), + }; + for _ in 0..3 { + paint(cx, &view, 120.); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + } + assert_eq!( + state.logical_scroll_top().item_ix, + expected, + "{action} must cancel old wheel input" + ); + assert_eq!(state.logical_scroll_top().offset_in_item, px(0.)); + } +} + +#[gpui_kit::test] +fn upward_wheel_immediately_after_jump_to_latest_starts_at_the_viewport_tail( + cx: &mut TestAppContext, +) { + let _f = fixture(cx); + for append_before_paint in [false, true] { + let heights = Rc::new(RefCell::new(vec![20.; 512])); + let state = cx.new(|cx| MessageScrollerState::new(512, cx)); + let view = cx.new(|_| Rows { + state: state.clone(), + heights: heights.clone(), + rendered: Default::default(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + state.update(cx, |state, cx| { + state.scroll_to_item(0, cx); + }); + paint(cx, &view, 120.); + state.update(cx, |state, cx| state.scroll_to_end(cx)); + if append_before_paint { + heights.borrow_mut().push(300.); + state.update(cx, |state, cx| state.append(1, cx)); + } + // Input arrives after the action but before the end request is painted. + wheel(cx, 20.); + paint(cx, &view, 120.); + assert_eq!( + position(&state, &heights.borrow(), cx), + heights.borrow().iter().sum::() - 100. - 20. + ); + assert!(!state.read_with(cx, |state, _| state.is_following_tail())); + } +} + +#[gpui_kit::test] +fn an_inert_wheel_preserves_a_native_end_request_through_append(cx: &mut TestAppContext) { + let heights = Rc::new(RefCell::new(vec![20.; 3])); + let state = gpui_kit::ListState::new(3, gpui_kit::ListAlignment::Top, px(400.)); + let view = cx.new(|_| NativeRows { + state: state.clone(), + heights: heights.clone(), + }); + let cx = cx.add_empty_window(); + paint(cx, &view, 120.); + state.scroll_to_end(); + wheel(cx, 20.); + heights.borrow_mut().push(300.); + state.splice(3..3, 1); + paint(cx, &view, 120.); + assert_eq!(state.bounds_for_item(3).unwrap().bottom(), px(100.)); +} diff --git a/crates/agent/src/panel/tests/servers.rs b/crates/agent/src/panel/tests/servers.rs index fdcfb70a..63b83daf 100644 --- a/crates/agent/src/panel/tests/servers.rs +++ b/crates/agent/src/panel/tests/servers.rs @@ -1,13 +1,14 @@ //! Saved servers: opening them from the attach menu, and agents connecting //! to attached servers in the background. use super::*; +mod menu; use nocterm_workspace::ConnectionDirectory as _; use std::{ cell::{Cell, RefCell}, time::Duration, }; -fn summary(id: &str, group: Option<&str>) -> nocterm_workspace::ConnectionSummary { +pub(super) fn summary(id: &str, group: Option<&str>) -> nocterm_workspace::ConnectionSummary { nocterm_workspace::ConnectionSummary { id: id.to_owned().into(), name: id.to_owned().into(), @@ -135,7 +136,7 @@ fn open_in_the_attach_menu_opens_a_tab(cx: &mut TestAppContext) { window.render_frame(cx); window.click("agent-attach-context", cx); window.render_frame(cx); - assert!(window.try_find("Active sessions").is_some()); + assert!(window.try_find("attach-local-terminal").is_some()); assert!(window.try_find("Saved servers").is_some()); // A server inside a folder is indented, yet its button stays in the menu. let menu = window.try_find("agent-picker").unwrap().bounds(); @@ -425,7 +426,6 @@ fn rows_attached_through_a_folder_do_not_toggle_on_their_own(cx: &mut TestAppCon assert!(thread.composer().attachments().is_empty()); }) }); - let terminal = format!("attach-{:?}", f.terminal); cx.update_window(f.handle, |_, window, cx| { window.render_frame(cx); window.click("agent-attach-context", cx); @@ -436,7 +436,7 @@ fn rows_attached_through_a_folder_do_not_toggle_on_their_own(cx: &mut TestAppCon assert_eq!(attachments(cx), prod); // Its servers and their sessions show as attached, and a click // cannot leave a duplicate behind once the folder is detached. - for row in ["attach-connection-db", terminal.as_str()] { + for row in ["attach-connection-db", "attach-connection-web"] { window.click(row.to_owned(), cx); window.render_frame(cx); assert_eq!(attachments(cx), prod, "{row}"); diff --git a/crates/agent/src/panel/tests/servers/menu.rs b/crates/agent/src/panel/tests/servers/menu.rs new file mode 100644 index 00000000..4104e52b --- /dev/null +++ b/crates/agent/src/panel/tests/servers/menu.rs @@ -0,0 +1,221 @@ +//! One attachment choice per saved server, and a permanent local shell choice. +use super::*; + +#[gpui_kit::test] +fn several_tabs_of_one_server_have_one_attachment_choice_covering_every_session( + cx: &mut TestAppContext, +) { + let f = fixture(cx); + let directory = install(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + for profile in ["web", "web", "db"] { + assert!(directory.open(profile, &f.workspace.downgrade(), window, cx)); + } + }) + .unwrap(); + new_chat(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + window.render_frame(cx); + window.click("agent-attach-context", cx); + let local = window.find("attach-local-terminal").bounds(); + let web = window.find("attach-connection-web").bounds(); + assert!(local.origin.y < window.find("Saved servers").bounds().origin.y); + assert!(local.origin.y < web.origin.y); + assert!(window.try_find("Active sessions").is_none()); + for entry in f.workspace.read(cx).terminals(cx) { + assert!( + window + .try_find(format!("attach-{:?}", entry.item)) + .is_none() + ); + } + window.click("attach-connection-web", cx); + let thread = f.panel.read(cx).current().unwrap(); + assert!( + thread + .read(cx) + .composer() + .attachments() + .contains(&Attachment::Connection("web".into())) + ); + let resolved = thread.update(cx, |thread, cx| thread.resolved(cx)); + assert_eq!( + resolved + .iter() + .filter(|(_, _, descriptor)| descriptor + .connection + .as_ref() + .is_some_and(|connection| connection.id == "web")) + .count(), + 2 + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn servers_with_the_same_display_name_keep_independent_attachment_choices(cx: &mut TestAppContext) { + let f = fixture(cx); + let mut web = summary("web", None); + let mut db = summary("db", None); + web.name = "Same name".into(); + db.name = "Same name".into(); + cx.update(|cx| { + f.workspace.update(cx, |workspace, _| { + workspace.set_connection_directory(Rc::new(Directory(RefCell::new(vec![web, db])))) + }) + }); + new_chat(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + window.render_frame(cx); + window.click("agent-attach-context", cx); + assert!(window.find("attach-connection-web").visible()); + assert!(window.find("attach-connection-db").visible()); + window.click("attach-connection-db", cx); + let thread = f.panel.read(cx).current().unwrap(); + assert!( + thread + .read(cx) + .composer() + .attachments() + .contains(&Attachment::Connection("db".into())) + ); + assert!( + !thread + .read(cx) + .composer() + .attachments() + .contains(&Attachment::Connection("web".into())) + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn a_closed_local_terminal_can_be_created_and_attached_from_the_first_row(cx: &mut TestAppContext) { + let f = fixture(cx); + cx.update_window(f.handle, |_, window, cx| { + f.workspace.update(cx, |workspace, cx| { + workspace.close_item(0, window, cx); + workspace.set_local_terminal_opener(|workspace, target, window, cx| { + let terminal = cx + .new(|cx| FakeTerminal::new(Access::new(None, Rc::new(SignIn::default())), cx)); + workspace.add_local_terminal_at(terminal, target, window, cx); + }); + }); + }) + .unwrap(); + new_chat(&f, cx); + assert!(cx.update(|cx| f.workspace.read(cx).terminals(cx).is_empty())); + cx.update_window(f.handle, |_, window, cx| { + window.render_frame(cx); + window.click("agent-attach-context", cx); + assert_eq!( + window.find("attach-local-terminal").label(), + Some("Local terminal") + ); + assert!( + f.workspace.read(cx).terminals(cx).is_empty(), + "opening the menu must not create a session" + ); + window.click("attach-local-terminal", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + let entries = f.workspace.read(cx).terminals(cx); + assert_eq!(entries.len(), 1); + let thread = f.panel.read(cx).current().unwrap(); + assert!( + thread + .read(cx) + .composer() + .attachments() + .contains(&Attachment::Terminal(entries[0].item)) + ); + }); + cx.update_window(f.handle, |_, window, cx| { + window.click("agent-attach-context", cx); + window.click("attach-local-terminal", cx); + assert_eq!( + f.workspace.read(cx).terminals(cx).len(), + 1, + "existing shell is reused" + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn the_server_row_can_detach_an_individually_attached_tab_then_attach_the_whole_server( + cx: &mut TestAppContext, +) { + let f = fixture(cx); + let directory = install(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + assert!(directory.open("web", &f.workspace.downgrade(), window, cx)); + }) + .unwrap(); + new_chat(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + window.click("agent-attach-context", cx); + let thread = f.panel.read(cx).current().unwrap(); + assert!( + thread + .read(cx) + .composer() + .attachments() + .iter() + .any(|attachment| matches!(attachment, Attachment::Terminal(_))) + ); + window.click("attach-connection-web", cx); + let thread = f.panel.read(cx).current().unwrap(); + assert!(thread.read(cx).composer().attachments().is_empty()); + window.click("attach-connection-web", cx); + assert_eq!( + thread.read(cx).composer().attachments(), + &[Attachment::Connection("web".into())] + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn a_project_chat_keeps_the_local_choice_without_falling_back_to_a_general_shell( + cx: &mut TestAppContext, +) { + let f = fixture(cx); + cx.update_window(f.handle, |_, window, cx| { + f.workspace.update(cx, |workspace, _| { + workspace + .set_local_terminal_opener(|_, _, _, _| panic!("must not open a General shell")); + }); + super::super::seat_binding::project_seat( + &f, + nocterm_project_model::ProjectId::from_bytes([1; 16]), + window, + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + new_chat(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + window.click("agent-attach-context", cx); + assert!(window.find("attach-local-terminal").visible()); + window.click("attach-local-terminal", cx); + assert!( + f.panel + .read(cx) + .current() + .unwrap() + .read(cx) + .workspace_seat() + .terminals(cx) + .unwrap() + .is_empty() + ); + }) + .unwrap(); + cx.run_until_parked(); +} diff --git a/crates/agent/src/panel/tests/terminal_fixture.rs b/crates/agent/src/panel/tests/terminal_fixture.rs index 60ddf134..8540fe1c 100644 --- a/crates/agent/src/panel/tests/terminal_fixture.rs +++ b/crates/agent/src/panel/tests/terminal_fixture.rs @@ -295,6 +295,20 @@ impl Item for FakeTerminal { } } +impl nocterm_workspace::LocalTerminal for FakeTerminal { + fn cwd(&self, _: &App) -> Option { + None + } + fn change_directory( + &mut self, + _: std::path::PathBuf, + _: &mut Window, + _: &mut Context, + ) -> Result<(), nocterm_workspace::LocalDirectoryError> { + Err(nocterm_workspace::LocalDirectoryError::Unavailable) + } +} + impl Access { pub(super) fn executor( &self, diff --git a/crates/agent/src/panel/tests/tool_input.rs b/crates/agent/src/panel/tests/tool_input.rs index 1d7a38d1..caaf15b0 100644 --- a/crates/agent/src/panel/tests/tool_input.rs +++ b/crates/agent/src/panel/tests/tool_input.rs @@ -1,4 +1,5 @@ use super::*; +mod scrolling; use gpui_kit::px; use nocterm_ai::model::session::{SessionNotice, SessionUpdate}; use nocterm_ai::model::tools::{ToolChanges, ToolPatch, ToolRecord, ToolStatus}; @@ -310,51 +311,6 @@ fn click_reveals_literal_source_copy_and_collapse_without_changing_result(cx: &m .unwrap(); } -#[gpui_kit::test] -fn large_input_is_bounded_scrollable_and_copied_in_full(cx: &mut TestAppContext) { - let script = format!( - "{}\n{}\nfinal line\n", - "long_line_".repeat(100), - "\tprint('unchanged')\n".repeat(13000) - ); - assert!(script.len() > 200 * 1024); - let f = tool_fixture(cx, "Large input", Some(json!(script))); - cx.update_window(f.handle, |_, window, cx| { - window.click(("tool-call", 0usize), cx) - }) - .unwrap(); - settle(&f, cx); - cx.update_window(f.handle, |_, window, cx| { - let scroll = window.find(("tool-input-scroll", 0usize)).bounds(); - let code = window.find(("tool-input-code", 0usize)).bounds(); - let entry = window.find(("agent-entry", 0usize)).bounds(); - assert!(scroll.size.height <= px(240.)); - assert!(scroll.right() <= entry.right() + px(1.)); - assert!(code.size.height > scroll.size.height * 2.); - assert!( - code.size.width > scroll.size.width, - "long code lines should scroll horizontally: {code:?}, {scroll:?}" - ); - window.scroll( - ("tool-input-scroll", 0usize), - gpui_kit::ScrollDelta::Pixels(gpui_kit::point(px(-80.), px(0.))), - cx, - ); - window.scroll( - ("tool-input-scroll", 0usize), - gpui_kit::ScrollDelta::Pixels(gpui_kit::point(px(0.), px(-80.))), - cx, - ); - window.render_frame(cx); - let moved = window.find(("tool-input-code", 0usize)).bounds(); - assert!(moved.origin.x < code.origin.x && moved.origin.y < code.origin.y); - assert_eq!(window.find(("tool-input-scroll", 0usize)).bounds(), scroll); - window.click(("copy-tool-input", 0usize), cx); - assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap(), script); - }) - .unwrap(); -} - #[gpui_kit::test] fn actual_hover_tooltip_is_short_and_bounded(cx: &mut TestAppContext) { let f = tool_fixture(cx, &"very long tool title\n".repeat(400), None); diff --git a/crates/agent/src/panel/tests/tool_input/scrolling.rs b/crates/agent/src/panel/tests/tool_input/scrolling.rs new file mode 100644 index 00000000..b25ef14c --- /dev/null +++ b/crates/agent/src/panel/tests/tool_input/scrolling.rs @@ -0,0 +1,206 @@ +//! Native wheel behavior of literal tool sections nested in a chat. +use super::*; +use gpui_kit::InputEvent as _; + +#[gpui_kit::test] +fn large_input_is_bounded_scrollable_and_copied_in_full(cx: &mut TestAppContext) { + let script = format!( + "{}\n{}\nfinal line\n", + "long_line_".repeat(100), + "\tprint('unchanged')\n".repeat(13000) + ); + assert!(script.len() > 200 * 1024); + let f = tool_fixture(cx, "Large input", Some(json!(script))); + cx.update_window(f.handle, |_, window, cx| { + window.click(("tool-call", 0usize), cx) + }) + .unwrap(); + settle(&f, cx); + cx.update_window(f.handle, |_, window, cx| { + let scroll = window.find(("tool-input-scroll", 0usize)).bounds(); + let code = window.find(("tool-input-code", 0usize)).bounds(); + let entry = window.find(("agent-entry", 0usize)).bounds(); + assert!(scroll.size.height <= px(240.)); + assert!(scroll.right() <= entry.right() + px(1.)); + assert!(code.size.height > scroll.size.height * 2.); + assert!( + code.size.width > scroll.size.width, + "long code lines should scroll horizontally: {code:?}, {scroll:?}" + ); + window.scroll( + ("tool-input-scroll", 0usize), + gpui_kit::ScrollDelta::Pixels(gpui_kit::point(px(-80.), px(0.))), + cx, + ); + window.scroll( + ("tool-input-scroll", 0usize), + gpui_kit::ScrollDelta::Pixels(gpui_kit::point(px(0.), px(-80.))), + cx, + ); + window.render_frame(cx); + let moved = window.find(("tool-input-code", 0usize)).bounds(); + assert!(moved.origin.x < code.origin.x && moved.origin.y < code.origin.y); + assert_eq!(window.find(("tool-input-scroll", 0usize)).bounds(), scroll); + window.click(("copy-tool-input", 0usize), cx); + assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap(), script); + }) + .unwrap(); +} + +fn nested_tool(cx: &mut TestAppContext, lines: usize) -> Fixture { + let f = fixture(cx); + new_chat(&f, cx); + cx.update(|cx| cx.set_reduce_motion(true)); + let thread = cx.update(|cx| f.panel.read(cx).current().unwrap()); + thread.update(cx, |thread, cx| { + let mut call = ToolRecord::new("script", "Scrollable command"); + call.status = ToolStatus::Succeeded; + call.raw_input = Some(json!(format!("{}\n", "echo line ".repeat(20)).repeat(lines))); + call.content = serde_json::from_value(json!([ + {"type":"content", "content":{"type":"text", "text":format!("{}\n", "result line ".repeat(20)).repeat(lines)}} + ])).unwrap(); + restore_entries(&f, thread, vec![ + Entry::User(vec![Content::text("Earlier question.\n\n".repeat(60))]), + Entry::Tool(call), + Entry::Agent("Later answer.\n\n".repeat(60)), + ], cx); + for row in 0..3 { thread.mark_dirty(row); } + cx.notify(); + }); + settle(&f, cx); + super::super::navigation::scroll_to(&f, 1, cx); + cx.update_window(f.handle, |_, window, cx| { + window.click(("tool-call", 1usize), cx) + }) + .unwrap(); + settle(&f, cx); + super::super::navigation::scroll_to(&f, 1, cx); + f +} + +fn section_id(namespace: &str, suffix: &str) -> (gpui_kit::SharedString, usize) { + (format!("{namespace}-{suffix}").into(), 1) +} + +#[gpui_kit::test] +fn wheel_over_tool_text_moves_only_the_text_in_both_directions(cx: &mut TestAppContext) { + let f = nested_tool(cx, 100); + for namespace in ["tool-input", "tool-output"] { + assert_nested_wheel(&f, namespace, cx); + } +} + +fn assert_nested_wheel(f: &Fixture, namespace: &str, cx: &mut TestAppContext) { + use gpui_kit::{ScrollDelta, ScrollWheelEvent, point}; + cx.update_window(f.handle, |_, window, cx| { + let viewport_id = section_id(namespace, "scroll"); + let code_id = section_id(namespace, "code"); + window.hover(viewport_id.clone(), cx); + let before = window.find(code_id.clone()).bounds(); + let viewport = window.find(viewport_id.clone()).bounds(); + let entry = window.find(("agent-entry", 1usize)).bounds(); + assert!(before.size.height > viewport.size.height * 2.); + // Dispatch a burst without rendering between events. + for _ in 0..3 { + window.dispatch_event( + ScrollWheelEvent { + position: viewport.center(), + delta: ScrollDelta::Pixels(point(px(0.), px(-20.))), + ..Default::default() + } + .to_platform_input(), + cx, + ); + } + window.render_frame(cx); + assert_eq!( + window.find(("agent-entry", 1usize)).bounds(), + entry, + "wheel over {namespace} must not move the transcript" + ); + assert_eq!( + window.find(code_id.clone()).bounds().top(), + before.top() - px(60.) + ); + window.scroll( + viewport_id.clone(), + ScrollDelta::Pixels(point(px(0.), px(40.))), + cx, + ); + assert_eq!(window.find(("agent-entry", 1usize)).bounds(), entry); + assert_eq!( + window.find(code_id.clone()).bounds().top(), + before.top() - px(20.) + ); + assert!(before.size.width > viewport.size.width); + window.scroll( + viewport_id, + ScrollDelta::Pixels(point(px(-40.), px(0.))), + cx, + ); + assert_eq!(window.find(("agent-entry", 1usize)).bounds(), entry); + assert_eq!( + window.find(code_id).bounds().left(), + before.left() - px(40.) + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn tool_text_chains_to_the_chat_only_after_reaching_either_edge(cx: &mut TestAppContext) { + use gpui_kit::{ScrollDelta, point}; + for namespace in ["tool-input", "tool-output"] { + for direction in [-1., 1.] { + let f = nested_tool(cx, 100); + cx.update_window(f.handle, |_, window, cx| { + let viewport_id = section_id(namespace, "scroll"); + let entry = window.find(("agent-entry", 1usize)).bounds(); + window.scroll( + viewport_id.clone(), + ScrollDelta::Pixels(point(px(0.), px(-60.))), + cx, + ); + window.scroll( + viewport_id.clone(), + ScrollDelta::Pixels(point(px(0.), px(direction * 1_000_000.))), + cx, + ); + assert_eq!( + window.find(("agent-entry", 1usize)).bounds(), + entry, + "reaching an inner edge must not also move the chat" + ); + window.scroll( + viewport_id, + ScrollDelta::Pixels(point(px(0.), px(direction * 24.))), + cx, + ); + let after = window.find(("agent-entry", 1usize)).bounds(); + assert_eq!(after.top(), entry.top() + px(direction * 24.)); + }) + .unwrap(); + } + } +} + +#[gpui_kit::test] +fn fitting_tool_text_passes_vertical_wheel_to_the_chat(cx: &mut TestAppContext) { + use gpui_kit::{ScrollDelta, point}; + for namespace in ["tool-input", "tool-output"] { + let f = nested_tool(cx, 1); + cx.update_window(f.handle, |_, window, cx| { + let entry = window.find(("agent-entry", 1usize)).bounds(); + window.scroll( + section_id(namespace, "scroll"), + ScrollDelta::Pixels(point(px(0.), px(-24.))), + cx, + ); + assert_eq!( + window.find(("agent-entry", 1usize)).bounds().top(), + entry.top() - px(24.) + ); + }) + .unwrap(); + } +} diff --git a/crates/ai/src/history.rs b/crates/ai/src/history.rs index 339152b1..4f9c5850 100644 --- a/crates/ai/src/history.rs +++ b/crates/ai/src/history.rs @@ -46,12 +46,12 @@ const MAX_SAVED_IMAGE_BYTES: usize = 512 * 1024; const OMITTED_IMAGE_TEXT: &str = "[Image not kept in history]"; /// Only the most recent entries of a very long chat are kept. /// Maximum persisted transcript rows, shared by pre-copy admission. -pub const MAX_SAVED_ENTRIES: usize = 2_000; +pub const MAX_SAVED_ENTRIES: usize = 20_000; /// Larger files are not read back. -pub const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; +pub const MAX_FILE_BYTES: u64 = 128 * 1024 * 1024; /// At most this many chats are restored, newest first. pub const MAX_RESTORED: usize = 200; -/// Maximum resident structural payload for a restored document or prepared catalog. +/// Maximum structural payload of one saved or live document, not an installation quota. pub const MAX_RESIDENT_BYTES: usize = preflight::LIMIT; /// Context references that survive application restarts. Local terminal ids do not. diff --git a/crates/ai/src/history/budget.rs b/crates/ai/src/history/budget.rs index 1727c976..ebc7a719 100644 --- a/crates/ai/src/history/budget.rs +++ b/crates/ai/src/history/budget.rs @@ -158,7 +158,7 @@ mod tests { SavedAttachment::LocalTerminal("local".into()), SavedAttachment::Connection("remote".into()), ]; - chat.entries = (0..2010) + chat.entries = (0..MAX_SAVED_ENTRIES + 10) .map(|index| Entry::Agent(format!("entry {index}"))) .collect(); let blocks = vec![ @@ -198,7 +198,10 @@ mod tests { materialized.entries = SavedChat::bounded_entries(&chat.entries); materialized.times = SavedChat::bounded_times(chat.entries.len(), &chat.times); assert_eq!(serde_json::to_vec(&materialized).unwrap(), expected); - assert_eq!(materialized.times[0], (chat.entries.len() - 2000) as u64); + assert_eq!( + materialized.times[0], + (chat.entries.len() - MAX_SAVED_ENTRIES) as u64 + ); let prepared = PreparedBudget::new(&chat).unwrap(); let mut budget = HistoryBudget::restored(Some(&prepared)); let mut metadata = materialized.clone(); diff --git a/crates/ai/src/history/decode.rs b/crates/ai/src/history/decode.rs index 02dc1657..c2c15fed 100644 --- a/crates/ai/src/history/decode.rs +++ b/crates/ai/src/history/decode.rs @@ -9,7 +9,7 @@ use std::marker::PhantomData; pub(super) fn entries<'de, D: serde::Deserializer<'de>>( d: D, ) -> Result, D::Error> { - bounded::<_, _, 2000>(d) + bounded::<_, _, { super::MAX_SAVED_ENTRIES }>(d) } pub(super) fn prompts<'de, D: serde::Deserializer<'de>>( d: D, @@ -25,7 +25,7 @@ pub(super) fn images<'de, D: serde::Deserializer<'de>>(d: D) -> Result(d) } pub(super) fn times<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - bounded::<_, _, 2000>(d) + bounded::<_, _, { super::MAX_SAVED_ENTRIES }>(d) } fn bounded<'de, D, T, const LIMIT: usize>(d: D) -> Result, D::Error> where diff --git a/crates/ai/src/history/preflight.rs b/crates/ai/src/history/preflight.rs index f6db6eeb..73b5af42 100644 --- a/crates/ai/src/history/preflight.rs +++ b/crates/ai/src/history/preflight.rs @@ -1,7 +1,7 @@ //! Structural accounting before serde allocates protocol objects and their vectors. use super::HistoryCodecError; use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; -pub(super) const LIMIT: usize = 64 * 1024 * 1024; +pub(super) const LIMIT: usize = 256 * 1024 * 1024; struct Budget { bytes: usize, exceeded: bool, diff --git a/crates/ai/src/lib.rs b/crates/ai/src/lib.rs index 47022ae3..d19bee24 100644 --- a/crates/ai/src/lib.rs +++ b/crates/ai/src/lib.rs @@ -2,7 +2,8 @@ //! //! Owns: neutral `model` records (the only agent vocabulary other crates see), agent //! profiles, catalog and settings resolution (`agents`), configuration rules, transcript reduction, session/tool rules, pure sandbox policy, -//! bounded history codecs, immutable scoped execution origins and capability/storage ports. +//! bounded history codecs, readable conversation export, immutable scoped execution origins +//! and capability/storage ports. //! The terminal tool catalog includes file transfers and shared agent-facing terminal, //! command, observation and transfer reports; display data never grants execution authority. //! File tools for terminals that run no programs are pure rules over a captured `RemoteFs`; diff --git a/crates/ai/src/storage.rs b/crates/ai/src/storage.rs index 38207fcb..776f4994 100644 --- a/crates/ai/src/storage.rs +++ b/crates/ai/src/storage.rs @@ -10,13 +10,11 @@ pub use prepared::{PreparedChatBytes, PreparedChatLoad}; #[derive(Clone, Debug, thiserror::Error)] pub enum AgentStorageError { - #[error("All normal installation history payload slots are retained or loading")] + #[error("Chat history is busy. Close an unused chat or try again shortly.")] PayloadCapacity, #[error("The history repository cannot prepare a charged decoded load")] AllocationUnsupported, - #[error( - "Saved history needs {incoming} bytes with {retained} retained; installation limit is {limit} bytes" - )] + #[error("Chat history needs more memory. Close an unused chat and try again.")] ResidentCapacity { retained: usize, incoming: usize, diff --git a/crates/ai/src/thread.rs b/crates/ai/src/thread.rs index c552d4fc..f0177419 100644 --- a/crates/ai/src/thread.rs +++ b/crates/ai/src/thread.rs @@ -7,6 +7,7 @@ use crate::model::{ usage::{ContextUsage, TokenTotals}, }; use serde::{Deserialize, Serialize}; +pub mod export; pub mod live; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", content = "value", rename_all = "snake_case")] diff --git a/crates/ai/src/thread/export.rs b/crates/ai/src/thread/export.rs new file mode 100644 index 00000000..902f3b60 --- /dev/null +++ b/crates/ai/src/thread/export.rs @@ -0,0 +1,132 @@ +//! Readable conversation text, preserving complete messages and tool results. +use super::Entry; +use crate::model::{ + content::{Content, ResourceBody}, + tools::ToolContent, +}; +use std::fmt::Write as _; + +pub fn conversation_text(title: &str, entries: &[Entry]) -> String { + let mut text = format!("# {title}\n\n"); + for entry in entries { + match entry { + Entry::User(parts) => { + text.push_str("## User\n\n"); + for part in parts { + content(&mut text, part); + } + } + Entry::Agent(value) => { + let _ = writeln!(text, "## Assistant\n\n{value}"); + } + Entry::Thought(value) => { + let _ = writeln!(text, "## Reasoning\n\n{value}"); + } + Entry::Content(value) => { + text.push_str("## Attachment\n\n"); + content(&mut text, value); + } + Entry::Tool(tool) => { + let _ = writeln!( + text, + "## Tool: {}\n\nStatus: {:?}\n", + tool.title, tool.status + ); + if let Some(input) = &tool.raw_input { + let _ = writeln!(text, "Input:\n{input:#}\n"); + } + for value in &tool.content { + match value { + ToolContent::Content(value) => content(&mut text, &value.content), + ToolContent::Diff(diff) => { + let _ = writeln!( + text, + "File: {}\nBefore:\n{}\nAfter:\n{}", + diff.path.display(), + diff.old_text.as_deref().unwrap_or_default(), + diff.new_text + ); + } + ToolContent::Terminal(terminal) => { + let _ = writeln!(text, "Terminal: {}", terminal.terminal_id); + } + } + } + if let Some(output) = &tool.raw_output { + let _ = writeln!(text, "Output:\n{output:#}"); + } + } + } + text.push('\n'); + } + text +} + +fn content(text: &mut String, content: &Content) { + match content { + Content::Text(value) => { + let _ = writeln!(text, "{}", value.text); + } + Content::Image(value) => { + let _ = writeln!( + text, + "[Image: {}]", + value.uri.as_deref().unwrap_or(&value.mime_type) + ); + } + Content::Audio(value) => { + let _ = writeln!(text, "[Audio: {}]", value.mime_type); + } + Content::ResourceReference(value) => { + let _ = writeln!(text, "[Resource: {}] {}", value.name, value.uri); + } + Content::EmbeddedResource(value) => match &value.resource { + ResourceBody::Text(value) => { + let _ = writeln!(text, "{}\n{}", value.uri, value.text); + } + ResourceBody::Blob(value) => { + let _ = writeln!(text, "[Resource: {}]", value.uri); + } + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::tools::ToolRecord; + + #[test] + fn conversation_export_keeps_full_text_reasoning_and_tool_output_in_order() { + let long = format!("{}\ncomplete ending", "answer ".repeat(50_000)); + let entries = vec![ + Entry::User(vec![Content::text("first\nsecond")]), + Entry::Thought("check the server".into()), + Entry::Tool( + ToolRecord::new("tool-1", "Read logs") + .raw_input(Some(serde_json::json!({"path":"server.log"}))) + .content(vec![ + Content::text("complete tool text").into(), + ToolContent::Diff(crate::model::tools::FileDiff { + path: "config.toml".into(), + old_text: Some("old settings".into()), + new_text: "new settings".into(), + meta: None, + }), + ]) + .raw_output(Some(serde_json::json!({"stdout":"all log lines"}))), + ), + Entry::Agent(long.clone()), + ]; + let text = conversation_text("Audit", &entries); + assert!(text.starts_with("# Audit\n\n## User\n\nfirst\nsecond\n")); + assert!(text.contains("## Reasoning\n\ncheck the server")); + assert!(text.contains("## Tool: Read logs")); + assert!(text.contains("all log lines")); + assert!(text.contains("server.log")); + assert!(text.contains("complete tool text")); + assert!(text.contains("File: config.toml\nBefore:\nold settings\nAfter:\nnew settings")); + assert!(text.ends_with(&format!("## Assistant\n\n{long}\n\n"))); + assert!(text.find("## Reasoning") < text.find("## Tool")); + } +} diff --git a/crates/ai/src/thread/live.rs b/crates/ai/src/thread/live.rs index 4fe60f3c..6fe22e7f 100644 --- a/crates/ai/src/thread/live.rs +++ b/crates/ai/src/thread/live.rs @@ -163,7 +163,7 @@ mod tests { assert!(resident_bytes(&state).unwrap() <= bytes); accepted += 1; } - assert!(accepted > 1 && accepted < 64); + assert!(accepted > 1 && accepted < MAX_RESIDENT_BYTES / (1024 * 1024)); assert!(matches!( admit_update(&state, bytes, &update), Err(HistoryCodecError::StructuralLimit) diff --git a/crates/ai/tests/history_residency.rs b/crates/ai/tests/history_residency.rs index efc9c4d0..69f7422b 100644 --- a/crates/ai/tests/history_residency.rs +++ b/crates/ai/tests/history_residency.rs @@ -2,6 +2,34 @@ use nocterm_ai::history::{SavedChat, SavedPrompt, SharedChat}; use std::sync::Arc; +#[test] +fn a_large_chat_reopens_with_every_saved_row_and_timestamp() { + use nocterm_ai::{history, thread::Entry}; + let mut chat = SavedChat::for_identity( + "codex".into(), + "00000000-0000-0000-0000-000000000002".into(), + 1, + ); + chat.entries = (0..20_000) + .map(|index| Entry::Agent(format!("answer {index}"))) + .collect(); + chat.times = (1..=20_000).collect(); + chat.entries[19_000] = Entry::Agent("x".repeat(33 * 1024 * 1024)); + let bytes = history::encode(&chat).unwrap(); + let restored = history::decode(&bytes, &chat.header.id).unwrap(); + assert_eq!(restored.entries.len(), 20_000); + assert!( + restored + .entries + .iter() + .zip(&chat.entries) + .all(|(actual, expected)| { + matches!((actual, expected), (Entry::Agent(a), Entry::Agent(e)) if a == e) + }) + ); + assert_eq!(restored.times, chat.times); +} + #[test] fn retained_snapshot_memory_counts_transcript_and_shared_queue_without_encoding() { let mut metadata = SavedChat::for_identity( diff --git a/crates/core/src/fs/bounded.rs b/crates/core/src/fs/bounded.rs index eb950bd5..5134952d 100644 --- a/crates/core/src/fs/bounded.rs +++ b/crates/core/src/fs/bounded.rs @@ -106,9 +106,11 @@ fn open_without_following(path: &Path) -> io::Result { use std::os::windows::fs::OpenOptionsExt as _; /// `FILE_FLAG_OPEN_REPARSE_POINT`: open the link itself, not its target. const OPEN_REPARSE_POINT: u32 = 0x0020_0000; + /// Inspect directories before refusing their kind, rather than reporting access denied. + const BACKUP_SEMANTICS: u32 = 0x0200_0000; OpenOptions::new() .read(true) - .custom_flags(OPEN_REPARSE_POINT) + .custom_flags(OPEN_REPARSE_POINT | BACKUP_SEMANTICS) .open(path) } diff --git a/crates/core/src/fs/tests.rs b/crates/core/src/fs/tests.rs index 2f02818d..929da96a 100644 --- a/crates/core/src/fs/tests.rs +++ b/crates/core/src/fs/tests.rs @@ -323,7 +323,7 @@ fn bounded_read_refuses_directories() { let directory = tempfile::tempdir().unwrap(); assert!(matches!( read_regular_bounded(directory.path(), 16).unwrap_err(), - BoundedReadError::NotRegular { .. } | BoundedReadError::Io { .. } + BoundedReadError::NotRegular { .. } )); } diff --git a/crates/localfs/Cargo.toml b/crates/localfs/Cargo.toml index 13426d28..07c96e1e 100644 --- a/crates/localfs/Cargo.toml +++ b/crates/localfs/Cargo.toml @@ -21,8 +21,14 @@ nix = { version = "0.28", features = ["fs"] } [target.'cfg(windows)'.dependencies] atomicwrites = "0.4.4" -winapi-util = "0.1.11" -windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Wdk_Storage_FileSystem"] } +fs_at = "0.2.1" +winx = "0.36.4" +ouroboros = "0.18.5" +# Pin the safe opened-handle identity wrapper; its API is not yet stable. +prikk-ffi = "=0.56.0" +cap-primitives = "4.0.3" +tracing.workspace = true [lints] workspace = true diff --git a/crates/localfs/src/cursor.rs b/crates/localfs/src/cursor.rs index 8ec31ce7..b7dc0be4 100644 --- a/crates/localfs/src/cursor.rs +++ b/crates/localfs/src/cursor.rs @@ -1,17 +1,25 @@ use crate::directory::DirectoryCapability; use nocterm_session::local_fs::*; +#[cfg(not(windows))] use std::{ffi::OsString, path::PathBuf}; +#[cfg(windows)] +mod windows; #[cfg(unix)] struct Cursor { entries: rustix::fs::Dir, path: PathBuf, } -#[cfg(not(unix))] +#[cfg(not(any(unix, windows)))] struct Cursor { entries: std::fs::ReadDir, path: PathBuf, } +#[cfg(windows)] +pub(crate) fn open(directory: &DirectoryCapability) -> LocalResult> { + windows::open(directory) +} +#[cfg(not(windows))] pub(crate) fn open(directory: &DirectoryCapability) -> LocalResult> { #[cfg(unix)] let entries = rustix::fs::Dir::read_from(directory.file()) @@ -24,6 +32,7 @@ pub(crate) fn open(directory: &DirectoryCapability) -> LocalResult LocalResult> { loop { diff --git a/crates/localfs/src/cursor/windows.rs b/crates/localfs/src/cursor/windows.rs new file mode 100644 index 00000000..8b626d8d --- /dev/null +++ b/crates/localfs/src/cursor/windows.rs @@ -0,0 +1,51 @@ +use crate::directory::DirectoryCapability; +use nocterm_session::local_fs::{LocalCursor, LocalFsError, LocalResult}; +use ouroboros::self_referencing; +use std::{ffi::OsString, fs::File}; + +/// Owns an independent enumeration position and retains the captured directory's +/// complete capability chain. The iterator borrows only its own reopened handle. +#[self_referencing] +struct Cursor { + directory: DirectoryCapability, + file: File, + #[borrows(mut file)] + #[not_covariant] + entries: fs_at::ReadDir<'this>, +} + +pub(super) fn open(directory: &DirectoryCapability) -> LocalResult> { + // Duplicating a handle would share its enumeration position. Reopen the + // captured object itself so concurrent cursors never disturb each other. + let file = crate::windows::open::enumeration(directory)?; + let cursor = CursorTryBuilder { + directory: directory.clone(), + file, + entries_builder: |file| fs_at::read_dir(file), + } + .try_build() + .map_err(|error| LocalFsError::from_io(directory.path(), error))?; + Ok(Box::new(cursor)) +} + +impl LocalCursor for Cursor { + fn next(&mut self) -> LocalResult> { + loop { + let next = self.with_entries_mut(|entries| { + entries + .next() + .transpose() + .map(|entry| entry.map(|entry| entry.name().to_os_string())) + }); + let Some(name) = + next.map_err(|error| LocalFsError::from_io(self.borrow_directory().path(), error))? + else { + return Ok(None); + }; + // An empty directory can produce an empty first record on Windows. + if !name.is_empty() && name != "." && name != ".." { + return Ok(Some(name)); + } + } + } +} diff --git a/crates/localfs/src/directory.rs b/crates/localfs/src/directory.rs index 574e4922..aaedfaa8 100644 --- a/crates/localfs/src/directory.rs +++ b/crates/localfs/src/directory.rs @@ -4,7 +4,7 @@ use std::{ path::{Component, Path, PathBuf}, sync::Arc, }; -#[cfg(not(unix))] +#[cfg(not(any(unix, windows)))] mod portable; #[cfg(unix)] mod unix; @@ -16,11 +16,10 @@ mod windows; #[derive(Clone)] pub(crate) struct DirectoryCapability { path: PathBuf, - #[cfg(unix)] + #[cfg(any(unix, windows))] handle: Arc, - /// Strict Windows capabilities hold their whole ancestry; browse ones hold nothing. #[cfg(windows)] - held: Option>, + _ancestor: Option>, identity: DirectoryIdentity, } impl DirectoryCapability { @@ -44,7 +43,11 @@ impl DirectoryCapability { { unix::browse(path) } - #[cfg(not(unix))] + #[cfg(windows)] + { + windows::browse(path) + } + #[cfg(not(any(unix, windows)))] { portable::browse(path) } @@ -52,6 +55,10 @@ impl DirectoryCapability { pub(crate) fn path(&self) -> &Path { &self.path } + #[cfg(windows)] + pub(crate) fn validate_publication_ancestry(&self) -> LocalResult<()> { + windows::validate_publication_ancestry(self) + } pub(crate) fn cursor(&self) -> LocalResult> { crate::cursor::open(self) } @@ -61,7 +68,11 @@ impl DirectoryCapability { { unix::metadata(self, name) } - #[cfg(not(unix))] + #[cfg(windows)] + { + windows::metadata(self, name) + } + #[cfg(not(any(unix, windows)))] { portable::metadata(self, name) } @@ -86,12 +97,16 @@ impl DirectoryCapability { { unix::target_is_directory(self, name) } - #[cfg(not(unix))] + #[cfg(windows)] + { + windows::target_is_directory(self, name) + } + #[cfg(not(any(unix, windows)))] { portable::target_is_directory(self, name) } } - #[cfg(unix)] + #[cfg(any(unix, windows))] pub(crate) fn file(&self) -> &std::fs::File { self.handle.as_ref() } @@ -203,4 +218,16 @@ pub(crate) fn metadata_native(metadata: &std::fs::Metadata) -> FileMetadata { } #[cfg(not(unix))] -pub(crate) use portable::is_link; +pub(crate) fn is_link(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() + & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT + != 0 + } + #[cfg(not(windows))] + { + metadata.is_symlink() + } +} diff --git a/crates/localfs/src/directory/portable.rs b/crates/localfs/src/directory/portable.rs index 795e7204..550f04e6 100644 --- a/crates/localfs/src/directory/portable.rs +++ b/crates/localfs/src/directory/portable.rs @@ -1,17 +1,4 @@ -//! Path-checked fallbacks: Windows browse handles and platforms without held directories. use super::*; -pub(crate) fn is_link(metadata: &std::fs::Metadata) -> bool { - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - metadata.file_attributes() & 0x400 != 0 - } - #[cfg(not(windows))] - { - metadata.is_symlink() - } -} -#[cfg(not(windows))] pub(super) fn strict(path: &Path) -> LocalResult { let mut current = PathBuf::new(); for component in path.components() { @@ -37,12 +24,9 @@ pub(super) fn browse(path: &Path) -> LocalResult { } Ok(DirectoryCapability { path: path.to_owned(), - #[cfg(windows)] - held: None, identity: DirectoryIdentity::portable(path.to_owned()), }) } -#[cfg(not(windows))] pub(super) fn child( directory: &DirectoryCapability, name: &OsStr, @@ -77,7 +61,6 @@ pub(super) fn metadata( pub(super) fn target_is_directory(directory: &DirectoryCapability, name: &OsStr) -> bool { std::fs::metadata(directory.path.join(name)).is_ok_and(|metadata| metadata.is_dir()) } -#[cfg(not(windows))] pub(super) fn remove_file(directory: &DirectoryCapability, name: &OsStr) -> LocalResult<()> { strict(directory.path())?; let path = directory.path.join(name); diff --git a/crates/localfs/src/directory/windows.rs b/crates/localfs/src/directory/windows.rs index 15df8b98..47ab20e7 100644 --- a/crates/localfs/src/directory/windows.rs +++ b/crates/localfs/src/directory/windows.rs @@ -1,108 +1,169 @@ -//! Windows held directories. A strict capability keeps an open handle to every -//! component from the volume root down. Handles are opened without -//! `FILE_SHARE_DELETE`, so while the capability lives no held component can be -//! renamed, removed or replaced by a junction; path operations below it therefore -//! resolve through the same directories that were checked. Reparse points are -//! opened as themselves and refused. use super::*; -use std::os::windows::fs::OpenOptionsExt as _; -use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, - FILE_FLAG_OPEN_REPARSE_POINT, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, - FILE_SHARE_WRITE, SYNCHRONIZE, -}; +use crate::windows::{diagnostic, open}; +use std::fs::File; +use windows_sys::Win32::Storage::FileSystem::FILE_READ_ATTRIBUTES; -/// One held directory and, through `parent`, every ancestor above it. -pub(crate) struct Held { - _file: std::fs::File, - _parent: Option>, +fn from_file( + path: PathBuf, + file: File, + ancestor: Option>, +) -> LocalResult { + let metadata = diagnostic::checked( + "GetFileInformationByHandle(metadata)", + &path, + FILE_READ_ATTRIBUTES, + 0, + 0, + file.metadata(), + )?; + if super::is_link(&metadata) { + return Err(LocalFsError::LinkRefused(path)); + } + if !metadata.is_dir() { + return Err(LocalFsError::NotRegular(path)); + } + let identity = diagnostic::checked( + "GetFileInformationByHandleEx(identity)", + &path, + FILE_READ_ATTRIBUTES, + 0, + 0, + crate::windows::identity(&file), + )?; + Ok(DirectoryCapability { + identity, + path, + handle: Arc::new(file), + _ancestor: ancestor, + }) } -/// Open `path` as a directory without following a final reparse point. The -/// requested list access takes part in share accounting, so the omitted delete -/// sharing actually pins the directory's name. -fn open(path: &Path, parent: Option>) -> LocalResult<(Arc, DirectoryIdentity)> { - let file = std::fs::OpenOptions::new() - .access_mode(FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | SYNCHRONIZE) - .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) - .map_err(|error| LocalFsError::from_io(path, error))?; - // The opened handle, not the path, supplies the attributes and identity. - let information = winapi_util::file::information(&file) - .map_err(|error| LocalFsError::from_io(path, error))?; - let attributes = information.file_attributes(); - if attributes & u64::from(FILE_ATTRIBUTE_REPARSE_POINT) != 0 - || attributes & u64::from(FILE_ATTRIBUTE_DIRECTORY) == 0 - { - return Err(LocalFsError::LinkRefused(path.to_owned())); +pub(super) fn validate_publication_ancestry(directory: &DirectoryCapability) -> LocalResult<()> { + let mut ancestor = Some(directory); + while let Some(directory) = ancestor { + let metadata = diagnostic::checked( + "GetFileInformationByHandle(ancestry)", + directory.path(), + FILE_READ_ATTRIBUTES, + 0, + 0, + directory.file().metadata(), + )?; + if super::is_link(&metadata) { + return Err(LocalFsError::LinkRefused(directory.path().into())); + } + ancestor = directory._ancestor.as_deref(); } - Ok(( - Arc::new(Held { - _file: file, - _parent: parent, - }), - DirectoryIdentity::native(information.volume_serial_number(), information.file_index()), - )) + Ok(()) } pub(super) fn strict(path: &Path) -> LocalResult { - let mut current = PathBuf::new(); - let mut held = None; - for component in path.components() { - current.push(component); - if matches!(component, Component::Prefix(_)) { - continue; + let normalized: PathBuf = path.components().collect(); + let _span = tracing::info_span!( + "windows_directory_open", pid = std::process::id(), + requested_path = ?path, normalized_path = ?normalized, + ) + .entered(); + if path.components().count() > 256 { + return Err(LocalFsError::BoundExceeded("native directory ancestry")); + } + let mut parts = path.components(); + let Some(Component::Prefix(prefix)) = parts.next() else { + return Err(LocalFsError::InvalidPath(path.into())); + }; + if !matches!(parts.next(), Some(Component::RootDir)) { + return Err(LocalFsError::InvalidPath(path.into())); + } + let root = PathBuf::from(prefix.as_os_str()).join(std::path::MAIN_SEPARATOR_STR); + let file = open::root(&root)?; + let file = open::pin_directory(&file, &root)?; + let mut directory = from_file(root, file, None)?; + for part in parts { + if let Component::Normal(name) = part { + directory = child(&directory, name, false)?; } - let parent = held.take().map(|(handle, _)| handle); - held = Some(open(¤t, parent)?); } - let (handle, identity) = held.ok_or_else(|| LocalFsError::InvalidPath(path.to_owned()))?; - Ok(DirectoryCapability { - path: path.to_owned(), - held: Some(handle), - identity, - }) + Ok(directory) +} + +pub(super) fn browse(path: &Path) -> LocalResult { + let canonical = + std::fs::canonicalize(path).map_err(|error| LocalFsError::from_io(path, error))?; + let _span = tracing::info_span!( + "windows_directory_browse", pid = std::process::id(), + requested_path = ?path, normalized_path = ?canonical, + ) + .entered(); + strict(&canonical) } pub(super) fn child( - directory: &DirectoryCapability, + parent: &DirectoryCapability, name: &OsStr, create: bool, ) -> LocalResult { - // A browse capability followed links and holds nothing; re-establish the chain. - let parent = match &directory.held { - Some(held) => held.clone(), - None => strict(directory.path())? - .held - .ok_or_else(|| LocalFsError::InvalidPath(directory.path().to_owned()))?, - }; - let path = directory.path.join(name); - if create { - match std::fs::create_dir(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(error) => return Err(LocalFsError::from_io(&path, error)), + check_name(name)?; + let path = parent.path.join(name); + if path.components().count() > 256 { + return Err(LocalFsError::BoundExceeded("native directory ancestry")); + } + let opened = open::child(parent, name, create)?; + let pinned = open::pin_directory(&opened, &path)?; + let child = from_file(path.clone(), pinned, Some(Arc::new(parent.clone())))?; + // Reopening with no-delete sharing may race an earlier rename. Confirm that + // the pinned object is still the parent's selected entry before admitting it. + let selected = open::child(parent, name, false)?; + let selected = diagnostic::checked( + "GetFileInformationByHandleEx(verify-identity)", + &path, + FILE_READ_ATTRIBUTES, + 0, + 0, + crate::windows::identity(&selected), + )?; + if child.identity != selected { + return Err(LocalFsError::LinkRefused(path)); + } + Ok(child) +} + +pub(super) fn metadata( + parent: &DirectoryCapability, + name: &OsStr, +) -> LocalResult> { + let path = parent.path.join(name); + match open::entry(parent, name, open::EntryAccess::Metadata) { + Ok(file) => { + let metadata = diagnostic::checked( + "GetFileInformationByHandle(entry-metadata)", + &path, + FILE_READ_ATTRIBUTES, + 0, + 0, + file.metadata(), + )?; + let mut value = metadata_native(&metadata); + value.is_symlink = super::is_link(&metadata); + Ok(Some(value)) } + Err(LocalFsError::NotFound(_)) => Ok(None), + Err(error) => Err(error), } - let (handle, identity) = open(&path, Some(parent))?; - Ok(DirectoryCapability { - path, - held: Some(handle), - identity, - }) } -pub(super) fn remove_file(directory: &DirectoryCapability, name: &OsStr) -> LocalResult<()> { - let _pinned = match directory.held { - Some(_) => None, - None => Some(strict(directory.path())?), - }; - let path = directory.path.join(name); - if super::portable::metadata(directory, name)?.is_some_and(|metadata| { - metadata.kind == nocterm_session::EntryKind::Directory && !metadata.is_symlink - }) { +pub(super) fn target_is_directory(directory: &DirectoryCapability, name: &OsStr) -> bool { + open::target_is_directory(directory, name) +} + +pub(super) fn remove_file(parent: &DirectoryCapability, name: &OsStr) -> LocalResult<()> { + let path = parent.path.join(name); + let file = open::entry(parent, name, open::EntryAccess::Remove)?; + let metadata = file + .metadata() + .map_err(|error| LocalFsError::from_io(&path, error))?; + if metadata.is_dir() && !super::is_link(&metadata) { return Err(LocalFsError::NotRegular(path)); } - std::fs::remove_file(&path).map_err(|error| LocalFsError::from_io(path, error)) + fs_at::os::windows::FileExt::delete_by_handle(file) + .map_err(|(_, error)| LocalFsError::from_io(path, error)) } diff --git a/crates/localfs/src/lib.rs b/crates/localfs/src/lib.rs index 471b6728..aeeac899 100644 --- a/crates/localfs/src/lib.rs +++ b/crates/localfs/src/lib.rs @@ -5,10 +5,11 @@ //! Does not own: views, transfer scheduling or remote filesystem protocols. //! Ports used: none. Ports provided: `LocalFs`, `LocalMutations`. //! Settings sections: none. Globals: none. -//! Invariants: strict operations pin every ancestor (Unix descriptors; Windows handles -//! held without delete sharing, ADR 0162); read-only browsing alone may follow links. -//! Portable traversal on other targets rechecks paths and retains its active-ancestor -//! race limitation. Publication errors precede commit; durability warnings follow it. +//! Invariants: strict traversal rejects linked ancestors. Windows retains a bounded +//! chain of native handles and guards publication against reparse conversion; +//! Unix uses directory-relative operations. Read-only browsing alone may follow links. +//! Other portable targets retain their active-ancestor race limitation. +//! Publication errors precede commit; durability warnings follow it. //! A committed stage relinquishes its temporary name before it can be reused. mod cursor; mod directory; @@ -16,7 +17,13 @@ mod listing; mod mutations; mod scan; mod source; +#[cfg(not(windows))] mod stage; +#[cfg(windows)] +#[path = "stage/windows.rs"] +mod stage; +#[cfg(windows)] +mod windows; use nocterm_session::{FileMetadata, FsCapabilities, local_fs::*}; use std::{ ffi::OsStr, diff --git a/crates/localfs/src/mutations.rs b/crates/localfs/src/mutations.rs index 9bfdf5c4..bdbda404 100644 --- a/crates/localfs/src/mutations.rs +++ b/crates/localfs/src/mutations.rs @@ -30,6 +30,7 @@ pub(crate) fn rename(path: &Path, name: &OsStr) -> LocalResult<()> { #[cfg(windows)] { let _ = old; + let _guard = crate::windows::PublicationGuard::new(&directory)?; atomicwrites::move_atomic(path, &directory.path().join(name)) .map_err(|error| LocalFsError::from_io(path, error)) } @@ -126,7 +127,39 @@ impl Removal { self.removed = self.removed.saturating_add(1); Ok(()) } - #[cfg(not(unix))] + #[cfg(windows)] + fn entry( + &mut self, + directory: &crate::directory::DirectoryCapability, + name: &OsStr, + depth: usize, + ) -> LocalResult<()> { + self.enter(depth)?; + let path = directory.path().join(name); + let metadata = directory + .inspect(name)? + .ok_or_else(|| LocalFsError::NotFound(path.clone()))?; + let is_directory = + metadata.kind == nocterm_session::EntryKind::Directory && !metadata.is_symlink; + if is_directory { + let child = directory.descend(name, false)?; + let mut cursor = child.cursor()?; + while let Some(name) = cursor.next()? { + self.entry(&child, &name, depth + 1)?; + } + } + self.cancel.check()?; + let options = fs_at::OpenOptions::default(); + let result = if is_directory { + options.rmdir_at(directory.file(), name) + } else { + options.unlink_at(directory.file(), name) + }; + result.map_err(|error| LocalFsError::from_io(path, error))?; + self.removed = self.removed.saturating_add(1); + Ok(()) + } + #[cfg(not(any(unix, windows)))] fn entry( &mut self, directory: &crate::directory::DirectoryCapability, @@ -152,14 +185,7 @@ impl Removal { if is_directory && (!current.is_dir() || crate::directory::is_link(¤t)) { return Err(LocalFsError::LinkRefused(path)); } - #[cfg(windows)] - let remove_dir = { - use std::os::windows::fs::MetadataExt; - current.file_attributes() & 0x10 != 0 - }; - #[cfg(not(windows))] - let remove_dir = is_directory; - let result = if remove_dir { + let result = if is_directory { std::fs::remove_dir(&path) } else { std::fs::remove_file(&path) diff --git a/crates/localfs/src/source.rs b/crates/localfs/src/source.rs index dba36ebc..51d2dbaf 100644 --- a/crates/localfs/src/source.rs +++ b/crates/localfs/src/source.rs @@ -40,7 +40,10 @@ pub(crate) fn open_relative( })?; fd.into() }; - #[cfg(not(unix))] + #[cfg(windows)] + let file = + crate::windows::open::entry(directory, name, crate::windows::open::EntryAccess::Source)?; + #[cfg(not(any(unix, windows)))] let file = { if directory.inspect(name)?.is_none_or(|metadata| { metadata.is_symlink || metadata.kind != nocterm_session::EntryKind::File @@ -49,13 +52,6 @@ pub(crate) fn open_relative( } let mut options = std::fs::OpenOptions::new(); options.read(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - options.custom_flags( - windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT, - ); - } options .open(path) .map_err(|error| LocalFsError::from_io(path, error))? diff --git a/crates/localfs/src/stage/windows.rs b/crates/localfs/src/stage/windows.rs new file mode 100644 index 00000000..351f4d1c --- /dev/null +++ b/crates/localfs/src/stage/windows.rs @@ -0,0 +1,123 @@ +//! Atomic publication under pinned ancestry and a nonempty, protected leaf. +use crate::{ + directory::{DirectoryCapability, check_name}, + windows::PublicationGuard, +}; +use nocterm_session::{fs::UploadMode, local_fs::*}; +use std::{ffi::OsStr, io::Write}; + +struct Stage { + directory: DirectoryCapability, + file: Option, + guard: Option, + synced: bool, + committed: bool, +} +pub(crate) fn create(directory: DirectoryCapability) -> LocalResult> { + let guard = PublicationGuard::new(&directory)?; + let file = tempfile::Builder::new() + .prefix(".nocterm-") + .suffix(".part") + .tempfile_in(directory.path()) + .map_err(|error| LocalFsError::from_io(directory.path(), error))?; + Ok(Box::new(Stage { + directory, + file: Some(file), + guard: Some(guard), + synced: false, + committed: false, + })) +} +impl Stage { + fn writable(&self) -> LocalResult<()> { + if self.committed { + Err(LocalFsError::Unsupported("a published stage")) + } else { + Ok(()) + } + } + fn file_mut(&mut self) -> LocalResult<&mut std::fs::File> { + self.file + .as_mut() + .map(tempfile::NamedTempFile::as_file_mut) + .ok_or(LocalFsError::Unsupported("a consumed stage")) + } +} +impl LocalStage for Stage { + fn write(&mut self, bytes: &[u8]) -> LocalResult<()> { + self.writable()?; + if bytes.len() > 32 * 1024 { + return Err(LocalFsError::BoundExceeded("stage chunk")); + } + let path = self.directory.path().to_owned(); + self.file_mut()? + .write_all(bytes) + .map_err(|error| LocalFsError::from_io(path, error))?; + self.synced = false; + Ok(()) + } + fn sync(&mut self) -> LocalResult<()> { + self.writable()?; + let path = self.directory.path().to_owned(); + self.file_mut()? + .sync_all() + .map_err(|error| LocalFsError::from_io(path, error))?; + self.synced = true; + Ok(()) + } + fn publish(&mut self, name: &OsStr, mode: UploadMode) -> LocalResult { + self.writable()?; + check_name(name)?; + if !self.synced { + self.sync()?; + } + let target = self.directory.path().join(name); + let file = self + .file + .take() + .ok_or(LocalFsError::Unsupported("a consumed stage"))?; + let result = if mode == UploadMode::Replace { + file.persist(&target) + } else { + file.persist_noclobber(&target) + }; + if let Err(error) = result { + self.file = Some(error.file); + return Err(LocalFsError::from_io(target, error.error)); + } + self.committed = true; + // After rename committed, cleanup failure is a warning, never a failed save. + if let Some(guard) = self.guard.take() + && let Err(error) = guard.close() + { + return Ok(PublicationOutcome::PublishedWithWarning( + DurabilityWarning::new(error), + )); + } + Ok(PublicationOutcome::Published) + } + fn cancel(mut self: Box) -> LocalResult<()> { + self.writable()?; + let file = self + .file + .take() + .ok_or(LocalFsError::Unsupported("a consumed stage"))?; + let path = file.path().to_owned(); + file.close() + .map_err(|error| LocalFsError::from_io(path, error))?; + if let Some(guard) = self.guard.take() { + guard + .close() + .map_err(|error| LocalFsError::from_io(self.directory.path(), error))?; + } + Ok(()) + } +} +impl Drop for Stage { + fn drop(&mut self) { + // Cleanup must finish before either the guard or pinned ancestry drops. + if let Some(file) = self.file.take() { + let _ = file.close(); + } + } +} diff --git a/crates/localfs/src/windows.rs b/crates/localfs/src/windows.rs new file mode 100644 index 00000000..25191677 --- /dev/null +++ b/crates/localfs/src/windows.rs @@ -0,0 +1,82 @@ +//! Safe native handle helpers and a bounded publication path guard. +use crate::directory::DirectoryCapability; +use fs_at::{ + OpenOptions, + os::windows::{FileExt, OpenOptionsExt}, +}; +use nocterm_session::local_fs::{LocalFsError, LocalResult}; +use std::{fs::File, io, os::windows::io::AsHandle}; +use windows_sys::Win32::Storage::FileSystem::{FILE_READ_ATTRIBUTES, FILE_WRITE_DATA}; +use winx::file::{AccessMode, Flags, ShareMode, reopen_file}; + +pub(crate) mod diagnostic; +pub(crate) mod open; + +pub(crate) fn identity(file: &File) -> io::Result { + use nocterm_session::local_fs::DirectoryIdentity; + Ok(match prikk_ffi::identity_of(file)? { + prikk_ffi::FileIdentity::Id128 { + volume_serial_number, + file_id, + } => DirectoryIdentity::native_wide(volume_serial_number, u128::from_le_bytes(file_id)), + prikk_ffi::FileIdentity::Id64 { + volume_serial_number, + file_index, + } => DirectoryIdentity::native(u64::from(volume_serial_number), file_index), + }) +} + +/// Ancestor handles deny deletion; this locked child also keeps the leaf nonempty, +/// so it cannot become a junction while path-based atomic rename is in progress. +/// All guard creation and deletion are relative/handle-based. +pub(crate) struct PublicationGuard { + file: Option, +} +impl PublicationGuard { + pub(crate) fn new(directory: &DirectoryCapability) -> LocalResult { + let mut bytes = [0; 16]; + getrandom::fill(&mut bytes) + .map_err(|error| LocalFsError::from_io(directory.path(), io::Error::other(error)))?; + let suffix: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let name = format!(".nocterm-{suffix}.guard"); + let file = OpenOptions::default() + .create_new(true) + .follow(false) + .desired_access(FILE_READ_ATTRIBUTES | FILE_WRITE_DATA) + .open_path_at(directory.file(), &name) + .map_err(|error| LocalFsError::from_io(directory.path(), error))?; + let pin = reopen_file( + file.as_handle(), + AccessMode::DELETE | AccessMode::FILE_READ_ATTRIBUTES, + ShareMode::FILE_SHARE_READ | ShareMode::FILE_SHARE_WRITE, + Flags::FILE_FLAG_OPEN_REPARSE_POINT, + ); + let guard = match pin { + Ok(pin) => Self { file: Some(pin) }, + Err(error) => { + drop(file); + let _ = OpenOptions::default().unlink_at(directory.file(), &name); + return Err(LocalFsError::from_io(directory.path(), error)); + } + }; + drop(file); + // Every ancestor is now nonempty and pinned. Reject reparse conversion + // that may have happened while an earlier ancestor was still empty. + directory.validate_publication_ancestry()?; + Ok(guard) + } + pub(crate) fn close(mut self) -> io::Result<()> { + self.file + .take() + .expect("guard owns its handle") + .delete_by_handle() + .map_err(|(_, error)| error) + } +} +impl Drop for PublicationGuard { + fn drop(&mut self) { + if let Some(file) = self.file.take() { + let _ = file.delete_by_handle(); + } + } +} diff --git a/crates/localfs/src/windows/diagnostic.rs b/crates/localfs/src/windows/diagnostic.rs new file mode 100644 index 00000000..fc4b2112 --- /dev/null +++ b/crates/localfs/src/windows/diagnostic.rs @@ -0,0 +1,35 @@ +//! Native operation context captured before error classification loses Win32 codes. +use nocterm_session::local_fs::{LocalFsError, LocalResult}; +use std::{io, path::Path}; + +pub(crate) fn checked( + operation: &'static str, + path: &Path, + access: u32, + share: u32, + flags: u32, + result: io::Result, +) -> LocalResult { + let build_source = option_env!("NOCTERM_BUILD_REVISION").unwrap_or("unversioned"); + match result { + Ok(value) => { + tracing::debug!( + build_source, pid = std::process::id(), operation, component_path = ?path, + component = ?path.file_name(), requested_access = access, + share_mode = share, requested_flags_or_options = flags, + "native filesystem operation succeeded", + ); + Ok(value) + } + Err(error) => { + tracing::warn!( + build_source, pid = std::process::id(), operation, component_path = ?path, + component = ?path.file_name(), requested_access = access, + share_mode = share, requested_flags_or_options = flags, + native_error = ?error.raw_os_error(), kind = ?error.kind(), + "native filesystem operation failed", + ); + Err(LocalFsError::from_io(path, error)) + } + } +} diff --git a/crates/localfs/src/windows/open.rs b/crates/localfs/src/windows/open.rs new file mode 100644 index 00000000..ce3c3c29 --- /dev/null +++ b/crates/localfs/src/windows/open.rs @@ -0,0 +1,146 @@ +//! Acquisition policy for captured Windows objects. Callers never translate +//! read/list intent through `read(true)`: fs_at's open_path_at ignores that flag. +use super::diagnostic; +use crate::directory::{DirectoryCapability, check_name}; +use fs_at::{OpenOptions, os::windows::OpenOptionsExt as _}; +use nocterm_session::local_fs::LocalResult; +use std::{ffi::OsStr, fs::File, path::Path}; +use windows_sys::Wdk::Storage::FileSystem::{FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT}; +use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_LIST_DIRECTORY, + FILE_READ_ATTRIBUTES, FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + FILE_TRAVERSE, +}; + +const SHARED: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; +const PINNED: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE; +const DIRECTORY_FLAGS: u32 = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT; + +/// Acquisition needs attributes, not permission to enumerate every ancestor. +pub(crate) fn root(path: &Path) -> LocalResult { + use std::os::windows::fs::OpenOptionsExt as _; + diagnostic::checked( + "CreateFileW(root/open-existing)", + path, + FILE_READ_ATTRIBUTES, + SHARED, + DIRECTORY_FLAGS, + std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES) + .share_mode(SHARED) + .custom_flags(DIRECTORY_FLAGS) + .open(path), + ) +} + +pub(crate) fn child(parent: &DirectoryCapability, name: &OsStr, create: bool) -> LocalResult { + check_name(name)?; + let options = FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT; + diagnostic::checked( + if create { + "NtCreateFile(child/open-or-create)" + } else { + "NtCreateFile(child/open-existing)" + }, + &parent.path().join(name), + FILE_READ_ATTRIBUTES, + SHARED, + options, + OpenOptions::default() + .desired_access(FILE_READ_ATTRIBUTES) + .create_options(options) + .create(create) + .open_path_at(parent.file(), name), + ) +} + +pub(crate) fn pin_directory(file: &File, path: &Path) -> LocalResult { + // Attribute-only handles do not participate in Windows share accounting. + // TRAVERSE (the directory equivalent of EXECUTE) activates the no-delete + // pin without requiring permission to list this directory's contents. + reopen_directory(file, path, FILE_TRAVERSE | FILE_READ_ATTRIBUTES) +} + +/// A distinct native handle owns its own enumeration position. Request listing +/// here only, so inaccessible ancestor listings do not prevent child traversal. +pub(crate) fn enumeration(directory: &DirectoryCapability) -> LocalResult { + reopen_directory( + directory.file(), + directory.path(), + FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, + ) +} + +fn reopen_directory(file: &File, path: &Path, access: u32) -> LocalResult { + use cap_primitives::fs::{OpenOptions, OpenOptionsExt as _}; + let mut options = OpenOptions::new(); + options + .access_mode(access) + .share_mode(PINNED) + .custom_flags(DIRECTORY_FLAGS); + // Win32 ReOpenFile can reject directory handles even with BACKUP. + // open(".") uses NT empty-name RootDirectory acquisition of this object; + // cap_primitives::fs::reopen would call ReOpenFile and must not be used. + diagnostic::checked( + "NtCreateFile(reopen-directory)", + path, + access, + PINNED, + DIRECTORY_FLAGS, + cap_primitives::fs::open(file, Path::new("."), &options), + ) +} + +pub(crate) enum EntryAccess { + Metadata, + Source, + Remove, +} + +/// Open the selected entry itself; validation uses this owned handle, never a +/// link-following preflight. Every operation shares the same no-follow policy. +pub(crate) fn entry( + directory: &DirectoryCapability, + name: &OsStr, + access: EntryAccess, +) -> LocalResult { + check_name(name)?; + let (operation, access) = match access { + EntryAccess::Metadata => ( + "NtCreateFile(entry-metadata/no-follow)", + FILE_READ_ATTRIBUTES, + ), + EntryAccess::Source => ( + "NtCreateFile(source/no-follow)", + FILE_READ_DATA | FILE_READ_ATTRIBUTES, + ), + EntryAccess::Remove => ( + "NtCreateFile(remove/no-follow)", + DELETE | FILE_READ_ATTRIBUTES, + ), + }; + diagnostic::checked( + operation, + &directory.path().join(name), + access, + SHARED, + FILE_OPEN_REPARSE_POINT, + OpenOptions::default() + .desired_access(access) + .create_options(FILE_OPEN_REPARSE_POINT) + .open_path_at(directory.file(), name), + ) +} + +/// Following a link is allowed only for read-only completion hints. No directory +/// listing, ACL read or data access is necessary to determine the target kind. +pub(crate) fn target_is_directory(directory: &DirectoryCapability, name: &OsStr) -> bool { + if check_name(name).is_err() { + return false; + } + OpenOptions::default() + .desired_access(FILE_READ_ATTRIBUTES) + .create_options(FILE_DIRECTORY_FILE) + .open_path_at(directory.file(), name) + .is_ok() +} diff --git a/crates/localfs/tests/windows_access.rs b/crates/localfs/tests/windows_access.rs new file mode 100644 index 00000000..67032743 --- /dev/null +++ b/crates/localfs/tests/windows_access.rs @@ -0,0 +1,116 @@ +//! Real ACL fixtures: only explicit denies on owned temporary paths are changed. +#![cfg(windows)] + +use nocterm_localfs::LocalFilesystem; +use nocterm_session::{ + fs::UploadMode, + local_fs::{LocalFs, LocalFsError}, +}; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, + process::Command, +}; + +#[test] +fn native_acl_child_storage_does_not_require_listing_its_ancestors() { + let root = tempfile::tempdir().unwrap(); + let parent = root.path().join("ancestor"); + let child = parent.join("history"); + std::fs::create_dir_all(&child).unwrap(); + // RD is FILE_LIST_DIRECTORY on this directory alone, not inherited by children. + let _deny = Deny::new(&parent, "RD"); + assert_eq!( + std::fs::read_dir(&parent).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + let fs = LocalFilesystem::with_base(child.clone()).unwrap(); + let ancestor = fs.open_directory(&parent).unwrap(); + assert!(std::fs::rename(&parent, root.path().join("moved")).is_err()); + assert!(matches!( + ancestor.entries(), + Err(LocalFsError::PermissionDenied(_)) + )); + let held = fs.open_directory(&child).unwrap(); + let mut stage = held.stage().unwrap(); + stage.write(b"acknowledged").unwrap(); + stage + .publish(OsStr::new("snapshot"), UploadMode::Create) + .unwrap(); + let mut source = held.open_source(OsStr::new("snapshot")).unwrap(); + assert_eq!(source.read(32).unwrap(), b"acknowledged"); + assert_eq!(held.entries().unwrap().next().unwrap().unwrap(), "snapshot"); +} + +#[test] +fn native_acl_source_reads_do_not_require_reading_extended_attributes() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("source"); + std::fs::write(&source, b"data").unwrap(); + // GENERIC_READ includes READ_EA, but a regular source never reads EAs. + let _deny = Deny::new(&source, "REA"); + assert_eq!( + std::fs::File::open(&source).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + let fs = LocalFilesystem::with_base(root.path().to_owned()).unwrap(); + let mut held = fs.open_source(&source).unwrap(); + assert_eq!(held.metadata().unwrap().size, Some(4)); + assert_eq!(held.read(32).unwrap(), b"data"); +} + +struct Deny { + path: PathBuf, + sid: String, +} +impl Deny { + fn new(path: &Path, permission: &str) -> Self { + let output = Command::new("whoami.exe") + .args(["/user", "/fo", "csv", "/nh"]) + .output() + .unwrap(); + assert!(output.status.success()); + // The localized account name can use the console code page. Only the + // ASCII SID after the final comma is used to identify the ACL trustee. + let sid = String::from_utf8_lossy(&output.stdout) + .trim() + .rsplit(',') + .next() + .unwrap() + .trim_matches('"') + .to_owned(); + assert!( + sid.starts_with("S-1-"), + "whoami must return a real Windows SID" + ); + let deny = Self { + path: path.to_owned(), + sid, + }; + let output = Command::new("icacls.exe") + .arg(path) + .args(["/deny", &format!("*{}:({permission})", deny.sid)]) + .output() + .unwrap(); + assert!(output.status.success(), "icacls /deny failed: {output:?}"); + deny + } +} +impl Drop for Deny { + fn drop(&mut self) { + let result = Command::new("icacls.exe") + .arg(&self.path) + .args(["/remove:d", &format!("*{}", self.sid)]) + .output(); + if !result.as_ref().is_ok_and(|output| output.status.success()) { + tracing::error!( + path = ?self.path, error = ?result, + "could not restore temporary fixture ACL", + ); + assert!( + std::thread::panicking(), + "temporary ACL cleanup failed: {result:?}" + ); + } + } +} diff --git a/crates/localfs/tests/windows_capabilities.rs b/crates/localfs/tests/windows_capabilities.rs new file mode 100644 index 00000000..ca035c69 --- /dev/null +++ b/crates/localfs/tests/windows_capabilities.rs @@ -0,0 +1,197 @@ +#![cfg(windows)] + +use nocterm_localfs::LocalFilesystem; +use nocterm_session::{ + fs::UploadMode, + local_fs::{LocalCursor, LocalFs}, +}; +use std::{ + collections::BTreeSet, + ffi::{OsStr, OsString}, + fs::OpenOptions, + io::Read, + os::windows::fs::{MetadataExt, OpenOptionsExt}, + path::Path, + process::Command, +}; +use windows_sys::Win32::Storage::FileSystem::{FILE_ATTRIBUTE_REPARSE_POINT, FILE_SHARE_READ}; + +#[test] +fn native_identity_and_cursors_retain_the_selected_directory_and_ancestors() { + let root = tempfile::tempdir().unwrap(); + let ancestor = root.path().join("ancestor"); + let selected = ancestor.join("selected"); + std::fs::create_dir_all(&selected).unwrap(); + std::fs::create_dir(ancestor.join("sibling")).unwrap(); + let filesystem = LocalFilesystem::with_base(root.path().to_owned()).unwrap(); + let held = filesystem.open_directory(&selected).unwrap(); + assert!(held.identity().is_held()); + assert_eq!( + held.identity(), + filesystem.open_directory(&selected).unwrap().identity() + ); + assert_ne!( + held.identity(), + filesystem + .open_directory(&ancestor.join("sibling")) + .unwrap() + .identity() + ); + assert!(std::fs::rename(&ancestor, root.path().join("moved")).is_err()); + assert!(std::fs::rename(&selected, ancestor.join("moved")).is_err()); + assert!(std::fs::remove_dir(&selected).is_err()); + let mut cursor = held.entries().unwrap(); + drop(held); + assert!(std::fs::rename(&ancestor, root.path().join("moved")).is_err()); + assert!(cursor.next().unwrap().is_none()); + drop(cursor); + std::fs::rename(&ancestor, root.path().join("moved")).unwrap(); + assert!(root.path().join("moved/selected").is_dir()); +} + +#[test] +fn concurrent_native_cursors_have_independent_enumeration_positions() { + let root = tempfile::tempdir().unwrap(); + // Long names require several native enumeration buffers, exposing a shared + // enumeration position even when the iterator prefetches its first buffer. + let expected: BTreeSet = (0..256) + .map(|index| OsString::from(format!("entry-{index:03}-{}", "x".repeat(120)))) + .collect(); + for name in &expected { + std::fs::write(root.path().join(name), b"").unwrap(); + } + let filesystem = LocalFilesystem::with_base(root.path().to_owned()).unwrap(); + let held = filesystem.open_directory(root.path()).unwrap(); + let mut first = held.entries().unwrap(); + let first_name = first.next().unwrap().unwrap(); + let mut second = held.entries().unwrap(); + let second_name = second.next().unwrap().unwrap(); + let mut first_names = collect_names(first.as_mut()); + first_names.insert(first_name); + let mut second_names = collect_names(second.as_mut()); + second_names.insert(second_name); + assert_eq!(first_names, expected); + assert_eq!(second_names, expected); +} + +#[test] +fn strict_capabilities_refuse_junctions_without_touching_their_targets() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret"), b"outside owner").unwrap(); + let filesystem = LocalFilesystem::with_base(root.path().to_owned()).unwrap(); + let alias = root.path().join("junction"); + junction(&alias, outside.path()); + let held = filesystem.open_directory(root.path()).unwrap(); + assert!(filesystem.open_directory(&alias).is_err()); + assert!(held.child(OsStr::new("junction")).is_err()); + assert!(held.create_child(OsStr::new("junction")).is_err()); + assert!(filesystem.open_source(&alias.join("secret")).is_err()); + assert!(held.open_source(OsStr::new("junction")).is_err()); + assert!( + held.metadata(OsStr::new("junction")) + .unwrap() + .unwrap() + .is_symlink + ); + held.remove_file(OsStr::new("junction")).unwrap(); + assert!(!alias.exists()); + assert_eq!( + std::fs::read(outside.path().join("secret")).unwrap(), + b"outside owner" + ); +} + +#[test] +fn replacement_denied_by_an_open_reader_retains_the_stage_and_original_snapshot() { + let root = tempfile::tempdir().unwrap(); + let snapshot = root.path().join("snapshot"); + std::fs::write(&snapshot, b"acknowledged").unwrap(); + let filesystem = LocalFilesystem::with_base(root.path().to_owned()).unwrap(); + let held = filesystem.open_directory(root.path()).unwrap(); + let mut stage = held.stage().unwrap(); + stage.write(b"replacement").unwrap(); + stage.sync().unwrap(); + let reader = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&snapshot) + .unwrap(); + assert!( + stage + .publish(OsStr::new("snapshot"), UploadMode::Replace) + .is_err() + ); + assert_eq!(std::fs::read(&snapshot).unwrap(), b"acknowledged"); + drop(reader); + let mut old_reader = std::fs::File::open(&snapshot).unwrap(); + let mut old_bytes = Vec::new(); + old_reader.read_to_end(&mut old_bytes).unwrap(); + assert_eq!(old_bytes, b"acknowledged"); + drop(old_reader); + stage + .publish(OsStr::new("snapshot"), UploadMode::Replace) + .unwrap(); + drop(stage); + assert_eq!(std::fs::read(&snapshot).unwrap(), b"replacement"); + assert_eq!( + collect_names(held.entries().unwrap().as_mut()), + BTreeSet::from([OsString::from("snapshot")]) + ); +} + +#[test] +fn stages_retain_ancestry_until_cancel_or_drop_finishes_private_cleanup() { + let root = tempfile::tempdir().unwrap(); + let selected = root.path().join("selected"); + std::fs::create_dir(&selected).unwrap(); + let filesystem = LocalFilesystem::with_base(root.path().to_owned()).unwrap(); + let held = filesystem.open_directory(&selected).unwrap(); + let mut canceled = held.stage().unwrap(); + canceled.write(b"canceled").unwrap(); + let guard = std::fs::read_dir(&selected) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.extension() + .is_some_and(|extension| extension == "guard") + }) + .expect("a live stage keeps a protected child in the leaf directory"); + assert!(std::fs::remove_file(&guard).is_err()); + assert!(std::fs::rename(&guard, selected.join("moved-guard")).is_err()); + let mut abandoned = held.stage().unwrap(); + abandoned.write(b"abandoned").unwrap(); + drop(held); + assert!(std::fs::rename(&selected, root.path().join("moved")).is_err()); + canceled.cancel().unwrap(); + assert!(std::fs::rename(&selected, root.path().join("moved")).is_err()); + drop(abandoned); + assert!(std::fs::read_dir(&selected).unwrap().next().is_none()); + std::fs::rename(&selected, root.path().join("moved")).unwrap(); +} + +fn collect_names(cursor: &mut dyn LocalCursor) -> BTreeSet { + let mut names = BTreeSet::new(); + while let Some(name) = cursor.next().unwrap() { + assert!(names.insert(name), "enumeration must not repeat an entry"); + } + names +} + +fn junction(link: &Path, target: &Path) { + let output = Command::new("cmd") + .args(["/D", "/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .unwrap(); + assert!( + output.status.success(), + "mklink /J failed: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let metadata = std::fs::symlink_metadata(link) + .expect("mklink must create a real junction before checking capability rejection"); + assert_ne!(metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT, 0); +} diff --git a/crates/localfs/tests/windows_users.rs b/crates/localfs/tests/windows_users.rs new file mode 100644 index 00000000..ddecf426 --- /dev/null +++ b/crates/localfs/tests/windows_users.rs @@ -0,0 +1,35 @@ +//! Real Users/profile traversal through production LocalFs (no ACL changes). +#![cfg(windows)] + +use nocterm_localfs::LocalFilesystem; +use nocterm_session::local_fs::{CancelFlag, ListingLimits, LocalFs}; +use std::path::PathBuf; + +#[test] +fn users_and_current_profile_open_and_list_through_native_capabilities() { + let profile = + PathBuf::from(std::env::var_os("USERPROFILE").expect("Windows supplies USERPROFILE")); + let profile = std::fs::canonicalize(profile).unwrap(); + let users = profile.parent().expect("profile has a Users parent"); + let filesystem = LocalFilesystem::with_base(profile.clone()).unwrap(); + for path in [users, profile.as_path()] { + let held = filesystem.open_directory(path).unwrap(); + assert!(held.identity().is_held()); + let mut entries = held.entries().unwrap(); + while entries.next().unwrap().is_some() {} + let listing = filesystem + .browse(path, ListingLimits::default(), &CancelFlag::default()) + .unwrap(); + assert_eq!(listing.directory, path); + if path == users { + assert!( + listing + .entries + .iter() + .any(|entry| Some(entry.name.as_os_str()) == profile.file_name()) + ); + } + } + // Acquisition and read-only browsing do not attempt to create history at an ancestor. + assert!(profile.is_dir()); +} diff --git a/crates/session/src/local_fs/policy.rs b/crates/session/src/local_fs/policy.rs index 694b1ea8..361d9517 100644 --- a/crates/session/src/local_fs/policy.rs +++ b/crates/session/src/local_fs/policy.rs @@ -31,17 +31,23 @@ pub struct DirectoryIdentity(Identity); #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] enum Identity { Native(u64, u64), + NativeWide(u64, u128), Portable(PathBuf), } impl DirectoryIdentity { /// Whether this supplied identity denotes retained native-directory semantics. /// Portable location identities do not prove held-ancestor safety. pub fn is_held(&self) -> bool { - matches!(self.0, Identity::Native(..)) + matches!(self.0, Identity::Native(..) | Identity::NativeWide(..)) } pub fn native(device: u64, inode: u64) -> Self { Self(Identity::Native(device, inode)) } + /// A held native object with a full 128-bit identifier. Its identity space + /// remains distinct from a filesystem's shorter fallback identifiers. + pub fn native_wide(device: u64, object: u128) -> Self { + Self(Identity::NativeWide(device, object)) + } /// Portable fallbacks provide weaker location identity, not Unix handle safety. pub fn portable(location: PathBuf) -> Self { Self(Identity::Portable(location)) diff --git a/crates/session/src/local_fs/tests.rs b/crates/session/src/local_fs/tests.rs index 698c387f..1e66b15d 100644 --- a/crates/session/src/local_fs/tests.rs +++ b/crates/session/src/local_fs/tests.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn native_identity_keeps_full_width_and_fallback_spaces_distinct() { + let wide = DirectoryIdentity::native_wide(1, (1u128 << 96) | 7); + assert!(wide.is_held()); + assert_ne!(wide, DirectoryIdentity::native_wide(1, 7)); + assert_ne!( + DirectoryIdentity::native_wide(1, 7), + DirectoryIdentity::native(1, 7) + ); + assert!(!DirectoryIdentity::portable("directory".into()).is_held()); +} use crate::{EntryKind, FileMetadata}; use std::ffi::OsStr; fn metadata(kind: EntryKind, size: Option, is_symlink: bool) -> FileMetadata { diff --git a/crates/ssh/src/file.rs b/crates/ssh/src/file.rs index da59ddee..2c5aca1d 100644 --- a/crates/ssh/src/file.rs +++ b/crates/ssh/src/file.rs @@ -14,7 +14,18 @@ pub(crate) fn read(path: &Path, limit: usize) -> io::Result>> rustix::fs::Mode::empty(), )? .into(); - #[cfg(not(unix))] + #[cfg(windows)] + let file = { + use std::os::windows::fs::OpenOptionsExt as _; + // FILE_FLAG_BACKUP_SEMANTICS permits inspecting a directory's kind; + // refusing it below must not become a misleading access-denied error. + const BACKUP_SEMANTICS: u32 = 0x0200_0000; + File::options() + .read(true) + .custom_flags(BACKUP_SEMANTICS) + .open(path)? + }; + #[cfg(not(any(unix, windows)))] let file = File::open(path)?; let metadata = file.metadata()?; if !metadata.is_file() { diff --git a/crates/workspace/src/workspace/composition.rs b/crates/workspace/src/workspace/composition.rs index 22aef38c..d6df0a37 100644 --- a/crates/workspace/src/workspace/composition.rs +++ b/crates/workspace/src/workspace/composition.rs @@ -192,7 +192,8 @@ impl Workspace { ) -> Option { self.factory().open_background(self, spec, window, cx) } - pub(crate) fn new_local_terminal(&mut self, window: &mut Window, cx: &mut Context) { + /// Opens a local shell through the installed provider's checked General destination. + pub fn new_local_terminal(&mut self, window: &mut Window, cx: &mut Context) { self.factory() .open_local(self, LocalTerminalTarget::Bottom, window, cx); } diff --git a/docs/AI_AGENTS.md b/docs/AI_AGENTS.md index ff68d404..e28f7bf0 100644 --- a/docs/AI_AGENTS.md +++ b/docs/AI_AGENTS.md @@ -92,10 +92,21 @@ own configuration (mode, model, effort and the agent's other options), unsent text, and the transcript: your messages, the agent's replies and reasoning, and tool calls with the input and output the agent reported for them. The terminal descriptors sent with each prompt are not saved, but a tool call's output can contain -terminal text. Images over 512 KiB are replaced by a note, only the last 2000 +terminal text. Images over 512 KiB are replaced by a note, only the last 20,000 entries of a chat are kept, and at most 200 chats are restored, pinned ones first. +The title's ellipsis menu copies the history ID or the complete loaded conversation +as readable text, including reasoning and tool inputs/results. Binary attachments +appear as references. Copying uses a bounded snapshot and formats it in the background; +unsubmitted drafts and queued messages are not part of the conversation. + +The attachment picker always starts with Local terminal. In the General chat context, +choosing it opens a shell when none is running. Other contexts use an existing shell +from their original workspace. Each saved server appears once, regardless of how +many tabs use it; attaching it covers all its sessions. A background session can still +be shown in a tab from that server's row. Folder attachments remain available. + After a restart, saved chats appear in the history without starting their agent. Browsing the history does not start an agent or MCP. A chat connects as soon as it is shown, whether new, saved or loaded from the history, unless diff --git a/docs/adr/0164-chat-history-capacity.md b/docs/adr/0164-chat-history-capacity.md new file mode 100644 index 00000000..f8490223 --- /dev/null +++ b/docs/adr/0164-chat-history-capacity.md @@ -0,0 +1,55 @@ +# 0164. Installation history capacity is separate from a single document + +Status: accepted + +Supersedes only the numeric history ceilings in [ADR0022](0022-agent-storage-owners.md), +[ADR0031](0031-archive-hydration-owner.md) and [ADR0142](0142-live-transcript-and-image-admission.md). +Their ownership, admission, delivery and eviction rules remain in force. + +## Context + +The installation reused the domain's 64 MiB single-document ceiling as its +aggregate history budget. Catalog work, immutable writer generations and hydrated +documents from every account and window competed for the same 63 MiB normal +lane. About 55 MiB of retained history could prevent a 14 MiB metadata scan or a +21 MiB document from being admitted. Separately, persistence kept only the last +2,000 rows even though live transcripts admitted 20,000 rows. + +## Decision + +`nocterm-agent-runtime::history_admission` owns an independent 1 GiB aggregate +history ceiling, preserving its 1 MiB and two-slot urgent lane. The existing +32-payload bound, weak participant accounting, clean hidden-history reclamation +and protected shown documents remain unchanged. The budget includes outstanding +catalog work, retained snapshots and prepared records across all windows/accounts. + +`nocterm-ai::history` keeps the per-document policy: 256 MiB conservative +structural cost, 128 MiB encoded data and the most recent 20,000 transcript rows +with aligned timestamps. Live transcript byte admission uses the same 256 MiB +ceiling and retains its existing 20,000-row limit. The decoder and persistence +projection use one saved-row constant. Collection, image, header and scope checks +continue to refuse before unbounded work; their other limits do not change. + +These are finite admission ceilings, not eagerly reserved buffers, measurements +of process RSS or an aggregate disk quota. History remains lazy; raising the +capacity starts no workers and preloads no transcripts. Existing formats and +ownership checks are unchanged. Existing saved rows are preserved; content +already discarded by an older writer cannot be reconstructed. + +## Alternatives + +- Increasing the shared single-document constant alone couples two different + policies and lets one transcript consume the installation's entire budget. +- Removing admission can turn a long conversation or many windows into an + allocation failure. It also removes headroom for urgent controls. +- Reserving the full new ceiling or eagerly restoring larger archives adds + startup and idle memory cost without helping a user opening one saved chat. + +## Consequences + +Ordinary catalog refresh and hydration can coexist with much larger retained +histories. Longer conversations survive reopening without the old 2,000-row cut. +Larger accepted payloads can take more memory and time to encode, scan or render; +existing background I/O, single hydration delivery gate and eviction remain +necessary. Older binaries still enforce their earlier read ceilings and may +refuse new files that exceed them; no automatic migration rewrites old files. diff --git a/docs/adr/0165-windows-ancestry-publication-guards.md b/docs/adr/0165-windows-ancestry-publication-guards.md new file mode 100644 index 00000000..34bf75e4 --- /dev/null +++ b/docs/adr/0165-windows-ancestry-publication-guards.md @@ -0,0 +1,79 @@ +# 0165. Windows holds directory ancestry and guards atomic publication + +Status: accepted. Supersedes the Windows acquisition and publication guarantees in +[ADR 0162](0162-windows-holds-directory-handles-for-strict-local-capabilities.md) +and extends [ADR 0017](0017-local-filesystem-capabilities.md). + +## Context + +New v3 chat namespaces require held native directories, including General +([ADR 0110](0110-local-history-environment-partitions.md)). A portable path identity +cannot provide that authority. Windows formerly provided only that weaker identity, +so new chat writes were refused even when the disk and agent were available. + +Windows sharing flags can prevent renaming or deleting an opened directory, but +do not prevent attribute-only access. An empty directory can become a reparse point +through an attribute handle. Merely opening a directory without delete sharing is +therefore insufficient for subsequent publication by pathname. + +## Decision + +`nocterm-localfs` implements Windows `LocalDirectory` through safe native API +wrappers. Strict acquisition opens one component relative to its parent, refuses +reparse points on the opened object, and retains every ancestor without delete +sharing. Acquisition checks that a newly pinned child still identifies the selected +entry. Ancestry is bounded to 256 components. Display paths never replace these +handles for reads, child acquisition, metadata, enumeration or removal. + +Native identity comes from the retained object's volume and file ID. A full +128-bit ID and the filesystem's shorter fallback occupy distinct identity spaces +in session's pure `DirectoryIdentity` descriptor. Portable identity remains +portable and does not satisfy v3 storage requirements. + +Each Windows stage also creates a random guard child relative to the held leaf, +retains it without delete sharing, and checks every retained ancestor for reparse +state after pinning the guard. This rejects conversion that happened before an +ancestor acquired its pinned child. The chain cannot now be renamed or emptied, and +the pinned guard prevents the leaf from becoming empty. Existing private temporary +file creation and atomic replacement can then safely use the protected pathname. +The guard lasts until temporary cleanup or committed publication finishes. Native +handle deletion removes the guard itself; a cleanup failure after commit is a +publication warning, preserving [ADR 0015](0015-private-file-publication.md). + +The adapter retains the whole capability chain for cursors and stages. Cursors +reopen an independent handle so enumeration positions never interfere. All native +work stays behind the existing synchronous session ports, off the UI executor. +No direct `unsafe` code or weakened workspace checks are introduced. + +The storage adapter keeps its existing environment/context paths, header ownership +checks and publication acknowledgement. Chat error presentation distinguishes +unsupported native storage, full disk/quota, denied access and other write errors; +all retain queued prompts and the original typed cause. + +## Alternatives + +Relabeling Portable identity would assert authority that does not exist. Falling +back to old General transcripts would change v3 namespace and context semantics. +Sending queued prompts before successful persistence would discard the durability +boundary. None is an acceptable substitute for Windows capabilities. + +Direct Windows FFI inside the adapter conflicts with the workspace's unsafe-code +prohibition. Path-based capability libraries alone also leave the empty-directory +reparse case unresolved. Safe relative operations, pinned ancestry and a temporary +publication guard preserve the existing ports and publication primitive. + +## Consequences + +Windows supplies native capability semantics for device/account and General/Project +v3 namespaces. Held directories cannot be renamed or deleted until their owners +release them; this intentionally differs from Unix handles surviving such moves. +Read-only browsing can still resolve a requested alias before strict acquisition. +Handle memory grows with bounded ancestry. Publication checks that bounded ancestry +once more and adds one small transient guard file. A process crash can leave an +empty guard or private candidate; these +are not history records and never select chat ownership. + +Production LocalFilesystem integration fixtures cover round trips, queue recovery, +namespace isolation, rejection of foreign records and atomic replacement. Windows +CI runs those fixtures and native capability tests. These checks do not establish +GUI rendering, a real Codex conversation or power-loss durability. diff --git a/docs/adr/README.md b/docs/adr/README.md index 03cc04b2..002bc5da 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,13 +24,13 @@ acceptance; a later record supersedes an earlier one. | [0016](0016-vault-storage-boundary.md) | Vault format and rules are independent of storage and lifecycle | | [0017](0017-local-filesystem-capabilities.md) | Native filesystem capabilities are injected through pure session ports | | [0018](0018-transfer-runtime-capabilities.md) | Transfer execution is an adapter over held filesystem and retryable publication ports | -| [0022](0022-agent-storage-owners.md) | Agent storage adapters and private lifecycle owners share persistence execution | +| [0022](0022-agent-storage-owners.md) | Agent storage adapters and private lifecycle owners share persistence execution (numeric history ceilings superseded by 0164) | | [0025](0025-native-program-opener.md) | External local programs have an explicit bounded native owner | | [0026](0026-workspace-state-owners.md) | Workspace transitions belong to private state owners with explicit shared layout (Sidebar and RightPanelSlot superseded in part by 0081) | | [0027](0027-connection-observation-ports.md) | Pure connection observation contracts and network I/O adapter | | [0028](0028-theme-library-boundary.md) | Pure theme domain model, adapter registry and theme service | | [0029](0029-output-recording-shared-driver.md) | Bounded append persistence and output recording | -| [0031](0031-archive-hydration-owner.md) | Hydrated archive and history permit state belong to a single owner | +| [0031](0031-archive-hydration-owner.md) | Hydrated archive and history permit state belong to a single owner (numeric resident ceiling superseded by 0164) | | [0032](0032-narrow-terminal-ports.md) | Separate terminal ports bind approvals and secret answers to original Item/session/prompt epochs | | [0033](0033-native-agent-sandbox-capabilities.md) | Inject native workspace preparation and fail-closed sandbox launch capabilities | | [0034](0034-chat-config-composer-owners.md) | Per-chat configuration and composer transitions have private feature owners | @@ -137,7 +137,7 @@ acceptance; a later record supersedes an earlier one. | [0139](0139-cooperative-authorized-keys-edits.md) | SFTP authorized-keys editors coordinate with a fixed no-clobber remote lock | | [0140](0140-offer-confirmation-publication.md) | Displayed key confirmations await bounded native publication | | [0141](0141-versioned-plugin-publication.md) | Plugin records select immutable package versions and await actual completion | -| [0142](0142-live-transcript-and-image-admission.md) | Live transcript and image preparation admit aggregate growth before expensive work | +| [0142](0142-live-transcript-and-image-admission.md) | Live transcript and image preparation admit aggregate growth before expensive work (numeric transcript byte ceiling superseded by 0164) | | [0143](0143-the-runner-publishes-chat-summaries-journal-v7.md) | The runner publishes signed chat summaries to its directory; journal v7 (superseded by 0148; earlier: journal version rule superseded by 0145) | | [0144](0144-the-device-side-runs-on-the-contract-device-core.md) | The device side of Cloud chats runs on the contract's device core; hub-io is a thin adapter (superseded by 0148; earlier: request signer, outbox and first-freshness rule superseded by 0146) | | [0145](0145-per-server-ca-principals-and-runner-certificate-sessions.md) | Per-server CA principals, chat SSH bindings and runner certificate sessions; journal v8, boot v3 (superseded by 0148; earlier: SSH row class, boot host-key rule and journal version superseded by 0147) | @@ -157,5 +157,7 @@ acceptance; a later record supersedes an earlier one. | [0159](0159-plugin-charts-toolbar-axis-and-toolbar-moves.md) | Plugin views draw charts of recent values (`chart`, ABI stays 1), a toolbar's calls name its axis in the zone (`cx.is_column()`), toolbars use the status bar's small text, and moving a toolbar is a layout edit | | [0160](0160-ci-compiles-each-kind-once-and-tests-the-plugin-feature-where-it-changes-code.md) | CI compiles each kind of build once (lint checks, build and tests), tests `user-plugins` only in the packages that declare it, saves caches from `dev` and `master`, and builds installers only for releases | | [0161](0161-user-plugins-ship-in-every-build.md) | User plugins ship in every build: the `user-plugins` cargo feature and the compile-out rule are gone, the `[plugins] user_plugins` switch and `--safe-mode` still decide whether a run loads them (supersedes the compile-out part of 0093) | -| [0162](0162-windows-holds-directory-handles-for-strict-local-capabilities.md) | Windows strict local filesystem capabilities hold every ancestor's handle without delete sharing and refuse reparse points, so new v3 chats save on Windows; a failed save names its class (amends 0110) | +| [0162](0162-windows-holds-directory-handles-for-strict-local-capabilities.md) | Windows strict local filesystem capabilities hold every ancestor's handle without delete sharing and refuse reparse points, so new v3 chats save on Windows; a failed save names its class (amends 0110; acquisition/publication guarantees superseded by 0165) | | [0163](0163-windows-agents-start-hidden-with-the-systems-search-path.md) | Windows agents start without a console window, the agent environment matches Windows names without case, and bare commands such as `npx` are also looked for in the usual Node.js locations | +| [0164](0164-chat-history-capacity.md) | Installation history capacity is separate from a single document; larger byte and row ceilings supersede numeric limits in 0022, 0031 and 0142 | +| [0165](0165-windows-ancestry-publication-guards.md) | Windows pins native ancestry, preserves full file identity and guards publication against reparse conversion (supersedes the guarantees in 0162) | diff --git a/docs/architecture/adapter.md b/docs/architecture/adapter.md index db7eedc8..f7e3a76d 100644 --- a/docs/architecture/adapter.md +++ b/docs/architecture/adapter.md @@ -195,13 +195,27 @@ blocking calls on the background executor. Native names remain `OsString`/`PathB through operations; lossy strings are presentation only. Read-only browsing may follow directory links and accepts linked parent navigation. -Strict traversal, source streams and mutations reject linked ancestors and pin each -directory: Unix holds descriptors and uses `*at` calls; Windows holds a handle to every -ancestor without delete sharing and refuses reparse points -([ADR 0162](../adr/0162-windows-holds-directory-handles-for-strict-local-capabilities.md)). -Walks and removals have entry/depth limits; cancellation can leave an explicitly -reported partial removal. Other portable targets check reparse/link state but do not -claim held-handle race guarantees. Staged publication distinguishes +Strict traversal, source streams and mutations reject linked ancestors. Unix uses +directory-relative operations. Windows retains up to 256 native ancestor handles +without delete sharing, opens descendants relative to their parent handles and +enumerates through independent native handles. During atomic publication, a locked +guard file keeps the leaf nonempty and prevents reparse conversion. It is removed +after candidate cleanup or committed publication. Windows identities preserve full +128-bit file IDs, with a distinct native fallback identity for shorter file IDs. +See [ADR 0165](../adr/0165-windows-ancestry-publication-guards.md). +Directory pins and cursors reopen the captured object through native relative +`NtCreateFile`; they avoid Win32 `ReOpenFile`, which can reject directory handles +on Windows despite backup semantics. A shared acquisition module keeps native +access masks and no-follow flags consistent across traversal, metadata, cursors +and sources. Retained pins request traversal and attributes; enumeration alone +requests listing. Attribute-only handles cannot enforce a sharing pin. Sources +request data and attributes without unrelated ACL or extended-attribute access. +Entry metadata opens the reparse point itself. Windows I/O diagnostics record the operation, +requested/normalized paths, component, access/sharing/options and native error code +before classifying it; they never record message contents or bridge credentials. +Walks and removals have entry/depth limits; cancellation can leave +an explicitly reported partial removal. Other portable targets check link state +but retain their active-ancestor race limitation. Staged publication distinguishes failure before commit from a durability warning after successful publication. Successful publication relinquishes its temporary name before returning; Drop cannot remove a later owner of that name. Explicit cancellation confirms removal @@ -273,8 +287,9 @@ The app supplies `LocalFs`; generated descendants, reads, private staging, publication and removal use retained `LocalDirectory` capabilities. The configured private storage root has trusted ancestry and alone may be canonicalized. Project buckets require supplied held identity, with typed refusal on a portable path -implementation. General retains that adapter's documented active-ancestor race -limitation. Generated links and final source links are refused. An archived-draft +implementation. Windows and Unix provide held capabilities for v3 namespaces; +portable identities support only older General history. Generated links and final +source links are refused. An archived-draft update merges only its draft and timestamp into the original scoped transcript under the service's shared serial gate. @@ -285,6 +300,14 @@ See [ADR0022](../adr/0022-agent-storage-owners.md). ## Agent usage boundary +The authenticated MCP relay belongs to `nocterm-acp`. Accepted sockets use blocking +reads. Its five-second authentication deadline is cleared on the retained buffered +reader itself; pipelined MCP bytes survive authentication and established sessions +may idle until their registration is revoked. Connection failures log only opaque +connection IDs, I/O error kinds and native codes. Debug lifecycle events correlate +authentication, EOF, registration revocation and bridge shutdown without recording +tokens or protocol contents. + The ACP adapter maps the connected agent's context usage, token totals and usage metadata into AI's neutral records. No adapter in Nocterm reads provider sign-in tokens or session logs, or requests private provider account endpoints. The agent diff --git a/docs/architecture/domain.md b/docs/architecture/domain.md index c43b12cb..ba3a9364 100644 --- a/docs/architecture/domain.md +++ b/docs/architecture/domain.md @@ -336,6 +336,10 @@ agent and `catalog()`, which the application injects. `nocterm-ai` owns pure transcript codecs, structural/collection validation, catalog summary rules and the `ChatRepository`/`AgentStateRepository` contracts. +Saved transcripts retain up to 20,000 rows and aligned timestamps, with a 128 MiB +encoded ceiling and a separate 256 MiB structural ceiling shared with live +transcript admission. The service's aggregate installation quota is independent +of these per-document limits ([ADR0164](../adr/0164-chat-history-capacity.md)). Versions one and two belong only to ThisComputer. Version one remains byte-stable and belongs to General. Version two requires an explicit `ContextId` and a canonical envelope whose authoritative catalog header @@ -371,6 +375,11 @@ a later launch. Native filesystem/home/PATH resolution belongs to the ACP adapte ## Agent transcript and configuration rules +`nocterm-ai::thread::export::conversation_text` formats neutral entries for plain-text +clipboard export. It preserves complete text, reasoning, tool inputs/outputs and +diffs, representing binary content as references. It performs no I/O and does not +select chats, load history or include unsubmitted drafts and queued prompts. + `nocterm-ai::thread::ThreadState` reduces conversation entries, timestamps, plan, usage, token counts and title. Per-chat options, modes, command revisions and reconciliation belong to the feature's private `ChatConfig`. Existing diff --git a/docs/architecture/feature.md b/docs/architecture/feature.md index 04789fb8..2fbd04dd 100644 --- a/docs/architecture/feature.md +++ b/docs/architecture/feature.md @@ -496,6 +496,13 @@ the `SettingsHandle`, the installation's `AgentsHandle` and the `SandboxProbe`; edits the `ai` section only through AI's validation rules. It reports availability while keeping the isolation controls visible; launch checks availability again rather than trusting the displayed result. +The agent attachment picker presents a permanent local-shell choice followed by +ad-hoc sessions and unique saved profiles. Profile identity, rather than display +name or tab count, determines uniqueness. Original surface receipts still govern +local creation and background-session presentation. The title menu captures its +original chat; MenuView retains at most one cancellable clipboard operation and +formats an admitted immutable snapshot through the pure AI conversation exporter. + A chat's input — the draft, the attachments and images for the next prompt and the queue of prompts waiting to be sent — is its `Composer`, kept apart from the conversation and the session ([ADR 0009](../adr/0009-thread-composer.md)). @@ -633,7 +640,11 @@ MessageList reuses one ImagePipeline for preview cache generations and remeasures rows only for accepted delivery. Its 32-entry, 64 MiB preview budget retains full immutable source identities and charges image/source allocations; one installation shares four physical image jobs across attachments and previews. -Counts and remaining bytes are admitted before file acquisition or base64/decoder allocation. It keeps +Counts and remaining bytes are admitted before file acquisition or base64/decoder allocation. +Literal tool input and output keep their scroll offset in GPUI's keyed element state. +Their axis masks consume wheel input before the transcript's mask; vertical input +chains to the transcript at a text boundary or when the text fits, and horizontal +input stays in the text viewport. MessageList keeps each agent message's and expanded reasoning's parsed Markdown per row, so a row scrolled back into view keeps its parse and streamed text parses only what was appended. PanelLayout keeps the panel's regions as cached views: the transcript, the diff --git a/docs/architecture/service.md b/docs/architecture/service.md index 8af3fa8c..4e616793 100644 --- a/docs/architecture/service.md +++ b/docs/architecture/service.md @@ -102,9 +102,13 @@ Saved history starts as a catalogue of at most 200 metadata rows, with transcrip files as the source of truth. Obsolete metadata sidecars are ignored. Opening or editing a row loads its document in the background. A shared loading permit bounds hydration and archive search, including payloads awaiting GUI delivery; -inactive, clean documents can be evicted under a conservative 64 MiB resident +inactive, clean documents can be evicted under a conservative 1 GiB installation budget. Collection limits and a structural preflight also bound allocation from -small JSON elements, independently of the 32 MiB file limit. +small JSON elements: a document is limited to 256 MiB of structural cost and +128 MiB encoded, with up to 20,000 saved rows. The installation budget is shared +across windows and accounts, rather than copied from the single-document cap +([ADR0164](../adr/0164-chat-history-capacity.md)). These are admission ceilings, +not eager memory allocations or an aggregate disk quota. Workspace chat scope selects document ownership, history and the original tool seat. All scopes use the existing local ACP connector, workdir preparation, @@ -143,6 +147,10 @@ the original primary StoreError in its standard source chain; the secondary diagnostic stays separately inspectable, and both messages remain displayed. Normal detach leaves a client registered when admission fails so its desired candidate can still be retried. An older revision from the same current owner cannot replace a newer admitted intent. +`PersistenceFailure` classifies the original typed cause chain, including failures +wrapped by the writer. Memory pressure, busy history, a conversation's own limit, +unsupported storage, full disks and denied access remain distinct. The chat status +offers recovery guidance; capacity counts remain in typed errors and diagnostic logs. Successful detach admission acknowledges its original client outside the Runtime borrow even after unregistering it; candidate revision validation remains feature-owned. AI disable finalizes session transitions before taking that durable capture. @@ -152,10 +160,10 @@ executor and gate. Ready capacity is 32 key tokens, with one newest retained intent per dirty document; a full ready queue never drops accepted work. This is not a 32-document limit or a constant total payload budget: client history/queue validation owns per-document admission, and repository codecs retain the existing -32 MiB encoded/64 MiB structural bounds. The service retains one newest payload +128 MiB encoded/256 MiB structural bounds. The service retains one newest payload per dirty key plus its immutable active operation; failed closed documents remain -until retry or capture. The UI's 64 MiB resident archive policy bounds hydrated -archive display, independently of dirty persistence admission. Exact +until retry or capture. The installation's 1 GiB history admission accounts for +hydrated display and retained immutable writer payloads. Exact physical key/owner/revision stamps are recorded before foreground acknowledgement. Clean closed keys release their payloads; failed dirty keys remain through retry or final capture. The existing snapshot receipt distinction is preserved: an diff --git a/docs/architecture/ui.md b/docs/architecture/ui.md index 9e841bc0..4eada698 100644 --- a/docs/architecture/ui.md +++ b/docs/architecture/ui.md @@ -120,6 +120,13 @@ on the tested Radeon/Vulkan system, so it is not enabled by default. The shared GPUI Div scroll handler uses the same snapped and rounded bounds as prepaint to clamp a wheel offset before comparing and notifying. Events keep bubbling; fitting containers and scroll boundaries avoid transient invalidation. +Variable-height virtual lists route wheel input separately from absolute scrollbar +positioning. They traverse measured spans using sum-tree seeks and measure missing +crossed rows during layout. At most 128 extra crossed-row measurements run in one +pass; a coalesced distance and an explicit next-frame request continue larger +movements. Ordinary visible/overdraw layout retains its own work. Explicit +uniform-height hints retain their approximate jump policy. Navigation, reset, +thumb dragging and removal of the anchored row cancel deferred wheel movement. ## Workspace diff --git a/licensing/sources.json b/licensing/sources.json index 5d12a5fe..4112f193 100644 --- a/licensing/sources.json +++ b/licensing/sources.json @@ -723,5 +723,30 @@ "path": "upstream/bytecodealliance-wasm-tools-license-mit-23f18e03dc49.txt", "url": "https://raw.githubusercontent.com/bytecodealliance/wasm-tools/e66235859a6ec0502bf6f9dcc358953eda4cafcc/LICENSE-MIT", "sha256": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + }, + { + "packages": [ + "fs_at@0.2.1" + ], + "path": "upstream/fs_at-0.2.1-declaration.txt", + "url": "https://docs.rs/crate/fs_at/0.2.1/source/Cargo.toml.orig", + "sha256": "ac8e7cd307f3a86d824a854070c51b11c65c1faba37eff53dc6729ea58328f0a", + "kind": "declaration" + }, + { + "packages": [ + "prikk-ffi@0.56.0" + ], + "path": "upstream/prikk-ffi-0.56.0-LICENSE", + "url": "https://raw.githubusercontent.com/prikk-vcs/prikk/808126bd207edb37142a1a59131656cf555cc30e/LICENSE", + "sha256": "f651689716d7e61870c050334baf1bfc0e44a2fb8f39c68b2a1412de140f97fe" + }, + { + "packages": [ + "prikk-ffi@0.56.0" + ], + "path": "upstream/prikk-ffi-0.56.0-NOTICE", + "url": "https://raw.githubusercontent.com/prikk-vcs/prikk/808126bd207edb37142a1a59131656cf555cc30e/NOTICE", + "sha256": "1310aaa99e29f68100ee5354ab37918505e254c135b54b21c9273fd5c2725337" } ] diff --git a/licensing/upstream/fs_at-0.2.1-declaration.txt b/licensing/upstream/fs_at-0.2.1-declaration.txt new file mode 100644 index 00000000..6f5885c8 --- /dev/null +++ b/licensing/upstream/fs_at-0.2.1-declaration.txt @@ -0,0 +1,65 @@ +Original licensing declaration from the published crate archive. +No standalone primary license file is included in this archive or its pinned repository. +The declared SPDX terms are reproduced separately in THIRD_PARTY_NOTICES.txt. + +Cargo.toml.orig (unmodified): + +[package] +authors = ["Robert Collins "] +categories = ["filesystem", "os"] +description = "Implementation of 'at' functions for various platforms" +edition = "2021" +license = "Apache-2.0" +name = "fs_at" +readme = "README.md" +repository = "https://github.com/rbtcollins/fs_at.git" +rust-version = "1.71.0" + +# Auto-publishes when changed. +version = "0.2.1" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +default = [] +log = ["dep:log"] +"workaround-procmon" = ["dep:once_cell"] + +[dependencies] +cfg-if = "1.0.0" +cvt = "0.1.1" +log = { version = "0.4.21", optional = true } + +[dev-dependencies] +env_logger = "0.11.3" +fs-set-times = "0.20.1" +rayon = "1.10.0" +tempfile = "3.10.1" +test-log = "0.2.16" + +[target.'cfg(not(windows))'.dependencies] +libc = "0.2.153" +# Saves nontrivial unsafe and platform specific code (Darwin vs other Unixes, +# MAX_PATH and more : consider it weak and something we can remove if expedient +# later. +nix = { version = "0.29.0", default-features = false, features = ["dir"] } + +[target.'cfg(windows)'.dependencies] +aligned = "0.4.1" +once_cell = { optional = true, version = "1.19.0" } + +[target.'cfg(windows)'.dependencies.windows-sys] +features = [ + "Wdk_Foundation", + "Wdk_Storage_FileSystem", + "Wdk_System_SystemServices", + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Ioctl", + "Win32_System_Kernel", + "Win32_System_SystemServices", + "Win32_System_WindowsProgramming", +] +version = "0.52.0" diff --git a/licensing/upstream/prikk-ffi-0.56.0-LICENSE b/licensing/upstream/prikk-ffi-0.56.0-LICENSE new file mode 100644 index 00000000..70acf11b --- /dev/null +++ b/licensing/upstream/prikk-ffi-0.56.0-LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 nabbisen + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/licensing/upstream/prikk-ffi-0.56.0-NOTICE b/licensing/upstream/prikk-ffi-0.56.0-NOTICE new file mode 100644 index 00000000..b9534908 --- /dev/null +++ b/licensing/upstream/prikk-ffi-0.56.0-NOTICE @@ -0,0 +1,4 @@ +Prikk +Copyright 2026 nabbisen + +This product is licensed under the Apache License, Version 2.0. diff --git a/src/agent_history_tests/catalog.rs b/src/agent_history_tests/catalog.rs index 2cfb5b02..43fa0d88 100644 --- a/src/agent_history_tests/catalog.rs +++ b/src/agent_history_tests/catalog.rs @@ -110,13 +110,16 @@ fn queued_images_drafts_and_metadata_survive_lazy_loading() { fn malformed_collection_and_nested_amplification_are_rejected_without_erasing_files() { let dir = tempfile::tempdir().unwrap(); let mut chat = chat(); - chat.entries = (0..2001).map(|_| Entry::Agent(String::new())).collect(); + chat.entries = (0..=nocterm_ai::history::MAX_SAVED_ENTRIES) + .map(|_| Entry::Agent(String::new())) + .collect(); let path = file(dir.path(), &chat.header.id).unwrap(); fs::write(&path, serde_json::to_string(&chat).unwrap()).unwrap(); assert!(load(dir.path(), &chat.header.id).is_err()); chat.entries = vec![Entry::Tool( - ToolRecord::new("tool", "amplification") - .raw_input(serde_json::json!({"tiny":vec![0; 140_000]})), + ToolRecord::new("tool", "amplification").raw_input( + serde_json::json!({"tiny":vec![0; nocterm_ai::history::MAX_RESIDENT_BYTES / 512 + 1]}), + ), )]; fs::write(&path, serde_json::to_string(&chat).unwrap()).unwrap(); assert!(load(dir.path(), &chat.header.id).is_err()); diff --git a/src/agent_history_tests/transcripts.rs b/src/agent_history_tests/transcripts.rs index 83e13349..fcf7873c 100644 --- a/src/agent_history_tests/transcripts.rs +++ b/src/agent_history_tests/transcripts.rs @@ -1,11 +1,10 @@ use super::*; use nocterm_ai::model::content::Content; use nocterm_ai::{ - history::{MAX_FILE_BYTES, SavedAttachment, SavedPrompt}, + history::{MAX_FILE_BYTES, MAX_SAVED_ENTRIES, SavedAttachment, SavedPrompt}, thread::Entry, }; const MAX_SAVED_IMAGE_BYTES: usize = 512 * 1024; -const MAX_SAVED_ENTRIES: usize = 2000; fn chat() -> SavedChat { let mut chat = SavedChat::for_identity("codex".into(), uuid::Uuid::new_v4().to_string(), 1); diff --git a/tests/native_history.rs b/tests/native_history.rs new file mode 100644 index 00000000..17c2575e --- /dev/null +++ b/tests/native_history.rs @@ -0,0 +1,256 @@ +//! Native chat storage through the application's production repository/filesystem composition. +#![cfg(any(unix, windows))] + +use futures::executor::block_on; +use nocterm_agent_storage::FileChatRepository; +use nocterm_ai::{ + history::{self, ContextId, SavedAttachment, SavedChat, SavedPrompt, SharedChat}, + local_environment::{ChatScope, LocalEnvironment}, + storage::{AgentStorageError, ChatRepository}, +}; +use nocterm_hubs_model::source_identity::{ + AccountIdentity, AccountScope, Fingerprint, HubIdentity, LocalAccountPartition, NamespaceId, +}; +use nocterm_localfs::LocalFilesystem; +use nocterm_session::local_fs::LocalFs; +use std::{ffi::OsStr, io::Read, path::Path, path::PathBuf, sync::Arc}; + +const ID: &str = "00000000-0000-4000-8000-0000000000a1"; + +fn repository(root: &Path) -> FileChatRepository { + let root = std::fs::canonicalize(root).unwrap(); + let filesystem = Arc::new(LocalFilesystem::with_base(root.clone()).unwrap()); + FileChatRepository::new(root, filesystem) +} + +fn snapshot(scope: &ChatScope, text: &str) -> SharedChat { + let mut metadata = SavedChat::for_environment("codex".into(), ID.into(), 1, scope.clone()); + metadata.header.name = Some(text.into()); + metadata.draft = Some(text.into()); + metadata.session_id = Some("retained-agent-session".into()); + SharedChat { + metadata, + queue: Vec::new(), + } +} + +fn record_path(root: &Path, scope: &ChatScope) -> PathBuf { + let partition = root + .join("scoped-v3") + .join(scope.environment().storage_key()); + let context = match scope.context() { + ContextId::General => partition.join("general"), + ContextId::Project(id) => partition.join("projects").join(id.to_string()), + }; + context.join(format!("{ID}.json")) +} + +fn hub(account: &str, authority: u8) -> LocalEnvironment { + LocalEnvironment::HubAccount(LocalAccountPartition::Stable(AccountScope::new( + HubIdentity::new( + NamespaceId::new("same-hub-instance").unwrap(), + Fingerprint::from_bytes([authority; 32]), + ), + AccountIdentity::new( + NamespaceId::new(account).unwrap(), + Fingerprint::from_bytes([3; 32]), + ), + ))) +} + +#[test] +fn native_v3_chat_is_saved_listed_and_reopened_from_its_history_directory() { + let directory = tempfile::tempdir().unwrap(); + let scope = ChatScope::this_computer(ContextId::General); + let original = snapshot(&scope, "new native chat"); + let repository = repository(directory.path()); + block_on(repository.save(Arc::new(original))).unwrap(); + let path = record_path(directory.path(), &scope); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(history::decode(&bytes, ID).unwrap().version, 3); + assert_eq!( + std::fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1 + ); + drop(repository); + + let reopened = self::repository(directory.path()); + let catalog = block_on(reopened.catalog_in(scope.clone())).unwrap(); + assert_eq!(catalog.len(), 1); + assert_eq!(catalog[0].id, ID); + assert_eq!(catalog[0].name.as_deref(), Some("new native chat")); + assert_eq!(catalog[0].environment, *scope.environment()); + let loaded = block_on(reopened.load_in(scope.clone(), ID.into())).unwrap(); + assert_eq!(loaded.header.chat_scope(), scope); + assert_eq!(loaded.draft.as_deref(), Some("new native chat")); + assert_eq!(loaded.session_id.as_deref(), Some("retained-agent-session")); +} + +#[test] +fn native_snapshot_recovers_the_complete_pending_queue_after_reopening() { + let directory = tempfile::tempdir().unwrap(); + let scope = ChatScope::this_computer(ContextId::General); + let mut original = snapshot(&scope, "queued chat"); + let prompts = ["first pending message", "second pending message"] + .into_iter() + .enumerate() + .map(|(id, text)| SavedPrompt { + id: id as u64 + 1, + text: text.into(), + images: Vec::new(), + attachments: vec![SavedAttachment::Connection("saved-server".into())], + }) + .collect::>(); + original.queue = prompts.iter().cloned().map(Arc::new).collect(); + let repository = repository(directory.path()); + block_on(repository.save(Arc::new(original))).unwrap(); + drop(repository); + + let reopened = self::repository(directory.path()); + let restored = block_on(reopened.load_in(scope, ID.into())).unwrap(); + assert_eq!(restored.queue, prompts); + assert_eq!( + restored.session_id.as_deref(), + Some("retained-agent-session") + ); +} + +#[test] +fn native_history_separates_device_accounts_authorities_and_project_contexts() { + let directory = tempfile::tempdir().unwrap(); + let repository = repository(directory.path()); + let contexts = [ + ContextId::General, + ContextId::Project("00000000000000000000000000000001".parse().unwrap()), + ContextId::Project("00000000000000000000000000000002".parse().unwrap()), + ]; + let environments = [ + LocalEnvironment::ThisComputer, + hub("alice", 1), + hub("bob", 1), + hub("alice", 2), + ]; + let scopes = environments + .into_iter() + .flat_map(|environment| { + contexts.map(|context| ChatScope::new(environment.clone(), context)) + }) + .collect::>(); + for (index, scope) in scopes.iter().enumerate() { + let chat = snapshot(scope, &format!("partition {index}")); + block_on(repository.save(Arc::new(chat))).unwrap(); + } + for (index, scope) in scopes.iter().enumerate() { + let loaded = block_on(repository.load_in(scope.clone(), ID.into())).unwrap(); + assert_eq!(loaded.header.chat_scope(), *scope); + assert_eq!(loaded.draft, Some(format!("partition {index}"))); + let catalog = block_on(repository.catalog_in(scope.clone())).unwrap(); + assert_eq!(catalog.len(), 1); + assert_eq!(catalog[0].name, Some(format!("partition {index}"))); + } + block_on(repository.delete_in(scopes[0].clone(), ID.into())).unwrap(); + assert!( + block_on(repository.catalog_in(scopes[0].clone())) + .unwrap() + .is_empty() + ); + for scope in &scopes[1..] { + assert!(block_on(repository.load_in(scope.clone(), ID.into())).is_ok()); + } +} + +#[test] +fn copied_foreign_records_cannot_be_loaded_overwritten_deleted_or_listed() { + let directory = tempfile::tempdir().unwrap(); + let repository = repository(directory.path()); + let original = ChatScope::this_computer(ContextId::General); + block_on(repository.save(Arc::new(snapshot(&original, "original")))).unwrap(); + let original_bytes = std::fs::read(record_path(directory.path(), &original)).unwrap(); + let destinations = [ + ChatScope::new(hub("alice", 1), ContextId::General), + ChatScope::this_computer(ContextId::Project( + "00000000000000000000000000000001".parse().unwrap(), + )), + ]; + for scope in destinations { + block_on(repository.save(Arc::new(snapshot(&scope, "owned")))).unwrap(); + let path = record_path(directory.path(), &scope); + std::fs::write(&path, &original_bytes).unwrap(); + let load = block_on(repository.load_in(scope.clone(), ID.into())).unwrap_err(); + assert!(matches!( + load, + AgentStorageError::EnvironmentMismatch { .. } | AgentStorageError::ScopeMismatch { .. } + )); + assert!(block_on(repository.save(Arc::new(snapshot(&scope, "replacement")))).is_err()); + assert!(block_on(repository.delete_in(scope.clone(), ID.into())).is_err()); + assert!(block_on(repository.catalog_in(scope)).unwrap().is_empty()); + assert_eq!(std::fs::read(path).unwrap(), original_bytes); + } +} + +#[test] +fn replacing_a_native_snapshot_publishes_complete_bytes_and_preserves_the_old_snapshot() { + let directory = tempfile::tempdir().unwrap(); + let scope = ChatScope::this_computer(ContextId::General); + let repository = repository(directory.path()); + block_on(repository.save(Arc::new(snapshot(&scope, "old snapshot")))).unwrap(); + let path = record_path(directory.path(), &scope); + let mut reader = std::fs::File::open(&path).unwrap(); + let mut old_bytes = Vec::new(); + #[cfg(windows)] + { + // Legacy Windows atomic rename may refuse an open target. Failure and + // retry with retained staging are covered by the native LocalFs fixture. + reader.read_to_end(&mut old_bytes).unwrap(); + drop(reader); + } + block_on(repository.save(Arc::new(snapshot(&scope, "replacement snapshot")))).unwrap(); + #[cfg(unix)] + reader.read_to_end(&mut old_bytes).unwrap(); + let old = history::decode(&old_bytes, ID).unwrap(); + assert_eq!(old.draft.as_deref(), Some("old snapshot")); + let replacement = block_on(repository.load_in(scope, ID.into())).unwrap(); + assert_eq!(replacement.draft.as_deref(), Some("replacement snapshot")); + assert_eq!( + std::fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1 + ); +} + +#[test] +fn interrupted_native_staging_preserves_the_last_acknowledged_snapshot() { + let directory = tempfile::tempdir().unwrap(); + let scope = ChatScope::this_computer(ContextId::General); + let repository = repository(directory.path()); + block_on(repository.save(Arc::new(snapshot(&scope, "acknowledged")))).unwrap(); + let path = record_path(directory.path(), &scope); + let old_bytes = std::fs::read(&path).unwrap(); + let filesystem = + LocalFilesystem::with_base(std::fs::canonicalize(directory.path()).unwrap()).unwrap(); + let held = filesystem.open_directory(path.parent().unwrap()).unwrap(); + let mut abandoned = held.stage().unwrap(); + abandoned.write(b"incomplete replacement").unwrap(); + abandoned.sync().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), old_bytes); + drop(abandoned); + let mut canceled = held.stage().unwrap(); + canceled.write(b"canceled replacement").unwrap(); + canceled.cancel().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), old_bytes); + assert_eq!( + std::fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1 + ); + assert_eq!( + block_on(repository.load_in(scope, ID.into())) + .unwrap() + .draft + .as_deref(), + Some("acknowledged") + ); + assert!( + held.metadata(OsStr::new(&format!("{ID}.json"))) + .unwrap() + .is_some() + ); +} diff --git a/vendor/gpui-base/NOCTERM.md b/vendor/gpui-base/NOCTERM.md index bc4c02e2..5cbde24c 100644 --- a/vendor/gpui-base/NOCTERM.md +++ b/vendor/gpui-base/NOCTERM.md @@ -69,3 +69,11 @@ such as its active panel's output, draws neither the other groups nor their tab The area's own change draws all its groups again. `dock/tab_group.rs` observes the views of the panels it holds through Nocterm's `AnyView::observe` in the vendored `gpui-pre`, because its tab bar draws every panel's title, including inactive ones. + +`ScrollbarHandle::scroll_wheel` separates relative vertical wheel input from +absolute thumb positioning. Its default retains the existing clamped native +handle behavior. The ListState implementation uses GPUI's measured relative +geometry, so ScrollableMask does not map wheel deltas through stale absolute +content-height estimates. Horizontal routing and axis locks keep their existing +behavior. Remove this patch when upstream provides equivalent relative wheel +routing for variable-height lists. diff --git a/vendor/gpui-base/src/scrollable_mask.rs b/vendor/gpui-base/src/scrollable_mask.rs index 1acf1dcc..08c7d571 100644 --- a/vendor/gpui-base/src/scrollable_mask.rs +++ b/vendor/gpui-base/src/scrollable_mask.rs @@ -1,3 +1,4 @@ +// Modified by Nocterm contributors; see NOCTERM.md. Licensed under Apache-2.0. use std::cell::RefCell; use std::rc::Rc; @@ -254,25 +255,11 @@ impl Element for ScrollableMask { } if !is_horizontal { - // The current offset must be clamped too: after a - // bubbled event, the scrolled element's own listener - // pushes the shared offset beyond the edge unclamped - // (the div only clamps on prepaint), and that - // transient overscroll would read as "room to scroll". - // `ScrollbarHandle` has no `max_offset`; recover it from - // the definition `content_size = viewport + max_offset`. - let axis_max = (scroll_handle.content_size().height - - scroll_handle.viewport_bounds().size.height) - .max(px(0.)); - let current = offset.y.clamp(-axis_max, px(0.)); - let new_offset = (current + delta.y).clamp(-axis_max, px(0.)); - if new_offset == current { + if !scroll_handle.scroll_wheel(delta.y) { // At the edge or no overflow: bubble to the parent. return; } - offset.y = new_offset; - scroll_handle.set_offset(offset); cx.notify(view_id); cx.stop_propagation(); return; diff --git a/vendor/gpui-base/src/scrollbar.rs b/vendor/gpui-base/src/scrollbar.rs index 456addb4..89eee2d5 100644 --- a/vendor/gpui-base/src/scrollbar.rs +++ b/vendor/gpui-base/src/scrollbar.rs @@ -1,3 +1,4 @@ +// Modified by Nocterm contributors; see NOCTERM.md. Licensed under Apache-2.0. use std::{cell::Cell, ops::Deref, panic::Location, rc::Rc}; use instant::{Duration, Instant}; @@ -73,6 +74,19 @@ pub trait ScrollbarHandle: 'static { fn offset(&self) -> Point; /// Set the offset of the scroll handle. fn set_offset(&self, offset: Point); + /// Apply a vertical wheel delta. Lists may defer missing-height measurement + /// instead of interpreting relative input as an absolute thumb position. + fn scroll_wheel(&self, delta: Pixels) -> bool { + let mut offset = self.offset(); + let maximum = (self.content_size().height - self.viewport_bounds().size.height).max(px(0.)); + let current = offset.y.clamp(-maximum, px(0.)); + offset.y = (current + delta).clamp(-maximum, px(0.)); + if offset.y == current { + return false; + } + self.set_offset(offset); + true + } /// The full size of the content, including padding. fn content_size(&self) -> Size; /// Called when start dragging the scrollbar thumb. @@ -119,6 +133,10 @@ impl ScrollbarHandle for UniformListScrollHandle { } impl ScrollbarHandle for ListState { + fn scroll_wheel(&self, delta: Pixels) -> bool { + self.scroll_wheel_pixels(delta) + } + fn viewport_bounds(&self) -> Bounds { ListState::viewport_bounds(self) } diff --git a/vendor/gpui-pre/NOCTERM_PATCH.md b/vendor/gpui-pre/NOCTERM_PATCH.md index 9516bdf4..6c3eae7c 100644 --- a/vendor/gpui-pre/NOCTERM_PATCH.md +++ b/vendor/gpui-pre/NOCTERM_PATCH.md @@ -177,3 +177,22 @@ for scroll extent, matching explicit `remeasure_items` behavior. Initial uniform hints now survive the first prepaint, and native wheel jumps can reach unseen rows without eagerly constructing the whole list. The public integration regression `nocterm_list_estimates` covers first paint, a width change and bounded row rendering. + +## Relative wheel geometry + +`src/elements/list/wheel.rs` resolves relative wheel movement through actual row +measurements. Measured spans still use logarithmic sum-tree seeks. Missing or +invalidated heights are measured during prepaint, with at most 128 additional +crossed-row measurements per pass; a scalar remainder schedules the next frame. +The normal visible/overdraw layout has its existing independent work, including +its existing handling of zero-height rows. A wheel step into a row that grew +outside the viewport enters from the approached edge, rather than jumping over +new content using a stale height hint. Explicit uniform-height hints retain their +existing approximate jump policy. Absolute scrollbar positioning remains separate. +Reset, explicit navigation, a thumb grab and removal of the anchored row discard +the pending wheel remainder. Tail following waits for the remainder to finish; +Bottom alignment retains its pinned-end semantics. Native events and the +`scroll_wheel_pixels` API used by toolkit masks share this geometry. +Application-level GUI regressions in `nocterm-agent` exercise cold history, +offscreen growth, width changes, direction reversals, nested tool sections, +continuation/idle frames and positioning cancellation through the public API. diff --git a/vendor/gpui-pre/src/elements/list.rs b/vendor/gpui-pre/src/elements/list.rs index 36ce9a44..d6948c78 100644 --- a/vendor/gpui-pre/src/elements/list.rs +++ b/vendor/gpui-pre/src/elements/list.rs @@ -1,3 +1,4 @@ +// Nocterm modifications, licensed under the upstream Apache-2.0 license. //! A list element that can be used to render a large number of differently sized elements //! efficiently. Clients of this API need to ensure that elements outside of the scrolled //! area do not change their height for this element to function correctly. If your elements @@ -10,8 +11,8 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges, Element, EntityId, FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, - Overflow, Pixels, Point, ScrollDelta, ScrollWheelEvent, Size, Style, StyleRefinement, Styled, - Window, point, px, size, + Overflow, Pixels, Point, ScrollWheelEvent, Size, Style, StyleRefinement, Styled, Window, point, + px, size, }; use collections::VecDeque; use refineable::Refineable as _; @@ -20,6 +21,8 @@ use sum_tree::{Bias, Dimensions, SumTree}; type RenderItemFn = dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static; +mod wheel; + /// Construct a new list element pub fn list( state: ListState, @@ -72,6 +75,8 @@ struct StateInner { scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, pending_scroll: Option, + pending_wheel: Pixels, + uniform_height_hint: bool, follow_state: FollowState, } @@ -324,6 +329,8 @@ impl ListState { scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, + pending_wheel: px(0.), + uniform_height_hint: false, follow_state: FollowState::default(), }))); this.splice(0..0, item_count); @@ -359,6 +366,8 @@ impl ListState { state.measuring_behavior.reset(); state.logical_scroll_top = None; state.pending_scroll = None; + state.pending_wheel = px(0.); + state.uniform_height_hint = false; state.scrollbar_drag_start_height = None; state.items.summary().count }; @@ -380,6 +389,7 @@ impl ListState { height, }; let mut state = self.0.borrow_mut(); + state.uniform_height_hint = true; let new_items = state .items .iter() @@ -484,6 +494,9 @@ impl ListState { pub fn is_scrolled_to_end(&self) -> Option { let state = self.0.borrow(); let bounds = state.last_layout_bounds?; + if state.pending_wheel != px(0.) { + return Some(false); + } let summary = state.items.summary(); if summary.has_unknown_height { return None; @@ -540,6 +553,7 @@ impl ListState { }) = state.logical_scroll_top.as_mut() { if old_range.contains(item_ix) { + state.pending_wheel = px(0.); *item_ix = old_range.start; *offset_in_item = px(0.); } else if old_range.end <= *item_ix { @@ -569,6 +583,7 @@ impl ListState { let current_offset = self.logical_scroll_top(); let state = &mut *self.0.borrow_mut(); + state.pending_wheel = px(0.); if distance < px(0.) { state.follow_state.stop_following(); @@ -602,6 +617,7 @@ impl ListState { /// growing (e.g. during streaming). pub fn scroll_to_end(&self) { let state = &mut *self.0.borrow_mut(); + state.pending_wheel = px(0.); let item_count = state.items.summary().count; state.pending_scroll = None; state.logical_scroll_top = Some(ListOffset { @@ -616,6 +632,7 @@ impl ListState { /// following occurs. pub fn set_follow_mode(&self, mode: FollowMode) { let state = &mut *self.0.borrow_mut(); + state.pending_wheel = px(0.); match mode { FollowMode::Normal => { @@ -659,6 +676,7 @@ impl ListState { /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); + state.pending_wheel = px(0.); let item_count = state.items.summary().count; if scroll_top.item_ix >= item_count { scroll_top.item_ix = item_count; @@ -676,6 +694,7 @@ impl ListState { /// Scroll the list to the given item, such that the item is fully visible. pub fn scroll_to_reveal_item(&self, ix: usize) { let state = &mut *self.0.borrow_mut(); + state.pending_wheel = px(0.); let mut scroll_top = state.logical_scroll_top(); let height = state @@ -742,6 +761,7 @@ impl ListState { /// as items in the overdraw get measured, and help offset scroll position changes accordingly. pub fn scrollbar_drag_started(&self) { let mut state = self.0.borrow_mut(); + state.pending_wheel = px(0.); state.scrollbar_drag_start_height = Some(state.items.summary().height); } @@ -767,6 +787,12 @@ impl ListState { self.0.borrow_mut().set_offset_from_scrollbar(point); } + /// Scroll by a wheel delta (positive moves toward older rows). + /// Missing row measurements are resolved during layout, with bounded work. + pub fn scroll_wheel_pixels(&self, delta: Pixels) -> bool { + self.0.borrow_mut().scroll_wheel(-delta) + } + /// Returns the maximum scroll offset according to the items we have measured. /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. pub fn max_offset_for_scrollbar(&self) -> Point { @@ -842,6 +868,108 @@ impl ListState { } impl StateInner { + fn scroll_wheel(&mut self, distance: Pixels) -> bool { + if self.reset || distance == px(0.) { + return false; + } + let before = self.logical_scroll_top(); + let mut distance = self.pending_wheel + distance; + let padding = self.last_padding.unwrap_or_default(); + let height = self.last_layout_bounds.unwrap_or_default().size.height; + let maximum = + (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); + let end_sentinel = before.item_ix == self.items.summary().count; + if end_sentinel && self.pending_wheel == px(0.) && distance < px(0.) { + // End requests point after the final row. Start the gesture at + // the viewport's top, measuring unknown trailing rows as needed. + distance -= (height - padding.top - padding.bottom).max(px(0.)); + } + if distance > px(0.) + && wheel::at_end(&self.items, before, height - padding.top - padding.bottom) + { + let cancelled = self.pending_wheel != px(0.); + self.pending_wheel = px(0.); + return cancelled; + } + let (mut anchor, remaining) = if self.pending_scroll.is_some() + && let Some(anchor) = wheel::within_invalidated_row(&self.items, before, distance) + { + (anchor, px(0.)) + } else if self.uniform_height_hint { + // Explicit uniform hints opt into approximate, logarithmic jumps. + // Variable-height histories do not use this measuring policy. + ( + wheel::estimated(&self.items, before, distance, maximum), + px(0.), + ) + } else { + wheel::advance(&mut self.items, before, distance, |_| None) + }; + // Fully known lists can clamp immediately, including their padding. + if remaining == px(0.) && self.items.summary().unrendered_count == 0 { + let position = self.scroll_top(&anchor).min(maximum); + let (start, ..) = + self.items + .find::((), &Height(position), Bias::Right); + anchor = ListOffset { + item_ix: start.count, + offset_in_item: position - start.height, + }; + } + let bottom = self.alignment == ListAlignment::Bottom + && remaining == px(0.) + && self.items.summary().unrendered_count == 0 + && self.scroll_top(&anchor) >= maximum; + let origin = self.scroll_top(&before); + let origin = if end_sentinel { + origin.min(maximum) + } else { + origin + }; + let changed = self.scroll_top(&anchor) != origin || self.pending_wheel != remaining; + if !changed { + // Preserve an end request through an inert gesture on fitting content. + return false; + } + self.pending_wheel = remaining; + self.rebase_pending_scroll(anchor); + self.logical_scroll_top = if bottom { None } else { Some(anchor) }; + if changed && distance < px(0.) { + self.follow_state.stop_following(); + } + changed + } + + fn resolve_wheel( + &mut self, + width: Pixels, + render_item: &mut RenderItemFn, + window: &mut Window, + cx: &mut App, + ) { + if self.pending_wheel == px(0.) { + return; + } + let mut budget = 128usize; + let before = self.logical_scroll_top(); + let (anchor, remaining) = + wheel::advance(&mut self.items, before, self.pending_wheel, |index| { + budget = budget.checked_sub(1)?; + let mut element = render_item(index, window, cx); + Some(element.layout_as_root( + size(width.into(), AvailableSpace::MinContent), + window, + cx, + )) + }); + self.logical_scroll_top = Some(anchor); + self.rebase_pending_scroll(anchor); + self.pending_wheel = remaining; + // Refresh controls derived from the committed anchor, and continue a + // large gesture on another frame when its measurement budget ran out. + window.request_animation_frame(); + } + /// Re-anchor a pending scroll adjustment from a remeasure onto a newly set /// scroll position, so it clamps to the remeasured item's new height on /// the next layout instead of reverting the scroll. @@ -897,7 +1025,6 @@ impl StateInner { fn scroll( &mut self, - scroll_top: &ListOffset, height: Pixels, delta: Point, current_view: EntityId, @@ -910,37 +1037,13 @@ impl StateInner { return; } - let padding = self.last_padding.unwrap_or_default(); - let scroll_max = - (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); - let new_scroll_top = (self.scroll_top(scroll_top) - delta.y) - .max(px(0.)) - .min(scroll_max); - - if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { - self.pending_scroll = None; - self.logical_scroll_top = None; - } else { - let (start, ..) = - self.items - .find::((), &Height(new_scroll_top), Bias::Right); - let scroll_top = ListOffset { - item_ix: start.count, - offset_in_item: new_scroll_top - start.height, - }; - // The user's scroll supersedes the position stashed by a - // remeasure; re-anchor the pending adjustment so it doesn't revert - // this scroll on the next layout. - self.rebase_pending_scroll(scroll_top); - self.logical_scroll_top = Some(scroll_top); - } - - if delta.y > px(0.) { - self.follow_state.stop_following(); + if !self.scroll_wheel(-delta.y) { + return; } + let scroll_top = self.logical_scroll_top(); if let Some(handler) = self.scroll_handler.as_mut() { - let visible_range = Self::visible_range(&self.items, height, scroll_top); + let visible_range = Self::visible_range(&self.items, height, &scroll_top); handler( &ListScrollEvent { visible_range, @@ -1205,10 +1308,21 @@ impl StateInner { new_items.append(cursor.suffix(), ()); self.items = new_items; + if self.alignment == ListAlignment::Bottom + && self.pending_wheel == px(0.) + && wheel::at_end( + &self.items, + scroll_top, + available_height - padding.top - padding.bottom, + ) + { + self.logical_scroll_top = None; + } + // If follow_tail mode is on but the user scrolled away // (is_following is false), check whether the current scroll // position has returned to the bottom. - if self.follow_state.has_stopped_following() { + if self.follow_state.has_stopped_following() && self.pending_wheel == px(0.) { let padding = self.last_padding.unwrap_or_default(); let total_height = self.items.summary().height + padding.top + padding.bottom; let scroll_offset = self.scroll_top(&scroll_top); @@ -1369,6 +1483,7 @@ impl StateInner { // Scrollbar support fn set_offset_from_scrollbar(&mut self, point: Point) { + self.pending_wheel = px(0.); let Some(bounds) = self.last_layout_bounds else { return; }; @@ -1562,6 +1677,7 @@ impl Element for List { let padding = style .padding .to_pixels(bounds.size.into(), window.rem_size()); + state.resolve_wheel(bounds.size.width, &mut self.render_item, window, cx); let layout = match state.prepaint_items(bounds, padding, true, &mut self.render_item, window, cx) { Ok(layout) => layout, @@ -1597,21 +1713,14 @@ impl Element for List { // div-based scroll containers. let list_state = self.state.clone(); let height = bounds.size.height; - let scroll_top = prepaint.layout.scroll_top; let hitbox_id = prepaint.hitbox.id; - let mut accumulated_scroll_delta = ScrollDelta::default(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) { - accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta); - let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.)); - list_state.0.borrow_mut().scroll( - &scroll_top, - height, - pixel_delta, - current_view, - window, - cx, - ) + let pixel_delta = event.delta.pixel_delta(px(20.)); + list_state + .0 + .borrow_mut() + .scroll(height, pixel_delta, current_view, window, cx) } }); diff --git a/vendor/gpui-pre/src/elements/list/wheel.rs b/vendor/gpui-pre/src/elements/list/wheel.rs new file mode 100644 index 00000000..089c04b4 --- /dev/null +++ b/vendor/gpui-pre/src/elements/list/wheel.rs @@ -0,0 +1,168 @@ +// Nocterm modifications, licensed under the upstream Apache-2.0 license. +//! Relative wheel movement uses measured rows, never stale or missing height hints. +use super::{Count, ListItem, ListItemSummary, ListOffset}; +use crate::{Pixels, Size, px}; +use sum_tree::{Bias, SumTree}; + +/// Resolve as much of a relative movement as the caller's measurement budget allows. +/// The remainder stays relative to the returned anchor, so later frames need no +/// absolute content-height estimate and no queue of input events. +pub(super) fn advance( + items: &mut SumTree, + mut anchor: ListOffset, + mut distance: Pixels, + mut measure: impl FnMut(usize) -> Option>, +) -> (ListOffset, Pixels) { + let count = items.summary().count; + while distance != px(0.) { + if distance < px(0.) { + let consumed = anchor.offset_in_item.min(-distance).max(px(0.)); + anchor.offset_in_item -= consumed; + distance += consumed; + if distance == px(0.) || anchor.item_ix == 0 { + return (anchor, px(0.)); + } + if skip_measured(items, &mut anchor, &mut distance) { + continue; + } + let previous = anchor.item_ix - 1; + let Some(height) = row_height(items, previous, &mut measure) else { + break; + }; + anchor.item_ix = previous; + anchor.offset_in_item = height; + } else { + if anchor.item_ix == count { + return (anchor, px(0.)); + } + if skip_measured(items, &mut anchor, &mut distance) { + continue; + } + let Some(height) = row_height(items, anchor.item_ix, &mut measure) else { + break; + }; + let consumed = (height - anchor.offset_in_item).max(px(0.)).min(distance); + anchor.offset_in_item += consumed; + distance -= consumed; + if distance == px(0.) { + if anchor.offset_in_item == height { + anchor.item_ix += 1; + anchor.offset_in_item = px(0.); + } + break; + } + anchor.item_ix += 1; + anchor.offset_in_item = px(0.); + } + } + (anchor, distance) +} + +/// Measured subtrees retain logarithmic seeks, even for very large deltas. +fn skip_measured( + items: &SumTree, + anchor: &mut ListOffset, + distance: &mut Pixels, +) -> bool { + let mut cursor = items.cursor::(()); + cursor.seek(&Count(anchor.item_ix), Bias::Right); + let origin = cursor.start().height + anchor.offset_in_item; + let upward = *distance < px(0.); + let (boundary, extent) = if upward { + cursor.search_backward(|summary| summary.unrendered_count > 0); + cursor + .item() + .map_or((0, px(0.)), |_| (cursor.end().count, cursor.end().height)) + } else { + if cursor.item().is_some_and(|item| item.size().is_none()) { + return false; + } + cursor.search_forward(|summary| summary.unrendered_count > 0); + (cursor.start().count, cursor.start().height) + }; + if boundary == anchor.item_ix { + return false; + } + let available = extent - origin; + if distance.abs() >= available.abs() { + *anchor = ListOffset { + item_ix: boundary, + offset_in_item: px(0.), + }; + *distance -= available; + } else { + let target = origin + *distance; + let (start, ..) = items.find::((), &super::Height(target), Bias::Right); + *anchor = ListOffset { + item_ix: start.count, + offset_in_item: target - start.height, + }; + *distance = px(0.); + } + true +} + +fn row_height( + items: &mut SumTree, + index: usize, + measure: &mut impl FnMut(usize) -> Option>, +) -> Option { + let mut cursor = items.cursor::(()); + cursor.seek(&Count(index), Bias::Right); + let item = cursor.item()?; + if let Some(size) = item.size() { + return Some(size.height); + } + let focus_handle = item.focus_handle(); + drop(cursor); + let size = measure(index)?; + let mut cursor = items.cursor::(()); + let mut replacement = cursor.slice(&Count(index), Bias::Right); + cursor.next(); + replacement.extend([ListItem::Measured { size, focus_handle }], ()); + replacement.append(cursor.suffix(), ()); + drop(cursor); + *items = replacement; + Some(size.height) +} + +/// A pending resize/remeasure owns clamping within its original top row. +pub(super) fn within_invalidated_row( + items: &SumTree, + anchor: ListOffset, + distance: Pixels, +) -> Option { + let mut cursor = items.cursor::(()); + cursor.seek(&Count(anchor.item_ix), Bias::Right); + let height = cursor.item()?.size_hint()?.height; + let offset = anchor.offset_in_item + distance; + (offset >= px(0.) && offset <= height).then_some(ListOffset { + item_ix: anchor.item_ix, + offset_in_item: offset, + }) +} + +pub(super) fn at_end(items: &SumTree, anchor: ListOffset, available: Pixels) -> bool { + let mut cursor = items.cursor::(()); + cursor.seek(&Count(anchor.item_ix), Bias::Right); + let suffix = cursor.suffix(); + suffix.summary().unrendered_count == 0 + && suffix.summary().height - anchor.offset_in_item <= available +} + +/// Preserve the caller's explicit approximate-uniform measuring policy. +pub(super) fn estimated( + items: &SumTree, + anchor: ListOffset, + distance: Pixels, + maximum: Pixels, +) -> ListOffset { + let mut cursor = items.cursor::(()); + cursor.seek(&Count(anchor.item_ix), Bias::Right); + let target = (cursor.start().height + anchor.offset_in_item + distance).clamp(px(0.), maximum); + let (start, ..) = items.find::((), &super::Height(target), Bias::Right); + ListOffset { + item_ix: start.count, + offset_in_item: target - start.height, + } +} diff --git a/vendor/patches/gpui-base/0006-relative-wheel-geometry.patch b/vendor/patches/gpui-base/0006-relative-wheel-geometry.patch new file mode 100644 index 00000000..7383b86e --- /dev/null +++ b/vendor/patches/gpui-base/0006-relative-wheel-geometry.patch @@ -0,0 +1,52 @@ +diff --git a/NOCTERM.md b/NOCTERM.md +index bc4c02e240f59b6b6d2adc469c603461cc107ceb..5cbde24cd150370a53faabad8c843c8bb4f4bb34 100644 +GIT binary patch +delta 483 +zcmYMwL2lGQ5CzaBl2>qpFR{o5<^VYWBoatSfi)@Hf83?%?lN6v6WGilxCk3|Tp+hX +zHW}EKN>%^;$Ctmaem=f9clUQgvz&WrH&VyU>ucW!pT8zD-@`delc1w9OXy0hY@vf> +z>?_bZFM(sT(n;@@mmnf7rN?Zpp;om{2i%5Zeuo|rKI#H(-pof;Nq62QM>36Fk8O@em +zC3S>6s3Y5Oa-7H+k*IBhKi}ouf8&o|&+uk$w(3X+zbv{+X5A`#YJV@iyIen7!zxWT +M%_UD(4?lE&0OsYjYybcN + +delta 7 +Ocmdm^eo}eENkIS(K?7O< + +diff --git a/src/scrollable_mask.rs b/src/scrollable_mask.rs +index 1acf1dcc56347b3a230f53484d3c07a1f66715d0..08c7d5717cc8a0aacee6a409900b012d991e7329 100644 +GIT binary patch +delta 146 +zcmZ25g=umxQ-Hp{f^U9GW?E)yib7JQf?s}eNorB9LUMjyNl|7}X-R%jv9&^RYN~>t +zzjKIdkgr~Dik^Z`W^!s?acYV}Xn%r&!D=pz +zXyZ;vn~7F=j+v_R1e$mPZm_GBDqt1n)m?X~yX`g~tRIbEw(^+k7hEPejgfH!sW2{h +zJ(`a{K8B`=?+sy*YE^jhV^8*3DHT#!p(Mr=B`rxiGUA*NVJc`=Ds8ccm2NG&>|ABM +z7G_lN8WDndu2I&82v_?AgD@lUHuK}&8x9u}ER{>dC{~qBOfU#Nd+GT3^!~Mi*Fjka +z2jRe7^gF$r?BDA~i~EL6*j_dmE+e^%cemZ|{^p^VOPVb<;gc@E#vT&NEf1T)|IAw= +z{l^=%V5iQv-9c}|o%Y{58LZ!E5Ut2uH`sAMUSD@-CmZhh!`hXd%U_R!S8x6RhA#4L + +diff --git a/src/scrollbar.rs b/src/scrollbar.rs +index 456addb4dfdeae55bf5cf5e97adf432e1bbdee06..89eee2d51329c5c1ba13a5ac43f5e7ad54f0634d 100644 +GIT binary patch +delta 661 +zcma))PiqrV6vZh>w6P>Ex+q%k2%`>U{s)IZYBAyG-SZGE5gZaVe8GNS$S` +zrWHFMh6qGBdH(d}(TihYGJ$6*B^xM1Wi#>^mohDA@L24N?w3yYa$J^r0f{s6tWv4* +zsvy$HNV9Cjfdh7*`OdbD;S1ks5}|6+y#InNuX?Ck{MhMWtn{GiIttf +zla{Q`2+EcfLk36-`D7ZLu9(0@Wu_=yP^_F4EiShRsdrjWT2s-3Ksw)BdOO6anv)Lo +z_{R2!hZh^YBaG1e3zz4CIEsfYqbW@cBj?Ig1|vjmR}&gpo&@!pqFAgJ8q|lkcCW8q +z_4wVL%e3;IEMtTvS1d%Tr75Gqinxbz9_@?x%Di=FGiuK7^~bxN`e%Q$?r-&aO#}OiTE)fJ-vP(=-G)6W+jyh) +W5aK1MTOZsUzTN5n*y(TVzWD=Kv)3#D + +delta 28 +kcmdnCjBU#Twv7R7T$`8j+-I45gV(rON@%;35Ti^Z0Hdu5@Bjb+ + +# End of generated Nocterm binary patch. diff --git a/vendor/patches/gpui-pre/0005-relative-wheel-geometry.patch b/vendor/patches/gpui-pre/0005-relative-wheel-geometry.patch new file mode 100644 index 00000000..c3f67554 --- /dev/null +++ b/vendor/patches/gpui-pre/0005-relative-wheel-geometry.patch @@ -0,0 +1,131 @@ +diff --git a/NOCTERM_PATCH.md b/NOCTERM_PATCH.md +index 9516bdf4f6700bd97398b098d865c5b9b0eccd20..6c3eae7cf6e34d2854855796d472a4ebab5321e1 100644 +GIT binary patch +delta 1297 +zcmY*ZO|Rrc3_O!xQPLinnI||Qz0*TNXb&K?TDb0b(w?+=opxk9$viIempH?pL!Hd7 +z*kj9I<*IW3`^P`$zn{OockkZe0~ZQe7;9!O7+6ORd+WNhTmQs`N3P*dMLeFil#}@x +z#;#>yhkac>>arCevgtfzP#-fDn6BN(okx$*n_EsCI +zU=f!~;U`_#lF^DnZ#oC=nH&-hzKKV}+L}&W6W741_IiNSzRXGL(0!Gdsiv|Kam8P3 +zdfXXkJo^bR4>3J>g49q@iy$%u2IIBUIb80EH`t5tI;jP?2Akt*I&J})CR1uFg44hU +z7)54leJcK($7w^buyvJJ^jf04<*IiZKjUuUW~Lyi +zZM0bTg_I4&O-2gdhGNnp{o4sI7uPZe!+Xq8Unp(XsRG`#;QyJ*8zajx12+jyjbYn;J1wqBgkYe~rBq4sv@^m_H)m@5X;HSz)X +zRZ?X1nd-oPKoaA=i2>t<&8heSmAzefs22Dh_CwdaCUxGBJxMCOpWVLy|4qz!4TJGLtAKxSIu=nUuTK0*_l% +zf+|z+Dz1{0IPirEG0Sv(yD<@&8Hp>$I&isTasVD>+!EC+R0QKHn*acOz2o2O9AQDV +zOsQRerSo?D(-q1s#!ChN))YL<=Be%a*5Gkg6w)b_DJzv#q0G3Nf>|N#`sKtgM|xXM +z;>F(&4!1o}B(_^Wn0`P0P|E?xLuEcaVPvLtWR1&JL{X!SBghAy^2V(-xbN>>o7>wc +z>CyV=)i +ziNsLbAT!govk%3RwckD&>%+U8we`U#u6N+bfwfDs3qS%qfJJRZAYBnIQ*4fcBj%_J +zN>quoK-e06QdHfH;dd;T%fVjex{R0fZEz@YJxLwPl?y~tkkl3S9JRpmq&Bk2g78!p +zs!Ww(R%kd|@WrZ-hXaQdZx)IiTvH0$4)9p0_;5_8Txu7yL4XlPGcdlxM~lB93_SAd&YKo`=gq0UN +zGp6pCZBwg#*T$lPP2-i5n>YB_ipD)Njbzuxbqx<18%w%(X^kzy&9abLkklcJGhWI- +zzJaU=8Uex$<`!Jx(DZ+0qrDx_PXce+MZnHedUO~sFg{0YW)VfLf)v&Ch(8~zGmc8B +zvt9~)Wy1nC-+3IU?b1e!?O3%rBVogL&sS__G8JtI(5t}(QBlCS5b-@zAg#yo|pvuf$0jr9znYqyF +zD&2w$eT8+SIam4N{10$MrOHMVSbHHJ8|V%eR5nqY=!y;E&u%0?ANnKKoJLT2?GjjJqLW!_o6IW1pcQSBfgSm%vrW07BNLL9SlcoLeA`eb4??T`YUXX7 +zk-%+B3Gw+GpIRH-50K!KhFK=O)TE(yp^xRGC>IyovHMuQS;A&=tO#R~^VZmMIe1v_ +zxSev20^xdrM4JFqSqiUI*0&j_QZ-0la40gRG!#w273Ip%G#J<7&R>t1SnM5$h_bu&wy~^0?M-+rryE4vr`M +zpCXTxN1!3l_~S@#m07ABDF-|eAHsFt`zqQ`CX&(GkI3220Y8|zU;7`|7}2~w>sckL +zHR$B_&v`C8N}Iv?0B_L+W`k@Sg~&j@1*`urkNDiu5?U4(8Mi2&{d2MzRS=$GmZ^D| +zS_RjeO;2WaPS^Gi#ggY<>%{ykgXb>Xy~STD)b=%+5y-IuaSKuimByDJNXB($X}tVe +zrgk+R8^kN`O~x|ka2Owdc63D(`%yWGjxQce;W$hwYBlyJ;g)aDxJQAk^kx%TqAPJp_@q!}DxoNeLv>|2Fcqx|Ki8;G +zl5h~7G7`A-*P%Yb^-5rZ7xYQ&KRj5UI(oXh{+Dmp5s;h)~V8;|}tQ9pWXDUQaS +zG(LCdr#SIgCtiNHuZzz4EOPzb&uZwcF(&lSdSg~dDXIm>Q?(Oav7yg|4M+#lAPfE% +tLBZGgSui~*DOUxpKJ|x1@WE@&*N#lZhHIyH#(MD4pU>0}yg&1c{{Z%wMkD|L + +delta 955 +zcmaKqT}TvB7={^lXWMl*|MDkO>yVMVv^y(SqJ_L76)A_ZpfrE33ZkwvT7T!K6c1 +zs)P1>pQpg(87q_?wbDyv)0uEuw?pINZu+^#u+oReM@-_RhgUv`( +zFcye=x6^mhYZHuiTIr*_|j1BCgQ3%uh8 +z;p?Yc@bM-K^A}FS{Om3WTy%hY$_|N;1>AGJajzFOgfP-Itd?kK#U5#T1gjbru!cwo +zqllu3L{wy?5nc;n0X26Z{*Ru#UZmnM<~3c#hmndsyc`TE>dI{_3!>bLc;T$Bkua7C +z(i9{TBq&&`luHOwaXVJYW*`ZP>kTP#5+&mpK@#7g=!Am@b5W@3v^m6AiugCD1i712qF#%VE&L$=*WhOC*YD_fnjNH?3QDL{uE% +zMVWA`4jWk*-%Lz7Ct-rZIzfCesE2ilCs^>WYLY>rDs&>5>)=Jt +zsx2;m%CF+Ge6dXGTf`PRxx)^H0jp6%hfG_AH7I$fGZ45#1=_F{K`Q9D +z)?D1e7WwVgz?KW6^M%z2+V-f5;?pnE3X{PfT$|qwcH@62h)oKNAP|J3Qz%+EW+JbC= +zfr>T`)-mn8Q?a&1^}}GOvwUOgTQtDhR^DlH(hA;f5fpZeJHKkfKIl=XkxrIEW0k-f +zn5ec=WuPUr%%Flsu?{r|#2SQj0@+5()*6IL3WLHdgAY*~-Z6u~1g(XCELtQZxah3` +z+@bSvZ#RkaaSDw9R_~aoFj)WqaD$yL;G(l8`sOltbycq{?K)>gG8Ht`a_+%bFQI5k>)PCylI0(dCSe)BCKZ!iwE8KBu +z_2~I%(Gq@~Z>HsMWrv9;Mha`nL8bo{wT$wDzQKqTu+@0RKO2SY{rs52{AY-0)M`DP +zaCX@oHn0wfa9n#hrwMOPjv+h{0X!k5<9{F0FUDx@(r*^2BO(Wwz%rgUkIjt4j^6{E +z$7sodAMRtGQE{~gj0ys3)MBY#0~N%3G$m*>=1Fjrd+?u5v;n +z73@%bfR?FSgc@7JbiQcBQ4(}UxlCBVMQAXNi>Nmx6xvC3b9$wjq`T +zVk*X!Ra&Z{{`sFpF&r7XrJE#)CCD$y$++agby>kp5eR7p)E^ba8|uwMc-hT$F626}4;da8LaWeP$_u>F}9SNju<}%_S6n<13`%G +z7;o98MOX)Tz%t;{LuisTbGFe*3ODOj@7h(=VsT>>m-6gqZQFmD-lc*u-f&U$WMbUZMOvPg-&5z|J`9qnB4 +z`6~@w%=QJiyNEx0xT?7B^=t1d;VKhuF#${zq|c>mmxn)}q@A8;14P2f&@nU9uOxjw +zP=Z#tcFSM5{B0>DlnLAe^M;j?#Q~zuqa>=4mDS~ziP{m7PRpm?lCYyzvJdHElHo}Q +zSVu1hyMnG?>!-M0Vc)XKU&6=wM2{M=#y#x5kQW8+O;mg$E_YR^PX3BF;i4FthfmIy +zZ0?W4-x&J4!?AP8SV4IIEhj0Y+eAJLT@Vy$3cz#O~*5Aa$ +z1{W7m#&zu9)7)%PxUOLG8Zsd+X;$)maa2^tTsqgH2*3UO!C~jip84G-o+Pp>g{k#64W{@3KS=%h!acaouaU0j^*2;g|&)=04% +zdX-4c*9tYN9nRkmao@MR;s#jXE4g=XcuOmIBk7ga-BXCJy=-~);h356(Xal-WTcwk +zu|4l>7h@To