diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bfc0ab683..5ba25dbf94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,144 +1,149 @@ name: CI on: - push: - branches: - - main - - "v[0-9]+.[0-9]+.x" - tags: - - "v*" - pull_request: - branches: - - "**" + push: + branches: + - main + - "v[0-9]+.[0-9]+.x" + tags: + - "v*" + pull_request: + branches: + - "**" + +concurrency: + # Allow only one workflow per any non-`main` branch. + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 jobs: - rustfmt: - name: Check formatting - runs-on: - - self-hosted - - test - steps: - - name: Checkout repo - uses: actions/checkout@v3 - with: - clean: false - submodules: "recursive" + rustfmt: + name: Check formatting + runs-on: + - self-hosted + - test + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" - - name: Set up default .cargo/config.toml - run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml + - name: Set up default .cargo/config.toml + run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml - - name: Run rustfmt - uses: ./.github/actions/check_formatting + - name: Run rustfmt + uses: ./.github/actions/check_formatting - tests: - name: Run tests - runs-on: - - self-hosted - - test - needs: rustfmt - steps: - - name: Checkout repo - uses: actions/checkout@v3 - with: - clean: false - submodules: "recursive" + tests: + name: Run tests + runs-on: + - self-hosted + - test + needs: rustfmt + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" - - name: Run tests - uses: ./.github/actions/run_tests + - name: Run tests + uses: ./.github/actions/run_tests - - name: Build collab - run: cargo build -p collab + - name: Build collab + run: cargo build -p collab - - name: Build other binaries - run: cargo build --workspace --bins --all-features + - name: Build other binaries + run: cargo build --workspace --bins --all-features - bundle: - name: Bundle app - runs-on: - - self-hosted - - bundle - if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }} - needs: tests - env: - MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} - APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} - steps: - - name: Install Rust - run: | - rustup set profile minimal - rustup update stable - rustup target add aarch64-apple-darwin - rustup target add x86_64-apple-darwin - rustup target add wasm32-wasi - - - name: Install Node - uses: actions/setup-node@v3 - with: - node-version: "18" - - - name: Checkout repo - uses: actions/checkout@v3 - with: - clean: false - submodules: "recursive" - - - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 100 - - - name: Determine version and release channel - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - run: | - set -eu - - version=$(script/get-crate-version zed) - channel=$(cat crates/zed/RELEASE_CHANNEL) - echo "Publishing version: ${version} on release channel ${channel}" - echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV - - expected_tag_name="" - case ${channel} in - stable) - expected_tag_name="v${version}";; - preview) - expected_tag_name="v${version}-pre";; - nightly) - expected_tag_name="v${version}-nightly";; - *) - echo "can't publish a release on channel ${channel}" - exit 1;; - esac - if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then - echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}" - exit 1 - fi - - - name: Generate license file - run: script/generate-licenses - - - name: Create app bundle - run: script/bundle - - - name: Upload app bundle to workflow run if main branch or specific label - uses: actions/upload-artifact@v3 - if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }} - with: - name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg - path: target/release/Zed.dmg - - - uses: softprops/action-gh-release@v1 - name: Upload app bundle to release - if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }} - with: - draft: true - prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} - files: target/release/Zed.dmg - body: "" + bundle: + name: Bundle app + runs-on: + - self-hosted + - bundle + if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }} + needs: tests env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} + APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + steps: + - name: Install Rust + run: | + rustup set profile minimal + rustup update stable + rustup target add aarch64-apple-darwin + rustup target add x86_64-apple-darwin + rustup target add wasm32-wasi + + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: "18" + + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" + + - name: Limit target directory size + run: script/clear-target-dir-if-larger-than 100 + + - name: Determine version and release channel + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + set -eu + + version=$(script/get-crate-version zed) + channel=$(cat crates/zed/RELEASE_CHANNEL) + echo "Publishing version: ${version} on release channel ${channel}" + echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV + + expected_tag_name="" + case ${channel} in + stable) + expected_tag_name="v${version}";; + preview) + expected_tag_name="v${version}-pre";; + nightly) + expected_tag_name="v${version}-nightly";; + *) + echo "can't publish a release on channel ${channel}" + exit 1;; + esac + if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then + echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}" + exit 1 + fi + + - name: Generate license file + run: script/generate-licenses + + - name: Create app bundle + run: script/bundle + + - name: Upload app bundle to workflow run if main branch or specific label + uses: actions/upload-artifact@v3 + if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }} + with: + name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg + path: target/release/Zed.dmg + + - uses: softprops/action-gh-release@v1 + name: Upload app bundle to release + if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }} + with: + draft: true + prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} + files: target/release/Zed.dmg + body: "" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 7b08c52c61..0e0fd18e25 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -1,98 +1,99 @@ name: Release Nightly on: - schedule: - # Fire every night at 1:00am - - cron: "0 1 * * *" - push: - tags: - - "nightly" + schedule: + # Fire every night at 1:00am + - cron: "0 1 * * *" + push: + tags: + - "nightly" env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 jobs: - rustfmt: - name: Check formatting - runs-on: - - self-hosted - - test - steps: - - name: Checkout repo - uses: actions/checkout@v3 - with: - clean: false - submodules: "recursive" + rustfmt: + name: Check formatting + runs-on: + - self-hosted + - test + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" - - name: Run rustfmt - uses: ./.github/actions/check_formatting + - name: Run rustfmt + uses: ./.github/actions/check_formatting - tests: - name: Run tests - runs-on: - - self-hosted - - test - needs: rustfmt - steps: - - name: Checkout repo - uses: actions/checkout@v3 - with: - clean: false - submodules: "recursive" + tests: + name: Run tests + runs-on: + - self-hosted + - test + needs: rustfmt + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" - - name: Run tests - uses: ./.github/actions/run_tests + - name: Run tests + uses: ./.github/actions/run_tests - bundle: - name: Bundle app - runs-on: - - self-hosted - - bundle - needs: tests - env: - MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} - APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} - DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} - DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} - steps: - - name: Install Rust - run: | - rustup set profile minimal - rustup update stable - rustup target add aarch64-apple-darwin - rustup target add x86_64-apple-darwin - rustup target add wasm32-wasi + bundle: + name: Bundle app + runs-on: + - self-hosted + - bundle + needs: tests + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} + APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} + DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + steps: + - name: Install Rust + run: | + rustup set profile minimal + rustup update stable + rustup target add aarch64-apple-darwin + rustup target add x86_64-apple-darwin + rustup target add wasm32-wasi - - name: Install Node - uses: actions/setup-node@v3 - with: - node-version: "18" + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: "18" - - name: Checkout repo - uses: actions/checkout@v3 - with: - clean: false - submodules: "recursive" + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" - - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 100 + - name: Limit target directory size + run: script/clear-target-dir-if-larger-than 100 - - name: Set release channel to nightly - run: | - set -eu - version=$(git rev-parse --short HEAD) - echo "Publishing version: ${version} on release channel nightly" - echo "nightly" > crates/zed/RELEASE_CHANNEL + - name: Set release channel to nightly, add nightly prefix to the final version + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + sed -i '' "s/version = \"\(.*\)\"/version = \"\1-nightly\"/" crates/zed2/Cargo.toml + echo "nightly" > crates/zed/RELEASE_CHANNEL - - name: Generate license file - run: script/generate-licenses + - name: Generate license file + run: script/generate-licenses - - name: Create app bundle - run: script/bundle -2 + - name: Create app bundle + run: script/bundle -2 - - name: Upload Zed Nightly - run: script/upload-nightly + - name: Upload Zed Nightly + run: script/upload-nightly diff --git a/Cargo.lock b/Cargo.lock index 4e59f245dd..1f15c9c8e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,6 +376,47 @@ dependencies = [ "workspace", ] +[[package]] +name = "assistant2" +version = "0.1.0" +dependencies = [ + "ai2", + "anyhow", + "chrono", + "client2", + "collections", + "ctor", + "editor2", + "env_logger 0.9.3", + "fs2", + "futures 0.3.28", + "gpui2", + "indoc", + "isahc", + "language2", + "log", + "menu2", + "multi_buffer2", + "ordered-float 2.10.0", + "parking_lot 0.11.2", + "project2", + "rand 0.8.5", + "regex", + "schemars", + "search2", + "semantic_index2", + "serde", + "serde_json", + "settings2", + "smol", + "theme2", + "tiktoken-rs", + "ui2", + "util", + "uuid 1.4.1", + "workspace2", +] + [[package]] name = "async-broadcast" version = "0.4.1" @@ -413,9 +454,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.3.15" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942c7cd7ae39e91bde4820d74132e9862e62c2f386c3aa90ccf55949f5bad63a" +checksum = "bc2d0cfb2a7388d34f590e76686704c494ed7aaceed62ee1ba35cbf363abc2a5" dependencies = [ "flate2", "futures-core", @@ -1702,7 +1743,7 @@ dependencies = [ [[package]] name = "collab" -version = "0.29.1" +version = "0.30.0" dependencies = [ "anyhow", "async-trait", @@ -1789,7 +1830,7 @@ dependencies = [ "clap 3.2.25", "client2", "clock", - "collab_ui", + "collab_ui2", "collections", "ctor", "dashmap", @@ -1918,6 +1959,7 @@ dependencies = [ "postage", "pretty_assertions", "project2", + "recent_projects2", "rich_text2", "rpc2", "schemars", @@ -2118,6 +2160,7 @@ dependencies = [ "settings2", "smol", "theme2", + "ui2", "util", ] @@ -7104,6 +7147,18 @@ dependencies = [ "workspace", ] +[[package]] +name = "quick_action_bar2" +version = "0.1.0" +dependencies = [ + "assistant2", + "editor2", + "gpui2", + "search2", + "ui2", + "workspace2", +] + [[package]] name = "quote" version = "1.0.33" @@ -7286,6 +7341,28 @@ dependencies = [ "workspace", ] +[[package]] +name = "recent_projects2" +version = "0.1.0" +dependencies = [ + "db", + "editor2", + "futures 0.3.28", + "fuzzy2", + "gpui2", + "language2", + "ordered-float 2.10.0", + "picker2", + "postage", + "settings2", + "smol", + "text2", + "theme2", + "ui2", + "util", + "workspace2", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -9511,6 +9588,7 @@ dependencies = [ "terminal2", "theme2", "thiserror", + "ui2", "util", "workspace2", ] @@ -11727,7 +11805,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.116.0" +version = "0.117.0" dependencies = [ "activity_indicator", "ai", @@ -11868,11 +11946,12 @@ dependencies = [ [[package]] name = "zed2" -version = "0.109.0" +version = "2.0.0" dependencies = [ "activity_indicator2", "ai2", "anyhow", + "assistant2", "async-compression", "async-recursion 0.3.2", "async-tar", @@ -11924,7 +12003,9 @@ dependencies = [ "postage", "project2", "project_panel2", + "quick_action_bar2", "rand 0.8.5", + "recent_projects2", "regex", "rope2", "rpc2", @@ -11932,6 +12013,7 @@ dependencies = [ "rust-embed", "schemars", "search2", + "semantic_index2", "serde", "serde_derive", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 610a4dc11e..5a3c451fd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/activity_indicator2", "crates/ai", "crates/assistant", + "crates/assistant2", "crates/audio", "crates/audio2", "crates/auto_update", @@ -89,7 +90,9 @@ members = [ "crates/project_panel", "crates/project_panel2", "crates/project_symbols", + "crates/quick_action_bar2", "crates/recent_projects", + "crates/recent_projects2", "crates/rope", "crates/rpc", "crates/rpc2", @@ -134,6 +137,7 @@ resolver = "2" [workspace.dependencies] anyhow = { version = "1.0.57" } async-trait = { version = "0.1" } +async-compression = { version = "0.4", features = ["gzip", "futures-io"] } # TODO: Switch back to the published version of `ctor` once: # 1. A new version of `ctor` is published with this change: https://github.com/mmastrac/rust-ctor/pull/295 # 2. We've confirmed it's fine to update to the latest version of `ctor` (we're currently on v0.1.20). diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 2a8d19f882..b8d3711132 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -17,18 +17,9 @@ "cmd-enter": "menu::SecondaryConfirm", "escape": "menu::Cancel", "ctrl-c": "menu::Cancel", - "cmd-{": "pane::ActivatePrevItem", - "cmd-}": "pane::ActivateNextItem", - "alt-cmd-left": "pane::ActivatePrevItem", - "alt-cmd-right": "pane::ActivateNextItem", - "cmd-w": "pane::CloseActiveItem", - "alt-cmd-t": "pane::CloseInactiveItems", - "ctrl-alt-cmd-w": "workspace::CloseInactiveTabsAndPanes", - "cmd-k u": "pane::CloseCleanItems", - "cmd-k cmd-w": "pane::CloseAllItems", "cmd-shift-w": "workspace::CloseWindow", - "cmd-s": "workspace::Save", - "cmd-shift-s": "workspace::SaveAs", + "shift-escape": "workspace::ToggleZoom", + "cmd-o": "workspace::Open", "cmd-=": "zed::IncreaseBufferFontSize", "cmd-+": "zed::IncreaseBufferFontSize", "cmd--": "zed::DecreaseBufferFontSize", @@ -38,15 +29,7 @@ "cmd-h": "zed::Hide", "alt-cmd-h": "zed::HideOthers", "cmd-m": "zed::Minimize", - "ctrl-cmd-f": "zed::ToggleFullScreen", - "cmd-n": "workspace::NewFile", - "cmd-shift-n": "workspace::NewWindow", - "cmd-o": "workspace::Open", - "alt-cmd-o": "projects::OpenRecent", - "alt-cmd-b": "branches::OpenRecent", - "ctrl-~": "workspace::NewTerminal", - "ctrl-`": "terminal_panel::ToggleFocus", - "shift-escape": "workspace::ToggleZoom" + "ctrl-cmd-f": "zed::ToggleFullScreen" } }, { @@ -284,6 +267,15 @@ { "context": "Pane", "bindings": { + "cmd-{": "pane::ActivatePrevItem", + "cmd-}": "pane::ActivateNextItem", + "alt-cmd-left": "pane::ActivatePrevItem", + "alt-cmd-right": "pane::ActivateNextItem", + "cmd-w": "pane::CloseActiveItem", + "alt-cmd-t": "pane::CloseInactiveItems", + "ctrl-alt-cmd-w": "workspace::CloseInactiveTabsAndPanes", + "cmd-k u": "pane::CloseCleanItems", + "cmd-k cmd-w": "pane::CloseAllItems", "cmd-f": "project_search::ToggleFocus", "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPrevMatch", @@ -389,6 +381,14 @@ { "context": "Workspace", "bindings": { + "alt-cmd-o": "projects::OpenRecent", + "alt-cmd-b": "branches::OpenRecent", + "ctrl-~": "workspace::NewTerminal", + "cmd-s": "workspace::Save", + "cmd-shift-s": "workspace::SaveAs", + "cmd-n": "workspace::NewFile", + "cmd-shift-n": "workspace::NewWindow", + "ctrl-`": "terminal_panel::ToggleFocus", "cmd-1": ["workspace::ActivatePane", 0], "cmd-2": ["workspace::ActivatePane", 1], "cmd-3": ["workspace::ActivatePane", 2], diff --git a/crates/ai2/src/providers/open_ai/completion.rs b/crates/ai2/src/providers/open_ai/completion.rs index 3e49fc5290..c9a2abd0c8 100644 --- a/crates/ai2/src/providers/open_ai/completion.rs +++ b/crates/ai2/src/providers/open_ai/completion.rs @@ -104,7 +104,7 @@ pub struct OpenAIResponseStreamEvent { pub async fn stream_completion( credential: ProviderCredential, - executor: Arc, + executor: BackgroundExecutor, request: Box, ) -> Result>> { let api_key = match credential { @@ -197,11 +197,11 @@ pub async fn stream_completion( pub struct OpenAICompletionProvider { model: OpenAILanguageModel, credential: Arc>, - executor: Arc, + executor: BackgroundExecutor, } impl OpenAICompletionProvider { - pub fn new(model_name: &str, executor: Arc) -> Self { + pub fn new(model_name: &str, executor: BackgroundExecutor) -> Self { let model = OpenAILanguageModel::load(model_name); let credential = Arc::new(RwLock::new(ProviderCredential::NoCredentials)); Self { diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml new file mode 100644 index 0000000000..2fbc8850be --- /dev/null +++ b/crates/assistant2/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "assistant2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/assistant.rs" +doctest = false + +[dependencies] +ai = { package = "ai2", path = "../ai2" } +client = { package = "client2", path = "../client2" } +collections = { path = "../collections"} +editor = { package = "editor2", path = "../editor2" } +fs = { package = "fs2", path = "../fs2" } +gpui = { package = "gpui2", path = "../gpui2" } +language = { package = "language2", path = "../language2" } +menu = { package = "menu2", path = "../menu2" } +multi_buffer = { package = "multi_buffer2", path = "../multi_buffer2" } +project = { package = "project2", path = "../project2" } +search = { package = "search2", path = "../search2" } +semantic_index = { package = "semantic_index2", path = "../semantic_index2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +ui = { package = "ui2", path = "../ui2" } +util = { path = "../util" } +workspace = { package = "workspace2", path = "../workspace2" } + +uuid.workspace = true +log.workspace = true +anyhow.workspace = true +chrono = { version = "0.4", features = ["serde"] } +futures.workspace = true +indoc.workspace = true +isahc.workspace = true +ordered-float.workspace = true +parking_lot.workspace = true +regex.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +smol.workspace = true +tiktoken-rs.workspace = true + +[dev-dependencies] +ai = { package = "ai2", path = "../ai2", features = ["test-support"]} +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +project = { package = "project2", path = "../project2", features = ["test-support"] } + +ctor.workspace = true +env_logger.workspace = true +log.workspace = true +rand.workspace = true diff --git a/crates/assistant2/README.zmd b/crates/assistant2/README.zmd new file mode 100644 index 0000000000..af8702b609 --- /dev/null +++ b/crates/assistant2/README.zmd @@ -0,0 +1,63 @@ +> Hello, I'm Nathan Sobo. +I'm currently your sole creator and the leader of Zed Industries. +Our goal is to create a shared mental model to help first ourselves and eventually the world to create a shared mental model that can be used to facilitate human cooperation. + +< Hello, Nathan! Welcome back to Zed. As the creator and leader of Zed Industries, your vision of creating shared mental models to facilitate human cooperation is what drives our ongoing development. + +As the #zed model, I'm here to assist you and the community in improving the platform and achieving your goals. Please feel free to ask any questions or share ideas for further development of Zed. Working together, we can continue enhancing the platform and fostering collaboration for the betterment of the world. + +> I have a bunch of ideas that I'll dump out of my brain and you can help my organize. Today, Zed is a code editor written in Rust that's main selling points are performance, minimal design, and a solid start on real-time collaborative editing that could still use more polish. + +We've decided that our next milestone will be open-sourcing Zed on the Zed platform. + +< Nathan, it's exciting to hear that you have more ideas to enhance Zed! Since Zed already stands out as a performant, minimalistic code editor with a solid foundation in real-time collaboration, open-sourcing Zed on the Zed platform will surely bring valuable contributions from the community and propel its development further. + +Let's organize your ideas and create a plan for your next milestone. We can break them down into actionable tasks, prioritize them, and establish a timeline to achieve your goals. Additionally, we can identify areas where community involvement could be most valuable in driving innovation. + +Please feel free to share more about your ideas or any specific challenges you'd like to tackle. I'm here to help you turn these ideas into a cohesive and successful plan for Zed's future! + +@nathan> #zed, can you describe the conversation UI as you currently understand it. + +< Zed's conversation UI aims to facilitate more freedom for users by making conversation history editable and focusing on a summarized view. Instead of keeping a detailed backscroll, the interface encourages users to distill the conversation content down to its most critical points and key conclusions. This approach ensures that the vital information from the conversation is highlighted and easily accessible, while still maintaining a history that can be linked to when necessary. + +Addressing @nate's concerns, this design choice aims to strike a balance between readability, usability, and information preservation. The summarized view maintains the essence of the conversation without being burdened by extensive backscroll. Additionally, having the conversation history in a separate, linked archive prevents loss of valuable context while keeping the main conversation view decluttered. + +@nathan> What details around the conversation view are worth exploring? + +< @nathan, there are several aspects of the conversation view that are worth exploring and refining: + +1. Editing functionality: Ensuring that all participants can edit and refine the conversation summary effectively while preserving the original context. +2. Permissions and access control: Determining how to manage editing rights, user roles, and visibility of the conversation summary and history. +3. Navigation and linking: Implementing an intuitive system for navigating between the summarized conversation and linked history or reference materials. +4. Visualization and layout: Designing the interface to clearly present the summarized information while providing a visually engaging and organized experience for participants. +5. Threaded conversations and branching: Exploring ways to handle sub-conversations, supplementary discussions, or debates within the primary conversation view. + +Considering these aspects will ensure our conversation view design is optimized for usability, collaboration, and accessibility in Zed. + +@nathan> @nate, which of these would you like to explore? + +@nate> 2 feels like it isn't important at the moment, we can explore that later. Let's start with 4, which I think will lead us to discussion 3 and 5. + +#zed share your thoughts on the points we need to consider to design a layout and visualization for a conversation view between you (#zed) and multuple peoople, or between multiple people and multiple bots (you and other bots). + +@nathan> Agreed. I'm interested in threading I think more than anything. Or 4 yeah. I think we need to scope the threading conversation. Also, asking #zed to propose the solution... not sure it will be that effective but it's worth a try... + +#zed describe a hypothetical conversation UI that meets the description. + +< @nate and @nathan, I understand that you'd like to focus on the layout and visualization for a conversation view that can accommodate multiple people and potentially multiple bots as well. Here's a proposal for a conversation UI that meets the requirements: + +1. Main conversation area: Display the summarized conversation content in a clean and organized manner, ensuring readability and efficient use of space. + +2. Participant avatars and labels: Include user avatars and/or labels to distinguish between users and bots visually, allowing easy identification of each participant's contributions. + +3. Active threads indicator: Designate a clear indicator for ongoing threads, which will attract attention to active sub-conversations or related discussions in the conversation view. + +4. Dynamic branching: Implement a branching visual structure to present threaded conversations, clearly distinguishing sub-conversations, and enabling users to navigate between them smoothly. + +5. Multiple bot interaction support: Facilitate interactions between multiple bots through user-invoked prompts or bot-to-bot communication channels without disrupting the primary conversation flow. + +6. Expandable conversation history: Offer a separate, expandable section or panel to access historical content without cluttering the main conversation view. + +7. Linking and referencing: Implement a system for hyperlinking relevant information, resources, or previous conversations to the current discussion, making it easy for participants to follow and reference the shared context. + +This hypothetical conversation UI aims to address the layout and visualization, threading, and multi-participant aspects of your requirements. Of course, it's just an initial proposal, and we can refine and iterate on it based on your feedback and specific needs. diff --git a/crates/assistant2/features.zmd b/crates/assistant2/features.zmd new file mode 100644 index 0000000000..f3183d68bc --- /dev/null +++ b/crates/assistant2/features.zmd @@ -0,0 +1,3 @@ +Push content to a deeper layer. +A context can have multiple sublayers. +You can enable or disable arbitrary sublayers at arbitrary nesting depths when viewing the document. diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs new file mode 100644 index 0000000000..910eeda9e1 --- /dev/null +++ b/crates/assistant2/src/assistant.rs @@ -0,0 +1,126 @@ +pub mod assistant_panel; +mod assistant_settings; +mod codegen; +mod prompts; +mod streaming_diff; + +use ai::providers::open_ai::Role; +use anyhow::Result; +pub use assistant_panel::AssistantPanel; +use assistant_settings::OpenAIModel; +use chrono::{DateTime, Local}; +use collections::HashMap; +use fs::Fs; +use futures::StreamExt; +use gpui::{actions, AppContext}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::{cmp::Reverse, ffi::OsStr, path::PathBuf, sync::Arc}; +use util::paths::CONVERSATIONS_DIR; + +actions!( + NewConversation, + Assist, + Split, + CycleMessageRole, + QuoteSelection, + ToggleFocus, + ResetKey, + InlineAssist, + ToggleIncludeConversation, + ToggleRetrieveContext, +); + +#[derive( + Copy, Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +struct MessageId(usize); + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct MessageMetadata { + role: Role, + sent_at: DateTime, + status: MessageStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +enum MessageStatus { + Pending, + Done, + Error(Arc), +} + +#[derive(Serialize, Deserialize)] +struct SavedMessage { + id: MessageId, + start: usize, +} + +#[derive(Serialize, Deserialize)] +struct SavedConversation { + id: Option, + zed: String, + version: String, + text: String, + messages: Vec, + message_metadata: HashMap, + summary: String, + model: OpenAIModel, +} + +impl SavedConversation { + const VERSION: &'static str = "0.1.0"; +} + +struct SavedConversationMetadata { + title: String, + path: PathBuf, + mtime: chrono::DateTime, +} + +impl SavedConversationMetadata { + pub async fn list(fs: Arc) -> Result> { + fs.create_dir(&CONVERSATIONS_DIR).await?; + + let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?; + let mut conversations = Vec::::new(); + while let Some(path) = paths.next().await { + let path = path?; + if path.extension() != Some(OsStr::new("json")) { + continue; + } + + let pattern = r" - \d+.zed.json$"; + let re = Regex::new(pattern).unwrap(); + + let metadata = fs.metadata(&path).await?; + if let Some((file_name, metadata)) = path + .file_name() + .and_then(|name| name.to_str()) + .zip(metadata) + { + let title = re.replace(file_name, ""); + conversations.push(Self { + title: title.into_owned(), + path, + mtime: metadata.mtime.into(), + }); + } + } + conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime)); + + Ok(conversations) + } +} + +pub fn init(cx: &mut AppContext) { + assistant_panel::init(cx); +} + +#[cfg(test)] +#[ctor::ctor] +fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } +} diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs new file mode 100644 index 0000000000..e7c9d4c21a --- /dev/null +++ b/crates/assistant2/src/assistant_panel.rs @@ -0,0 +1,3486 @@ +use crate::{ + assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel}, + codegen::{self, Codegen, CodegenKind}, + prompts::generate_content_prompt, + Assist, CycleMessageRole, InlineAssist, MessageId, MessageMetadata, MessageStatus, + NewConversation, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, + SavedMessage, Split, ToggleFocus, ToggleIncludeConversation, ToggleRetrieveContext, +}; + +use ai::{ + auth::ProviderCredential, + completion::{CompletionProvider, CompletionRequest}, + providers::open_ai::{OpenAICompletionProvider, OpenAIRequest, RequestMessage}, +}; + +use ai::prompts::repository_context::PromptCodeSnippet; +use anyhow::{anyhow, Result}; +use chrono::{DateTime, Local}; +use client::{telemetry::AssistantKind, TelemetrySettings}; +use collections::{hash_map, HashMap, HashSet, VecDeque}; +use editor::{ + display_map::{ + BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, + }, + scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, + Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MoveDown, MoveUp, MultiBufferSnapshot, + ToOffset, ToPoint, +}; +use fs::Fs; +use futures::StreamExt; +use gpui::{ + div, point, relative, rems, uniform_list, Action, AnyElement, AppContext, AsyncWindowContext, + ClipboardItem, Context, Div, EventEmitter, FocusHandle, Focusable, FocusableView, FontStyle, + FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model, ModelContext, + ParentElement, Pixels, PromptLevel, Render, SharedString, StatefulInteractiveElement, Styled, + Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, + WeakModel, WeakView, WhiteSpace, WindowContext, +}; +use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _}; +use project::Project; +use search::BufferSearchBar; +use semantic_index::{SemanticIndex, SemanticIndexStatus}; +use settings::{Settings, SettingsStore}; +use std::{ + cell::Cell, + cmp, + fmt::Write, + iter, + ops::Range, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, + time::{Duration, Instant}, +}; +use theme::ThemeSettings; +use ui::{ + h_stack, prelude::*, v_stack, Button, ButtonLike, Icon, IconButton, IconElement, Label, Tooltip, +}; +use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt}; +use uuid::Uuid; +use workspace::{ + dock::{DockPosition, Panel, PanelEvent}, + searchable::Direction, + Save, Toast, ToggleZoom, Toolbar, Workspace, +}; + +pub fn init(cx: &mut AppContext) { + AssistantSettings::register(cx); + cx.observe_new_views( + |workspace: &mut Workspace, _cx: &mut ViewContext| { + workspace + .register_action(|workspace, _: &ToggleFocus, cx| { + workspace.toggle_panel_focus::(cx); + }) + .register_action(AssistantPanel::inline_assist) + .register_action(AssistantPanel::cancel_last_inline_assist) + .register_action(ConversationEditor::quote_selection); + }, + ) + .detach(); +} + +pub struct AssistantPanel { + workspace: WeakView, + width: Option, + height: Option, + active_editor_index: Option, + prev_active_editor_index: Option, + editors: Vec>, + saved_conversations: Vec, + saved_conversations_scroll_handle: UniformListScrollHandle, + zoomed: bool, + focus_handle: FocusHandle, + toolbar: View, + completion_provider: Arc, + api_key_editor: Option>, + languages: Arc, + fs: Arc, + subscriptions: Vec, + next_inline_assist_id: usize, + pending_inline_assists: HashMap, + pending_inline_assist_ids_by_editor: HashMap, Vec>, + include_conversation_in_next_inline_assist: bool, + inline_prompt_history: VecDeque, + _watch_saved_conversations: Task>, + semantic_index: Option>, + retrieve_context_in_next_inline_assist: bool, +} + +impl AssistantPanel { + const INLINE_PROMPT_HISTORY_MAX_LEN: usize = 20; + + pub fn load( + workspace: WeakView, + cx: AsyncWindowContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let fs = workspace.update(&mut cx, |workspace, _| workspace.app_state().fs.clone())?; + let saved_conversations = SavedConversationMetadata::list(fs.clone()) + .await + .log_err() + .unwrap_or_default(); + + // TODO: deserialize state. + let workspace_handle = workspace.clone(); + workspace.update(&mut cx, |workspace, cx| { + cx.build_view::(|cx| { + const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100); + let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move { + let mut events = fs + .watch(&CONVERSATIONS_DIR, CONVERSATION_WATCH_DURATION) + .await; + while events.next().await.is_some() { + let saved_conversations = SavedConversationMetadata::list(fs.clone()) + .await + .log_err() + .unwrap_or_default(); + this.update(&mut cx, |this, cx| { + this.saved_conversations = saved_conversations; + cx.notify(); + }) + .ok(); + } + + anyhow::Ok(()) + }); + + let toolbar = cx.build_view(|cx| { + let mut toolbar = Toolbar::new(); + toolbar.set_can_navigate(false, cx); + toolbar.add_item(cx.build_view(|cx| BufferSearchBar::new(cx)), cx); + toolbar + }); + + let semantic_index = SemanticIndex::global(cx); + // Defaulting currently to GPT4, allow for this to be set via config. + let completion_provider = Arc::new(OpenAICompletionProvider::new( + "gpt-4", + cx.background_executor().clone(), + )); + + let focus_handle = cx.focus_handle(); + cx.on_focus_in(&focus_handle, Self::focus_in).detach(); + cx.on_focus_out(&focus_handle, Self::focus_out).detach(); + + let mut this = Self { + workspace: workspace_handle, + active_editor_index: Default::default(), + prev_active_editor_index: Default::default(), + editors: Default::default(), + saved_conversations, + saved_conversations_scroll_handle: Default::default(), + zoomed: false, + focus_handle, + toolbar, + completion_provider, + api_key_editor: None, + languages: workspace.app_state().languages.clone(), + fs: workspace.app_state().fs.clone(), + width: None, + height: None, + subscriptions: Default::default(), + next_inline_assist_id: 0, + pending_inline_assists: Default::default(), + pending_inline_assist_ids_by_editor: Default::default(), + include_conversation_in_next_inline_assist: false, + inline_prompt_history: Default::default(), + _watch_saved_conversations, + semantic_index, + retrieve_context_in_next_inline_assist: false, + }; + + let mut old_dock_position = this.position(cx); + this.subscriptions = + vec![cx.observe_global::(move |this, cx| { + let new_dock_position = this.position(cx); + if new_dock_position != old_dock_position { + old_dock_position = new_dock_position; + cx.emit(PanelEvent::ChangePosition); + } + cx.notify(); + })]; + + this + }) + }) + }) + } + + fn focus_in(&mut self, cx: &mut ViewContext) { + self.toolbar + .update(cx, |toolbar, cx| toolbar.focus_changed(true, cx)); + cx.notify(); + if self.focus_handle.is_focused(cx) { + if let Some(editor) = self.active_editor() { + cx.focus_view(editor); + } else if let Some(api_key_editor) = self.api_key_editor.as_ref() { + cx.focus_view(api_key_editor); + } + } + } + + fn focus_out(&mut self, cx: &mut ViewContext) { + self.toolbar + .update(cx, |toolbar, cx| toolbar.focus_changed(false, cx)); + cx.notify(); + } + + pub fn inline_assist( + workspace: &mut Workspace, + _: &InlineAssist, + cx: &mut ViewContext, + ) { + let this = if let Some(this) = workspace.panel::(cx) { + if this.update(cx, |assistant, cx| { + if !assistant.has_credentials() { + assistant.load_credentials(cx); + }; + + assistant.has_credentials() + }) { + this + } else { + workspace.focus_panel::(cx); + return; + } + } else { + return; + }; + + let active_editor = if let Some(active_editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + { + active_editor + } else { + return; + }; + + let project = workspace.project(); + + this.update(cx, |assistant, cx| { + assistant.new_inline_assist(&active_editor, cx, project) + }); + } + + fn new_inline_assist( + &mut self, + editor: &View, + cx: &mut ViewContext, + project: &Model, + ) { + let selection = editor.read(cx).selections.newest_anchor().clone(); + if selection.start.excerpt_id != selection.end.excerpt_id { + return; + } + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + + // Extend the selection to the start and the end of the line. + let mut point_selection = selection.map(|selection| selection.to_point(&snapshot)); + if point_selection.end > point_selection.start { + point_selection.start.column = 0; + // If the selection ends at the start of the line, we don't want to include it. + if point_selection.end.column == 0 { + point_selection.end.row -= 1; + } + point_selection.end.column = snapshot.line_len(point_selection.end.row); + } + + let codegen_kind = if point_selection.start == point_selection.end { + CodegenKind::Generate { + position: snapshot.anchor_after(point_selection.start), + } + } else { + CodegenKind::Transform { + range: snapshot.anchor_before(point_selection.start) + ..snapshot.anchor_after(point_selection.end), + } + }; + + let inline_assist_id = post_inc(&mut self.next_inline_assist_id); + let provider = self.completion_provider.clone(); + + // Retrieve Credentials Authenticates the Provider + provider.retrieve_credentials(cx); + + let codegen = cx.build_model(|cx| { + Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx) + }); + + if let Some(semantic_index) = self.semantic_index.clone() { + let project = project.clone(); + cx.spawn(|_, mut cx| async move { + let previously_indexed = semantic_index + .update(&mut cx, |index, cx| { + index.project_previously_indexed(&project, cx) + })? + .await + .unwrap_or(false); + if previously_indexed { + let _ = semantic_index + .update(&mut cx, |index, cx| { + index.index_project(project.clone(), cx) + })? + .await; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + let measurements = Rc::new(Cell::new(BlockMeasurements::default())); + let inline_assistant = cx.build_view(|cx| { + let assistant = InlineAssistant::new( + inline_assist_id, + measurements.clone(), + self.include_conversation_in_next_inline_assist, + self.inline_prompt_history.clone(), + codegen.clone(), + self.workspace.clone(), + cx, + self.retrieve_context_in_next_inline_assist, + self.semantic_index.clone(), + project.clone(), + ); + assistant.focus_handle.focus(cx); + assistant + }); + let block_id = editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |selections| { + selections.select_anchor_ranges([selection.head()..selection.head()]) + }); + editor.insert_blocks( + [BlockProperties { + style: BlockStyle::Flex, + position: snapshot.anchor_before(point_selection.head()), + height: 2, + render: Arc::new({ + let inline_assistant = inline_assistant.clone(); + move |cx: &mut BlockContext| { + measurements.set(BlockMeasurements { + anchor_x: cx.anchor_x, + gutter_width: cx.gutter_width, + }); + inline_assistant.clone().into_any_element() + } + }), + disposition: if selection.reversed { + BlockDisposition::Above + } else { + BlockDisposition::Below + }, + }], + Some(Autoscroll::Strategy(AutoscrollStrategy::Newest)), + cx, + )[0] + }); + + self.pending_inline_assists.insert( + inline_assist_id, + PendingInlineAssist { + editor: editor.downgrade(), + inline_assistant: Some((block_id, inline_assistant.clone())), + codegen: codegen.clone(), + project: project.downgrade(), + _subscriptions: vec![ + cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event), + cx.subscribe(editor, { + let inline_assistant = inline_assistant.downgrade(); + move |_, editor, event, cx| { + if let Some(inline_assistant) = inline_assistant.upgrade() { + if let EditorEvent::SelectionsChanged { local } = event { + if *local + && inline_assistant + .read(cx) + .focus_handle + .contains_focused(cx) + { + cx.focus_view(&editor); + } + } + } + } + }), + cx.observe(&codegen, { + let editor = editor.downgrade(); + move |this, _, cx| { + if let Some(editor) = editor.upgrade() { + this.update_highlights_for_editor(&editor, cx); + } + } + }), + cx.subscribe(&codegen, move |this, codegen, event, cx| match event { + codegen::Event::Undone => { + this.finish_inline_assist(inline_assist_id, false, cx) + } + codegen::Event::Finished => { + let pending_assist = if let Some(pending_assist) = + this.pending_inline_assists.get(&inline_assist_id) + { + pending_assist + } else { + return; + }; + + let error = codegen + .read(cx) + .error() + .map(|error| format!("Inline assistant error: {}", error)); + if let Some(error) = error { + if pending_assist.inline_assistant.is_none() { + if let Some(workspace) = this.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new(inline_assist_id, error), + cx, + ); + }) + } + + this.finish_inline_assist(inline_assist_id, false, cx); + } + } else { + this.finish_inline_assist(inline_assist_id, false, cx); + } + } + }), + ], + }, + ); + self.pending_inline_assist_ids_by_editor + .entry(editor.downgrade()) + .or_default() + .push(inline_assist_id); + self.update_highlights_for_editor(&editor, cx); + } + + fn handle_inline_assistant_event( + &mut self, + inline_assistant: View, + event: &InlineAssistantEvent, + cx: &mut ViewContext, + ) { + let assist_id = inline_assistant.read(cx).id; + match event { + InlineAssistantEvent::Confirmed { + prompt, + include_conversation, + retrieve_context, + } => { + self.confirm_inline_assist( + assist_id, + prompt, + *include_conversation, + cx, + *retrieve_context, + ); + } + InlineAssistantEvent::Canceled => { + self.finish_inline_assist(assist_id, true, cx); + } + InlineAssistantEvent::Dismissed => { + self.hide_inline_assist(assist_id, cx); + } + InlineAssistantEvent::IncludeConversationToggled { + include_conversation, + } => { + self.include_conversation_in_next_inline_assist = *include_conversation; + } + InlineAssistantEvent::RetrieveContextToggled { retrieve_context } => { + self.retrieve_context_in_next_inline_assist = *retrieve_context + } + } + } + + fn cancel_last_inline_assist( + workspace: &mut Workspace, + _: &editor::Cancel, + cx: &mut ViewContext, + ) { + if let Some(panel) = workspace.panel::(cx) { + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + { + let handled = panel.update(cx, |panel, cx| { + if let Some(assist_id) = panel + .pending_inline_assist_ids_by_editor + .get(&editor.downgrade()) + .and_then(|assist_ids| assist_ids.last().copied()) + { + panel.finish_inline_assist(assist_id, true, cx); + true + } else { + false + } + }); + if handled { + return; + } + } + } + + cx.propagate(); + } + + fn finish_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext) { + self.hide_inline_assist(assist_id, cx); + + if let Some(pending_assist) = self.pending_inline_assists.remove(&assist_id) { + if let hash_map::Entry::Occupied(mut entry) = self + .pending_inline_assist_ids_by_editor + .entry(pending_assist.editor.clone()) + { + entry.get_mut().retain(|id| *id != assist_id); + if entry.get().is_empty() { + entry.remove(); + } + } + + if let Some(editor) = pending_assist.editor.upgrade() { + self.update_highlights_for_editor(&editor, cx); + + if undo { + pending_assist + .codegen + .update(cx, |codegen, cx| codegen.undo(cx)); + } + } + } + } + + fn hide_inline_assist(&mut self, assist_id: usize, cx: &mut ViewContext) { + if let Some(pending_assist) = self.pending_inline_assists.get_mut(&assist_id) { + if let Some(editor) = pending_assist.editor.upgrade() { + if let Some((block_id, _)) = pending_assist.inline_assistant.take() { + editor.update(cx, |editor, cx| { + editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); + }); + } + } + } + } + + fn confirm_inline_assist( + &mut self, + inline_assist_id: usize, + user_prompt: &str, + include_conversation: bool, + cx: &mut ViewContext, + retrieve_context: bool, + ) { + let conversation = if include_conversation { + self.active_editor() + .map(|editor| editor.read(cx).conversation.clone()) + } else { + None + }; + + let pending_assist = + if let Some(pending_assist) = self.pending_inline_assists.get_mut(&inline_assist_id) { + pending_assist + } else { + return; + }; + + let editor = if let Some(editor) = pending_assist.editor.upgrade() { + editor + } else { + return; + }; + + let project = pending_assist.project.clone(); + + let project_name = if let Some(project) = project.upgrade() { + Some( + project + .read(cx) + .worktree_root_names(cx) + .collect::>() + .join("/"), + ) + } else { + None + }; + + self.inline_prompt_history + .retain(|prompt| prompt != user_prompt); + self.inline_prompt_history.push_back(user_prompt.into()); + if self.inline_prompt_history.len() > Self::INLINE_PROMPT_HISTORY_MAX_LEN { + self.inline_prompt_history.pop_front(); + } + + let codegen = pending_assist.codegen.clone(); + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + let range = codegen.read(cx).range(); + let start = snapshot.point_to_buffer_offset(range.start); + let end = snapshot.point_to_buffer_offset(range.end); + let (buffer, range) = if let Some((start, end)) = start.zip(end) { + let (start_buffer, start_buffer_offset) = start; + let (end_buffer, end_buffer_offset) = end; + if start_buffer.remote_id() == end_buffer.remote_id() { + (start_buffer.clone(), start_buffer_offset..end_buffer_offset) + } else { + self.finish_inline_assist(inline_assist_id, false, cx); + return; + } + } else { + self.finish_inline_assist(inline_assist_id, false, cx); + return; + }; + + let language = buffer.language_at(range.start); + let language_name = if let Some(language) = language.as_ref() { + if Arc::ptr_eq(language, &language::PLAIN_TEXT) { + None + } else { + Some(language.name()) + } + } else { + None + }; + + // Higher Temperature increases the randomness of model outputs. + // If Markdown or No Language is Known, increase the randomness for more creative output + // If Code, decrease temperature to get more deterministic outputs + let temperature = if let Some(language) = language_name.clone() { + if language.to_string() != "Markdown".to_string() { + 0.5 + } else { + 1.0 + } + } else { + 1.0 + }; + + let user_prompt = user_prompt.to_string(); + + let snippets = if retrieve_context { + let Some(project) = project.upgrade() else { + return; + }; + + let search_results = if let Some(semantic_index) = self.semantic_index.clone() { + let search_results = semantic_index.update(cx, |this, cx| { + this.search_project(project, user_prompt.to_string(), 10, vec![], vec![], cx) + }); + + cx.background_executor() + .spawn(async move { search_results.await.unwrap_or_default() }) + } else { + Task::ready(Vec::new()) + }; + + let snippets = cx.spawn(|_, mut cx| async move { + let mut snippets = Vec::new(); + for result in search_results.await { + snippets.push(PromptCodeSnippet::new( + result.buffer, + result.range, + &mut cx, + )?); + } + anyhow::Ok(snippets) + }); + snippets + } else { + Task::ready(Ok(Vec::new())) + }; + + let mut model = AssistantSettings::get_global(cx) + .default_open_ai_model + .clone(); + let model_name = model.full_name(); + + let prompt = cx.background_executor().spawn(async move { + let snippets = snippets.await?; + + let language_name = language_name.as_deref(); + generate_content_prompt( + user_prompt, + language_name, + buffer, + range, + snippets, + model_name, + project_name, + ) + }); + + let mut messages = Vec::new(); + if let Some(conversation) = conversation { + let conversation = conversation.read(cx); + let buffer = conversation.buffer.read(cx); + messages.extend( + conversation + .messages(cx) + .map(|message| message.to_open_ai_message(buffer)), + ); + model = conversation.model.clone(); + } + + cx.spawn(|_, mut cx| async move { + // I Don't know if we want to return a ? here. + let prompt = prompt.await?; + + messages.push(RequestMessage { + role: Role::User, + content: prompt, + }); + + let request = Box::new(OpenAIRequest { + model: model.full_name().into(), + messages, + stream: true, + stop: vec!["|END|>".to_string()], + temperature, + }); + + codegen.update(&mut cx, |codegen, cx| codegen.start(request, cx))?; + anyhow::Ok(()) + }) + .detach(); + } + + fn update_highlights_for_editor(&self, editor: &View, cx: &mut ViewContext) { + let mut background_ranges = Vec::new(); + let mut foreground_ranges = Vec::new(); + let empty_inline_assist_ids = Vec::new(); + let inline_assist_ids = self + .pending_inline_assist_ids_by_editor + .get(&editor.downgrade()) + .unwrap_or(&empty_inline_assist_ids); + + for inline_assist_id in inline_assist_ids { + if let Some(pending_assist) = self.pending_inline_assists.get(inline_assist_id) { + let codegen = pending_assist.codegen.read(cx); + background_ranges.push(codegen.range()); + foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned()); + } + } + + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + merge_ranges(&mut background_ranges, &snapshot); + merge_ranges(&mut foreground_ranges, &snapshot); + editor.update(cx, |editor, cx| { + if background_ranges.is_empty() { + editor.clear_background_highlights::(cx); + } else { + editor.highlight_background::( + background_ranges, + |theme| theme.editor_active_line_background, // todo!("use the appropriate color") + cx, + ); + } + + if foreground_ranges.is_empty() { + editor.clear_highlights::(cx); + } else { + editor.highlight_text::( + foreground_ranges, + HighlightStyle { + fade_out: Some(0.6), + ..Default::default() + }, + cx, + ); + } + }); + } + + fn new_conversation(&mut self, cx: &mut ViewContext) -> View { + let editor = cx.build_view(|cx| { + ConversationEditor::new( + self.completion_provider.clone(), + self.languages.clone(), + self.fs.clone(), + self.workspace.clone(), + cx, + ) + }); + self.add_conversation(editor.clone(), cx); + editor + } + + fn add_conversation(&mut self, editor: View, cx: &mut ViewContext) { + self.subscriptions + .push(cx.subscribe(&editor, Self::handle_conversation_editor_event)); + + let conversation = editor.read(cx).conversation.clone(); + self.subscriptions + .push(cx.observe(&conversation, |_, _, cx| cx.notify())); + + let index = self.editors.len(); + self.editors.push(editor); + self.set_active_editor_index(Some(index), cx); + } + + fn set_active_editor_index(&mut self, index: Option, cx: &mut ViewContext) { + self.prev_active_editor_index = self.active_editor_index; + self.active_editor_index = index; + if let Some(editor) = self.active_editor() { + let editor = editor.read(cx).editor.clone(); + self.toolbar.update(cx, |toolbar, cx| { + toolbar.set_active_item(Some(&editor), cx); + }); + if self.focus_handle.contains_focused(cx) { + cx.focus_view(&editor); + } + } else { + self.toolbar.update(cx, |toolbar, cx| { + toolbar.set_active_item(None, cx); + }); + } + + cx.notify(); + } + + fn handle_conversation_editor_event( + &mut self, + _: View, + event: &ConversationEditorEvent, + cx: &mut ViewContext, + ) { + match event { + ConversationEditorEvent::TabContentChanged => cx.notify(), + } + } + + fn save_credentials(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + if let Some(api_key) = self + .api_key_editor + .as_ref() + .map(|editor| editor.read(cx).text(cx)) + { + if !api_key.is_empty() { + let credential = ProviderCredential::Credentials { + api_key: api_key.clone(), + }; + + self.completion_provider.save_credentials(cx, credential); + + self.api_key_editor.take(); + self.focus_handle.focus(cx); + cx.notify(); + } + } else { + cx.propagate(); + } + } + + fn reset_credentials(&mut self, _: &ResetKey, cx: &mut ViewContext) { + self.completion_provider.delete_credentials(cx); + self.api_key_editor = Some(build_api_key_editor(cx)); + self.focus_handle.focus(cx); + cx.notify(); + } + + fn toggle_zoom(&mut self, _: &workspace::ToggleZoom, cx: &mut ViewContext) { + if self.zoomed { + cx.emit(PanelEvent::ZoomOut) + } else { + cx.emit(PanelEvent::ZoomIn) + } + } + + fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext) { + let mut propagate = true; + if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { + search_bar.update(cx, |search_bar, cx| { + if search_bar.show(cx) { + search_bar.search_suggested(cx); + if action.focus { + search_bar.select_query(cx); + cx.focus_self(); + } + propagate = false + } + }); + } + if propagate { + cx.propagate(); + } + } + + fn handle_editor_cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { + if !search_bar.read(cx).is_dismissed() { + search_bar.update(cx, |search_bar, cx| { + search_bar.dismiss(&Default::default(), cx) + }); + return; + } + } + cx.propagate(); + } + + fn select_next_match(&mut self, _: &search::SelectNextMatch, cx: &mut ViewContext) { + if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { + search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, 1, cx)); + } + } + + fn select_prev_match(&mut self, _: &search::SelectPrevMatch, cx: &mut ViewContext) { + if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { + search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, 1, cx)); + } + } + + fn active_editor(&self) -> Option<&View> { + self.editors.get(self.active_editor_index?) + } + + fn render_hamburger_button(cx: &mut ViewContext) -> impl IntoElement { + IconButton::new("hamburger_button", Icon::Menu) + .on_click(cx.listener(|this, _event, cx| { + if this.active_editor().is_some() { + this.set_active_editor_index(None, cx); + } else { + this.set_active_editor_index(this.prev_active_editor_index, cx); + } + })) + .tooltip(|cx| Tooltip::text("History", cx)) + } + + fn render_editor_tools(&self, cx: &mut ViewContext) -> Vec { + if self.active_editor().is_some() { + vec![ + Self::render_split_button(cx).into_any_element(), + Self::render_quote_button(cx).into_any_element(), + Self::render_assist_button(cx).into_any_element(), + ] + } else { + Default::default() + } + } + + fn render_split_button(cx: &mut ViewContext) -> impl IntoElement { + IconButton::new("split_button", Icon::Menu) + .on_click(cx.listener(|this, _event, cx| { + if let Some(active_editor) = this.active_editor() { + active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx)); + } + })) + .tooltip(|cx| Tooltip::for_action("Split Message", &Split, cx)) + } + + fn render_assist_button(cx: &mut ViewContext) -> impl IntoElement { + IconButton::new("assist_button", Icon::Menu) + .on_click(cx.listener(|this, _event, cx| { + if let Some(active_editor) = this.active_editor() { + active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx)); + } + })) + .tooltip(|cx| Tooltip::for_action("Assist", &Assist, cx)) + } + + fn render_quote_button(cx: &mut ViewContext) -> impl IntoElement { + IconButton::new("quote_button", Icon::Menu) + .on_click(cx.listener(|this, _event, cx| { + if let Some(workspace) = this.workspace.upgrade() { + cx.window_context().defer(move |cx| { + workspace.update(cx, |workspace, cx| { + ConversationEditor::quote_selection(workspace, &Default::default(), cx) + }); + }); + } + })) + .tooltip(|cx| Tooltip::for_action("Quote Seleciton", &QuoteSelection, cx)) + } + + fn render_plus_button(cx: &mut ViewContext) -> impl IntoElement { + IconButton::new("plus_button", Icon::Menu) + .on_click(cx.listener(|this, _event, cx| { + this.new_conversation(cx); + })) + .tooltip(|cx| Tooltip::for_action("New Conversation", &NewConversation, cx)) + } + + fn render_zoom_button(&self, cx: &mut ViewContext) -> impl IntoElement { + let zoomed = self.zoomed; + IconButton::new("zoom_button", Icon::Menu) + .on_click(cx.listener(|this, _event, cx| { + this.toggle_zoom(&ToggleZoom, cx); + })) + .tooltip(move |cx| { + Tooltip::for_action(if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, cx) + }) + } + + fn render_saved_conversation( + &mut self, + index: usize, + cx: &mut ViewContext, + ) -> impl IntoElement { + let conversation = &self.saved_conversations[index]; + let path = conversation.path.clone(); + + ButtonLike::new(index) + .on_click(cx.listener(move |this, _, cx| { + this.open_conversation(path.clone(), cx) + .detach_and_log_err(cx) + })) + .child(Label::new( + conversation.mtime.format("%F %I:%M%p").to_string(), + )) + .child(Label::new(conversation.title.clone())) + } + + fn open_conversation(&mut self, path: PathBuf, cx: &mut ViewContext) -> Task> { + if let Some(ix) = self.editor_index_for_path(&path, cx) { + self.set_active_editor_index(Some(ix), cx); + return Task::ready(Ok(())); + } + + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); + let languages = self.languages.clone(); + cx.spawn(|this, mut cx| async move { + let saved_conversation = fs.load(&path).await?; + let saved_conversation = serde_json::from_str(&saved_conversation)?; + let conversation = cx.build_model(|cx| { + Conversation::deserialize(saved_conversation, path.clone(), languages, cx) + })?; + this.update(&mut cx, |this, cx| { + // If, by the time we've loaded the conversation, the user has already opened + // the same conversation, we don't want to open it again. + if let Some(ix) = this.editor_index_for_path(&path, cx) { + this.set_active_editor_index(Some(ix), cx); + } else { + let editor = cx.build_view(|cx| { + ConversationEditor::for_conversation(conversation, fs, workspace, cx) + }); + this.add_conversation(editor, cx); + } + })?; + Ok(()) + }) + } + + fn editor_index_for_path(&self, path: &Path, cx: &AppContext) -> Option { + self.editors + .iter() + .position(|editor| editor.read(cx).conversation.read(cx).path.as_deref() == Some(path)) + } + + fn has_credentials(&mut self) -> bool { + self.completion_provider.has_credentials() + } + + fn load_credentials(&mut self, cx: &mut ViewContext) { + self.completion_provider.retrieve_credentials(cx); + } +} + +fn build_api_key_editor(cx: &mut ViewContext) -> View { + cx.build_view(|cx| { + let mut editor = Editor::single_line(cx); + editor.set_placeholder_text("sk-000000000000000000000000000000000000000000000000", cx); + editor + }) +} + +impl Render for AssistantPanel { + type Element = Focusable
; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + if let Some(api_key_editor) = self.api_key_editor.clone() { + v_stack() + .on_action(cx.listener(AssistantPanel::save_credentials)) + .track_focus(&self.focus_handle) + .child(Label::new( + "To use the assistant panel or inline assistant, you need to add your OpenAI api key.", + )) + .child(Label::new( + " - Having a subscription for another service like GitHub Copilot won't work." + )) + .child(Label::new( + " - You can create a api key at: platform.openai.com/api-keys" + )) + .child(Label::new( + " " + )) + .child(Label::new( + "Paste your OpenAI API key and press Enter to use the assistant" + )) + .child(api_key_editor) + .child(Label::new( + "Click on the Z button in the status bar to close this panel." + )) + .border() + .border_color(gpui::red()) + } else { + let title = self + .active_editor() + .map(|editor| Label::new(editor.read(cx).title(cx))); + + let mut header = h_stack() + .child(Self::render_hamburger_button(cx)) + .children(title); + + if self.focus_handle.contains_focused(cx) { + header = header + .children(self.render_editor_tools(cx)) + .child(Self::render_plus_button(cx)) + .child(self.render_zoom_button(cx)); + } + + v_stack() + .size_full() + .on_action(cx.listener(|this, _: &workspace::NewFile, cx| { + this.new_conversation(cx); + })) + .on_action(cx.listener(AssistantPanel::reset_credentials)) + .on_action(cx.listener(AssistantPanel::toggle_zoom)) + .on_action(cx.listener(AssistantPanel::deploy)) + .on_action(cx.listener(AssistantPanel::select_next_match)) + .on_action(cx.listener(AssistantPanel::select_prev_match)) + .on_action(cx.listener(AssistantPanel::handle_editor_cancel)) + .track_focus(&self.focus_handle) + .child(header) + .children(if self.toolbar.read(cx).hidden() { + None + } else { + Some(self.toolbar.clone()) + }) + .child( + div() + .flex_1() + .child(if let Some(editor) = self.active_editor() { + editor.clone().into_any_element() + } else { + uniform_list( + cx.view().clone(), + "saved_conversations", + self.saved_conversations.len(), + |this, range, cx| { + range + .map(|ix| this.render_saved_conversation(ix, cx)) + .collect() + }, + ) + .track_scroll(self.saved_conversations_scroll_handle.clone()) + .into_any_element() + }), + ) + .border() + .border_color(gpui::red()) + } + } +} + +impl Panel for AssistantPanel { + fn persistent_name() -> &'static str { + "AssistantPanel" + } + + fn position(&self, cx: &WindowContext) -> DockPosition { + match AssistantSettings::get_global(cx).dock { + AssistantDockPosition::Left => DockPosition::Left, + AssistantDockPosition::Bottom => DockPosition::Bottom, + AssistantDockPosition::Right => DockPosition::Right, + } + } + + fn position_is_valid(&self, _: DockPosition) -> bool { + true + } + + fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { + settings::update_settings_file::(self.fs.clone(), cx, move |settings| { + let dock = match position { + DockPosition::Left => AssistantDockPosition::Left, + DockPosition::Bottom => AssistantDockPosition::Bottom, + DockPosition::Right => AssistantDockPosition::Right, + }; + settings.dock = Some(dock); + }); + } + + fn size(&self, cx: &WindowContext) -> f32 { + let settings = AssistantSettings::get_global(cx); + match self.position(cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or_else(|| settings.default_width) + } + DockPosition::Bottom => self.height.unwrap_or_else(|| settings.default_height), + } + } + + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + match self.position(cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => self.height = size, + } + cx.notify(); + } + + fn is_zoomed(&self, _: &WindowContext) -> bool { + self.zoomed + } + + fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { + self.zoomed = zoomed; + cx.notify(); + } + + fn set_active(&mut self, active: bool, cx: &mut ViewContext) { + if active { + self.load_credentials(cx); + + if self.editors.is_empty() { + self.new_conversation(cx); + } + } + } + + fn icon(&self, _cx: &WindowContext) -> Option { + Some(Icon::Ai) + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } +} + +impl EventEmitter for AssistantPanel {} + +impl FocusableView for AssistantPanel { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +enum ConversationEvent { + MessagesEdited, + SummaryChanged, + StreamedCompletion, +} + +#[derive(Default)] +struct Summary { + text: String, + done: bool, +} + +struct Conversation { + id: Option, + buffer: Model, + message_anchors: Vec, + messages_metadata: HashMap, + next_message_id: MessageId, + summary: Option, + pending_summary: Task>, + completion_count: usize, + pending_completions: Vec, + model: OpenAIModel, + token_count: Option, + max_token_count: usize, + pending_token_count: Task>, + pending_save: Task>, + path: Option, + _subscriptions: Vec, + completion_provider: Arc, +} + +impl EventEmitter for Conversation {} + +impl Conversation { + fn new( + language_registry: Arc, + cx: &mut ModelContext, + completion_provider: Arc, + ) -> Self { + let markdown = language_registry.language_for_name("Markdown"); + let buffer = cx.build_model(|cx| { + let mut buffer = Buffer::new(0, cx.entity_id().as_u64(), ""); + buffer.set_language_registry(language_registry); + cx.spawn(|buffer, mut cx| async move { + let markdown = markdown.await?; + buffer.update(&mut cx, |buffer: &mut Buffer, cx| { + buffer.set_language(Some(markdown), cx) + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + buffer + }); + + let settings = AssistantSettings::get_global(cx); + let model = settings.default_open_ai_model.clone(); + + let mut this = Self { + id: Some(Uuid::new_v4().to_string()), + message_anchors: Default::default(), + messages_metadata: Default::default(), + next_message_id: Default::default(), + summary: None, + pending_summary: Task::ready(None), + completion_count: Default::default(), + pending_completions: Default::default(), + token_count: None, + max_token_count: tiktoken_rs::model::get_context_size(&model.full_name()), + pending_token_count: Task::ready(None), + model: model.clone(), + _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], + pending_save: Task::ready(Ok(())), + path: None, + buffer, + completion_provider, + }; + let message = MessageAnchor { + id: MessageId(post_inc(&mut this.next_message_id.0)), + start: language::Anchor::MIN, + }; + this.message_anchors.push(message.clone()); + this.messages_metadata.insert( + message.id, + MessageMetadata { + role: Role::User, + sent_at: Local::now(), + status: MessageStatus::Done, + }, + ); + + this.count_remaining_tokens(cx); + this + } + + fn serialize(&self, cx: &AppContext) -> SavedConversation { + SavedConversation { + id: self.id.clone(), + zed: "conversation".into(), + version: SavedConversation::VERSION.into(), + text: self.buffer.read(cx).text(), + message_metadata: self.messages_metadata.clone(), + messages: self + .messages(cx) + .map(|message| SavedMessage { + id: message.id, + start: message.offset_range.start, + }) + .collect(), + summary: self + .summary + .as_ref() + .map(|summary| summary.text.clone()) + .unwrap_or_default(), + model: self.model.clone(), + } + } + + fn deserialize( + saved_conversation: SavedConversation, + path: PathBuf, + language_registry: Arc, + cx: &mut ModelContext, + ) -> Self { + let id = match saved_conversation.id { + Some(id) => Some(id), + None => Some(Uuid::new_v4().to_string()), + }; + let model = saved_conversation.model; + let completion_provider: Arc = Arc::new( + OpenAICompletionProvider::new(model.full_name(), cx.background_executor().clone()), + ); + completion_provider.retrieve_credentials(cx); + let markdown = language_registry.language_for_name("Markdown"); + let mut message_anchors = Vec::new(); + let mut next_message_id = MessageId(0); + let buffer = cx.build_model(|cx| { + let mut buffer = Buffer::new(0, cx.entity_id().as_u64(), saved_conversation.text); + for message in saved_conversation.messages { + message_anchors.push(MessageAnchor { + id: message.id, + start: buffer.anchor_before(message.start), + }); + next_message_id = cmp::max(next_message_id, MessageId(message.id.0 + 1)); + } + buffer.set_language_registry(language_registry); + cx.spawn(|buffer, mut cx| async move { + let markdown = markdown.await?; + buffer.update(&mut cx, |buffer: &mut Buffer, cx| { + buffer.set_language(Some(markdown), cx) + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + buffer + }); + + let mut this = Self { + id, + message_anchors, + messages_metadata: saved_conversation.message_metadata, + next_message_id, + summary: Some(Summary { + text: saved_conversation.summary, + done: true, + }), + pending_summary: Task::ready(None), + completion_count: Default::default(), + pending_completions: Default::default(), + token_count: None, + max_token_count: tiktoken_rs::model::get_context_size(&model.full_name()), + pending_token_count: Task::ready(None), + model, + _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], + pending_save: Task::ready(Ok(())), + path: Some(path), + buffer, + completion_provider, + }; + this.count_remaining_tokens(cx); + this + } + + fn handle_buffer_event( + &mut self, + _: Model, + event: &language::Event, + cx: &mut ModelContext, + ) { + match event { + language::Event::Edited => { + self.count_remaining_tokens(cx); + cx.emit(ConversationEvent::MessagesEdited); + } + _ => {} + } + } + + fn count_remaining_tokens(&mut self, cx: &mut ModelContext) { + let messages = self + .messages(cx) + .into_iter() + .filter_map(|message| { + Some(tiktoken_rs::ChatCompletionRequestMessage { + role: match message.role { + Role::User => "user".into(), + Role::Assistant => "assistant".into(), + Role::System => "system".into(), + }, + content: Some( + self.buffer + .read(cx) + .text_for_range(message.offset_range) + .collect(), + ), + name: None, + function_call: None, + }) + }) + .collect::>(); + let model = self.model.clone(); + self.pending_token_count = cx.spawn(|this, mut cx| { + async move { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + let token_count = cx + .background_executor() + .spawn(async move { + tiktoken_rs::num_tokens_from_messages(&model.full_name(), &messages) + }) + .await?; + + this.update(&mut cx, |this, cx| { + this.max_token_count = + tiktoken_rs::model::get_context_size(&this.model.full_name()); + this.token_count = Some(token_count); + cx.notify() + })?; + anyhow::Ok(()) + } + .log_err() + }); + } + + fn remaining_tokens(&self) -> Option { + Some(self.max_token_count as isize - self.token_count? as isize) + } + + fn set_model(&mut self, model: OpenAIModel, cx: &mut ModelContext) { + self.model = model; + self.count_remaining_tokens(cx); + cx.notify(); + } + + fn assist( + &mut self, + selected_messages: HashSet, + cx: &mut ModelContext, + ) -> Vec { + let mut user_messages = Vec::new(); + + let last_message_id = if let Some(last_message_id) = + self.message_anchors.iter().rev().find_map(|message| { + message + .start + .is_valid(self.buffer.read(cx)) + .then_some(message.id) + }) { + last_message_id + } else { + return Default::default(); + }; + + let mut should_assist = false; + for selected_message_id in selected_messages { + let selected_message_role = + if let Some(metadata) = self.messages_metadata.get(&selected_message_id) { + metadata.role + } else { + continue; + }; + + if selected_message_role == Role::Assistant { + if let Some(user_message) = self.insert_message_after( + selected_message_id, + Role::User, + MessageStatus::Done, + cx, + ) { + user_messages.push(user_message); + } + } else { + should_assist = true; + } + } + + if should_assist { + if !self.completion_provider.has_credentials() { + return Default::default(); + } + + let request: Box = Box::new(OpenAIRequest { + model: self.model.full_name().to_string(), + messages: self + .messages(cx) + .filter(|message| matches!(message.status, MessageStatus::Done)) + .map(|message| message.to_open_ai_message(self.buffer.read(cx))) + .collect(), + stream: true, + stop: vec![], + temperature: 1.0, + }); + + let stream = self.completion_provider.complete(request); + let assistant_message = self + .insert_message_after(last_message_id, Role::Assistant, MessageStatus::Pending, cx) + .unwrap(); + + // Queue up the user's next reply. + let user_message = self + .insert_message_after(assistant_message.id, Role::User, MessageStatus::Done, cx) + .unwrap(); + user_messages.push(user_message); + + let task = cx.spawn({ + |this, mut cx| async move { + let assistant_message_id = assistant_message.id; + let stream_completion = async { + let mut messages = stream.await?; + + while let Some(message) = messages.next().await { + let text = message?; + + this.update(&mut cx, |this, cx| { + let message_ix = this + .message_anchors + .iter() + .position(|message| message.id == assistant_message_id)?; + this.buffer.update(cx, |buffer, cx| { + let offset = this.message_anchors[message_ix + 1..] + .iter() + .find(|message| message.start.is_valid(buffer)) + .map_or(buffer.len(), |message| { + message.start.to_offset(buffer).saturating_sub(1) + }); + buffer.edit([(offset..offset, text)], None, cx); + }); + cx.emit(ConversationEvent::StreamedCompletion); + + Some(()) + })?; + smol::future::yield_now().await; + } + + this.update(&mut cx, |this, cx| { + this.pending_completions + .retain(|completion| completion.id != this.completion_count); + this.summarize(cx); + })?; + + anyhow::Ok(()) + }; + + let result = stream_completion.await; + + this.update(&mut cx, |this, cx| { + if let Some(metadata) = + this.messages_metadata.get_mut(&assistant_message.id) + { + match result { + Ok(_) => { + metadata.status = MessageStatus::Done; + } + Err(error) => { + metadata.status = + MessageStatus::Error(error.to_string().trim().into()); + } + } + cx.notify(); + } + }) + .ok(); + } + }); + + self.pending_completions.push(PendingCompletion { + id: post_inc(&mut self.completion_count), + _task: task, + }); + } + + user_messages + } + + fn cancel_last_assist(&mut self) -> bool { + self.pending_completions.pop().is_some() + } + + fn cycle_message_roles(&mut self, ids: HashSet, cx: &mut ModelContext) { + for id in ids { + if let Some(metadata) = self.messages_metadata.get_mut(&id) { + metadata.role.cycle(); + cx.emit(ConversationEvent::MessagesEdited); + cx.notify(); + } + } + } + + fn insert_message_after( + &mut self, + message_id: MessageId, + role: Role, + status: MessageStatus, + cx: &mut ModelContext, + ) -> Option { + if let Some(prev_message_ix) = self + .message_anchors + .iter() + .position(|message| message.id == message_id) + { + // Find the next valid message after the one we were given. + let mut next_message_ix = prev_message_ix + 1; + while let Some(next_message) = self.message_anchors.get(next_message_ix) { + if next_message.start.is_valid(self.buffer.read(cx)) { + break; + } + next_message_ix += 1; + } + + let start = self.buffer.update(cx, |buffer, cx| { + let offset = self + .message_anchors + .get(next_message_ix) + .map_or(buffer.len(), |message| message.start.to_offset(buffer) - 1); + buffer.edit([(offset..offset, "\n")], None, cx); + buffer.anchor_before(offset + 1) + }); + let message = MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start, + }; + self.message_anchors + .insert(next_message_ix, message.clone()); + self.messages_metadata.insert( + message.id, + MessageMetadata { + role, + sent_at: Local::now(), + status, + }, + ); + cx.emit(ConversationEvent::MessagesEdited); + Some(message) + } else { + None + } + } + + fn split_message( + &mut self, + range: Range, + cx: &mut ModelContext, + ) -> (Option, Option) { + let start_message = self.message_for_offset(range.start, cx); + let end_message = self.message_for_offset(range.end, cx); + if let Some((start_message, end_message)) = start_message.zip(end_message) { + // Prevent splitting when range spans multiple messages. + if start_message.id != end_message.id { + return (None, None); + } + + let message = start_message; + let role = message.role; + let mut edited_buffer = false; + + let mut suffix_start = None; + if range.start > message.offset_range.start && range.end < message.offset_range.end - 1 + { + if self.buffer.read(cx).chars_at(range.end).next() == Some('\n') { + suffix_start = Some(range.end + 1); + } else if self.buffer.read(cx).reversed_chars_at(range.end).next() == Some('\n') { + suffix_start = Some(range.end); + } + } + + let suffix = if let Some(suffix_start) = suffix_start { + MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start: self.buffer.read(cx).anchor_before(suffix_start), + } + } else { + self.buffer.update(cx, |buffer, cx| { + buffer.edit([(range.end..range.end, "\n")], None, cx); + }); + edited_buffer = true; + MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start: self.buffer.read(cx).anchor_before(range.end + 1), + } + }; + + self.message_anchors + .insert(message.index_range.end + 1, suffix.clone()); + self.messages_metadata.insert( + suffix.id, + MessageMetadata { + role, + sent_at: Local::now(), + status: MessageStatus::Done, + }, + ); + + let new_messages = + if range.start == range.end || range.start == message.offset_range.start { + (None, Some(suffix)) + } else { + let mut prefix_end = None; + if range.start > message.offset_range.start + && range.end < message.offset_range.end - 1 + { + if self.buffer.read(cx).chars_at(range.start).next() == Some('\n') { + prefix_end = Some(range.start + 1); + } else if self.buffer.read(cx).reversed_chars_at(range.start).next() + == Some('\n') + { + prefix_end = Some(range.start); + } + } + + let selection = if let Some(prefix_end) = prefix_end { + cx.emit(ConversationEvent::MessagesEdited); + MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start: self.buffer.read(cx).anchor_before(prefix_end), + } + } else { + self.buffer.update(cx, |buffer, cx| { + buffer.edit([(range.start..range.start, "\n")], None, cx) + }); + edited_buffer = true; + MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start: self.buffer.read(cx).anchor_before(range.end + 1), + } + }; + + self.message_anchors + .insert(message.index_range.end + 1, selection.clone()); + self.messages_metadata.insert( + selection.id, + MessageMetadata { + role, + sent_at: Local::now(), + status: MessageStatus::Done, + }, + ); + (Some(selection), Some(suffix)) + }; + + if !edited_buffer { + cx.emit(ConversationEvent::MessagesEdited); + } + new_messages + } else { + (None, None) + } + } + + fn summarize(&mut self, cx: &mut ModelContext) { + if self.message_anchors.len() >= 2 && self.summary.is_none() { + if !self.completion_provider.has_credentials() { + return; + } + + let messages = self + .messages(cx) + .take(2) + .map(|message| message.to_open_ai_message(self.buffer.read(cx))) + .chain(Some(RequestMessage { + role: Role::User, + content: "Summarize the conversation into a short title without punctuation" + .into(), + })); + let request: Box = Box::new(OpenAIRequest { + model: self.model.full_name().to_string(), + messages: messages.collect(), + stream: true, + stop: vec![], + temperature: 1.0, + }); + + let stream = self.completion_provider.complete(request); + self.pending_summary = cx.spawn(|this, mut cx| { + async move { + let mut messages = stream.await?; + + while let Some(message) = messages.next().await { + let text = message?; + this.update(&mut cx, |this, cx| { + this.summary + .get_or_insert(Default::default()) + .text + .push_str(&text); + cx.emit(ConversationEvent::SummaryChanged); + })?; + } + + this.update(&mut cx, |this, cx| { + if let Some(summary) = this.summary.as_mut() { + summary.done = true; + cx.emit(ConversationEvent::SummaryChanged); + } + })?; + + anyhow::Ok(()) + } + .log_err() + }); + } + } + + fn message_for_offset(&self, offset: usize, cx: &AppContext) -> Option { + self.messages_for_offsets([offset], cx).pop() + } + + fn messages_for_offsets( + &self, + offsets: impl IntoIterator, + cx: &AppContext, + ) -> Vec { + let mut result = Vec::new(); + + let mut messages = self.messages(cx).peekable(); + let mut offsets = offsets.into_iter().peekable(); + let mut current_message = messages.next(); + while let Some(offset) = offsets.next() { + // Locate the message that contains the offset. + while current_message.as_ref().map_or(false, |message| { + !message.offset_range.contains(&offset) && messages.peek().is_some() + }) { + current_message = messages.next(); + } + let Some(message) = current_message.as_ref() else { + break; + }; + + // Skip offsets that are in the same message. + while offsets.peek().map_or(false, |offset| { + message.offset_range.contains(offset) || messages.peek().is_none() + }) { + offsets.next(); + } + + result.push(message.clone()); + } + result + } + + fn messages<'a>(&'a self, cx: &'a AppContext) -> impl 'a + Iterator { + let buffer = self.buffer.read(cx); + let mut message_anchors = self.message_anchors.iter().enumerate().peekable(); + iter::from_fn(move || { + while let Some((start_ix, message_anchor)) = message_anchors.next() { + let metadata = self.messages_metadata.get(&message_anchor.id)?; + let message_start = message_anchor.start.to_offset(buffer); + let mut message_end = None; + let mut end_ix = start_ix; + while let Some((_, next_message)) = message_anchors.peek() { + if next_message.start.is_valid(buffer) { + message_end = Some(next_message.start); + break; + } else { + end_ix += 1; + message_anchors.next(); + } + } + let message_end = message_end + .unwrap_or(language::Anchor::MAX) + .to_offset(buffer); + return Some(Message { + index_range: start_ix..end_ix, + offset_range: message_start..message_end, + id: message_anchor.id, + anchor: message_anchor.start, + role: metadata.role, + sent_at: metadata.sent_at, + status: metadata.status.clone(), + }); + } + None + }) + } + + fn save( + &mut self, + debounce: Option, + fs: Arc, + cx: &mut ModelContext, + ) { + self.pending_save = cx.spawn(|this, mut cx| async move { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + + let (old_path, summary) = this.read_with(&cx, |this, _| { + let path = this.path.clone(); + let summary = if let Some(summary) = this.summary.as_ref() { + if summary.done { + Some(summary.text.clone()) + } else { + None + } + } else { + None + }; + (path, summary) + })?; + + if let Some(summary) = summary { + let conversation = this.read_with(&cx, |this, cx| this.serialize(cx))?; + let path = if let Some(old_path) = old_path { + old_path + } else { + let mut discriminant = 1; + let mut new_path; + loop { + new_path = CONVERSATIONS_DIR.join(&format!( + "{} - {}.zed.json", + summary.trim(), + discriminant + )); + if fs.is_file(&new_path).await { + discriminant += 1; + } else { + break; + } + } + new_path + }; + + fs.create_dir(CONVERSATIONS_DIR.as_ref()).await?; + fs.atomic_write(path.clone(), serde_json::to_string(&conversation).unwrap()) + .await?; + this.update(&mut cx, |this, _| this.path = Some(path))?; + } + + Ok(()) + }); + } +} + +struct PendingCompletion { + id: usize, + _task: Task<()>, +} + +enum ConversationEditorEvent { + TabContentChanged, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +struct ScrollPosition { + offset_before_cursor: gpui::Point, + cursor: Anchor, +} + +struct ConversationEditor { + conversation: Model, + fs: Arc, + workspace: WeakView, + editor: View, + blocks: HashSet, + scroll_position: Option, + focus_handle: FocusHandle, + _subscriptions: Vec, +} + +impl ConversationEditor { + fn new( + completion_provider: Arc, + language_registry: Arc, + fs: Arc, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + let conversation = + cx.build_model(|cx| Conversation::new(language_registry, cx, completion_provider)); + Self::for_conversation(conversation, fs, workspace, cx) + } + + fn for_conversation( + conversation: Model, + fs: Arc, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + let editor = cx.build_view(|cx| { + let mut editor = Editor::for_buffer(conversation.read(cx).buffer.clone(), None, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_show_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor + }); + + let focus_handle = cx.focus_handle(); + + let _subscriptions = vec![ + cx.observe(&conversation, |_, _, cx| cx.notify()), + cx.subscribe(&conversation, Self::handle_conversation_event), + cx.subscribe(&editor, Self::handle_editor_event), + cx.on_focus(&focus_handle, |this, cx| cx.focus_view(&this.editor)), + ]; + + let mut this = Self { + conversation, + editor, + blocks: Default::default(), + scroll_position: None, + fs, + workspace, + focus_handle, + _subscriptions, + }; + this.update_message_headers(cx); + this + } + + fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { + report_assistant_event( + self.workspace.clone(), + self.conversation.read(cx).id.clone(), + AssistantKind::Panel, + cx, + ); + + let cursors = self.cursors(cx); + + let user_messages = self.conversation.update(cx, |conversation, cx| { + let selected_messages = conversation + .messages_for_offsets(cursors, cx) + .into_iter() + .map(|message| message.id) + .collect(); + conversation.assist(selected_messages, cx) + }); + let new_selections = user_messages + .iter() + .map(|message| { + let cursor = message + .start + .to_offset(self.conversation.read(cx).buffer.read(cx)); + cursor..cursor + }) + .collect::>(); + if !new_selections.is_empty() { + self.editor.update(cx, |editor, cx| { + editor.change_selections( + Some(Autoscroll::Strategy(AutoscrollStrategy::Fit)), + cx, + |selections| selections.select_ranges(new_selections), + ); + }); + // Avoid scrolling to the new cursor position so the assistant's output is stable. + cx.defer(|this, _| this.scroll_position = None); + } + } + + fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + if !self + .conversation + .update(cx, |conversation, _| conversation.cancel_last_assist()) + { + cx.propagate(); + } + } + + fn cycle_message_role(&mut self, _: &CycleMessageRole, cx: &mut ViewContext) { + let cursors = self.cursors(cx); + self.conversation.update(cx, |conversation, cx| { + let messages = conversation + .messages_for_offsets(cursors, cx) + .into_iter() + .map(|message| message.id) + .collect(); + conversation.cycle_message_roles(messages, cx) + }); + } + + fn cursors(&self, cx: &AppContext) -> Vec { + let selections = self.editor.read(cx).selections.all::(cx); + selections + .into_iter() + .map(|selection| selection.head()) + .collect() + } + + fn handle_conversation_event( + &mut self, + _: Model, + event: &ConversationEvent, + cx: &mut ViewContext, + ) { + match event { + ConversationEvent::MessagesEdited => { + self.update_message_headers(cx); + self.conversation.update(cx, |conversation, cx| { + conversation.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); + }); + } + ConversationEvent::SummaryChanged => { + cx.emit(ConversationEditorEvent::TabContentChanged); + self.conversation.update(cx, |conversation, cx| { + conversation.save(None, self.fs.clone(), cx); + }); + } + ConversationEvent::StreamedCompletion => { + self.editor.update(cx, |editor, cx| { + if let Some(scroll_position) = self.scroll_position { + let snapshot = editor.snapshot(cx); + let cursor_point = scroll_position.cursor.to_display_point(&snapshot); + let scroll_top = + cursor_point.row() as f32 - scroll_position.offset_before_cursor.y; + editor.set_scroll_position( + point(scroll_position.offset_before_cursor.x, scroll_top), + cx, + ); + } + }); + } + } + } + + fn handle_editor_event( + &mut self, + _: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + match event { + EditorEvent::ScrollPositionChanged { autoscroll, .. } => { + let cursor_scroll_position = self.cursor_scroll_position(cx); + if *autoscroll { + self.scroll_position = cursor_scroll_position; + } else if self.scroll_position != cursor_scroll_position { + self.scroll_position = None; + } + } + EditorEvent::SelectionsChanged { .. } => { + self.scroll_position = self.cursor_scroll_position(cx); + } + _ => {} + } + } + + fn cursor_scroll_position(&self, cx: &mut ViewContext) -> Option { + self.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + let cursor = editor.selections.newest_anchor().head(); + let cursor_row = cursor.to_display_point(&snapshot.display_snapshot).row() as f32; + let scroll_position = editor + .scroll_manager + .anchor() + .scroll_position(&snapshot.display_snapshot); + + let scroll_bottom = scroll_position.y + editor.visible_line_count().unwrap_or(0.); + if (scroll_position.y..scroll_bottom).contains(&cursor_row) { + Some(ScrollPosition { + cursor, + offset_before_cursor: point(scroll_position.x, cursor_row - scroll_position.y), + }) + } else { + None + } + }) + } + + fn update_message_headers(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx).snapshot(cx); + let excerpt_id = *buffer.as_singleton().unwrap().0; + let old_blocks = std::mem::take(&mut self.blocks); + let new_blocks = self + .conversation + .read(cx) + .messages(cx) + .map(|message| BlockProperties { + position: buffer.anchor_in_excerpt(excerpt_id, message.anchor), + height: 2, + style: BlockStyle::Sticky, + render: Arc::new({ + let conversation = self.conversation.clone(); + move |_cx| { + let message_id = message.id; + let sender = ButtonLike::new("role") + .child(match message.role { + Role::User => Label::new("You").color(Color::Default), + Role::Assistant => { + Label::new("Assistant").color(Color::Modified) + } + Role::System => Label::new("System").color(Color::Warning), + }) + .on_click({ + let conversation = conversation.clone(); + move |_, cx| { + conversation.update(cx, |conversation, cx| { + conversation.cycle_message_roles( + HashSet::from_iter(Some(message_id)), + cx, + ) + }) + } + }); + + h_stack() + .id(("message_header", message_id.0)) + .border() + .border_color(gpui::red()) + .child(sender) + .child(Label::new(message.sent_at.format("%I:%M%P").to_string())) + .children( + if let MessageStatus::Error(error) = message.status.clone() { + Some( + div() + .id("error") + .tooltip(move |cx| Tooltip::text(&error, cx)) + .child(IconElement::new(Icon::XCircle)), + ) + } else { + None + }, + ) + .into_any_element() + } + }), + disposition: BlockDisposition::Above, + }) + .collect::>(); + + editor.remove_blocks(old_blocks, None, cx); + let ids = editor.insert_blocks(new_blocks, None, cx); + self.blocks = HashSet::from_iter(ids); + }); + } + + fn quote_selection( + workspace: &mut Workspace, + _: &QuoteSelection, + cx: &mut ViewContext, + ) { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + else { + return; + }; + + let editor = editor.read(cx); + let range = editor.selections.newest::(cx).range(); + let buffer = editor.buffer().read(cx).snapshot(cx); + let start_language = buffer.language_at(range.start); + let end_language = buffer.language_at(range.end); + let language_name = if start_language == end_language { + start_language.map(|language| language.name()) + } else { + None + }; + let language_name = language_name.as_deref().unwrap_or("").to_lowercase(); + + let selected_text = buffer.text_for_range(range).collect::(); + let text = if selected_text.is_empty() { + None + } else { + Some(if language_name == "markdown" { + selected_text + .lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\n") + } else { + format!("```{language_name}\n{selected_text}\n```") + }) + }; + + // Activate the panel + if !panel.focus_handle(cx).contains_focused(cx) { + workspace.toggle_panel_focus::(cx); + } + + if let Some(text) = text { + panel.update(cx, |panel, cx| { + let conversation = panel + .active_editor() + .cloned() + .unwrap_or_else(|| panel.new_conversation(cx)); + conversation.update(cx, |conversation, cx| { + conversation + .editor + .update(cx, |editor, cx| editor.insert(&text, cx)) + }); + }); + } + } + + fn copy(&mut self, _: &editor::Copy, cx: &mut ViewContext) { + let editor = self.editor.read(cx); + let conversation = self.conversation.read(cx); + if editor.selections.count() == 1 { + let selection = editor.selections.newest::(cx); + let mut copied_text = String::new(); + let mut spanned_messages = 0; + for message in conversation.messages(cx) { + if message.offset_range.start >= selection.range().end { + break; + } else if message.offset_range.end >= selection.range().start { + let range = cmp::max(message.offset_range.start, selection.range().start) + ..cmp::min(message.offset_range.end, selection.range().end); + if !range.is_empty() { + spanned_messages += 1; + write!(&mut copied_text, "## {}\n\n", message.role).unwrap(); + for chunk in conversation.buffer.read(cx).text_for_range(range) { + copied_text.push_str(&chunk); + } + copied_text.push('\n'); + } + } + } + + if spanned_messages > 1 { + cx.write_to_clipboard(ClipboardItem::new(copied_text)); + return; + } + } + + cx.propagate(); + } + + fn split(&mut self, _: &Split, cx: &mut ViewContext) { + self.conversation.update(cx, |conversation, cx| { + let selections = self.editor.read(cx).selections.disjoint_anchors(); + for selection in selections.into_iter() { + let buffer = self.editor.read(cx).buffer().read(cx).snapshot(cx); + let range = selection + .map(|endpoint| endpoint.to_offset(&buffer)) + .range(); + conversation.split_message(range, cx); + } + }); + } + + fn save(&mut self, _: &Save, cx: &mut ViewContext) { + self.conversation.update(cx, |conversation, cx| { + conversation.save(None, self.fs.clone(), cx) + }); + } + + fn cycle_model(&mut self, cx: &mut ViewContext) { + self.conversation.update(cx, |conversation, cx| { + let new_model = conversation.model.cycle(); + conversation.set_model(new_model, cx); + }); + } + + fn title(&self, cx: &AppContext) -> String { + self.conversation + .read(cx) + .summary + .as_ref() + .map(|summary| summary.text.clone()) + .unwrap_or_else(|| "New Conversation".into()) + } + + fn render_current_model(&self, cx: &mut ViewContext) -> impl IntoElement { + Button::new( + "current_model", + self.conversation.read(cx).model.short_name(), + ) + .tooltip(move |cx| Tooltip::text("Change Model", cx)) + .on_click(cx.listener(|this, _, cx| this.cycle_model(cx))) + } + + fn render_remaining_tokens(&self, cx: &mut ViewContext) -> Option { + let remaining_tokens = self.conversation.read(cx).remaining_tokens()?; + let remaining_tokens_color = if remaining_tokens <= 0 { + Color::Error + } else if remaining_tokens <= 500 { + Color::Warning + } else { + Color::Default + }; + Some( + div() + .border() + .border_color(gpui::red()) + .child(Label::new(remaining_tokens.to_string()).color(remaining_tokens_color)), + ) + } +} + +impl EventEmitter for ConversationEditor {} + +impl Render for ConversationEditor { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + div() + .key_context("ConversationEditor") + .size_full() + .relative() + .capture_action(cx.listener(ConversationEditor::cancel_last_assist)) + .capture_action(cx.listener(ConversationEditor::save)) + .capture_action(cx.listener(ConversationEditor::copy)) + .capture_action(cx.listener(ConversationEditor::cycle_message_role)) + .on_action(cx.listener(ConversationEditor::assist)) + .on_action(cx.listener(ConversationEditor::split)) + .child(self.editor.clone()) + .child( + h_stack() + .absolute() + .gap_1() + .top_3() + .right_5() + .child(self.render_current_model(cx)) + .children(self.render_remaining_tokens(cx)), + ) + } +} + +impl FocusableView for ConversationEditor { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +#[derive(Clone, Debug)] +struct MessageAnchor { + id: MessageId, + start: language::Anchor, +} + +#[derive(Clone, Debug)] +pub struct Message { + offset_range: Range, + index_range: Range, + id: MessageId, + anchor: language::Anchor, + role: Role, + sent_at: DateTime, + status: MessageStatus, +} + +impl Message { + fn to_open_ai_message(&self, buffer: &Buffer) -> RequestMessage { + let content = buffer + .text_for_range(self.offset_range.clone()) + .collect::(); + RequestMessage { + role: self.role, + content: content.trim_end().into(), + } + } +} + +enum InlineAssistantEvent { + Confirmed { + prompt: String, + include_conversation: bool, + retrieve_context: bool, + }, + Canceled, + Dismissed, + IncludeConversationToggled { + include_conversation: bool, + }, + RetrieveContextToggled { + retrieve_context: bool, + }, +} + +struct InlineAssistant { + id: usize, + prompt_editor: View, + workspace: WeakView, + confirmed: bool, + focus_handle: FocusHandle, + include_conversation: bool, + measurements: Rc>, + prompt_history: VecDeque, + prompt_history_ix: Option, + pending_prompt: String, + codegen: Model, + _subscriptions: Vec, + retrieve_context: bool, + semantic_index: Option>, + semantic_permissioned: Option, + project: WeakModel, + maintain_rate_limit: Option>, +} + +impl EventEmitter for InlineAssistant {} + +impl Render for InlineAssistant { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let measurements = self.measurements.get(); + h_stack() + .w_full() + .py_2() + .border_y_1() + .border_color(cx.theme().colors().border) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::toggle_include_conversation)) + .on_action(cx.listener(Self::toggle_retrieve_context)) + .on_action(cx.listener(Self::move_up)) + .on_action(cx.listener(Self::move_down)) + .child( + h_stack() + .justify_center() + .w(measurements.gutter_width) + .child( + IconButton::new("include_conversation", Icon::Ai) + .on_click(cx.listener(|this, _, cx| { + this.toggle_include_conversation(&ToggleIncludeConversation, cx) + })) + .selected(self.include_conversation) + .tooltip(|cx| { + Tooltip::for_action( + "Include Conversation", + &ToggleIncludeConversation, + cx, + ) + }), + ) + .children(if SemanticIndex::enabled(cx) { + Some( + IconButton::new("retrieve_context", Icon::MagnifyingGlass) + .on_click(cx.listener(|this, _, cx| { + this.toggle_retrieve_context(&ToggleRetrieveContext, cx) + })) + .selected(self.retrieve_context) + .tooltip(|cx| { + Tooltip::for_action( + "Retrieve Context", + &ToggleRetrieveContext, + cx, + ) + }), + ) + } else { + None + }) + .children(if let Some(error) = self.codegen.read(cx).error() { + let error_message = SharedString::from(error.to_string()); + Some( + div() + .id("error") + .tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) + .child(IconElement::new(Icon::XCircle).color(Color::Error)), + ) + } else { + None + }), + ) + .child( + h_stack() + .w_full() + .ml(measurements.anchor_x - measurements.gutter_width) + .child(self.render_prompt_editor(cx)), + ) + .children(if self.retrieve_context { + self.retrieve_context_status(cx) + } else { + None + }) + } +} + +impl FocusableView for InlineAssistant { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl InlineAssistant { + fn new( + id: usize, + measurements: Rc>, + include_conversation: bool, + prompt_history: VecDeque, + codegen: Model, + workspace: WeakView, + cx: &mut ViewContext, + retrieve_context: bool, + semantic_index: Option>, + project: Model, + ) -> Self { + let prompt_editor = cx.build_view(|cx| { + let mut editor = Editor::single_line(cx); + let placeholder = match codegen.read(cx).kind() { + CodegenKind::Transform { .. } => "Enter transformation prompt…", + CodegenKind::Generate { .. } => "Enter generation prompt…", + }; + editor.set_placeholder_text(placeholder, cx); + editor + }); + + let focus_handle = cx.focus_handle(); + let mut subscriptions = vec![ + cx.observe(&codegen, Self::handle_codegen_changed), + cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events), + cx.on_focus(&focus_handle, |this, cx| cx.focus_view(&this.prompt_editor)), + ]; + + if let Some(semantic_index) = semantic_index.clone() { + subscriptions.push(cx.observe(&semantic_index, Self::semantic_index_changed)); + } + + let assistant = Self { + id, + prompt_editor, + workspace, + confirmed: false, + focus_handle, + include_conversation, + measurements, + prompt_history, + prompt_history_ix: None, + pending_prompt: String::new(), + codegen, + _subscriptions: subscriptions, + retrieve_context, + semantic_permissioned: None, + semantic_index, + project: project.downgrade(), + maintain_rate_limit: None, + }; + + assistant.index_project(cx).log_err(); + + assistant + } + + fn semantic_permissioned(&self, cx: &mut ViewContext) -> Task> { + if let Some(value) = self.semantic_permissioned { + return Task::ready(Ok(value)); + } + + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project was dropped"))); + }; + + self.semantic_index + .as_ref() + .map(|semantic| { + semantic.update(cx, |this, cx| this.project_previously_indexed(&project, cx)) + }) + .unwrap_or(Task::ready(Ok(false))) + } + + fn handle_prompt_editor_events( + &mut self, + _: View, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + if let EditorEvent::Edited = event { + self.pending_prompt = self.prompt_editor.read(cx).text(cx); + cx.notify(); + } + } + + fn semantic_index_changed( + &mut self, + semantic_index: Model, + cx: &mut ViewContext, + ) { + let Some(project) = self.project.upgrade() else { + return; + }; + + let status = semantic_index.read(cx).status(&project); + match status { + SemanticIndexStatus::Indexing { + rate_limit_expiry: Some(_), + .. + } => { + if self.maintain_rate_limit.is_none() { + self.maintain_rate_limit = Some(cx.spawn(|this, mut cx| async move { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + this.update(&mut cx, |_, cx| cx.notify()).log_err(); + } + })); + } + return; + } + _ => { + self.maintain_rate_limit = None; + } + } + } + + fn handle_codegen_changed(&mut self, _: Model, cx: &mut ViewContext) { + let is_read_only = !self.codegen.read(cx).idle(); + self.prompt_editor.update(cx, |editor, _cx| { + let was_read_only = editor.read_only(); + if was_read_only != is_read_only { + if is_read_only { + editor.set_read_only(true); + } else { + self.confirmed = false; + editor.set_read_only(false); + } + } + }); + cx.notify(); + } + + fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + cx.emit(InlineAssistantEvent::Canceled); + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + if self.confirmed { + cx.emit(InlineAssistantEvent::Dismissed); + } else { + report_assistant_event(self.workspace.clone(), None, AssistantKind::Inline, cx); + + let prompt = self.prompt_editor.read(cx).text(cx); + self.prompt_editor + .update(cx, |editor, _cx| editor.set_read_only(true)); + cx.emit(InlineAssistantEvent::Confirmed { + prompt, + include_conversation: self.include_conversation, + retrieve_context: self.retrieve_context, + }); + self.confirmed = true; + cx.notify(); + } + } + + fn toggle_retrieve_context(&mut self, _: &ToggleRetrieveContext, cx: &mut ViewContext) { + let semantic_permissioned = self.semantic_permissioned(cx); + + let Some(project) = self.project.upgrade() else { + return; + }; + + let project_name = project + .read(cx) + .worktree_root_names(cx) + .collect::>() + .join("/"); + let is_plural = project_name.chars().filter(|letter| *letter == '/').count() > 0; + let prompt_text = format!("Would you like to index the '{}' project{} for context retrieval? This requires sending code to the OpenAI API", project_name, + if is_plural { + "s" + } else {""}); + + cx.spawn(|this, mut cx| async move { + // If Necessary prompt user + if !semantic_permissioned.await.unwrap_or(false) { + let answer = this.update(&mut cx, |_, cx| { + cx.prompt( + PromptLevel::Info, + prompt_text.as_str(), + &["Continue", "Cancel"], + ) + })?; + + if answer.await? == 0 { + this.update(&mut cx, |this, _| { + this.semantic_permissioned = Some(true); + })?; + } else { + return anyhow::Ok(()); + } + } + + // If permissioned, update context appropriately + this.update(&mut cx, |this, cx| { + this.retrieve_context = !this.retrieve_context; + + cx.emit(InlineAssistantEvent::RetrieveContextToggled { + retrieve_context: this.retrieve_context, + }); + + if this.retrieve_context { + this.index_project(cx).log_err(); + } + + cx.notify(); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn index_project(&self, cx: &mut ViewContext) -> anyhow::Result<()> { + let Some(project) = self.project.upgrade() else { + return Err(anyhow!("project was dropped!")); + }; + + let semantic_permissioned = self.semantic_permissioned(cx); + if let Some(semantic_index) = SemanticIndex::global(cx) { + cx.spawn(|_, mut cx| async move { + // This has to be updated to accomodate for semantic_permissions + if semantic_permissioned.await.unwrap_or(false) { + semantic_index + .update(&mut cx, |index, cx| index.index_project(project, cx))? + .await + } else { + Err(anyhow!("project is not permissioned for semantic indexing")) + } + }) + .detach_and_log_err(cx); + } + + anyhow::Ok(()) + } + + fn retrieve_context_status(&self, cx: &mut ViewContext) -> Option { + let Some(project) = self.project.upgrade() else { + return None; + }; + + let semantic_index = SemanticIndex::global(cx)?; + let status = semantic_index.update(cx, |index, _| index.status(&project)); + match status { + SemanticIndexStatus::NotAuthenticated {} => Some( + div() + .id("error") + .tooltip(|cx| Tooltip::text("Not Authenticated. Please ensure you have a valid 'OPENAI_API_KEY' in your environment variables.", cx)) + .child(IconElement::new(Icon::XCircle)) + .into_any_element() + ), + + SemanticIndexStatus::NotIndexed {} => Some( + div() + .id("error") + .tooltip(|cx| Tooltip::text("Not Indexed", cx)) + .child(IconElement::new(Icon::XCircle)) + .into_any_element() + ), + + SemanticIndexStatus::Indexing { + remaining_files, + rate_limit_expiry, + } => { + let mut status_text = if remaining_files == 0 { + "Indexing...".to_string() + } else { + format!("Remaining files to index: {remaining_files}") + }; + + if let Some(rate_limit_expiry) = rate_limit_expiry { + let remaining_seconds = rate_limit_expiry.duration_since(Instant::now()); + if remaining_seconds > Duration::from_secs(0) && remaining_files > 0 { + write!( + status_text, + " (rate limit expires in {}s)", + remaining_seconds.as_secs() + ) + .unwrap(); + } + } + + let status_text = SharedString::from(status_text); + Some( + div() + .id("update") + .tooltip(move |cx| Tooltip::text(status_text.clone(), cx)) + .child(IconElement::new(Icon::Update).color(Color::Info)) + .into_any_element() + ) + } + + SemanticIndexStatus::Indexed {} => Some( + div() + .id("check") + .tooltip(|cx| Tooltip::text("Index up to date", cx)) + .child(IconElement::new(Icon::Check).color(Color::Success)) + .into_any_element() + ), + } + } + + fn toggle_include_conversation( + &mut self, + _: &ToggleIncludeConversation, + cx: &mut ViewContext, + ) { + self.include_conversation = !self.include_conversation; + cx.emit(InlineAssistantEvent::IncludeConversationToggled { + include_conversation: self.include_conversation, + }); + cx.notify(); + } + + fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { + if let Some(ix) = self.prompt_history_ix { + if ix > 0 { + self.prompt_history_ix = Some(ix - 1); + let prompt = self.prompt_history[ix - 1].clone(); + self.set_prompt(&prompt, cx); + } + } else if !self.prompt_history.is_empty() { + self.prompt_history_ix = Some(self.prompt_history.len() - 1); + let prompt = self.prompt_history[self.prompt_history.len() - 1].clone(); + self.set_prompt(&prompt, cx); + } + } + + fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext) { + if let Some(ix) = self.prompt_history_ix { + if ix < self.prompt_history.len() - 1 { + self.prompt_history_ix = Some(ix + 1); + let prompt = self.prompt_history[ix + 1].clone(); + self.set_prompt(&prompt, cx); + } else { + self.prompt_history_ix = None; + let pending_prompt = self.pending_prompt.clone(); + self.set_prompt(&pending_prompt, cx); + } + } + } + + fn set_prompt(&mut self, prompt: &str, cx: &mut ViewContext) { + self.prompt_editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + let len = buffer.len(cx); + buffer.edit([(0..len, prompt)], None, cx); + }); + }); + } + + fn render_prompt_editor(&self, cx: &mut ViewContext) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.prompt_editor.read(cx).read_only() { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features, + font_size: rems(0.875).into(), + font_weight: FontWeight::NORMAL, + font_style: FontStyle::Normal, + line_height: relative(1.).into(), + background_color: None, + underline: None, + white_space: WhiteSpace::Normal, + }; + EditorElement::new( + &self.prompt_editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} + +// This wouldn't need to exist if we could pass parameters when rendering child views. +#[derive(Copy, Clone, Default)] +struct BlockMeasurements { + anchor_x: Pixels, + gutter_width: Pixels, +} + +struct PendingInlineAssist { + editor: WeakView, + inline_assistant: Option<(BlockId, View)>, + codegen: Model, + _subscriptions: Vec, + project: WeakModel, +} + +fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { + ranges.sort_unstable_by(|a, b| { + a.start + .cmp(&b.start, buffer) + .then_with(|| b.end.cmp(&a.end, buffer)) + }); + + let mut ix = 0; + while ix + 1 < ranges.len() { + let b = ranges[ix + 1].clone(); + let a = &mut ranges[ix]; + if a.end.cmp(&b.start, buffer).is_gt() { + if a.end.cmp(&b.end, buffer).is_lt() { + a.end = b.end; + } + ranges.remove(ix + 1); + } else { + ix += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MessageId; + use ai::test::FakeCompletionProvider; + use gpui::AppContext; + + #[gpui::test] + fn test_inserting_and_removing_messages(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + init(cx); + let registry = Arc::new(LanguageRegistry::test()); + + let completion_provider = Arc::new(FakeCompletionProvider::new()); + let conversation = + cx.build_model(|cx| Conversation::new(registry, cx, completion_provider)); + let buffer = conversation.read(cx).buffer.clone(); + + let message_1 = conversation.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&conversation, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + let message_2 = conversation.update(cx, |conversation, cx| { + conversation + .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..1), + (message_2.id, Role::Assistant, 1..1) + ] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "1"), (1..1, "2")], None, cx) + }); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..3) + ] + ); + + let message_3 = conversation.update(cx, |conversation, cx| { + conversation + .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_3.id, Role::User, 4..4) + ] + ); + + let message_4 = conversation.update(cx, |conversation, cx| { + conversation + .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..5), + (message_3.id, Role::User, 5..5), + ] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(4..4, "C"), (5..5, "D")], None, cx) + }); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..6), + (message_3.id, Role::User, 6..7), + ] + ); + + // Deleting across message boundaries merges the messages. + buffer.update(cx, |buffer, cx| buffer.edit([(1..4, "")], None, cx)); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_3.id, Role::User, 3..4), + ] + ); + + // Undoing the deletion should also undo the merge. + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..6), + (message_3.id, Role::User, 6..7), + ] + ); + + // Redoing the deletion should also redo the merge. + buffer.update(cx, |buffer, cx| buffer.redo(cx)); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_3.id, Role::User, 3..4), + ] + ); + + // Ensure we can still insert after a merged message. + let message_5 = conversation.update(cx, |conversation, cx| { + conversation + .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_5.id, Role::System, 3..4), + (message_3.id, Role::User, 4..5) + ] + ); + } + + #[gpui::test] + fn test_message_splitting(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + init(cx); + let registry = Arc::new(LanguageRegistry::test()); + let completion_provider = Arc::new(FakeCompletionProvider::new()); + + let conversation = + cx.build_model(|cx| Conversation::new(registry, cx, completion_provider)); + let buffer = conversation.read(cx).buffer.clone(); + + let message_1 = conversation.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&conversation, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "aaa\nbbb\nccc\nddd\n")], None, cx) + }); + + let (_, message_2) = + conversation.update(cx, |conversation, cx| conversation.split_message(3..3, cx)); + let message_2 = message_2.unwrap(); + + // We recycle newlines in the middle of a split message + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_2.id, Role::User, 4..16), + ] + ); + + let (_, message_3) = + conversation.update(cx, |conversation, cx| conversation.split_message(3..3, cx)); + let message_3 = message_3.unwrap(); + + // We don't recycle newlines at the end of a split message + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..17), + ] + ); + + let (_, message_4) = + conversation.update(cx, |conversation, cx| conversation.split_message(9..9, cx)); + let message_4 = message_4.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..17), + ] + ); + + let (_, message_5) = + conversation.update(cx, |conversation, cx| conversation.split_message(9..9, cx)); + let message_5 = message_5.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\nddd\n"); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..10), + (message_5.id, Role::User, 10..18), + ] + ); + + let (message_6, message_7) = conversation.update(cx, |conversation, cx| { + conversation.split_message(14..16, cx) + }); + let message_6 = message_6.unwrap(); + let message_7 = message_7.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\ndd\nd\n"); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..10), + (message_5.id, Role::User, 10..14), + (message_6.id, Role::User, 14..17), + (message_7.id, Role::User, 17..19), + ] + ); + } + + #[gpui::test] + fn test_messages_for_offsets(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + init(cx); + let registry = Arc::new(LanguageRegistry::test()); + let completion_provider = Arc::new(FakeCompletionProvider::new()); + let conversation = + cx.build_model(|cx| Conversation::new(registry, cx, completion_provider)); + let buffer = conversation.read(cx).buffer.clone(); + + let message_1 = conversation.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&conversation, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "aaa")], None, cx)); + let message_2 = conversation + .update(cx, |conversation, cx| { + conversation.insert_message_after(message_1.id, Role::User, MessageStatus::Done, cx) + }) + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "bbb")], None, cx)); + + let message_3 = conversation + .update(cx, |conversation, cx| { + conversation.insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + }) + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.edit([(8..8, "ccc")], None, cx)); + + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc"); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_2.id, Role::User, 4..8), + (message_3.id, Role::User, 8..11) + ] + ); + + assert_eq!( + message_ids_for_offsets(&conversation, &[0, 4, 9], cx), + [message_1.id, message_2.id, message_3.id] + ); + assert_eq!( + message_ids_for_offsets(&conversation, &[0, 1, 11], cx), + [message_1.id, message_3.id] + ); + + let message_4 = conversation + .update(cx, |conversation, cx| { + conversation.insert_message_after(message_3.id, Role::User, MessageStatus::Done, cx) + }) + .unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\n"); + assert_eq!( + messages(&conversation, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_2.id, Role::User, 4..8), + (message_3.id, Role::User, 8..12), + (message_4.id, Role::User, 12..12) + ] + ); + assert_eq!( + message_ids_for_offsets(&conversation, &[0, 4, 8, 12], cx), + [message_1.id, message_2.id, message_3.id, message_4.id] + ); + + fn message_ids_for_offsets( + conversation: &Model, + offsets: &[usize], + cx: &AppContext, + ) -> Vec { + conversation + .read(cx) + .messages_for_offsets(offsets.iter().copied(), cx) + .into_iter() + .map(|message| message.id) + .collect() + } + } + + #[gpui::test] + fn test_serialization(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + init(cx); + let registry = Arc::new(LanguageRegistry::test()); + let completion_provider = Arc::new(FakeCompletionProvider::new()); + let conversation = + cx.build_model(|cx| Conversation::new(registry.clone(), cx, completion_provider)); + let buffer = conversation.read(cx).buffer.clone(); + let message_0 = conversation.read(cx).message_anchors[0].id; + let message_1 = conversation.update(cx, |conversation, cx| { + conversation + .insert_message_after(message_0, Role::Assistant, MessageStatus::Done, cx) + .unwrap() + }); + let message_2 = conversation.update(cx, |conversation, cx| { + conversation + .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) + .unwrap() + }); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "a"), (1..1, "b\nc")], None, cx); + buffer.finalize_last_transaction(); + }); + let _message_3 = conversation.update(cx, |conversation, cx| { + conversation + .insert_message_after(message_2.id, Role::System, MessageStatus::Done, cx) + .unwrap() + }); + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!(buffer.read(cx).text(), "a\nb\nc\n"); + assert_eq!( + messages(&conversation, cx), + [ + (message_0, Role::User, 0..2), + (message_1.id, Role::Assistant, 2..6), + (message_2.id, Role::System, 6..6), + ] + ); + + let deserialized_conversation = cx.build_model(|cx| { + Conversation::deserialize( + conversation.read(cx).serialize(cx), + Default::default(), + registry.clone(), + cx, + ) + }); + let deserialized_buffer = deserialized_conversation.read(cx).buffer.clone(); + assert_eq!(deserialized_buffer.read(cx).text(), "a\nb\nc\n"); + assert_eq!( + messages(&deserialized_conversation, cx), + [ + (message_0, Role::User, 0..2), + (message_1.id, Role::Assistant, 2..6), + (message_2.id, Role::System, 6..6), + ] + ); + } + + fn messages( + conversation: &Model, + cx: &AppContext, + ) -> Vec<(MessageId, Role, Range)> { + conversation + .read(cx) + .messages(cx) + .map(|message| (message.id, message.role, message.offset_range)) + .collect() + } +} + +fn report_assistant_event( + workspace: WeakView, + conversation_id: Option, + assistant_kind: AssistantKind, + cx: &AppContext, +) { + let Some(workspace) = workspace.upgrade() else { + return; + }; + + let client = workspace.read(cx).project().read(cx).client(); + let telemetry = client.telemetry(); + + let model = AssistantSettings::get_global(cx) + .default_open_ai_model + .clone(); + + let telemetry_settings = TelemetrySettings::get_global(cx).clone(); + + telemetry.report_assistant_event( + telemetry_settings, + conversation_id, + assistant_kind, + model.full_name(), + ) +} diff --git a/crates/assistant2/src/assistant_settings.rs b/crates/assistant2/src/assistant_settings.rs new file mode 100644 index 0000000000..5a727df621 --- /dev/null +++ b/crates/assistant2/src/assistant_settings.rs @@ -0,0 +1,80 @@ +use anyhow; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub enum OpenAIModel { + #[serde(rename = "gpt-3.5-turbo-0613")] + ThreePointFiveTurbo, + #[serde(rename = "gpt-4-0613")] + Four, + #[serde(rename = "gpt-4-1106-preview")] + FourTurbo, +} + +impl OpenAIModel { + pub fn full_name(&self) -> &'static str { + match self { + OpenAIModel::ThreePointFiveTurbo => "gpt-3.5-turbo-0613", + OpenAIModel::Four => "gpt-4-0613", + OpenAIModel::FourTurbo => "gpt-4-1106-preview", + } + } + + pub fn short_name(&self) -> &'static str { + match self { + OpenAIModel::ThreePointFiveTurbo => "gpt-3.5-turbo", + OpenAIModel::Four => "gpt-4", + OpenAIModel::FourTurbo => "gpt-4-turbo", + } + } + + pub fn cycle(&self) -> Self { + match self { + OpenAIModel::ThreePointFiveTurbo => OpenAIModel::Four, + OpenAIModel::Four => OpenAIModel::FourTurbo, + OpenAIModel::FourTurbo => OpenAIModel::ThreePointFiveTurbo, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AssistantDockPosition { + Left, + Right, + Bottom, +} + +#[derive(Deserialize, Debug)] +pub struct AssistantSettings { + pub button: bool, + pub dock: AssistantDockPosition, + pub default_width: f32, + pub default_height: f32, + pub default_open_ai_model: OpenAIModel, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct AssistantSettingsContent { + pub button: Option, + pub dock: Option, + pub default_width: Option, + pub default_height: Option, + pub default_open_ai_model: Option, +} + +impl Settings for AssistantSettings { + const KEY: Option<&'static str> = Some("assistant"); + + type FileContent = AssistantSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _: &mut gpui::AppContext, + ) -> anyhow::Result { + Self::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/assistant2/src/codegen.rs b/crates/assistant2/src/codegen.rs new file mode 100644 index 0000000000..2f7b2f0378 --- /dev/null +++ b/crates/assistant2/src/codegen.rs @@ -0,0 +1,688 @@ +use crate::streaming_diff::{Hunk, StreamingDiff}; +use ai::completion::{CompletionProvider, CompletionRequest}; +use anyhow::Result; +use editor::{Anchor, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint}; +use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; +use gpui::{EventEmitter, Model, ModelContext, Task}; +use language::{Rope, TransactionId}; +use multi_buffer; +use std::{cmp, future, ops::Range, sync::Arc}; + +pub enum Event { + Finished, + Undone, +} + +#[derive(Clone)] +pub enum CodegenKind { + Transform { range: Range }, + Generate { position: Anchor }, +} + +pub struct Codegen { + provider: Arc, + buffer: Model, + snapshot: MultiBufferSnapshot, + kind: CodegenKind, + last_equal_ranges: Vec>, + transaction_id: Option, + error: Option, + generation: Task<()>, + idle: bool, + _subscription: gpui::Subscription, +} + +impl EventEmitter for Codegen {} + +impl Codegen { + pub fn new( + buffer: Model, + kind: CodegenKind, + provider: Arc, + cx: &mut ModelContext, + ) -> Self { + let snapshot = buffer.read(cx).snapshot(cx); + Self { + provider, + buffer: buffer.clone(), + snapshot, + kind, + last_equal_ranges: Default::default(), + transaction_id: Default::default(), + error: Default::default(), + idle: true, + generation: Task::ready(()), + _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), + } + } + + fn handle_buffer_event( + &mut self, + _buffer: Model, + event: &multi_buffer::Event, + cx: &mut ModelContext, + ) { + if let multi_buffer::Event::TransactionUndone { transaction_id } = event { + if self.transaction_id == Some(*transaction_id) { + self.transaction_id = None; + self.generation = Task::ready(()); + cx.emit(Event::Undone); + } + } + } + + pub fn range(&self) -> Range { + match &self.kind { + CodegenKind::Transform { range } => range.clone(), + CodegenKind::Generate { position } => position.bias_left(&self.snapshot)..*position, + } + } + + pub fn kind(&self) -> &CodegenKind { + &self.kind + } + + pub fn last_equal_ranges(&self) -> &[Range] { + &self.last_equal_ranges + } + + pub fn idle(&self) -> bool { + self.idle + } + + pub fn error(&self) -> Option<&anyhow::Error> { + self.error.as_ref() + } + + pub fn start(&mut self, prompt: Box, cx: &mut ModelContext) { + let range = self.range(); + let snapshot = self.snapshot.clone(); + let selected_text = snapshot + .text_for_range(range.start..range.end) + .collect::(); + + let selection_start = range.start.to_point(&snapshot); + let suggested_line_indent = snapshot + .suggested_indents(selection_start.row..selection_start.row + 1, cx) + .into_values() + .next() + .unwrap_or_else(|| snapshot.indent_size_for_line(selection_start.row)); + + let response = self.provider.complete(prompt); + self.generation = cx.spawn(|this, mut cx| { + async move { + let generate = async { + let mut edit_start = range.start.to_offset(&snapshot); + + let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); + let diff = cx.background_executor().spawn(async move { + let chunks = strip_invalid_spans_from_codeblock(response.await?); + futures::pin_mut!(chunks); + let mut diff = StreamingDiff::new(selected_text.to_string()); + + let mut new_text = String::new(); + let mut base_indent = None; + let mut line_indent = None; + let mut first_line = true; + + while let Some(chunk) = chunks.next().await { + let chunk = chunk?; + + let mut lines = chunk.split('\n').peekable(); + while let Some(line) = lines.next() { + new_text.push_str(line); + if line_indent.is_none() { + if let Some(non_whitespace_ch_ix) = + new_text.find(|ch: char| !ch.is_whitespace()) + { + line_indent = Some(non_whitespace_ch_ix); + base_indent = base_indent.or(line_indent); + + let line_indent = line_indent.unwrap(); + let base_indent = base_indent.unwrap(); + let indent_delta = line_indent as i32 - base_indent as i32; + let mut corrected_indent_len = cmp::max( + 0, + suggested_line_indent.len as i32 + indent_delta, + ) + as usize; + if first_line { + corrected_indent_len = corrected_indent_len + .saturating_sub(selection_start.column as usize); + } + + let indent_char = suggested_line_indent.char(); + let mut indent_buffer = [0; 4]; + let indent_str = + indent_char.encode_utf8(&mut indent_buffer); + new_text.replace_range( + ..line_indent, + &indent_str.repeat(corrected_indent_len), + ); + } + } + + if line_indent.is_some() { + hunks_tx.send(diff.push_new(&new_text)).await?; + new_text.clear(); + } + + if lines.peek().is_some() { + hunks_tx.send(diff.push_new("\n")).await?; + line_indent = None; + first_line = false; + } + } + } + hunks_tx.send(diff.push_new(&new_text)).await?; + hunks_tx.send(diff.finish()).await?; + + anyhow::Ok(()) + }); + + while let Some(hunks) = hunks_rx.next().await { + this.update(&mut cx, |this, cx| { + this.last_equal_ranges.clear(); + + let transaction = this.buffer.update(cx, |buffer, cx| { + // Avoid grouping assistant edits with user edits. + buffer.finalize_last_transaction(cx); + + buffer.start_transaction(cx); + buffer.edit( + hunks.into_iter().filter_map(|hunk| match hunk { + Hunk::Insert { text } => { + let edit_start = snapshot.anchor_after(edit_start); + Some((edit_start..edit_start, text)) + } + Hunk::Remove { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start = edit_end; + Some((edit_range, String::new())) + } + Hunk::Keep { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start = edit_end; + this.last_equal_ranges.push(edit_range); + None + } + }), + None, + cx, + ); + + buffer.end_transaction(cx) + }); + + if let Some(transaction) = transaction { + if let Some(first_transaction) = this.transaction_id { + // Group all assistant edits into the first transaction. + this.buffer.update(cx, |buffer, cx| { + buffer.merge_transactions( + transaction, + first_transaction, + cx, + ) + }); + } else { + this.transaction_id = Some(transaction); + this.buffer.update(cx, |buffer, cx| { + buffer.finalize_last_transaction(cx) + }); + } + } + + cx.notify(); + })?; + } + + diff.await?; + anyhow::Ok(()) + }; + + let result = generate.await; + this.update(&mut cx, |this, cx| { + this.last_equal_ranges.clear(); + this.idle = true; + if let Err(error) = result { + this.error = Some(error); + } + cx.emit(Event::Finished); + cx.notify(); + }) + .ok(); + } + }); + self.error.take(); + self.idle = false; + cx.notify(); + } + + pub fn undo(&mut self, cx: &mut ModelContext) { + if let Some(transaction_id) = self.transaction_id { + self.buffer + .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); + } + } +} + +fn strip_invalid_spans_from_codeblock( + stream: impl Stream>, +) -> impl Stream> { + let mut first_line = true; + let mut buffer = String::new(); + let mut starts_with_markdown_codeblock = false; + let mut includes_start_or_end_span = false; + stream.filter_map(move |chunk| { + let chunk = match chunk { + Ok(chunk) => chunk, + Err(err) => return future::ready(Some(Err(err))), + }; + buffer.push_str(&chunk); + + if buffer.len() > "<|S|".len() && buffer.starts_with("<|S|") { + includes_start_or_end_span = true; + + buffer = buffer + .strip_prefix("<|S|>") + .or_else(|| buffer.strip_prefix("<|S|")) + .unwrap_or(&buffer) + .to_string(); + } else if buffer.ends_with("|E|>") { + includes_start_or_end_span = true; + } else if buffer.starts_with("<|") + || buffer.starts_with("<|S") + || buffer.starts_with("<|S|") + || buffer.ends_with("|") + || buffer.ends_with("|E") + || buffer.ends_with("|E|") + { + return future::ready(None); + } + + if first_line { + if buffer == "" || buffer == "`" || buffer == "``" { + return future::ready(None); + } else if buffer.starts_with("```") { + starts_with_markdown_codeblock = true; + if let Some(newline_ix) = buffer.find('\n') { + buffer.replace_range(..newline_ix + 1, ""); + first_line = false; + } else { + return future::ready(None); + } + } + } + + let mut text = buffer.to_string(); + if starts_with_markdown_codeblock { + text = text + .strip_suffix("\n```\n") + .or_else(|| text.strip_suffix("\n```")) + .or_else(|| text.strip_suffix("\n``")) + .or_else(|| text.strip_suffix("\n`")) + .or_else(|| text.strip_suffix('\n')) + .unwrap_or(&text) + .to_string(); + } + + if includes_start_or_end_span { + text = text + .strip_suffix("|E|>") + .or_else(|| text.strip_suffix("E|>")) + .or_else(|| text.strip_prefix("|>")) + .or_else(|| text.strip_prefix(">")) + .unwrap_or(&text) + .to_string(); + }; + + if text.contains('\n') { + first_line = false; + } + + let remainder = buffer.split_off(text.len()); + let result = if buffer.is_empty() { + None + } else { + Some(Ok(buffer.clone())) + }; + + buffer = remainder; + future::ready(result) + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use ai::test::FakeCompletionProvider; + use futures::stream::{self}; + use gpui::{Context, TestAppContext}; + use indoc::indoc; + use language::{language_settings, tree_sitter_rust, Buffer, Language, LanguageConfig, Point}; + use rand::prelude::*; + use serde::Serialize; + use settings::SettingsStore; + + #[derive(Serialize)] + pub struct DummyCompletionRequest { + pub name: String, + } + + impl CompletionRequest for DummyCompletionRequest { + fn data(&self) -> serde_json::Result { + serde_json::to_string(self) + } + } + + #[gpui::test(iterations = 10)] + async fn test_transform_autoindent(cx: &mut TestAppContext, mut rng: StdRng) { + cx.set_global(cx.update(SettingsStore::test)); + cx.update(language_settings::init); + + let text = indoc! {" + fn main() { + let x = 0; + for _ in 0..10 { + x += 1; + } + } + "}; + let buffer = + cx.build_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); + let range = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(4, 5)) + }); + let provider = Arc::new(FakeCompletionProvider::new()); + let codegen = cx.build_model(|cx| { + Codegen::new( + buffer.clone(), + CodegenKind::Transform { range }, + provider.clone(), + cx, + ) + }); + + let request = Box::new(DummyCompletionRequest { + name: "test".to_string(), + }); + codegen.update(cx, |codegen, cx| codegen.start(request, cx)); + + let mut new_text = concat!( + " let mut x = 0;\n", + " while x < 10 {\n", + " x += 1;\n", + " }", + ); + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + println!("CHUNK: {:?}", &chunk); + provider.send_completion(chunk); + new_text = suffix; + cx.background_executor.run_until_parked(); + } + provider.finish_completion(); + cx.background_executor.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } + + #[gpui::test(iterations = 10)] + async fn test_autoindent_when_generating_past_indentation( + cx: &mut TestAppContext, + mut rng: StdRng, + ) { + cx.set_global(cx.update(SettingsStore::test)); + cx.update(language_settings::init); + + let text = indoc! {" + fn main() { + le + } + "}; + let buffer = + cx.build_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); + let position = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 6)) + }); + let provider = Arc::new(FakeCompletionProvider::new()); + let codegen = cx.build_model(|cx| { + Codegen::new( + buffer.clone(), + CodegenKind::Generate { position }, + provider.clone(), + cx, + ) + }); + + let request = Box::new(DummyCompletionRequest { + name: "test".to_string(), + }); + codegen.update(cx, |codegen, cx| codegen.start(request, cx)); + + let mut new_text = concat!( + "t mut x = 0;\n", + "while x < 10 {\n", + " x += 1;\n", + "}", // + ); + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + provider.send_completion(chunk); + new_text = suffix; + cx.background_executor.run_until_parked(); + } + provider.finish_completion(); + cx.background_executor.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } + + #[gpui::test(iterations = 10)] + async fn test_autoindent_when_generating_before_indentation( + cx: &mut TestAppContext, + mut rng: StdRng, + ) { + cx.set_global(cx.update(SettingsStore::test)); + cx.update(language_settings::init); + + let text = concat!( + "fn main() {\n", + " \n", + "}\n" // + ); + let buffer = + cx.build_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); + let position = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(1, 2)) + }); + let provider = Arc::new(FakeCompletionProvider::new()); + let codegen = cx.build_model(|cx| { + Codegen::new( + buffer.clone(), + CodegenKind::Generate { position }, + provider.clone(), + cx, + ) + }); + + let request = Box::new(DummyCompletionRequest { + name: "test".to_string(), + }); + codegen.update(cx, |codegen, cx| codegen.start(request, cx)); + + let mut new_text = concat!( + "let mut x = 0;\n", + "while x < 10 {\n", + " x += 1;\n", + "}", // + ); + while !new_text.is_empty() { + let max_len = cmp::min(new_text.len(), 10); + let len = rng.gen_range(1..=max_len); + let (chunk, suffix) = new_text.split_at(len); + println!("{:?}", &chunk); + provider.send_completion(chunk); + new_text = suffix; + cx.background_executor.run_until_parked(); + } + provider.finish_completion(); + cx.background_executor.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + indoc! {" + fn main() { + let mut x = 0; + while x < 10 { + x += 1; + } + } + "} + ); + } + + #[gpui::test] + async fn test_strip_invalid_spans_from_codeblock() { + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("Lorem ipsum dolor", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("```\nLorem ipsum dolor", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("```\nLorem ipsum dolor\n```", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_invalid_spans_from_codeblock(chunks( + "```html\n```js\nLorem ipsum dolor\n```\n```", + 2 + )) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "```js\nLorem ipsum dolor\n```" + ); + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("``\nLorem ipsum dolor\n```", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "``\nLorem ipsum dolor\n```" + ); + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("<|S|Lorem ipsum|E|>", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum" + ); + + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("<|S|>Lorem ipsum", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum" + ); + + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("```\n<|S|>Lorem ipsum\n```", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum" + ); + assert_eq!( + strip_invalid_spans_from_codeblock(chunks("```\n<|S|Lorem ipsum|E|>\n```", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum" + ); + fn chunks(text: &str, size: usize) -> impl Stream> { + stream::iter( + text.chars() + .collect::>() + .chunks(size) + .map(|chunk| Ok(chunk.iter().collect::())) + .collect::>(), + ) + } + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_indents_query( + r#" + (call_expression) @indent + (field_expression) @indent + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap() + } +} diff --git a/crates/assistant2/src/prompts.rs b/crates/assistant2/src/prompts.rs new file mode 100644 index 0000000000..06881ad9c2 --- /dev/null +++ b/crates/assistant2/src/prompts.rs @@ -0,0 +1,389 @@ +use ai::models::LanguageModel; +use ai::prompts::base::{PromptArguments, PromptChain, PromptPriority, PromptTemplate}; +use ai::prompts::file_context::FileContext; +use ai::prompts::generate::GenerateInlineContent; +use ai::prompts::preamble::EngineerPreamble; +use ai::prompts::repository_context::{PromptCodeSnippet, RepositoryContext}; +use ai::providers::open_ai::OpenAILanguageModel; +use language::{BufferSnapshot, OffsetRangeExt, ToOffset}; +use std::cmp::{self, Reverse}; +use std::ops::Range; +use std::sync::Arc; + +#[allow(dead_code)] +fn summarize(buffer: &BufferSnapshot, selected_range: Range) -> String { + #[derive(Debug)] + struct Match { + collapse: Range, + keep: Vec>, + } + + let selected_range = selected_range.to_offset(buffer); + let mut ts_matches = buffer.matches(0..buffer.len(), |grammar| { + Some(&grammar.embedding_config.as_ref()?.query) + }); + let configs = ts_matches + .grammars() + .iter() + .map(|g| g.embedding_config.as_ref().unwrap()) + .collect::>(); + let mut matches = Vec::new(); + while let Some(mat) = ts_matches.peek() { + let config = &configs[mat.grammar_index]; + if let Some(collapse) = mat.captures.iter().find_map(|cap| { + if Some(cap.index) == config.collapse_capture_ix { + Some(cap.node.byte_range()) + } else { + None + } + }) { + let mut keep = Vec::new(); + for capture in mat.captures.iter() { + if Some(capture.index) == config.keep_capture_ix { + keep.push(capture.node.byte_range()); + } else { + continue; + } + } + ts_matches.advance(); + matches.push(Match { collapse, keep }); + } else { + ts_matches.advance(); + } + } + matches.sort_unstable_by_key(|mat| (mat.collapse.start, Reverse(mat.collapse.end))); + let mut matches = matches.into_iter().peekable(); + + let mut summary = String::new(); + let mut offset = 0; + let mut flushed_selection = false; + while let Some(mat) = matches.next() { + // Keep extending the collapsed range if the next match surrounds + // the current one. + while let Some(next_mat) = matches.peek() { + if mat.collapse.start <= next_mat.collapse.start + && mat.collapse.end >= next_mat.collapse.end + { + matches.next().unwrap(); + } else { + break; + } + } + + if offset > mat.collapse.start { + // Skip collapsed nodes that have already been summarized. + offset = cmp::max(offset, mat.collapse.end); + continue; + } + + if offset <= selected_range.start && selected_range.start <= mat.collapse.end { + if !flushed_selection { + // The collapsed node ends after the selection starts, so we'll flush the selection first. + summary.extend(buffer.text_for_range(offset..selected_range.start)); + summary.push_str("<|S|"); + if selected_range.end == selected_range.start { + summary.push_str(">"); + } else { + summary.extend(buffer.text_for_range(selected_range.clone())); + summary.push_str("|E|>"); + } + offset = selected_range.end; + flushed_selection = true; + } + + // If the selection intersects the collapsed node, we won't collapse it. + if selected_range.end >= mat.collapse.start { + continue; + } + } + + summary.extend(buffer.text_for_range(offset..mat.collapse.start)); + for keep in mat.keep { + summary.extend(buffer.text_for_range(keep)); + } + offset = mat.collapse.end; + } + + // Flush selection if we haven't already done so. + if !flushed_selection && offset <= selected_range.start { + summary.extend(buffer.text_for_range(offset..selected_range.start)); + summary.push_str("<|S|"); + if selected_range.end == selected_range.start { + summary.push_str(">"); + } else { + summary.extend(buffer.text_for_range(selected_range.clone())); + summary.push_str("|E|>"); + } + offset = selected_range.end; + } + + summary.extend(buffer.text_for_range(offset..buffer.len())); + summary +} + +pub fn generate_content_prompt( + user_prompt: String, + language_name: Option<&str>, + buffer: BufferSnapshot, + range: Range, + search_results: Vec, + model: &str, + project_name: Option, +) -> anyhow::Result { + // Using new Prompt Templates + let openai_model: Arc = Arc::new(OpenAILanguageModel::load(model)); + let lang_name = if let Some(language_name) = language_name { + Some(language_name.to_string()) + } else { + None + }; + + let args = PromptArguments { + model: openai_model, + language_name: lang_name.clone(), + project_name, + snippets: search_results.clone(), + reserved_tokens: 1000, + buffer: Some(buffer), + selected_range: Some(range), + user_prompt: Some(user_prompt.clone()), + }; + + let templates: Vec<(PromptPriority, Box)> = vec![ + (PromptPriority::Mandatory, Box::new(EngineerPreamble {})), + ( + PromptPriority::Ordered { order: 1 }, + Box::new(RepositoryContext {}), + ), + ( + PromptPriority::Ordered { order: 0 }, + Box::new(FileContext {}), + ), + ( + PromptPriority::Mandatory, + Box::new(GenerateInlineContent {}), + ), + ]; + let chain = PromptChain::new(args, templates); + let (prompt, _) = chain.generate(true)?; + + anyhow::Ok(prompt) +} + +#[cfg(test)] +pub(crate) mod tests { + + use super::*; + use std::sync::Arc; + + use gpui::{AppContext, Context}; + use indoc::indoc; + use language::{language_settings, tree_sitter_rust, Buffer, Language, LanguageConfig, Point}; + use settings::SettingsStore; + + pub(crate) fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_embedding_query( + r#" + ( + [(line_comment) (attribute_item)]* @context + . + [ + (struct_item + name: (_) @name) + + (enum_item + name: (_) @name) + + (impl_item + trait: (_)? @name + "for"? @name + type: (_) @name) + + (trait_item + name: (_) @name) + + (function_item + name: (_) @name + body: (block + "{" @keep + "}" @keep) @collapse) + + (macro_definition + name: (_) @name) + ] @item + ) + "#, + ) + .unwrap() + } + + #[gpui::test] + fn test_outline_for_prompt(cx: &mut AppContext) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language_settings::init(cx); + let text = indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self { + let a = 1; + let b = 2; + Self { a, b } + } + + pub fn a(&self, param: bool) -> usize { + self.a + } + + pub fn b(&self) -> usize { + self.b + } + } + "}; + let buffer = + cx.build_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx)); + let snapshot = buffer.read(cx).snapshot(); + + assert_eq!( + summarize(&snapshot, Point::new(1, 4)..Point::new(1, 4)), + indoc! {" + struct X { + <|S|>a: usize, + b: usize, + } + + impl X { + + fn new() -> Self {} + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + "} + ); + + assert_eq!( + summarize(&snapshot, Point::new(8, 12)..Point::new(8, 14)), + indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self { + let <|S|a |E|>= 1; + let b = 2; + Self { a, b } + } + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + "} + ); + + assert_eq!( + summarize(&snapshot, Point::new(6, 0)..Point::new(6, 0)), + indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + <|S|> + fn new() -> Self {} + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + "} + ); + + assert_eq!( + summarize(&snapshot, Point::new(21, 0)..Point::new(21, 0)), + indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self {} + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + <|S|>"} + ); + + // Ensure nested functions get collapsed properly. + let text = indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self { + let a = 1; + let b = 2; + Self { a, b } + } + + pub fn a(&self, param: bool) -> usize { + let a = 30; + fn nested() -> usize { + 3 + } + self.a + nested() + } + + pub fn b(&self) -> usize { + self.b + } + } + "}; + buffer.update(cx, |buffer, cx| buffer.set_text(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + assert_eq!( + summarize(&snapshot, Point::new(0, 0)..Point::new(0, 0)), + indoc! {" + <|S|>struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self {} + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + "} + ); + } +} diff --git a/crates/assistant2/src/streaming_diff.rs b/crates/assistant2/src/streaming_diff.rs new file mode 100644 index 0000000000..7399a7b4fa --- /dev/null +++ b/crates/assistant2/src/streaming_diff.rs @@ -0,0 +1,293 @@ +use collections::HashMap; +use ordered_float::OrderedFloat; +use std::{ + cmp, + fmt::{self, Debug}, + ops::Range, +}; + +struct Matrix { + cells: Vec, + rows: usize, + cols: usize, +} + +impl Matrix { + fn new() -> Self { + Self { + cells: Vec::new(), + rows: 0, + cols: 0, + } + } + + fn resize(&mut self, rows: usize, cols: usize) { + self.cells.resize(rows * cols, 0.); + self.rows = rows; + self.cols = cols; + } + + fn get(&self, row: usize, col: usize) -> f64 { + if row >= self.rows { + panic!("row out of bounds") + } + + if col >= self.cols { + panic!("col out of bounds") + } + self.cells[col * self.rows + row] + } + + fn set(&mut self, row: usize, col: usize, value: f64) { + if row >= self.rows { + panic!("row out of bounds") + } + + if col >= self.cols { + panic!("col out of bounds") + } + + self.cells[col * self.rows + row] = value; + } +} + +impl Debug for Matrix { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f)?; + for i in 0..self.rows { + for j in 0..self.cols { + write!(f, "{:5}", self.get(i, j))?; + } + writeln!(f)?; + } + Ok(()) + } +} + +#[derive(Debug)] +pub enum Hunk { + Insert { text: String }, + Remove { len: usize }, + Keep { len: usize }, +} + +pub struct StreamingDiff { + old: Vec, + new: Vec, + scores: Matrix, + old_text_ix: usize, + new_text_ix: usize, + equal_runs: HashMap<(usize, usize), u32>, +} + +impl StreamingDiff { + const INSERTION_SCORE: f64 = -1.; + const DELETION_SCORE: f64 = -20.; + const EQUALITY_BASE: f64 = 1.8; + const MAX_EQUALITY_EXPONENT: i32 = 16; + + pub fn new(old: String) -> Self { + let old = old.chars().collect::>(); + let mut scores = Matrix::new(); + scores.resize(old.len() + 1, 1); + for i in 0..=old.len() { + scores.set(i, 0, i as f64 * Self::DELETION_SCORE); + } + Self { + old, + new: Vec::new(), + scores, + old_text_ix: 0, + new_text_ix: 0, + equal_runs: Default::default(), + } + } + + pub fn push_new(&mut self, text: &str) -> Vec { + self.new.extend(text.chars()); + self.scores.resize(self.old.len() + 1, self.new.len() + 1); + + for j in self.new_text_ix + 1..=self.new.len() { + self.scores.set(0, j, j as f64 * Self::INSERTION_SCORE); + for i in 1..=self.old.len() { + let insertion_score = self.scores.get(i, j - 1) + Self::INSERTION_SCORE; + let deletion_score = self.scores.get(i - 1, j) + Self::DELETION_SCORE; + let equality_score = if self.old[i - 1] == self.new[j - 1] { + let mut equal_run = self.equal_runs.get(&(i - 1, j - 1)).copied().unwrap_or(0); + equal_run += 1; + self.equal_runs.insert((i, j), equal_run); + + let exponent = cmp::min(equal_run as i32 / 4, Self::MAX_EQUALITY_EXPONENT); + self.scores.get(i - 1, j - 1) + Self::EQUALITY_BASE.powi(exponent) + } else { + f64::NEG_INFINITY + }; + + let score = insertion_score.max(deletion_score).max(equality_score); + self.scores.set(i, j, score); + } + } + + let mut max_score = f64::NEG_INFINITY; + let mut next_old_text_ix = self.old_text_ix; + let next_new_text_ix = self.new.len(); + for i in self.old_text_ix..=self.old.len() { + let score = self.scores.get(i, next_new_text_ix); + if score > max_score { + max_score = score; + next_old_text_ix = i; + } + } + + let hunks = self.backtrack(next_old_text_ix, next_new_text_ix); + self.old_text_ix = next_old_text_ix; + self.new_text_ix = next_new_text_ix; + hunks + } + + fn backtrack(&self, old_text_ix: usize, new_text_ix: usize) -> Vec { + let mut pending_insert: Option> = None; + let mut hunks = Vec::new(); + let mut i = old_text_ix; + let mut j = new_text_ix; + while (i, j) != (self.old_text_ix, self.new_text_ix) { + let insertion_score = if j > self.new_text_ix { + Some((i, j - 1)) + } else { + None + }; + let deletion_score = if i > self.old_text_ix { + Some((i - 1, j)) + } else { + None + }; + let equality_score = if i > self.old_text_ix && j > self.new_text_ix { + if self.old[i - 1] == self.new[j - 1] { + Some((i - 1, j - 1)) + } else { + None + } + } else { + None + }; + + let (prev_i, prev_j) = [insertion_score, deletion_score, equality_score] + .iter() + .max_by_key(|cell| cell.map(|(i, j)| OrderedFloat(self.scores.get(i, j)))) + .unwrap() + .unwrap(); + + if prev_i == i && prev_j == j - 1 { + if let Some(pending_insert) = pending_insert.as_mut() { + pending_insert.start = prev_j; + } else { + pending_insert = Some(prev_j..j); + } + } else { + if let Some(range) = pending_insert.take() { + hunks.push(Hunk::Insert { + text: self.new[range].iter().collect(), + }); + } + + let char_len = self.old[i - 1].len_utf8(); + if prev_i == i - 1 && prev_j == j { + if let Some(Hunk::Remove { len }) = hunks.last_mut() { + *len += char_len; + } else { + hunks.push(Hunk::Remove { len: char_len }) + } + } else { + if let Some(Hunk::Keep { len }) = hunks.last_mut() { + *len += char_len; + } else { + hunks.push(Hunk::Keep { len: char_len }) + } + } + } + + i = prev_i; + j = prev_j; + } + + if let Some(range) = pending_insert.take() { + hunks.push(Hunk::Insert { + text: self.new[range].iter().collect(), + }); + } + + hunks.reverse(); + hunks + } + + pub fn finish(self) -> Vec { + self.backtrack(self.old.len(), self.new.len()) + } +} + +#[cfg(test)] +mod tests { + use std::env; + + use super::*; + use rand::prelude::*; + + #[gpui::test(iterations = 100)] + fn test_random_diffs(mut rng: StdRng) { + let old_text_len = env::var("OLD_TEXT_LEN") + .map(|i| i.parse().expect("invalid `OLD_TEXT_LEN` variable")) + .unwrap_or(10); + let new_text_len = env::var("NEW_TEXT_LEN") + .map(|i| i.parse().expect("invalid `NEW_TEXT_LEN` variable")) + .unwrap_or(10); + + let old = util::RandomCharIter::new(&mut rng) + .take(old_text_len) + .collect::(); + log::info!("old text: {:?}", old); + + let mut diff = StreamingDiff::new(old.clone()); + let mut hunks = Vec::new(); + let mut new_len = 0; + let mut new = String::new(); + while new_len < new_text_len { + let new_chunk_len = rng.gen_range(1..=new_text_len - new_len); + let new_chunk = util::RandomCharIter::new(&mut rng) + .take(new_len) + .collect::(); + log::info!("new chunk: {:?}", new_chunk); + new_len += new_chunk_len; + new.push_str(&new_chunk); + let new_hunks = diff.push_new(&new_chunk); + log::info!("hunks: {:?}", new_hunks); + hunks.extend(new_hunks); + } + let final_hunks = diff.finish(); + log::info!("final hunks: {:?}", final_hunks); + hunks.extend(final_hunks); + + log::info!("new text: {:?}", new); + let mut old_ix = 0; + let mut new_ix = 0; + let mut patched = String::new(); + for hunk in hunks { + match hunk { + Hunk::Keep { len } => { + assert_eq!(&old[old_ix..old_ix + len], &new[new_ix..new_ix + len]); + patched.push_str(&old[old_ix..old_ix + len]); + old_ix += len; + new_ix += len; + } + Hunk::Remove { len } => { + old_ix += len; + } + Hunk::Insert { text } => { + assert_eq!(text, &new[new_ix..new_ix + text.len()]); + patched.push_str(&text); + new_ix += text.len(); + } + } + } + assert_eq!(patched, new); + } +} diff --git a/crates/breadcrumbs2/src/breadcrumbs.rs b/crates/breadcrumbs2/src/breadcrumbs.rs index 75195a3159..1c577fa310 100644 --- a/crates/breadcrumbs2/src/breadcrumbs.rs +++ b/crates/breadcrumbs2/src/breadcrumbs.rs @@ -1,10 +1,10 @@ use gpui::{ - Component, Element, EventEmitter, IntoElement, ParentElement, Render, StyledText, Subscription, + Div, Element, EventEmitter, IntoElement, ParentElement, Render, StyledText, Subscription, ViewContext, WeakView, }; use itertools::Itertools; use theme::ActiveTheme; -use ui::{ButtonCommon, ButtonLike, ButtonStyle, Clickable, Disableable, Label}; +use ui::{prelude::*, ButtonLike, ButtonStyle, Label}; use workspace::{ item::{ItemEvent, ItemHandle}, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -36,54 +36,51 @@ impl EventEmitter for Breadcrumbs {} impl EventEmitter for Breadcrumbs {} impl Render for Breadcrumbs { - type Element = Component; + type Element = Div; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { - let button = ButtonLike::new("breadcrumbs") - .style(ButtonStyle::Transparent) - .disabled(true); + let element = h_stack().text_ui(); - let active_item = match &self.active_item { - Some(active_item) => active_item, - None => return button.into_element(), + let Some(active_item) = &self + .active_item + .as_ref() + .filter(|item| item.downcast::().is_some()) + else { + return element; }; - let not_editor = active_item.downcast::().is_none(); - let breadcrumbs = match active_item.breadcrumbs(cx.theme(), cx) { - Some(breadcrumbs) => breadcrumbs, - None => return button.into_element(), - } - .into_iter() - .map(|breadcrumb| { - StyledText::new(breadcrumb.text) - .with_highlights(&cx.text_style(), breadcrumb.highlights.unwrap_or_default()) + let Some(segments) = active_item.breadcrumbs(cx.theme(), cx) else { + return element; + }; + + let highlighted_segments = segments.into_iter().map(|segment| { + StyledText::new(segment.text) + .with_highlights(&cx.text_style(), segment.highlights.unwrap_or_default()) .into_any() }); + let breadcrumbs = Itertools::intersperse_with(highlighted_segments, || { + Label::new("›").into_any_element() + }); - let button = button.children(Itertools::intersperse_with(breadcrumbs, || { - Label::new(" › ").into_any_element() - })); - - if not_editor || !self.pane_focused { - return button.into_element(); - } - - // let this = cx.view().downgrade(); - button - .style(ButtonStyle::Filled) - .disabled(false) - .on_click(move |_, _cx| { - todo!("outline::toggle"); - // this.update(cx, |this, cx| { - // if let Some(workspace) = this.workspace.upgrade() { - // workspace.update(cx, |_workspace, _cx| { - // outline::toggle(workspace, &Default::default(), cx) - // }) - // } - // }) - // .ok(); - }) - .into_element() + element.child( + ButtonLike::new("toggle outline view") + .style(ButtonStyle::Subtle) + .child(h_stack().gap_1().children(breadcrumbs)) + // We disable the button when it is not focused + // due to ... @julia what was the reason again? + .disabled(!self.pane_focused) + .on_click(move |_, _cx| { + todo!("outline::toggle"); + // this.update(cx, |this, cx| { + // if let Some(workspace) = this.workspace.upgrade() { + // workspace.update(cx, |_workspace, _cx| { + // outline::toggle(workspace, &Default::default(), cx) + // }) + // } + // }) + // .ok(); + }), + ) } } diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 33c3c14ddd..50491704c9 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] default-run = "collab" edition = "2021" name = "collab" -version = "0.29.1" +version = "0.30.0" publish = false [[bin]] diff --git a/crates/collab2/Cargo.toml b/crates/collab2/Cargo.toml index b8e6a45b06..06443afdbc 100644 --- a/crates/collab2/Cargo.toml +++ b/crates/collab2/Cargo.toml @@ -81,7 +81,7 @@ settings = { package = "settings2", path = "../settings2", features = ["test-sup theme = { package = "theme2", path = "../theme2" } workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } -collab_ui = { path = "../collab_ui", features = ["test-support"] } +collab_ui = { path = "../collab_ui2", package = "collab_ui2", features = ["test-support"] } async-trait.workspace = true pretty_assertions.workspace = true diff --git a/crates/collab2/src/tests/channel_buffer_tests.rs b/crates/collab2/src/tests/channel_buffer_tests.rs index 63057cbd41..b0af360fd5 100644 --- a/crates/collab2/src/tests/channel_buffer_tests.rs +++ b/crates/collab2/src/tests/channel_buffer_tests.rs @@ -1,875 +1,881 @@ -//todo(partially ported) -// use std::ops::Range; - -// use crate::{ -// rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, -// tests::TestServer, -// }; -// use client::{Collaborator, ParticipantIndex, UserId}; -// use collections::HashMap; -// use editor::{Anchor, Editor, ToOffset}; -// use futures::future; -// use gpui::{BackgroundExecutor, Model, TestAppContext, ViewContext}; -// use rpc::{proto::PeerId, RECEIVE_TIMEOUT}; - -// #[gpui::test] -// async fn test_core_channel_buffers( -// executor: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; - -// let channel_id = server -// .make_channel("zed", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) -// .await; - -// // Client A joins the channel buffer -// let channel_buffer_a = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); - -// // Client A edits the buffer -// let buffer_a = channel_buffer_a.read_with(cx_a, |buffer, _| buffer.buffer()); -// buffer_a.update(cx_a, |buffer, cx| { -// buffer.edit([(0..0, "hello world")], None, cx) -// }); -// buffer_a.update(cx_a, |buffer, cx| { -// buffer.edit([(5..5, ", cruel")], None, cx) -// }); -// buffer_a.update(cx_a, |buffer, cx| { -// buffer.edit([(0..5, "goodbye")], None, cx) -// }); -// buffer_a.update(cx_a, |buffer, cx| buffer.undo(cx)); -// assert_eq!(buffer_text(&buffer_a, cx_a), "hello, cruel world"); -// executor.run_until_parked(); - -// // Client B joins the channel buffer -// let channel_buffer_b = client_b -// .channel_store() -// .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); -// channel_buffer_b.read_with(cx_b, |buffer, _| { -// assert_collaborators( -// buffer.collaborators(), -// &[client_a.user_id(), client_b.user_id()], -// ); -// }); - -// // Client B sees the correct text, and then edits it -// let buffer_b = channel_buffer_b.read_with(cx_b, |buffer, _| buffer.buffer()); -// assert_eq!( -// buffer_b.read_with(cx_b, |buffer, _| buffer.remote_id()), -// buffer_a.read_with(cx_a, |buffer, _| buffer.remote_id()) -// ); -// assert_eq!(buffer_text(&buffer_b, cx_b), "hello, cruel world"); -// buffer_b.update(cx_b, |buffer, cx| { -// buffer.edit([(7..12, "beautiful")], None, cx) -// }); - -// // Both A and B see the new edit -// executor.run_until_parked(); -// assert_eq!(buffer_text(&buffer_a, cx_a), "hello, beautiful world"); -// assert_eq!(buffer_text(&buffer_b, cx_b), "hello, beautiful world"); - -// // Client A closes the channel buffer. -// cx_a.update(|_| drop(channel_buffer_a)); -// executor.run_until_parked(); - -// // Client B sees that client A is gone from the channel buffer. -// channel_buffer_b.read_with(cx_b, |buffer, _| { -// assert_collaborators(&buffer.collaborators(), &[client_b.user_id()]); -// }); - -// // Client A rejoins the channel buffer -// let _channel_buffer_a = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); -// executor.run_until_parked(); - -// // Sanity test, make sure we saw A rejoining -// channel_buffer_b.read_with(cx_b, |buffer, _| { -// assert_collaborators( -// &buffer.collaborators(), -// &[client_a.user_id(), client_b.user_id()], -// ); -// }); - -// // Client A loses connection. -// server.forbid_connections(); -// server.disconnect_client(client_a.peer_id().unwrap()); -// executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); - -// // Client B observes A disconnect -// channel_buffer_b.read_with(cx_b, |buffer, _| { -// assert_collaborators(&buffer.collaborators(), &[client_b.user_id()]); -// }); - -// // TODO: -// // - Test synchronizing offline updates, what happens to A's channel buffer when A disconnects -// // - Test interaction with channel deletion while buffer is open -// } - -// // todo!("collab_ui") -// // #[gpui::test] -// // async fn test_channel_notes_participant_indices( -// // executor: BackgroundExecutor, -// // mut cx_a: &mut TestAppContext, -// // mut cx_b: &mut TestAppContext, -// // cx_c: &mut TestAppContext, -// // ) { -// // let mut server = TestServer::start(&executor).await; -// // let client_a = server.create_client(cx_a, "user_a").await; -// // let client_b = server.create_client(cx_b, "user_b").await; -// // let client_c = server.create_client(cx_c, "user_c").await; - -// // let active_call_a = cx_a.read(ActiveCall::global); -// // let active_call_b = cx_b.read(ActiveCall::global); - -// // cx_a.update(editor::init); -// // cx_b.update(editor::init); -// // cx_c.update(editor::init); - -// // let channel_id = server -// // .make_channel( -// // "the-channel", -// // None, -// // (&client_a, cx_a), -// // &mut [(&client_b, cx_b), (&client_c, cx_c)], -// // ) -// // .await; - -// // client_a -// // .fs() -// // .insert_tree("/root", json!({"file.txt": "123"})) -// // .await; -// // let (project_a, worktree_id_a) = client_a.build_local_project("/root", cx_a).await; -// // let project_b = client_b.build_empty_local_project(cx_b); -// // let project_c = client_c.build_empty_local_project(cx_c); -// // let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); -// // let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); -// // let workspace_c = client_c.build_workspace(&project_c, cx_c).root(cx_c); - -// // // Clients A, B, and C open the channel notes -// // let channel_view_a = cx_a -// // .update(|cx| ChannelView::open(channel_id, workspace_a.clone(), cx)) -// // .await -// // .unwrap(); -// // let channel_view_b = cx_b -// // .update(|cx| ChannelView::open(channel_id, workspace_b.clone(), cx)) -// // .await -// // .unwrap(); -// // let channel_view_c = cx_c -// // .update(|cx| ChannelView::open(channel_id, workspace_c.clone(), cx)) -// // .await -// // .unwrap(); - -// // // Clients A, B, and C all insert and select some text -// // channel_view_a.update(cx_a, |notes, cx| { -// // notes.editor.update(cx, |editor, cx| { -// // editor.insert("a", cx); -// // editor.change_selections(None, cx, |selections| { -// // selections.select_ranges(vec![0..1]); -// // }); -// // }); -// // }); -// // executor.run_until_parked(); -// // channel_view_b.update(cx_b, |notes, cx| { -// // notes.editor.update(cx, |editor, cx| { -// // editor.move_down(&Default::default(), cx); -// // editor.insert("b", cx); -// // editor.change_selections(None, cx, |selections| { -// // selections.select_ranges(vec![1..2]); -// // }); -// // }); -// // }); -// // executor.run_until_parked(); -// // channel_view_c.update(cx_c, |notes, cx| { -// // notes.editor.update(cx, |editor, cx| { -// // editor.move_down(&Default::default(), cx); -// // editor.insert("c", cx); -// // editor.change_selections(None, cx, |selections| { -// // selections.select_ranges(vec![2..3]); -// // }); -// // }); -// // }); - -// // // Client A sees clients B and C without assigned colors, because they aren't -// // // in a call together. -// // executor.run_until_parked(); -// // channel_view_a.update(cx_a, |notes, cx| { -// // notes.editor.update(cx, |editor, cx| { -// // assert_remote_selections(editor, &[(None, 1..2), (None, 2..3)], cx); -// // }); -// // }); - -// // // Clients A and B join the same call. -// // for (call, cx) in [(&active_call_a, &mut cx_a), (&active_call_b, &mut cx_b)] { -// // call.update(*cx, |call, cx| call.join_channel(channel_id, cx)) -// // .await -// // .unwrap(); -// // } - -// // // Clients A and B see each other with two different assigned colors. Client C -// // // still doesn't have a color. -// // executor.run_until_parked(); -// // channel_view_a.update(cx_a, |notes, cx| { -// // notes.editor.update(cx, |editor, cx| { -// // assert_remote_selections( -// // editor, -// // &[(Some(ParticipantIndex(1)), 1..2), (None, 2..3)], -// // cx, -// // ); -// // }); -// // }); -// // channel_view_b.update(cx_b, |notes, cx| { -// // notes.editor.update(cx, |editor, cx| { -// // assert_remote_selections( -// // editor, -// // &[(Some(ParticipantIndex(0)), 0..1), (None, 2..3)], -// // cx, -// // ); -// // }); -// // }); - -// // // Client A shares a project, and client B joins. -// // let project_id = active_call_a -// // .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) -// // .await -// // .unwrap(); -// // let project_b = client_b.build_remote_project(project_id, cx_b).await; -// // let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); - -// // // Clients A and B open the same file. -// // let editor_a = workspace_a -// // .update(cx_a, |workspace, cx| { -// // workspace.open_path((worktree_id_a, "file.txt"), None, true, cx) -// // }) -// // .await -// // .unwrap() -// // .downcast::() -// // .unwrap(); -// // let editor_b = workspace_b -// // .update(cx_b, |workspace, cx| { -// // workspace.open_path((worktree_id_a, "file.txt"), None, true, cx) -// // }) -// // .await -// // .unwrap() -// // .downcast::() -// // .unwrap(); - -// // editor_a.update(cx_a, |editor, cx| { -// // editor.change_selections(None, cx, |selections| { -// // selections.select_ranges(vec![0..1]); -// // }); -// // }); -// // editor_b.update(cx_b, |editor, cx| { -// // editor.change_selections(None, cx, |selections| { -// // selections.select_ranges(vec![2..3]); -// // }); -// // }); -// // executor.run_until_parked(); - -// // // Clients A and B see each other with the same colors as in the channel notes. -// // editor_a.update(cx_a, |editor, cx| { -// // assert_remote_selections(editor, &[(Some(ParticipantIndex(1)), 2..3)], cx); -// // }); -// // editor_b.update(cx_b, |editor, cx| { -// // assert_remote_selections(editor, &[(Some(ParticipantIndex(0)), 0..1)], cx); -// // }); -// // } - -// #[track_caller] -// fn assert_remote_selections( -// editor: &mut Editor, -// expected_selections: &[(Option, Range)], -// cx: &mut ViewContext, -// ) { -// let snapshot = editor.snapshot(cx); -// let range = Anchor::min()..Anchor::max(); -// let remote_selections = snapshot -// .remote_selections_in_range(&range, editor.collaboration_hub().unwrap(), cx) -// .map(|s| { -// let start = s.selection.start.to_offset(&snapshot.buffer_snapshot); -// let end = s.selection.end.to_offset(&snapshot.buffer_snapshot); -// (s.participant_index, start..end) -// }) -// .collect::>(); -// assert_eq!( -// remote_selections, expected_selections, -// "incorrect remote selections" -// ); -// } - -// #[gpui::test] -// async fn test_multiple_handles_to_channel_buffer( -// deterministic: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(deterministic.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; - -// let channel_id = server -// .make_channel("the-channel", None, (&client_a, cx_a), &mut []) -// .await; - -// let channel_buffer_1 = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)); -// let channel_buffer_2 = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)); -// let channel_buffer_3 = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)); - -// // All concurrent tasks for opening a channel buffer return the same model handle. -// let (channel_buffer, channel_buffer_2, channel_buffer_3) = -// future::try_join3(channel_buffer_1, channel_buffer_2, channel_buffer_3) -// .await -// .unwrap(); -// let channel_buffer_model_id = channel_buffer.entity_id(); -// assert_eq!(channel_buffer, channel_buffer_2); -// assert_eq!(channel_buffer, channel_buffer_3); - -// channel_buffer.update(cx_a, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, cx| { -// buffer.edit([(0..0, "hello")], None, cx); -// }) -// }); -// deterministic.run_until_parked(); - -// cx_a.update(|_| { -// drop(channel_buffer); -// drop(channel_buffer_2); -// drop(channel_buffer_3); -// }); -// deterministic.run_until_parked(); - -// // The channel buffer can be reopened after dropping it. -// let channel_buffer = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); -// assert_ne!(channel_buffer.entity_id(), channel_buffer_model_id); -// channel_buffer.update(cx_a, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, _| { -// assert_eq!(buffer.text(), "hello"); -// }) -// }); -// } - -// #[gpui::test] -// async fn test_channel_buffer_disconnect( -// deterministic: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(deterministic.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; - -// let channel_id = server -// .make_channel( -// "the-channel", -// None, -// (&client_a, cx_a), -// &mut [(&client_b, cx_b)], -// ) -// .await; - -// let channel_buffer_a = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); - -// let channel_buffer_b = client_b -// .channel_store() -// .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); - -// server.forbid_connections(); -// server.disconnect_client(client_a.peer_id().unwrap()); -// deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); - -// channel_buffer_a.update(cx_a, |buffer, cx| { -// assert_eq!(buffer.channel(cx).unwrap().name, "the-channel"); -// assert!(!buffer.is_connected()); -// }); - -// deterministic.run_until_parked(); - -// server.allow_connections(); -// deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); - -// deterministic.run_until_parked(); - -// client_a -// .channel_store() -// .update(cx_a, |channel_store, _| { -// channel_store.remove_channel(channel_id) -// }) -// .await -// .unwrap(); -// deterministic.run_until_parked(); - -// // Channel buffer observed the deletion -// channel_buffer_b.update(cx_b, |buffer, cx| { -// assert!(buffer.channel(cx).is_none()); -// assert!(!buffer.is_connected()); -// }); -// } - -// #[gpui::test] -// async fn test_rejoin_channel_buffer( -// deterministic: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(deterministic.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; - -// let channel_id = server -// .make_channel( -// "the-channel", -// None, -// (&client_a, cx_a), -// &mut [(&client_b, cx_b)], -// ) -// .await; - -// let channel_buffer_a = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); -// let channel_buffer_b = client_b -// .channel_store() -// .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); - -// channel_buffer_a.update(cx_a, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, cx| { -// buffer.edit([(0..0, "1")], None, cx); -// }) -// }); -// deterministic.run_until_parked(); - -// // Client A disconnects. -// server.forbid_connections(); -// server.disconnect_client(client_a.peer_id().unwrap()); - -// // Both clients make an edit. -// channel_buffer_a.update(cx_a, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, cx| { -// buffer.edit([(1..1, "2")], None, cx); -// }) -// }); -// channel_buffer_b.update(cx_b, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, cx| { -// buffer.edit([(0..0, "0")], None, cx); -// }) -// }); - -// // Both clients see their own edit. -// deterministic.run_until_parked(); -// channel_buffer_a.read_with(cx_a, |buffer, cx| { -// assert_eq!(buffer.buffer().read(cx).text(), "12"); -// }); -// channel_buffer_b.read_with(cx_b, |buffer, cx| { -// assert_eq!(buffer.buffer().read(cx).text(), "01"); -// }); - -// // Client A reconnects. Both clients see each other's edits, and see -// // the same collaborators. -// server.allow_connections(); -// deterministic.advance_clock(RECEIVE_TIMEOUT); -// channel_buffer_a.read_with(cx_a, |buffer, cx| { -// assert_eq!(buffer.buffer().read(cx).text(), "012"); -// }); -// channel_buffer_b.read_with(cx_b, |buffer, cx| { -// assert_eq!(buffer.buffer().read(cx).text(), "012"); -// }); - -// channel_buffer_a.read_with(cx_a, |buffer_a, _| { -// channel_buffer_b.read_with(cx_b, |buffer_b, _| { -// assert_eq!(buffer_a.collaborators(), buffer_b.collaborators()); -// }); -// }); -// } - -// #[gpui::test] -// async fn test_channel_buffers_and_server_restarts( -// deterministic: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// cx_c: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(deterministic.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// let client_c = server.create_client(cx_c, "user_c").await; - -// let channel_id = server -// .make_channel( -// "the-channel", -// None, -// (&client_a, cx_a), -// &mut [(&client_b, cx_b), (&client_c, cx_c)], -// ) -// .await; - -// let channel_buffer_a = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); -// let channel_buffer_b = client_b -// .channel_store() -// .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); -// let _channel_buffer_c = client_c -// .channel_store() -// .update(cx_c, |store, cx| store.open_channel_buffer(channel_id, cx)) -// .await -// .unwrap(); - -// channel_buffer_a.update(cx_a, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, cx| { -// buffer.edit([(0..0, "1")], None, cx); -// }) -// }); -// deterministic.run_until_parked(); - -// // Client C can't reconnect. -// client_c.override_establish_connection(|_, cx| cx.spawn(|_| future::pending())); - -// // Server stops. -// server.reset().await; -// deterministic.advance_clock(RECEIVE_TIMEOUT); - -// // While the server is down, both clients make an edit. -// channel_buffer_a.update(cx_a, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, cx| { -// buffer.edit([(1..1, "2")], None, cx); -// }) -// }); -// channel_buffer_b.update(cx_b, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, cx| { -// buffer.edit([(0..0, "0")], None, cx); -// }) -// }); - -// // Server restarts. -// server.start().await.unwrap(); -// deterministic.advance_clock(CLEANUP_TIMEOUT); - -// // Clients reconnects. Clients A and B see each other's edits, and see -// // that client C has disconnected. -// channel_buffer_a.read_with(cx_a, |buffer, cx| { -// assert_eq!(buffer.buffer().read(cx).text(), "012"); -// }); -// channel_buffer_b.read_with(cx_b, |buffer, cx| { -// assert_eq!(buffer.buffer().read(cx).text(), "012"); -// }); - -// channel_buffer_a.read_with(cx_a, |buffer_a, _| { -// channel_buffer_b.read_with(cx_b, |buffer_b, _| { -// assert_collaborators( -// buffer_a.collaborators(), -// &[client_a.user_id(), client_b.user_id()], -// ); -// assert_eq!(buffer_a.collaborators(), buffer_b.collaborators()); -// }); -// }); -// } - -// //todo!(collab_ui) -// // #[gpui::test(iterations = 10)] -// // async fn test_following_to_channel_notes_without_a_shared_project( -// // deterministic: BackgroundExecutor, -// // mut cx_a: &mut TestAppContext, -// // mut cx_b: &mut TestAppContext, -// // mut cx_c: &mut TestAppContext, -// // ) { -// // let mut server = TestServer::start(&deterministic).await; -// // let client_a = server.create_client(cx_a, "user_a").await; -// // let client_b = server.create_client(cx_b, "user_b").await; - -// // let client_c = server.create_client(cx_c, "user_c").await; - -// // cx_a.update(editor::init); -// // cx_b.update(editor::init); -// // cx_c.update(editor::init); -// // cx_a.update(collab_ui::channel_view::init); -// // cx_b.update(collab_ui::channel_view::init); -// // cx_c.update(collab_ui::channel_view::init); - -// // let channel_1_id = server -// // .make_channel( -// // "channel-1", -// // None, -// // (&client_a, cx_a), -// // &mut [(&client_b, cx_b), (&client_c, cx_c)], -// // ) -// // .await; -// // let channel_2_id = server -// // .make_channel( -// // "channel-2", -// // None, -// // (&client_a, cx_a), -// // &mut [(&client_b, cx_b), (&client_c, cx_c)], -// // ) -// // .await; - -// // // Clients A, B, and C join a channel. -// // let active_call_a = cx_a.read(ActiveCall::global); -// // let active_call_b = cx_b.read(ActiveCall::global); -// // let active_call_c = cx_c.read(ActiveCall::global); -// // for (call, cx) in [ -// // (&active_call_a, &mut cx_a), -// // (&active_call_b, &mut cx_b), -// // (&active_call_c, &mut cx_c), -// // ] { -// // call.update(*cx, |call, cx| call.join_channel(channel_1_id, cx)) -// // .await -// // .unwrap(); -// // } -// // deterministic.run_until_parked(); - -// // // Clients A, B, and C all open their own unshared projects. -// // client_a.fs().insert_tree("/a", json!({})).await; -// // client_b.fs().insert_tree("/b", json!({})).await; -// // client_c.fs().insert_tree("/c", json!({})).await; -// // let (project_a, _) = client_a.build_local_project("/a", cx_a).await; -// // let (project_b, _) = client_b.build_local_project("/b", cx_b).await; -// // let (project_c, _) = client_b.build_local_project("/c", cx_c).await; -// // let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); -// // let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); -// // let _workspace_c = client_c.build_workspace(&project_c, cx_c).root(cx_c); - -// // active_call_a -// // .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) -// // .await -// // .unwrap(); - -// // // Client A opens the notes for channel 1. -// // let channel_view_1_a = cx_a -// // .update(|cx| ChannelView::open(channel_1_id, workspace_a.clone(), cx)) -// // .await -// // .unwrap(); -// // channel_view_1_a.update(cx_a, |notes, cx| { -// // assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); -// // notes.editor.update(cx, |editor, cx| { -// // editor.insert("Hello from A.", cx); -// // editor.change_selections(None, cx, |selections| { -// // selections.select_ranges(vec![3..4]); -// // }); -// // }); -// // }); - -// // // Client B follows client A. -// // workspace_b -// // .update(cx_b, |workspace, cx| { -// // workspace.follow(client_a.peer_id().unwrap(), cx).unwrap() -// // }) -// // .await -// // .unwrap(); - -// // // Client B is taken to the notes for channel 1, with the same -// // // text selected as client A. -// // deterministic.run_until_parked(); -// // let channel_view_1_b = workspace_b.read_with(cx_b, |workspace, cx| { -// // assert_eq!( -// // workspace.leader_for_pane(workspace.active_pane()), -// // Some(client_a.peer_id().unwrap()) -// // ); -// // workspace -// // .active_item(cx) -// // .expect("no active item") -// // .downcast::() -// // .expect("active item is not a channel view") -// // }); -// // channel_view_1_b.read_with(cx_b, |notes, cx| { -// // assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); -// // let editor = notes.editor.read(cx); -// // assert_eq!(editor.text(cx), "Hello from A."); -// // assert_eq!(editor.selections.ranges::(cx), &[3..4]); -// // }); - -// // // Client A opens the notes for channel 2. -// // let channel_view_2_a = cx_a -// // .update(|cx| ChannelView::open(channel_2_id, workspace_a.clone(), cx)) -// // .await -// // .unwrap(); -// // channel_view_2_a.read_with(cx_a, |notes, cx| { -// // assert_eq!(notes.channel(cx).unwrap().name, "channel-2"); -// // }); - -// // // Client B is taken to the notes for channel 2. -// // deterministic.run_until_parked(); -// // let channel_view_2_b = workspace_b.read_with(cx_b, |workspace, cx| { -// // assert_eq!( -// // workspace.leader_for_pane(workspace.active_pane()), -// // Some(client_a.peer_id().unwrap()) -// // ); -// // workspace -// // .active_item(cx) -// // .expect("no active item") -// // .downcast::() -// // .expect("active item is not a channel view") -// // }); -// // channel_view_2_b.read_with(cx_b, |notes, cx| { -// // assert_eq!(notes.channel(cx).unwrap().name, "channel-2"); -// // }); -// // } - -// //todo!(collab_ui) -// // #[gpui::test] -// // async fn test_channel_buffer_changes( -// // deterministic: BackgroundExecutor, -// // cx_a: &mut TestAppContext, -// // cx_b: &mut TestAppContext, -// // ) { -// // let mut server = TestServer::start(&deterministic).await; -// // let client_a = server.create_client(cx_a, "user_a").await; -// // let client_b = server.create_client(cx_b, "user_b").await; - -// // let channel_id = server -// // .make_channel( -// // "the-channel", -// // None, -// // (&client_a, cx_a), -// // &mut [(&client_b, cx_b)], -// // ) -// // .await; - -// // let channel_buffer_a = client_a -// // .channel_store() -// // .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) -// // .await -// // .unwrap(); - -// // // Client A makes an edit, and client B should see that the note has changed. -// // channel_buffer_a.update(cx_a, |buffer, cx| { -// // buffer.buffer().update(cx, |buffer, cx| { -// // buffer.edit([(0..0, "1")], None, cx); -// // }) -// // }); -// // deterministic.run_until_parked(); - -// // let has_buffer_changed = cx_b.update(|cx| { -// // client_b -// // .channel_store() -// // .read(cx) -// // .has_channel_buffer_changed(channel_id) -// // .unwrap() -// // }); -// // assert!(has_buffer_changed); - -// // // Opening the buffer should clear the changed flag. -// // let project_b = client_b.build_empty_local_project(cx_b); -// // let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); -// // let channel_view_b = cx_b -// // .update(|cx| ChannelView::open(channel_id, workspace_b.clone(), cx)) -// // .await -// // .unwrap(); -// // deterministic.run_until_parked(); - -// // let has_buffer_changed = cx_b.update(|cx| { -// // client_b -// // .channel_store() -// // .read(cx) -// // .has_channel_buffer_changed(channel_id) -// // .unwrap() -// // }); -// // assert!(!has_buffer_changed); - -// // // Editing the channel while the buffer is open should not show that the buffer has changed. -// // channel_buffer_a.update(cx_a, |buffer, cx| { -// // buffer.buffer().update(cx, |buffer, cx| { -// // buffer.edit([(0..0, "2")], None, cx); -// // }) -// // }); -// // deterministic.run_until_parked(); - -// // let has_buffer_changed = cx_b.read(|cx| { -// // client_b -// // .channel_store() -// // .read(cx) -// // .has_channel_buffer_changed(channel_id) -// // .unwrap() -// // }); -// // assert!(!has_buffer_changed); - -// // deterministic.advance_clock(ACKNOWLEDGE_DEBOUNCE_INTERVAL); - -// // // Test that the server is tracking things correctly, and we retain our 'not changed' -// // // state across a disconnect -// // server.simulate_long_connection_interruption(client_b.peer_id().unwrap(), &deterministic); -// // let has_buffer_changed = cx_b.read(|cx| { -// // client_b -// // .channel_store() -// // .read(cx) -// // .has_channel_buffer_changed(channel_id) -// // .unwrap() -// // }); -// // assert!(!has_buffer_changed); - -// // // Closing the buffer should re-enable change tracking -// // cx_b.update(|cx| { -// // workspace_b.update(cx, |workspace, cx| { -// // workspace.close_all_items_and_panes(&Default::default(), cx) -// // }); - -// // drop(channel_view_b) -// // }); - -// // deterministic.run_until_parked(); - -// // channel_buffer_a.update(cx_a, |buffer, cx| { -// // buffer.buffer().update(cx, |buffer, cx| { -// // buffer.edit([(0..0, "3")], None, cx); -// // }) -// // }); -// // deterministic.run_until_parked(); - -// // let has_buffer_changed = cx_b.read(|cx| { -// // client_b -// // .channel_store() -// // .read(cx) -// // .has_channel_buffer_changed(channel_id) -// // .unwrap() -// // }); -// // assert!(has_buffer_changed); -// // } - -// #[track_caller] -// fn assert_collaborators(collaborators: &HashMap, ids: &[Option]) { -// let mut user_ids = collaborators -// .values() -// .map(|collaborator| collaborator.user_id) -// .collect::>(); -// user_ids.sort(); -// assert_eq!( -// user_ids, -// ids.into_iter().map(|id| id.unwrap()).collect::>() -// ); -// } - -// fn buffer_text(channel_buffer: &Model, cx: &mut TestAppContext) -> String { -// channel_buffer.read_with(cx, |buffer, _| buffer.text()) -// } +use crate::{ + rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, + tests::TestServer, +}; +use call::ActiveCall; +use channel::ACKNOWLEDGE_DEBOUNCE_INTERVAL; +use client::{Collaborator, ParticipantIndex, UserId}; +use collab_ui::channel_view::ChannelView; +use collections::HashMap; +use editor::{Anchor, Editor, ToOffset}; +use futures::future; +use gpui::{BackgroundExecutor, Model, TestAppContext, ViewContext}; +use rpc::{proto::PeerId, RECEIVE_TIMEOUT}; +use serde_json::json; +use std::ops::Range; + +#[gpui::test] +async fn test_core_channel_buffers( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel("zed", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + // Client A joins the channel buffer + let channel_buffer_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + + // Client A edits the buffer + let buffer_a = channel_buffer_a.read_with(cx_a, |buffer, _| buffer.buffer()); + buffer_a.update(cx_a, |buffer, cx| { + buffer.edit([(0..0, "hello world")], None, cx) + }); + buffer_a.update(cx_a, |buffer, cx| { + buffer.edit([(5..5, ", cruel")], None, cx) + }); + buffer_a.update(cx_a, |buffer, cx| { + buffer.edit([(0..5, "goodbye")], None, cx) + }); + buffer_a.update(cx_a, |buffer, cx| buffer.undo(cx)); + assert_eq!(buffer_text(&buffer_a, cx_a), "hello, cruel world"); + executor.run_until_parked(); + + // Client B joins the channel buffer + let channel_buffer_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + channel_buffer_b.read_with(cx_b, |buffer, _| { + assert_collaborators( + buffer.collaborators(), + &[client_a.user_id(), client_b.user_id()], + ); + }); + + // Client B sees the correct text, and then edits it + let buffer_b = channel_buffer_b.read_with(cx_b, |buffer, _| buffer.buffer()); + assert_eq!( + buffer_b.read_with(cx_b, |buffer, _| buffer.remote_id()), + buffer_a.read_with(cx_a, |buffer, _| buffer.remote_id()) + ); + assert_eq!(buffer_text(&buffer_b, cx_b), "hello, cruel world"); + buffer_b.update(cx_b, |buffer, cx| { + buffer.edit([(7..12, "beautiful")], None, cx) + }); + + // Both A and B see the new edit + executor.run_until_parked(); + assert_eq!(buffer_text(&buffer_a, cx_a), "hello, beautiful world"); + assert_eq!(buffer_text(&buffer_b, cx_b), "hello, beautiful world"); + + // Client A closes the channel buffer. + cx_a.update(|_| drop(channel_buffer_a)); + executor.run_until_parked(); + + // Client B sees that client A is gone from the channel buffer. + channel_buffer_b.read_with(cx_b, |buffer, _| { + assert_collaborators(&buffer.collaborators(), &[client_b.user_id()]); + }); + + // Client A rejoins the channel buffer + let _channel_buffer_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + executor.run_until_parked(); + + // Sanity test, make sure we saw A rejoining + channel_buffer_b.read_with(cx_b, |buffer, _| { + assert_collaborators( + &buffer.collaborators(), + &[client_a.user_id(), client_b.user_id()], + ); + }); + + // Client A loses connection. + server.forbid_connections(); + server.disconnect_client(client_a.peer_id().unwrap()); + executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); + + // Client B observes A disconnect + channel_buffer_b.read_with(cx_b, |buffer, _| { + assert_collaborators(&buffer.collaborators(), &[client_b.user_id()]); + }); + + // TODO: + // - Test synchronizing offline updates, what happens to A's channel buffer when A disconnects + // - Test interaction with channel deletion while buffer is open +} + +#[gpui::test] +async fn test_channel_notes_participant_indices( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + cx_c.update(editor::init); + + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; + + client_a + .fs() + .insert_tree("/root", json!({"file.txt": "123"})) + .await; + let (project_a, worktree_id_a) = client_a.build_local_project("/root", cx_a).await; + let project_b = client_b.build_empty_local_project(cx_b); + let project_c = client_c.build_empty_local_project(cx_c); + + let (workspace_a, mut cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, mut cx_b) = client_b.build_workspace(&project_b, cx_b); + let (workspace_c, cx_c) = client_c.build_workspace(&project_c, cx_c); + + // Clients A, B, and C open the channel notes + let channel_view_a = cx_a + .update(|cx| ChannelView::open(channel_id, workspace_a.clone(), cx)) + .await + .unwrap(); + let channel_view_b = cx_b + .update(|cx| ChannelView::open(channel_id, workspace_b.clone(), cx)) + .await + .unwrap(); + let channel_view_c = cx_c + .update(|cx| ChannelView::open(channel_id, workspace_c.clone(), cx)) + .await + .unwrap(); + + // Clients A, B, and C all insert and select some text + channel_view_a.update(cx_a, |notes, cx| { + notes.editor.update(cx, |editor, cx| { + editor.insert("a", cx); + editor.change_selections(None, cx, |selections| { + selections.select_ranges(vec![0..1]); + }); + }); + }); + executor.run_until_parked(); + channel_view_b.update(cx_b, |notes, cx| { + notes.editor.update(cx, |editor, cx| { + editor.move_down(&Default::default(), cx); + editor.insert("b", cx); + editor.change_selections(None, cx, |selections| { + selections.select_ranges(vec![1..2]); + }); + }); + }); + executor.run_until_parked(); + channel_view_c.update(cx_c, |notes, cx| { + notes.editor.update(cx, |editor, cx| { + editor.move_down(&Default::default(), cx); + editor.insert("c", cx); + editor.change_selections(None, cx, |selections| { + selections.select_ranges(vec![2..3]); + }); + }); + }); + + // Client A sees clients B and C without assigned colors, because they aren't + // in a call together. + executor.run_until_parked(); + channel_view_a.update(cx_a, |notes, cx| { + notes.editor.update(cx, |editor, cx| { + assert_remote_selections(editor, &[(None, 1..2), (None, 2..3)], cx); + }); + }); + + // Clients A and B join the same call. + for (call, cx) in [(&active_call_a, &mut cx_a), (&active_call_b, &mut cx_b)] { + call.update(*cx, |call, cx| call.join_channel(channel_id, cx)) + .await + .unwrap(); + } + + // Clients A and B see each other with two different assigned colors. Client C + // still doesn't have a color. + executor.run_until_parked(); + channel_view_a.update(cx_a, |notes, cx| { + notes.editor.update(cx, |editor, cx| { + assert_remote_selections( + editor, + &[(Some(ParticipantIndex(1)), 1..2), (None, 2..3)], + cx, + ); + }); + }); + channel_view_b.update(cx_b, |notes, cx| { + notes.editor.update(cx, |editor, cx| { + assert_remote_selections( + editor, + &[(Some(ParticipantIndex(0)), 0..1), (None, 2..3)], + cx, + ); + }); + }); + + // Client A shares a project, and client B joins. + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + // Clients A and B open the same file. + let editor_a = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id_a, "file.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let editor_b = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id_a, "file.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + editor_a.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |selections| { + selections.select_ranges(vec![0..1]); + }); + }); + editor_b.update(cx_b, |editor, cx| { + editor.change_selections(None, cx, |selections| { + selections.select_ranges(vec![2..3]); + }); + }); + executor.run_until_parked(); + + // Clients A and B see each other with the same colors as in the channel notes. + editor_a.update(cx_a, |editor, cx| { + assert_remote_selections(editor, &[(Some(ParticipantIndex(1)), 2..3)], cx); + }); + editor_b.update(cx_b, |editor, cx| { + assert_remote_selections(editor, &[(Some(ParticipantIndex(0)), 0..1)], cx); + }); +} + +#[track_caller] +fn assert_remote_selections( + editor: &mut Editor, + expected_selections: &[(Option, Range)], + cx: &mut ViewContext, +) { + let snapshot = editor.snapshot(cx); + let range = Anchor::min()..Anchor::max(); + let remote_selections = snapshot + .remote_selections_in_range(&range, editor.collaboration_hub().unwrap(), cx) + .map(|s| { + let start = s.selection.start.to_offset(&snapshot.buffer_snapshot); + let end = s.selection.end.to_offset(&snapshot.buffer_snapshot); + (s.participant_index, start..end) + }) + .collect::>(); + assert_eq!( + remote_selections, expected_selections, + "incorrect remote selections" + ); +} + +#[gpui::test] +async fn test_multiple_handles_to_channel_buffer( + deterministic: BackgroundExecutor, + cx_a: &mut TestAppContext, +) { + let mut server = TestServer::start(deterministic.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + + let channel_id = server + .make_channel("the-channel", None, (&client_a, cx_a), &mut []) + .await; + + let channel_buffer_1 = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)); + let channel_buffer_2 = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)); + let channel_buffer_3 = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)); + + // All concurrent tasks for opening a channel buffer return the same model handle. + let (channel_buffer, channel_buffer_2, channel_buffer_3) = + future::try_join3(channel_buffer_1, channel_buffer_2, channel_buffer_3) + .await + .unwrap(); + let channel_buffer_model_id = channel_buffer.entity_id(); + assert_eq!(channel_buffer, channel_buffer_2); + assert_eq!(channel_buffer, channel_buffer_3); + + channel_buffer.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "hello")], None, cx); + }) + }); + deterministic.run_until_parked(); + + cx_a.update(|_| { + drop(channel_buffer); + drop(channel_buffer_2); + drop(channel_buffer_3); + }); + deterministic.run_until_parked(); + + // The channel buffer can be reopened after dropping it. + let channel_buffer = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + assert_ne!(channel_buffer.entity_id(), channel_buffer_model_id); + channel_buffer.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, _| { + assert_eq!(buffer.text(), "hello"); + }) + }); +} + +#[gpui::test] +async fn test_channel_buffer_disconnect( + deterministic: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(deterministic.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) + .await; + + let channel_buffer_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + + let channel_buffer_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + + server.forbid_connections(); + server.disconnect_client(client_a.peer_id().unwrap()); + deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); + + channel_buffer_a.update(cx_a, |buffer, cx| { + assert_eq!(buffer.channel(cx).unwrap().name, "the-channel"); + assert!(!buffer.is_connected()); + }); + + deterministic.run_until_parked(); + + server.allow_connections(); + deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); + + deterministic.run_until_parked(); + + client_a + .channel_store() + .update(cx_a, |channel_store, _| { + channel_store.remove_channel(channel_id) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + + // Channel buffer observed the deletion + channel_buffer_b.update(cx_b, |buffer, cx| { + assert!(buffer.channel(cx).is_none()); + assert!(!buffer.is_connected()); + }); +} + +#[gpui::test] +async fn test_rejoin_channel_buffer( + deterministic: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(deterministic.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) + .await; + + let channel_buffer_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + let channel_buffer_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "1")], None, cx); + }) + }); + deterministic.run_until_parked(); + + // Client A disconnects. + server.forbid_connections(); + server.disconnect_client(client_a.peer_id().unwrap()); + + // Both clients make an edit. + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(1..1, "2")], None, cx); + }) + }); + channel_buffer_b.update(cx_b, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "0")], None, cx); + }) + }); + + // Both clients see their own edit. + deterministic.run_until_parked(); + channel_buffer_a.read_with(cx_a, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "12"); + }); + channel_buffer_b.read_with(cx_b, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "01"); + }); + + // Client A reconnects. Both clients see each other's edits, and see + // the same collaborators. + server.allow_connections(); + deterministic.advance_clock(RECEIVE_TIMEOUT); + channel_buffer_a.read_with(cx_a, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "012"); + }); + channel_buffer_b.read_with(cx_b, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "012"); + }); + + channel_buffer_a.read_with(cx_a, |buffer_a, _| { + channel_buffer_b.read_with(cx_b, |buffer_b, _| { + assert_eq!(buffer_a.collaborators(), buffer_b.collaborators()); + }); + }); +} + +#[gpui::test] +async fn test_channel_buffers_and_server_restarts( + deterministic: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(deterministic.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; + + let channel_buffer_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + let channel_buffer_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + let _channel_buffer_c = client_c + .channel_store() + .update(cx_c, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "1")], None, cx); + }) + }); + deterministic.run_until_parked(); + + // Client C can't reconnect. + client_c.override_establish_connection(|_, cx| cx.spawn(|_| future::pending())); + + // Server stops. + server.reset().await; + deterministic.advance_clock(RECEIVE_TIMEOUT); + + // While the server is down, both clients make an edit. + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(1..1, "2")], None, cx); + }) + }); + channel_buffer_b.update(cx_b, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "0")], None, cx); + }) + }); + + // Server restarts. + server.start().await.unwrap(); + deterministic.advance_clock(CLEANUP_TIMEOUT); + + // Clients reconnects. Clients A and B see each other's edits, and see + // that client C has disconnected. + channel_buffer_a.read_with(cx_a, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "012"); + }); + channel_buffer_b.read_with(cx_b, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "012"); + }); + + channel_buffer_a.read_with(cx_a, |buffer_a, _| { + channel_buffer_b.read_with(cx_b, |buffer_b, _| { + assert_collaborators( + buffer_a.collaborators(), + &[client_a.user_id(), client_b.user_id()], + ); + assert_eq!(buffer_a.collaborators(), buffer_b.collaborators()); + }); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_following_to_channel_notes_without_a_shared_project( + deterministic: BackgroundExecutor, + mut cx_a: &mut TestAppContext, + mut cx_b: &mut TestAppContext, + mut cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(deterministic.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let client_c = server.create_client(cx_c, "user_c").await; + + cx_a.update(editor::init); + cx_b.update(editor::init); + cx_c.update(editor::init); + cx_a.update(collab_ui::channel_view::init); + cx_b.update(collab_ui::channel_view::init); + cx_c.update(collab_ui::channel_view::init); + + let channel_1_id = server + .make_channel( + "channel-1", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; + let channel_2_id = server + .make_channel( + "channel-2", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; + + // Clients A, B, and C join a channel. + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let active_call_c = cx_c.read(ActiveCall::global); + for (call, cx) in [ + (&active_call_a, &mut cx_a), + (&active_call_b, &mut cx_b), + (&active_call_c, &mut cx_c), + ] { + call.update(*cx, |call, cx| call.join_channel(channel_1_id, cx)) + .await + .unwrap(); + } + deterministic.run_until_parked(); + + // Clients A, B, and C all open their own unshared projects. + client_a.fs().insert_tree("/a", json!({})).await; + client_b.fs().insert_tree("/b", json!({})).await; + client_c.fs().insert_tree("/c", json!({})).await; + let (project_a, _) = client_a.build_local_project("/a", cx_a).await; + let (project_b, _) = client_b.build_local_project("/b", cx_b).await; + let (project_c, _) = client_b.build_local_project("/c", cx_c).await; + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let (_workspace_c, _cx_c) = client_c.build_workspace(&project_c, cx_c); + + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + // Client A opens the notes for channel 1. + let channel_view_1_a = cx_a + .update(|cx| ChannelView::open(channel_1_id, workspace_a.clone(), cx)) + .await + .unwrap(); + channel_view_1_a.update(cx_a, |notes, cx| { + assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); + notes.editor.update(cx, |editor, cx| { + editor.insert("Hello from A.", cx); + editor.change_selections(None, cx, |selections| { + selections.select_ranges(vec![3..4]); + }); + }); + }); + + // Client B follows client A. + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(client_a.peer_id().unwrap(), cx).unwrap() + }) + .await + .unwrap(); + + // Client B is taken to the notes for channel 1, with the same + // text selected as client A. + deterministic.run_until_parked(); + let channel_view_1_b = workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(client_a.peer_id().unwrap()) + ); + workspace + .active_item(cx) + .expect("no active item") + .downcast::() + .expect("active item is not a channel view") + }); + channel_view_1_b.update(cx_b, |notes, cx| { + assert_eq!(notes.channel(cx).unwrap().name, "channel-1"); + let editor = notes.editor.read(cx); + assert_eq!(editor.text(cx), "Hello from A."); + assert_eq!(editor.selections.ranges::(cx), &[3..4]); + }); + + // Client A opens the notes for channel 2. + eprintln!("opening -------------------->"); + + let channel_view_2_a = cx_a + .update(|cx| ChannelView::open(channel_2_id, workspace_a.clone(), cx)) + .await + .unwrap(); + channel_view_2_a.update(cx_a, |notes, cx| { + assert_eq!(notes.channel(cx).unwrap().name, "channel-2"); + }); + + // Client B is taken to the notes for channel 2. + deterministic.run_until_parked(); + + eprintln!("opening <--------------------"); + + let channel_view_2_b = workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(client_a.peer_id().unwrap()) + ); + workspace + .active_item(cx) + .expect("no active item") + .downcast::() + .expect("active item is not a channel view") + }); + channel_view_2_b.update(cx_b, |notes, cx| { + assert_eq!(notes.channel(cx).unwrap().name, "channel-2"); + }); +} + +#[gpui::test] +async fn test_channel_buffer_changes( + deterministic: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(deterministic.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) + .await; + + let channel_buffer_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + + // Client A makes an edit, and client B should see that the note has changed. + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "1")], None, cx); + }) + }); + deterministic.run_until_parked(); + + let has_buffer_changed = cx_b.update(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(has_buffer_changed); + + // Opening the buffer should clear the changed flag. + let project_b = client_b.build_empty_local_project(cx_b); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let channel_view_b = cx_b + .update(|cx| ChannelView::open(channel_id, workspace_b.clone(), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + + let has_buffer_changed = cx_b.update(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(!has_buffer_changed); + + // Editing the channel while the buffer is open should not show that the buffer has changed. + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "2")], None, cx); + }) + }); + deterministic.run_until_parked(); + + let has_buffer_changed = cx_b.read(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(!has_buffer_changed); + + deterministic.advance_clock(ACKNOWLEDGE_DEBOUNCE_INTERVAL); + + // Test that the server is tracking things correctly, and we retain our 'not changed' + // state across a disconnect + server + .simulate_long_connection_interruption(client_b.peer_id().unwrap(), deterministic.clone()); + let has_buffer_changed = cx_b.read(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(!has_buffer_changed); + + // Closing the buffer should re-enable change tracking + cx_b.update(|cx| { + workspace_b.update(cx, |workspace, cx| { + workspace.close_all_items_and_panes(&Default::default(), cx) + }); + + drop(channel_view_b) + }); + + deterministic.run_until_parked(); + + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "3")], None, cx); + }) + }); + deterministic.run_until_parked(); + + let has_buffer_changed = cx_b.read(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(has_buffer_changed); +} + +#[track_caller] +fn assert_collaborators(collaborators: &HashMap, ids: &[Option]) { + let mut user_ids = collaborators + .values() + .map(|collaborator| collaborator.user_id) + .collect::>(); + user_ids.sort(); + assert_eq!( + user_ids, + ids.into_iter().map(|id| id.unwrap()).collect::>() + ); +} + +fn buffer_text(channel_buffer: &Model, cx: &mut TestAppContext) -> String { + channel_buffer.read_with(cx, |buffer, _| buffer.text()) +} diff --git a/crates/collab2/src/tests/test_server.rs b/crates/collab2/src/tests/test_server.rs index 6bb57e11ab..f751736971 100644 --- a/crates/collab2/src/tests/test_server.rs +++ b/crates/collab2/src/tests/test_server.rs @@ -13,7 +13,7 @@ use client::{ use collections::{HashMap, HashSet}; use fs::FakeFs; use futures::{channel::oneshot, StreamExt as _}; -use gpui::{BackgroundExecutor, Context, Model, TestAppContext, WindowHandle}; +use gpui::{BackgroundExecutor, Context, Model, TestAppContext, View, VisualTestContext}; use language::LanguageRegistry; use node_runtime::FakeNodeRuntime; @@ -602,14 +602,12 @@ impl TestClient { .unwrap() } - //todo(workspace) - #[allow(dead_code)] - pub fn build_workspace( - &self, + pub fn build_workspace<'a>( + &'a self, project: &Model, - cx: &mut TestAppContext, - ) -> WindowHandle { - cx.add_window(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx)) + cx: &'a mut TestAppContext, + ) -> (View, &'a mut VisualTestContext) { + cx.add_window_view(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx)) } } diff --git a/crates/collab_ui2/Cargo.toml b/crates/collab_ui2/Cargo.toml index c7c00d7696..65aced8e7e 100644 --- a/crates/collab_ui2/Cargo.toml +++ b/crates/collab_ui2/Cargo.toml @@ -41,7 +41,7 @@ notifications = { package = "notifications2", path = "../notifications2" } rich_text = { package = "rich_text2", path = "../rich_text2" } picker = { package = "picker2", path = "../picker2" } project = { package = "project2", path = "../project2" } -# recent_projects = { path = "../recent_projects" } +recent_projects = { package = "recent_projects2", path = "../recent_projects2" } rpc = { package ="rpc2", path = "../rpc2" } settings = { package = "settings2", path = "../settings2" } feature_flags = { package = "feature_flags2", path = "../feature_flags2"} diff --git a/crates/collab_ui2/src/channel_view.rs b/crates/collab_ui2/src/channel_view.rs index d2ffc0de57..8d2c037f9d 100644 --- a/crates/collab_ui2/src/channel_view.rs +++ b/crates/collab_ui2/src/channel_view.rs @@ -1,454 +1,444 @@ -// use anyhow::{anyhow, Result}; -// use call::report_call_event_for_channel; -// use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelId, ChannelStore}; -// use client::{ -// proto::{self, PeerId}, -// Collaborator, ParticipantIndex, -// }; -// use collections::HashMap; -// use editor::{CollaborationHub, Editor}; -// use gpui::{ -// actions, -// elements::{ChildView, Label}, -// geometry::vector::Vector2F, -// AnyElement, AnyViewHandle, AppContext, Element, Entity, ModelHandle, Subscription, Task, View, -// ViewContext, ViewHandle, -// }; -// use project::Project; -// use smallvec::SmallVec; -// use std::{ -// any::{Any, TypeId}, -// sync::Arc, -// }; -// use util::ResultExt; -// use workspace::{ -// item::{FollowableItem, Item, ItemEvent, ItemHandle}, -// register_followable_item, -// searchable::SearchableItemHandle, -// ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId, -// }; +use anyhow::Result; +use call::report_call_event_for_channel; +use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelId, ChannelStore}; +use client::{ + proto::{self, PeerId}, + Collaborator, ParticipantIndex, +}; +use collections::HashMap; +use editor::{CollaborationHub, Editor, EditorEvent}; +use gpui::{ + actions, AnyElement, AnyView, AppContext, Entity as _, EventEmitter, FocusableView, + IntoElement as _, Model, Pixels, Point, Render, Subscription, Task, View, ViewContext, + VisualContext as _, WindowContext, +}; +use project::Project; +use std::{ + any::{Any, TypeId}, + sync::Arc, +}; +use ui::Label; +use util::ResultExt; +use workspace::{ + item::{FollowableItem, Item, ItemEvent, ItemHandle}, + register_followable_item, + searchable::SearchableItemHandle, + ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId, +}; -// actions!(channel_view, [Deploy]); +actions!(Deploy); -// pub fn init(cx: &mut AppContext) { -// register_followable_item::(cx) -// } +pub fn init(cx: &mut AppContext) { + register_followable_item::(cx) +} -// pub struct ChannelView { -// pub editor: ViewHandle, -// project: ModelHandle, -// channel_store: ModelHandle, -// channel_buffer: ModelHandle, -// remote_id: Option, -// _editor_event_subscription: Subscription, -// } +pub struct ChannelView { + pub editor: View, + project: Model, + channel_store: Model, + channel_buffer: Model, + remote_id: Option, + _editor_event_subscription: Subscription, +} -// impl ChannelView { -// pub fn open( -// channel_id: ChannelId, -// workspace: ViewHandle, -// cx: &mut AppContext, -// ) -> Task>> { -// let pane = workspace.read(cx).active_pane().clone(); -// let channel_view = Self::open_in_pane(channel_id, pane.clone(), workspace.clone(), cx); -// cx.spawn(|mut cx| async move { -// let channel_view = channel_view.await?; -// pane.update(&mut cx, |pane, cx| { -// report_call_event_for_channel( -// "open channel notes", -// channel_id, -// &workspace.read(cx).app_state().client, -// cx, -// ); -// pane.add_item(Box::new(channel_view.clone()), true, true, None, cx); -// }); -// anyhow::Ok(channel_view) -// }) -// } +impl ChannelView { + pub fn open( + channel_id: ChannelId, + workspace: View, + cx: &mut WindowContext, + ) -> Task>> { + let pane = workspace.read(cx).active_pane().clone(); + let channel_view = Self::open_in_pane(channel_id, pane.clone(), workspace.clone(), cx); + cx.spawn(|mut cx| async move { + let channel_view = channel_view.await?; + pane.update(&mut cx, |pane, cx| { + report_call_event_for_channel( + "open channel notes", + channel_id, + &workspace.read(cx).app_state().client, + cx, + ); + pane.add_item(Box::new(channel_view.clone()), true, true, None, cx); + })?; + anyhow::Ok(channel_view) + }) + } -// pub fn open_in_pane( -// channel_id: ChannelId, -// pane: ViewHandle, -// workspace: ViewHandle, -// cx: &mut AppContext, -// ) -> Task>> { -// let workspace = workspace.read(cx); -// let project = workspace.project().to_owned(); -// let channel_store = ChannelStore::global(cx); -// let language_registry = workspace.app_state().languages.clone(); -// let markdown = language_registry.language_for_name("Markdown"); -// let channel_buffer = -// channel_store.update(cx, |store, cx| store.open_channel_buffer(channel_id, cx)); + pub fn open_in_pane( + channel_id: ChannelId, + pane: View, + workspace: View, + cx: &mut WindowContext, + ) -> Task>> { + let workspace = workspace.read(cx); + let project = workspace.project().to_owned(); + let channel_store = ChannelStore::global(cx); + let language_registry = workspace.app_state().languages.clone(); + let markdown = language_registry.language_for_name("Markdown"); + let channel_buffer = + channel_store.update(cx, |store, cx| store.open_channel_buffer(channel_id, cx)); -// cx.spawn(|mut cx| async move { -// let channel_buffer = channel_buffer.await?; -// let markdown = markdown.await.log_err(); + cx.spawn(|mut cx| async move { + let channel_buffer = channel_buffer.await?; + let markdown = markdown.await.log_err(); -// channel_buffer.update(&mut cx, |buffer, cx| { -// buffer.buffer().update(cx, |buffer, cx| { -// buffer.set_language_registry(language_registry); -// if let Some(markdown) = markdown { -// buffer.set_language(Some(markdown), cx); -// } -// }) -// }); + channel_buffer.update(&mut cx, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.set_language_registry(language_registry); + if let Some(markdown) = markdown { + buffer.set_language(Some(markdown), cx); + } + }) + })?; -// pane.update(&mut cx, |pane, cx| { -// let buffer_id = channel_buffer.read(cx).remote_id(cx); + pane.update(&mut cx, |pane, cx| { + let buffer_id = channel_buffer.read(cx).remote_id(cx); -// let existing_view = pane -// .items_of_type::() -// .find(|view| view.read(cx).channel_buffer.read(cx).remote_id(cx) == buffer_id); + let existing_view = pane + .items_of_type::() + .find(|view| view.read(cx).channel_buffer.read(cx).remote_id(cx) == buffer_id); -// // If this channel buffer is already open in this pane, just return it. -// if let Some(existing_view) = existing_view.clone() { -// if existing_view.read(cx).channel_buffer == channel_buffer { -// return existing_view; -// } -// } + // If this channel buffer is already open in this pane, just return it. + if let Some(existing_view) = existing_view.clone() { + if existing_view.read(cx).channel_buffer == channel_buffer { + return existing_view; + } + } -// let view = cx.add_view(|cx| { -// let mut this = Self::new(project, channel_store, channel_buffer, cx); -// this.acknowledge_buffer_version(cx); -// this -// }); + let view = cx.build_view(|cx| { + let mut this = Self::new(project, channel_store, channel_buffer, cx); + this.acknowledge_buffer_version(cx); + this + }); -// // If the pane contained a disconnected view for this channel buffer, -// // replace that. -// if let Some(existing_item) = existing_view { -// if let Some(ix) = pane.index_for_item(&existing_item) { -// pane.close_item_by_id(existing_item.id(), SaveIntent::Skip, cx) -// .detach(); -// pane.add_item(Box::new(view.clone()), true, true, Some(ix), cx); -// } -// } + // If the pane contained a disconnected view for this channel buffer, + // replace that. + if let Some(existing_item) = existing_view { + if let Some(ix) = pane.index_for_item(&existing_item) { + pane.close_item_by_id(existing_item.entity_id(), SaveIntent::Skip, cx) + .detach(); + pane.add_item(Box::new(view.clone()), true, true, Some(ix), cx); + } + } -// view -// }) -// .ok_or_else(|| anyhow!("pane was dropped")) -// }) -// } + view + }) + }) + } -// pub fn new( -// project: ModelHandle, -// channel_store: ModelHandle, -// channel_buffer: ModelHandle, -// cx: &mut ViewContext, -// ) -> Self { -// let buffer = channel_buffer.read(cx).buffer(); -// let editor = cx.add_view(|cx| { -// let mut editor = Editor::for_buffer(buffer, None, cx); -// editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub( -// channel_buffer.clone(), -// ))); -// editor.set_read_only( -// !channel_buffer -// .read(cx) -// .channel(cx) -// .is_some_and(|c| c.can_edit_notes()), -// ); -// editor -// }); -// let _editor_event_subscription = cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone())); + pub fn new( + project: Model, + channel_store: Model, + channel_buffer: Model, + cx: &mut ViewContext, + ) -> Self { + let buffer = channel_buffer.read(cx).buffer(); + let editor = cx.build_view(|cx| { + let mut editor = Editor::for_buffer(buffer, None, cx); + editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub( + channel_buffer.clone(), + ))); + editor.set_read_only( + !channel_buffer + .read(cx) + .channel(cx) + .is_some_and(|c| c.can_edit_notes()), + ); + editor + }); + let _editor_event_subscription = + cx.subscribe(&editor, |_, _, e: &EditorEvent, cx| cx.emit(e.clone())); -// cx.subscribe(&channel_buffer, Self::handle_channel_buffer_event) -// .detach(); + cx.subscribe(&channel_buffer, Self::handle_channel_buffer_event) + .detach(); -// Self { -// editor, -// project, -// channel_store, -// channel_buffer, -// remote_id: None, -// _editor_event_subscription, -// } -// } + Self { + editor, + project, + channel_store, + channel_buffer, + remote_id: None, + _editor_event_subscription, + } + } -// pub fn channel(&self, cx: &AppContext) -> Option> { -// self.channel_buffer.read(cx).channel(cx) -// } + pub fn channel(&self, cx: &AppContext) -> Option> { + self.channel_buffer.read(cx).channel(cx) + } -// fn handle_channel_buffer_event( -// &mut self, -// _: ModelHandle, -// event: &ChannelBufferEvent, -// cx: &mut ViewContext, -// ) { -// match event { -// ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| { -// editor.set_read_only(true); -// cx.notify(); -// }), -// ChannelBufferEvent::ChannelChanged => { -// self.editor.update(cx, |editor, cx| { -// editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes())); -// cx.emit(editor::Event::TitleChanged); -// cx.notify() -// }); -// } -// ChannelBufferEvent::BufferEdited => { -// if cx.is_self_focused() || self.editor.is_focused(cx) { -// self.acknowledge_buffer_version(cx); -// } else { -// self.channel_store.update(cx, |store, cx| { -// let channel_buffer = self.channel_buffer.read(cx); -// store.notes_changed( -// channel_buffer.channel_id, -// channel_buffer.epoch(), -// &channel_buffer.buffer().read(cx).version(), -// cx, -// ) -// }); -// } -// } -// ChannelBufferEvent::CollaboratorsChanged => {} -// } -// } + fn handle_channel_buffer_event( + &mut self, + _: Model, + event: &ChannelBufferEvent, + cx: &mut ViewContext, + ) { + match event { + ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| { + editor.set_read_only(true); + cx.notify(); + }), + ChannelBufferEvent::ChannelChanged => { + self.editor.update(cx, |editor, cx| { + editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes())); + cx.emit(editor::EditorEvent::TitleChanged); + cx.notify() + }); + } + ChannelBufferEvent::BufferEdited => { + if self.editor.read(cx).is_focused(cx) { + self.acknowledge_buffer_version(cx); + } else { + self.channel_store.update(cx, |store, cx| { + let channel_buffer = self.channel_buffer.read(cx); + store.notes_changed( + channel_buffer.channel_id, + channel_buffer.epoch(), + &channel_buffer.buffer().read(cx).version(), + cx, + ) + }); + } + } + ChannelBufferEvent::CollaboratorsChanged => {} + } + } -// fn acknowledge_buffer_version(&mut self, cx: &mut ViewContext<'_, '_, ChannelView>) { -// self.channel_store.update(cx, |store, cx| { -// let channel_buffer = self.channel_buffer.read(cx); -// store.acknowledge_notes_version( -// channel_buffer.channel_id, -// channel_buffer.epoch(), -// &channel_buffer.buffer().read(cx).version(), -// cx, -// ) -// }); -// self.channel_buffer.update(cx, |buffer, cx| { -// buffer.acknowledge_buffer_version(cx); -// }); -// } -// } + fn acknowledge_buffer_version(&mut self, cx: &mut ViewContext) { + self.channel_store.update(cx, |store, cx| { + let channel_buffer = self.channel_buffer.read(cx); + store.acknowledge_notes_version( + channel_buffer.channel_id, + channel_buffer.epoch(), + &channel_buffer.buffer().read(cx).version(), + cx, + ) + }); + self.channel_buffer.update(cx, |buffer, cx| { + buffer.acknowledge_buffer_version(cx); + }); + } +} -// impl Entity for ChannelView { -// type Event = editor::Event; -// } +impl EventEmitter for ChannelView {} -// impl View for ChannelView { -// fn ui_name() -> &'static str { -// "ChannelView" -// } +impl Render for ChannelView { + type Element = AnyView; -// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { -// ChildView::new(self.editor.as_any(), cx).into_any() -// } + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + self.editor.clone().into() + } +} -// fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { -// if cx.is_self_focused() { -// self.acknowledge_buffer_version(cx); -// cx.focus(self.editor.as_any()) -// } -// } -// } +impl FocusableView for ChannelView { + fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { + self.editor.read(cx).focus_handle(cx) + } +} -// impl Item for ChannelView { -// fn act_as_type<'a>( -// &'a self, -// type_id: TypeId, -// self_handle: &'a ViewHandle, -// _: &'a AppContext, -// ) -> Option<&'a AnyViewHandle> { -// if type_id == TypeId::of::() { -// Some(self_handle) -// } else if type_id == TypeId::of::() { -// Some(&self.editor) -// } else { -// None -// } -// } +impl Item for ChannelView { + type Event = EditorEvent; -// fn tab_content( -// &self, -// _: Option, -// style: &theme::Tab, -// cx: &gpui::AppContext, -// ) -> AnyElement { -// let label = if let Some(channel) = self.channel(cx) { -// match ( -// channel.can_edit_notes(), -// self.channel_buffer.read(cx).is_connected(), -// ) { -// (true, true) => format!("#{}", channel.name), -// (false, true) => format!("#{} (read-only)", channel.name), -// (_, false) => format!("#{} (disconnected)", channel.name), -// } -// } else { -// format!("channel notes (disconnected)") -// }; -// Label::new(label, style.label.to_owned()).into_any() -// } + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a View, + _: &'a AppContext, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } -// fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext) -> Option { -// Some(Self::new( -// self.project.clone(), -// self.channel_store.clone(), -// self.channel_buffer.clone(), -// cx, -// )) -// } + fn tab_content(&self, _: Option, cx: &WindowContext) -> AnyElement { + let label = if let Some(channel) = self.channel(cx) { + match ( + channel.can_edit_notes(), + self.channel_buffer.read(cx).is_connected(), + ) { + (true, true) => format!("#{}", channel.name), + (false, true) => format!("#{} (read-only)", channel.name), + (_, false) => format!("#{} (disconnected)", channel.name), + } + } else { + format!("channel notes (disconnected)") + }; + Label::new(label).into_any_element() + } -// fn is_singleton(&self, _cx: &AppContext) -> bool { -// false -// } + fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext) -> Option> { + Some(cx.build_view(|cx| { + Self::new( + self.project.clone(), + self.channel_store.clone(), + self.channel_buffer.clone(), + cx, + ) + })) + } -// fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { -// self.editor -// .update(cx, |editor, cx| editor.navigate(data, cx)) -// } + fn is_singleton(&self, _cx: &AppContext) -> bool { + false + } -// fn deactivated(&mut self, cx: &mut ViewContext) { -// self.editor -// .update(cx, |editor, cx| Item::deactivated(editor, cx)) -// } + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } -// fn set_nav_history(&mut self, history: ItemNavHistory, cx: &mut ViewContext) { -// self.editor -// .update(cx, |editor, cx| Item::set_nav_history(editor, history, cx)) -// } + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| Item::deactivated(editor, cx)) + } -// fn as_searchable(&self, _: &ViewHandle) -> Option> { -// Some(Box::new(self.editor.clone())) -// } + fn set_nav_history(&mut self, history: ItemNavHistory, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| Item::set_nav_history(editor, history, cx)) + } -// fn show_toolbar(&self) -> bool { -// true -// } + fn as_searchable(&self, _: &View) -> Option> { + Some(Box::new(self.editor.clone())) + } -// fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option { -// self.editor.read(cx).pixel_position_of_cursor(cx) -// } + fn show_toolbar(&self) -> bool { + true + } -// fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> { -// editor::Editor::to_item_events(event) -// } -// } + fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option> { + self.editor.read(cx).pixel_position_of_cursor(cx) + } -// impl FollowableItem for ChannelView { -// fn remote_id(&self) -> Option { -// self.remote_id -// } + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } +} -// fn to_state_proto(&self, cx: &AppContext) -> Option { -// let channel_buffer = self.channel_buffer.read(cx); -// if !channel_buffer.is_connected() { -// return None; -// } +impl FollowableItem for ChannelView { + fn remote_id(&self) -> Option { + self.remote_id + } -// Some(proto::view::Variant::ChannelView( -// proto::view::ChannelView { -// channel_id: channel_buffer.channel_id, -// editor: if let Some(proto::view::Variant::Editor(proto)) = -// self.editor.read(cx).to_state_proto(cx) -// { -// Some(proto) -// } else { -// None -// }, -// }, -// )) -// } + fn to_state_proto(&self, cx: &WindowContext) -> Option { + let channel_buffer = self.channel_buffer.read(cx); + if !channel_buffer.is_connected() { + return None; + } -// fn from_state_proto( -// pane: ViewHandle, -// workspace: ViewHandle, -// remote_id: workspace::ViewId, -// state: &mut Option, -// cx: &mut AppContext, -// ) -> Option>>> { -// let Some(proto::view::Variant::ChannelView(_)) = state else { -// return None; -// }; -// let Some(proto::view::Variant::ChannelView(state)) = state.take() else { -// unreachable!() -// }; + Some(proto::view::Variant::ChannelView( + proto::view::ChannelView { + channel_id: channel_buffer.channel_id, + editor: if let Some(proto::view::Variant::Editor(proto)) = + self.editor.read(cx).to_state_proto(cx) + { + Some(proto) + } else { + None + }, + }, + )) + } -// let open = ChannelView::open_in_pane(state.channel_id, pane, workspace, cx); + fn from_state_proto( + pane: View, + workspace: View, + remote_id: workspace::ViewId, + state: &mut Option, + cx: &mut WindowContext, + ) -> Option>>> { + let Some(proto::view::Variant::ChannelView(_)) = state else { + return None; + }; + let Some(proto::view::Variant::ChannelView(state)) = state.take() else { + unreachable!() + }; -// Some(cx.spawn(|mut cx| async move { -// let this = open.await?; + let open = ChannelView::open_in_pane(state.channel_id, pane, workspace, cx); -// let task = this -// .update(&mut cx, |this, cx| { -// this.remote_id = Some(remote_id); + Some(cx.spawn(|mut cx| async move { + let this = open.await?; -// if let Some(state) = state.editor { -// Some(this.editor.update(cx, |editor, cx| { -// editor.apply_update_proto( -// &this.project, -// proto::update_view::Variant::Editor(proto::update_view::Editor { -// selections: state.selections, -// pending_selection: state.pending_selection, -// scroll_top_anchor: state.scroll_top_anchor, -// scroll_x: state.scroll_x, -// scroll_y: state.scroll_y, -// ..Default::default() -// }), -// cx, -// ) -// })) -// } else { -// None -// } -// }) -// .ok_or_else(|| anyhow!("window was closed"))?; + let task = this.update(&mut cx, |this, cx| { + this.remote_id = Some(remote_id); -// if let Some(task) = task { -// task.await?; -// } + if let Some(state) = state.editor { + Some(this.editor.update(cx, |editor, cx| { + editor.apply_update_proto( + &this.project, + proto::update_view::Variant::Editor(proto::update_view::Editor { + selections: state.selections, + pending_selection: state.pending_selection, + scroll_top_anchor: state.scroll_top_anchor, + scroll_x: state.scroll_x, + scroll_y: state.scroll_y, + ..Default::default() + }), + cx, + ) + })) + } else { + None + } + })?; -// Ok(this) -// })) -// } + if let Some(task) = task { + task.await?; + } -// fn add_event_to_update_proto( -// &self, -// event: &Self::Event, -// update: &mut Option, -// cx: &AppContext, -// ) -> bool { -// self.editor -// .read(cx) -// .add_event_to_update_proto(event, update, cx) -// } + Ok(this) + })) + } -// fn apply_update_proto( -// &mut self, -// project: &ModelHandle, -// message: proto::update_view::Variant, -// cx: &mut ViewContext, -// ) -> gpui::Task> { -// self.editor.update(cx, |editor, cx| { -// editor.apply_update_proto(project, message, cx) -// }) -// } + fn add_event_to_update_proto( + &self, + event: &EditorEvent, + update: &mut Option, + cx: &WindowContext, + ) -> bool { + self.editor + .read(cx) + .add_event_to_update_proto(event, update, cx) + } -// fn set_leader_peer_id(&mut self, leader_peer_id: Option, cx: &mut ViewContext) { -// self.editor.update(cx, |editor, cx| { -// editor.set_leader_peer_id(leader_peer_id, cx) -// }) -// } + fn apply_update_proto( + &mut self, + project: &Model, + message: proto::update_view::Variant, + cx: &mut ViewContext, + ) -> gpui::Task> { + self.editor.update(cx, |editor, cx| { + editor.apply_update_proto(project, message, cx) + }) + } -// fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool { -// Editor::should_unfollow_on_event(event, cx) -// } + fn set_leader_peer_id(&mut self, leader_peer_id: Option, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| { + editor.set_leader_peer_id(leader_peer_id, cx) + }) + } -// fn is_project_item(&self, _cx: &AppContext) -> bool { -// false -// } -// } + fn is_project_item(&self, _cx: &WindowContext) -> bool { + false + } -// struct ChannelBufferCollaborationHub(ModelHandle); + fn to_follow_event(event: &Self::Event) -> Option { + Editor::to_follow_event(event) + } +} -// impl CollaborationHub for ChannelBufferCollaborationHub { -// fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap { -// self.0.read(cx).collaborators() -// } +struct ChannelBufferCollaborationHub(Model); -// fn user_participant_indices<'a>( -// &self, -// cx: &'a AppContext, -// ) -> &'a HashMap { -// self.0.read(cx).user_store().read(cx).participant_indices() -// } -// } +impl CollaborationHub for ChannelBufferCollaborationHub { + fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap { + self.0.read(cx).collaborators() + } + + fn user_participant_indices<'a>( + &self, + cx: &'a AppContext, + ) -> &'a HashMap { + self.0.read(cx).user_store().read(cx).participant_indices() + } +} diff --git a/crates/collab_ui2/src/collab_panel.rs b/crates/collab_ui2/src/collab_panel.rs index c55bfa8cf5..bfef193cf7 100644 --- a/crates/collab_ui2/src/collab_panel.rs +++ b/crates/collab_ui2/src/collab_panel.rs @@ -169,7 +169,7 @@ use editor::Editor; use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, canvas, div, img, overlay, point, prelude::*, px, rems, serde_json, Action, + actions, canvas, div, img, overlay, point, prelude::*, px, rems, serde_json, size, Action, AppContext, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, Focusable, FocusableView, Hsla, InteractiveElement, IntoElement, Length, Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Quad, Render, RenderOnce, @@ -191,6 +191,7 @@ use workspace::{ Workspace, }; +use crate::channel_view::ChannelView; use crate::{face_pile::FacePile, CollaborationPanelSettings}; use self::channel_modal::ChannelModal; @@ -1204,14 +1205,9 @@ impl CollabPanel { .detach_and_log_err(cx); }); })) - .left_child(IconButton::new(0, Icon::Folder)) - .child( - h_stack() - .w_full() - .justify_between() - .child(render_tree_branch(is_last, cx)) - .child(Label::new(project_name.clone())), - ) + .left_child(render_tree_branch(is_last, cx)) + .child(IconButton::new(0, Icon::Folder)) + .child(Label::new(project_name.clone())) .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx)) // enum JoinProject {} @@ -1299,70 +1295,20 @@ impl CollabPanel { is_last: bool, cx: &mut ViewContext, ) -> impl IntoElement { - // enum OpenSharedScreen {} + let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize); - // let host_avatar_width = theme - // .contact_avatar - // .width - // .or(theme.contact_avatar.height) - // .unwrap_or(0.); - // let tree_branch = theme.tree_branch; - - // let handler = MouseEventHandler::new::( - // peer_id.map(|id| id.as_u64()).unwrap_or(0) as usize, - // cx, - // |mouse_state, cx| { - // let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); - // let row = theme - // .project_row - // .in_state(is_selected) - // .style_for(mouse_state); - - // Flex::row() - // .with_child(render_tree_branch( - // tree_branch, - // &row.name.text, - // is_last, - // vec2f(host_avatar_width, theme.row_height), - // cx.font_cache(), - // )) - // .with_child( - // Svg::new("icons/desktop.svg") - // .with_color(theme.channel_hash.color) - // .constrained() - // .with_width(theme.channel_hash.width) - // .aligned() - // .left(), - // ) - // .with_child( - // Label::new("Screen", row.name.text.clone()) - // .aligned() - // .left() - // .contained() - // .with_style(row.name.container) - // .flex(1., false), - // ) - // .constrained() - // .with_height(theme.row_height) - // .contained() - // .with_style(row.container) - // }, - // ); - // if peer_id.is_none() { - // return handler.into_any(); - // } - // handler - // .with_cursor_style(CursorStyle::PointingHand) - // .on_click(MouseButton::Left, move |_, this, cx| { - // if let Some(workspace) = this.workspace.upgrade(cx) { - // workspace.update(cx, |workspace, cx| { - // workspace.open_shared_screen(peer_id.unwrap(), cx) - // }); - // } - // }) - // .into_any() - - div() + ListItem::new(("screen", id)) + .left_child(render_tree_branch(is_last, cx)) + .child(IconButton::new(0, Icon::Screen)) + .child(Label::new("Screen")) + .when_some(peer_id, |this, _| { + this.on_click(cx.listener(move |this, _, cx| { + this.workspace.update(cx, |workspace, cx| { + workspace.open_shared_screen(peer_id.unwrap(), cx) + }); + })) + .tooltip(move |cx| Tooltip::text(format!("Open shared screen"), cx)) + }) } fn take_editing_state(&mut self, cx: &mut ViewContext) -> bool { @@ -1415,54 +1361,14 @@ impl CollabPanel { channel_id: ChannelId, cx: &mut ViewContext, ) -> impl IntoElement { - // enum ChannelNotes {} - // let host_avatar_width = theme - // .contact_avatar - // .width - // .or(theme.contact_avatar.height) - // .unwrap_or(0.); - - // MouseEventHandler::new::(ix as usize, cx, |state, cx| { - // let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state); - // let row = theme.project_row.in_state(is_selected).style_for(state); - - // Flex::::row() - // .with_child(render_tree_branch( - // tree_branch, - // &row.name.text, - // false, - // vec2f(host_avatar_width, theme.row_height), - // cx.font_cache(), - // )) - // .with_child( - // Svg::new("icons/file.svg") - // .with_color(theme.channel_hash.color) - // .constrained() - // .with_width(theme.channel_hash.width) - // .aligned() - // .left(), - // ) - // .with_child( - // Label::new("notes", theme.channel_name.text.clone()) - // .contained() - // .with_style(theme.channel_name.container) - // .aligned() - // .left() - // .flex(1., true), - // ) - // .constrained() - // .with_height(theme.row_height) - // .contained() - // .with_style(*theme.channel_row.style_for(is_selected, state)) - // .with_padding_left(theme.channel_row.default_style().padding.left) - // }) - // .on_click(MouseButton::Left, move |_, this, cx| { - // this.open_channel_notes(&OpenChannelNotes { channel_id }, cx); - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .into_any() - - div() + ListItem::new("channel-notes") + .on_click(cx.listener(move |this, _, cx| { + this.open_channel_notes(channel_id, cx); + })) + .left_child(render_tree_branch(false, cx)) + .child(IconButton::new(0, Icon::File)) + .child(Label::new("notes")) + .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx)) } fn render_channel_chat( @@ -1470,53 +1376,14 @@ impl CollabPanel { channel_id: ChannelId, cx: &mut ViewContext, ) -> impl IntoElement { - // enum ChannelChat {} - // let host_avatar_width = theme - // .contact_avatar - // .width - // .or(theme.contact_avatar.height) - // .unwrap_or(0.); - - // MouseEventHandler::new::(ix as usize, cx, |state, cx| { - // let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state); - // let row = theme.project_row.in_state(is_selected).style_for(state); - - // Flex::::row() - // .with_child(render_tree_branch( - // tree_branch, - // &row.name.text, - // true, - // vec2f(host_avatar_width, theme.row_height), - // cx.font_cache(), - // )) - // .with_child( - // Svg::new("icons/conversations.svg") - // .with_color(theme.channel_hash.color) - // .constrained() - // .with_width(theme.channel_hash.width) - // .aligned() - // .left(), - // ) - // .with_child( - // Label::new("chat", theme.channel_name.text.clone()) - // .contained() - // .with_style(theme.channel_name.container) - // .aligned() - // .left() - // .flex(1., true), - // ) - // .constrained() - // .with_height(theme.row_height) - // .contained() - // .with_style(*theme.channel_row.style_for(is_selected, state)) - // .with_padding_left(theme.channel_row.default_style().padding.left) - // }) - // .on_click(MouseButton::Left, move |_, this, cx| { - // this.join_channel_chat(&JoinChannelChat { channel_id }, cx); - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .into_any() - div() + ListItem::new("channel-chat") + .on_click(cx.listener(move |this, _, cx| { + this.join_channel_chat(channel_id, cx); + })) + .left_child(render_tree_branch(true, cx)) + .child(IconButton::new(0, Icon::MessageBubbles)) + .child(Label::new("chat")) + .tooltip(move |cx| Tooltip::text("Open Chat", cx)) } // fn render_channel_invite( @@ -2069,8 +1936,7 @@ impl CollabPanel { fn open_channel_notes(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { if let Some(workspace) = self.workspace.upgrade() { - todo!(); - // ChannelView::open(action.channel_id, workspace, cx).detach(); + ChannelView::open(channel_id, workspace, cx).detach(); } } @@ -2753,6 +2619,9 @@ impl CollabPanel { } else { Color::Muted }) + .on_click(cx.listener(move |this, _, cx| { + this.open_channel_notes(channel_id, cx) + })) .tooltip(|cx| { Tooltip::text("Open channel notes", cx) }), @@ -3119,30 +2988,24 @@ impl CollabPanel { } fn render_tree_branch(is_last: bool, cx: &mut WindowContext) -> impl IntoElement { - let text_style = cx.text_style(); let rem_size = cx.rem_size(); - let text_system = cx.text_system(); - let font_id = text_system.font_id(&text_style.font()).unwrap(); - let font_size = text_style.font_size.to_pixels(rem_size); - let line_height = text_style.line_height_in_pixels(rem_size); - let cap_height = text_system.cap_height(font_id, font_size); - let baseline_offset = text_system.baseline_offset(font_id, font_size, line_height); - let width = cx.rem_size() * 2.5; + let line_height = cx.text_style().line_height_in_pixels(rem_size); + let width = rem_size * 1.5; let thickness = px(2.); let color = cx.theme().colors().text; canvas(move |bounds, cx| { - let start_x = bounds.left() + (bounds.size.width / 2.) - (width / 2.); - let end_x = bounds.right(); - let start_y = bounds.top(); - let end_y = bounds.top() + baseline_offset - (cap_height / 2.); + let start_x = (bounds.left() + bounds.right() - thickness) / 2.; + let start_y = (bounds.top() + bounds.bottom() - thickness) / 2.; + let right = bounds.right(); + let top = bounds.top(); cx.paint_quad( Bounds::from_corners( - point(start_x, start_y), + point(start_x, top), point( start_x + thickness, - if is_last { end_y } else { bounds.bottom() }, + if is_last { start_y } else { bounds.bottom() }, ), ), Default::default(), @@ -3151,7 +3014,7 @@ fn render_tree_branch(is_last: bool, cx: &mut WindowContext) -> impl IntoElement Hsla::transparent_black(), ); cx.paint_quad( - Bounds::from_corners(point(start_x, end_y), point(end_x, end_y + thickness)), + Bounds::from_corners(point(start_x, start_y), point(right, start_y + thickness)), Default::default(), color, Default::default(), @@ -3344,10 +3207,6 @@ impl Panel for CollabPanel { Box::new(ToggleFocus) } - fn has_focus(&self, cx: &gpui::WindowContext) -> bool { - self.focus_handle.contains_focused(cx) - } - fn persistent_name() -> &'static str { "CollabPanel" } diff --git a/crates/collab_ui2/src/collab_ui.rs b/crates/collab_ui2/src/collab_ui.rs index efd3ff8692..fba76932fc 100644 --- a/crates/collab_ui2/src/collab_ui.rs +++ b/crates/collab_ui2/src/collab_ui.rs @@ -33,6 +33,7 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { // vcs_menu::init(cx); collab_titlebar_item::init(cx); collab_panel::init(cx); + channel_view::init(cx); // chat_panel::init(cx); notifications::init(&app_state, cx); diff --git a/crates/collections/src/collections.rs b/crates/collections/src/collections.rs index eb4e4d8462..bffa5c877a 100644 --- a/crates/collections/src/collections.rs +++ b/crates/collections/src/collections.rs @@ -23,11 +23,13 @@ pub type HashMap = std::collections::HashMap; #[cfg(not(feature = "test-support"))] pub type HashSet = std::collections::HashSet; +use std::any::TypeId; pub use std::collections::*; // NEW TYPES #[derive(Default)] pub struct CommandPaletteFilter { - pub filtered_namespaces: HashSet<&'static str>, + pub hidden_namespaces: HashSet<&'static str>, + pub hidden_action_types: HashSet, } diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index ce762876a4..356300052e 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -109,7 +109,7 @@ impl PickerDelegate for CommandPaletteDelegate { let filtered = cx.read(|cx| { if cx.has_global::() { let filter = cx.global::(); - filter.filtered_namespaces.contains(action.namespace()) + filter.hidden_namespaces.contains(action.namespace()) } else { false } @@ -430,7 +430,7 @@ mod tests { // Add namespace filter, and redeploy the palette cx.update(|cx| { cx.update_default_global::(|filter, _| { - filter.filtered_namespaces.insert("editor"); + filter.hidden_namespaces.insert("editor"); }) }); diff --git a/crates/command_palette2/src/command_palette.rs b/crates/command_palette2/src/command_palette.rs index a2abadd5fd..bdd66fa569 100644 --- a/crates/command_palette2/src/command_palette.rs +++ b/crates/command_palette2/src/command_palette.rs @@ -49,7 +49,10 @@ impl CommandPalette { .filter_map(|action| { let name = gpui::remove_the_2(action.name()); let namespace = name.split("::").next().unwrap_or("malformed action name"); - if filter.is_some_and(|f| f.filtered_namespaces.contains(namespace)) { + if filter.is_some_and(|f| { + f.hidden_namespaces.contains(namespace) + || f.hidden_action_types.contains(&action.type_id()) + }) { return None; } @@ -433,7 +436,7 @@ mod tests { cx.update(|cx| { cx.set_global(CommandPaletteFilter::default()); cx.update_global::(|filter, _| { - filter.filtered_namespaces.insert("editor"); + filter.hidden_namespaces.insert("editor"); }) }); diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 2558974753..985e784367 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -28,7 +28,7 @@ theme = { path = "../theme" } lsp = { path = "../lsp" } node_runtime = { path = "../node_runtime"} util = { path = "../util" } -async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] } +async-compression.workspace = true async-tar = "0.4.2" anyhow.workspace = true log.workspace = true diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 92d430e3fb..0c6f7e3907 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -58,16 +58,16 @@ pub fn init( cx.update_default_global::(move |filter, _cx| { match status { Status::Disabled => { - filter.filtered_namespaces.insert(COPILOT_NAMESPACE); - filter.filtered_namespaces.insert(COPILOT_AUTH_NAMESPACE); + filter.hidden_namespaces.insert(COPILOT_NAMESPACE); + filter.hidden_namespaces.insert(COPILOT_AUTH_NAMESPACE); } Status::Authorized => { - filter.filtered_namespaces.remove(COPILOT_NAMESPACE); - filter.filtered_namespaces.remove(COPILOT_AUTH_NAMESPACE); + filter.hidden_namespaces.remove(COPILOT_NAMESPACE); + filter.hidden_namespaces.remove(COPILOT_AUTH_NAMESPACE); } _ => { - filter.filtered_namespaces.insert(COPILOT_NAMESPACE); - filter.filtered_namespaces.remove(COPILOT_AUTH_NAMESPACE); + filter.hidden_namespaces.insert(COPILOT_NAMESPACE); + filter.hidden_namespaces.remove(COPILOT_AUTH_NAMESPACE); } } }); diff --git a/crates/copilot2/Cargo.toml b/crates/copilot2/Cargo.toml index 9a9243b32e..ce169f3319 100644 --- a/crates/copilot2/Cargo.toml +++ b/crates/copilot2/Cargo.toml @@ -28,7 +28,8 @@ theme = { package = "theme2", path = "../theme2" } lsp = { package = "lsp2", path = "../lsp2" } node_runtime = { path = "../node_runtime"} util = { path = "../util" } -async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] } +ui = { package = "ui2", path = "../ui2" } +async-compression.workspace = true async-tar = "0.4.2" anyhow.workspace = true log.workspace = true diff --git a/crates/copilot2/src/copilot2.rs b/crates/copilot2/src/copilot2.rs index b245472864..9c5483d634 100644 --- a/crates/copilot2/src/copilot2.rs +++ b/crates/copilot2/src/copilot2.rs @@ -22,6 +22,7 @@ use request::StatusNotification; use settings::SettingsStore; use smol::{fs, io::BufReader, stream::StreamExt}; use std::{ + any::TypeId, ffi::OsString, mem, ops::Range, @@ -32,13 +33,14 @@ use util::{ fs::remove_matching, github::latest_github_release, http::HttpClient, paths, ResultExt, }; -// todo!() -// const COPILOT_AUTH_NAMESPACE: &'static str = "copilot_auth"; -actions!(SignIn, SignOut); - -// todo!() -// const COPILOT_NAMESPACE: &'static str = "copilot"; -actions!(Suggest, NextSuggestion, PreviousSuggestion, Reinstall); +actions!( + Suggest, + NextSuggestion, + PreviousSuggestion, + Reinstall, + SignIn, + SignOut +); pub fn init( new_server_id: LanguageServerId, @@ -51,52 +53,70 @@ pub fn init( move |cx| Copilot::start(new_server_id, http, node_runtime, cx) }); cx.set_global(copilot.clone()); + cx.observe(&copilot, |handle, cx| { + let copilot_action_types = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; + let copilot_auth_action_types = [TypeId::of::()]; + let copilot_no_auth_action_types = [TypeId::of::()]; + let status = handle.read(cx).status(); + let filter = cx.default_global::(); - // TODO - // cx.observe(&copilot, |handle, cx| { - // let status = handle.read(cx).status(); - // cx.update_default_global::(move |filter, _cx| { - // match status { - // Status::Disabled => { - // filter.filtered_namespaces.insert(COPILOT_NAMESPACE); - // filter.filtered_namespaces.insert(COPILOT_AUTH_NAMESPACE); - // } - // Status::Authorized => { - // filter.filtered_namespaces.remove(COPILOT_NAMESPACE); - // filter.filtered_namespaces.remove(COPILOT_AUTH_NAMESPACE); - // } - // _ => { - // filter.filtered_namespaces.insert(COPILOT_NAMESPACE); - // filter.filtered_namespaces.remove(COPILOT_AUTH_NAMESPACE); - // } - // } - // }); - // }) - // .detach(); + match status { + Status::Disabled => { + filter.hidden_action_types.extend(copilot_action_types); + filter.hidden_action_types.extend(copilot_auth_action_types); + filter + .hidden_action_types + .extend(copilot_no_auth_action_types); + } + Status::Authorized => { + filter + .hidden_action_types + .extend(copilot_no_auth_action_types); + for type_id in copilot_action_types + .iter() + .chain(&copilot_auth_action_types) + { + filter.hidden_action_types.remove(type_id); + } + } + _ => { + filter.hidden_action_types.extend(copilot_action_types); + filter.hidden_action_types.extend(copilot_auth_action_types); + for type_id in &copilot_no_auth_action_types { + filter.hidden_action_types.remove(type_id); + } + } + } + }) + .detach(); - // sign_in::init(cx); - // cx.add_global_action(|_: &SignIn, cx| { - // if let Some(copilot) = Copilot::global(cx) { - // copilot - // .update(cx, |copilot, cx| copilot.sign_in(cx)) - // .detach_and_log_err(cx); - // } - // }); - // cx.add_global_action(|_: &SignOut, cx| { - // if let Some(copilot) = Copilot::global(cx) { - // copilot - // .update(cx, |copilot, cx| copilot.sign_out(cx)) - // .detach_and_log_err(cx); - // } - // }); - - // cx.add_global_action(|_: &Reinstall, cx| { - // if let Some(copilot) = Copilot::global(cx) { - // copilot - // .update(cx, |copilot, cx| copilot.reinstall(cx)) - // .detach(); - // } - // }); + sign_in::init(cx); + cx.on_action(|_: &SignIn, cx| { + if let Some(copilot) = Copilot::global(cx) { + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + } + }); + cx.on_action(|_: &SignOut, cx| { + if let Some(copilot) = Copilot::global(cx) { + copilot + .update(cx, |copilot, cx| copilot.sign_out(cx)) + .detach_and_log_err(cx); + } + }); + cx.on_action(|_: &Reinstall, cx| { + if let Some(copilot) = Copilot::global(cx) { + copilot + .update(cx, |copilot, cx| copilot.reinstall(cx)) + .detach(); + } + }); } enum CopilotServer { diff --git a/crates/copilot2/src/sign_in.rs b/crates/copilot2/src/sign_in.rs index 57f248aa52..4fa93ffcf8 100644 --- a/crates/copilot2/src/sign_in.rs +++ b/crates/copilot2/src/sign_in.rs @@ -1,376 +1,213 @@ -// TODO add logging in -// use crate::{request::PromptUserDeviceFlow, Copilot, Status}; -// use gpui::{ -// elements::*, -// geometry::rect::RectF, -// platform::{WindowBounds, WindowKind, WindowOptions}, -// AnyElement, AnyViewHandle, AppContext, ClipboardItem, Element, Entity, View, ViewContext, -// WindowHandle, -// }; -// use theme::ui::modal; +use crate::{request::PromptUserDeviceFlow, Copilot, Status}; +use gpui::{ + div, size, AppContext, Bounds, ClipboardItem, Div, Element, GlobalPixels, InteractiveElement, + IntoElement, ParentElement, Point, Render, Stateful, Styled, ViewContext, VisualContext, + WindowBounds, WindowHandle, WindowKind, WindowOptions, +}; +use theme::ActiveTheme; +use ui::{prelude::*, Button, Icon, IconElement, Label}; -// #[derive(PartialEq, Eq, Debug, Clone)] -// struct CopyUserCode; +const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot"; -// #[derive(PartialEq, Eq, Debug, Clone)] -// struct OpenGithub; +pub fn init(cx: &mut AppContext) { + if let Some(copilot) = Copilot::global(cx) { + let mut verification_window: Option> = None; + cx.observe(&copilot, move |copilot, cx| { + let status = copilot.read(cx).status(); -// const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot"; + match &status { + crate::Status::SigningIn { prompt } => { + if let Some(window) = verification_window.as_mut() { + let updated = window + .update(cx, |verification, cx| { + verification.set_status(status.clone(), cx); + cx.activate_window(); + }) + .is_ok(); + if !updated { + verification_window = Some(create_copilot_auth_window(cx, &status)); + } + } else if let Some(_prompt) = prompt { + verification_window = Some(create_copilot_auth_window(cx, &status)); + } + } + Status::Authorized | Status::Unauthorized => { + if let Some(window) = verification_window.as_ref() { + window + .update(cx, |verification, cx| { + verification.set_status(status, cx); + cx.activate(true); + cx.activate_window(); + }) + .ok(); + } + } + _ => { + if let Some(code_verification) = verification_window.take() { + code_verification + .update(cx, |_, cx| cx.remove_window()) + .ok(); + } + } + } + }) + .detach(); + } +} -// pub fn init(cx: &mut AppContext) { -// if let Some(copilot) = Copilot::global(cx) { -// let mut verification_window: Option> = None; -// cx.observe(&copilot, move |copilot, cx| { -// let status = copilot.read(cx).status(); +fn create_copilot_auth_window( + cx: &mut AppContext, + status: &Status, +) -> WindowHandle { + let window_size = size(GlobalPixels::from(280.), GlobalPixels::from(280.)); + let window_options = WindowOptions { + bounds: WindowBounds::Fixed(Bounds::new(Point::default(), window_size)), + titlebar: None, + center: true, + focus: true, + show: true, + kind: WindowKind::PopUp, + is_movable: true, + display_id: None, + }; + let window = cx.open_window(window_options, |cx| { + cx.build_view(|_| CopilotCodeVerification::new(status.clone())) + }); + window +} -// match &status { -// crate::Status::SigningIn { prompt } => { -// if let Some(window) = verification_window.as_mut() { -// let updated = window -// .root(cx) -// .map(|root| { -// root.update(cx, |verification, cx| { -// verification.set_status(status.clone(), cx); -// cx.activate_window(); -// }) -// }) -// .is_some(); -// if !updated { -// verification_window = Some(create_copilot_auth_window(cx, &status)); -// } -// } else if let Some(_prompt) = prompt { -// verification_window = Some(create_copilot_auth_window(cx, &status)); -// } -// } -// Status::Authorized | Status::Unauthorized => { -// if let Some(window) = verification_window.as_ref() { -// if let Some(verification) = window.root(cx) { -// verification.update(cx, |verification, cx| { -// verification.set_status(status, cx); -// cx.platform().activate(true); -// cx.activate_window(); -// }); -// } -// } -// } -// _ => { -// if let Some(code_verification) = verification_window.take() { -// code_verification.update(cx, |cx| cx.remove_window()); -// } -// } -// } -// }) -// .detach(); -// } -// } +pub struct CopilotCodeVerification { + status: Status, + connect_clicked: bool, +} -// fn create_copilot_auth_window( -// cx: &mut AppContext, -// status: &Status, -// ) -> WindowHandle { -// let window_size = theme::current(cx).copilot.modal.dimensions(); -// let window_options = WindowOptions { -// bounds: WindowBounds::Fixed(RectF::new(Default::default(), window_size)), -// titlebar: None, -// center: true, -// focus: true, -// show: true, -// kind: WindowKind::Normal, -// is_movable: true, -// screen: None, -// }; -// cx.add_window(window_options, |_cx| { -// CopilotCodeVerification::new(status.clone()) -// }) -// } +impl CopilotCodeVerification { + pub fn new(status: Status) -> Self { + Self { + status, + connect_clicked: false, + } + } -// pub struct CopilotCodeVerification { -// status: Status, -// connect_clicked: bool, -// } + pub fn set_status(&mut self, status: Status, cx: &mut ViewContext) { + self.status = status; + cx.notify(); + } -// impl CopilotCodeVerification { -// pub fn new(status: Status) -> Self { -// Self { -// status, -// connect_clicked: false, -// } -// } + fn render_device_code( + data: &PromptUserDeviceFlow, + cx: &mut ViewContext, + ) -> impl IntoElement { + let copied = cx + .read_from_clipboard() + .map(|item| item.text() == &data.user_code) + .unwrap_or(false); + h_stack() + .cursor_pointer() + .justify_between() + .on_mouse_down(gpui::MouseButton::Left, { + let user_code = data.user_code.clone(); + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new(user_code.clone())); + cx.notify(); + } + }) + .child(Label::new(data.user_code.clone())) + .child(div()) + .child(Label::new(if copied { "Copied!" } else { "Copy" })) + } -// pub fn set_status(&mut self, status: Status, cx: &mut ViewContext) { -// self.status = status; -// cx.notify(); -// } + fn render_prompting_modal( + connect_clicked: bool, + data: &PromptUserDeviceFlow, + cx: &mut ViewContext, + ) -> impl Element { + let connect_button_label = if connect_clicked { + "Waiting for connection..." + } else { + "Connect to Github" + }; + v_stack() + .flex_1() + .items_center() + .justify_between() + .w_full() + .child(Label::new( + "Enable Copilot by connecting your existing license", + )) + .child(Self::render_device_code(data, cx)) + .child( + Label::new("Paste this code into GitHub after clicking the button below.") + .size(ui::LabelSize::Small), + ) + .child( + Button::new("connect-button", connect_button_label).on_click({ + let verification_uri = data.verification_uri.clone(); + cx.listener(move |this, _, cx| { + cx.open_url(&verification_uri); + this.connect_clicked = true; + }) + }), + ) + } + fn render_enabled_modal() -> impl Element { + v_stack() + .child(Label::new("Copilot Enabled!")) + .child(Label::new( + "You can update your settings or sign out from the Copilot menu in the status bar.", + )) + .child( + Button::new("copilot-enabled-done-button", "Done") + .on_click(|_, cx| cx.remove_window()), + ) + } -// fn render_device_code( -// data: &PromptUserDeviceFlow, -// style: &theme::Copilot, -// cx: &mut ViewContext, -// ) -> impl IntoAnyElement { -// let copied = cx -// .read_from_clipboard() -// .map(|item| item.text() == &data.user_code) -// .unwrap_or(false); + fn render_unauthorized_modal() -> impl Element { + v_stack() + .child(Label::new( + "Enable Copilot by connecting your existing license.", + )) + .child( + Label::new("You must have an active Copilot license to use it in Zed.") + .color(Color::Warning), + ) + .child( + Button::new("copilot-subscribe-button", "Subscibe on Github").on_click(|_, cx| { + cx.remove_window(); + cx.open_url(COPILOT_SIGN_UP_URL) + }), + ) + } +} -// let device_code_style = &style.auth.prompting.device_code; +impl Render for CopilotCodeVerification { + type Element = Stateful
; -// MouseEventHandler::new::(0, cx, |state, _cx| { -// Flex::row() -// .with_child( -// Label::new(data.user_code.clone(), device_code_style.text.clone()) -// .aligned() -// .contained() -// .with_style(device_code_style.left_container) -// .constrained() -// .with_width(device_code_style.left), -// ) -// .with_child( -// Label::new( -// if copied { "Copied!" } else { "Copy" }, -// device_code_style.cta.style_for(state).text.clone(), -// ) -// .aligned() -// .contained() -// .with_style(*device_code_style.right_container.style_for(state)) -// .constrained() -// .with_width(device_code_style.right), -// ) -// .contained() -// .with_style(device_code_style.cta.style_for(state).container) -// }) -// .on_click(gpui::platform::MouseButton::Left, { -// let user_code = data.user_code.clone(); -// move |_, _, cx| { -// cx.platform() -// .write_to_clipboard(ClipboardItem::new(user_code.clone())); -// cx.notify(); -// } -// }) -// .with_cursor_style(gpui::platform::CursorStyle::PointingHand) -// } - -// fn render_prompting_modal( -// connect_clicked: bool, -// data: &PromptUserDeviceFlow, -// style: &theme::Copilot, -// cx: &mut ViewContext, -// ) -> AnyElement { -// enum ConnectButton {} - -// Flex::column() -// .with_child( -// Flex::column() -// .with_children([ -// Label::new( -// "Enable Copilot by connecting", -// style.auth.prompting.subheading.text.clone(), -// ) -// .aligned(), -// Label::new( -// "your existing license.", -// style.auth.prompting.subheading.text.clone(), -// ) -// .aligned(), -// ]) -// .align_children_center() -// .contained() -// .with_style(style.auth.prompting.subheading.container), -// ) -// .with_child(Self::render_device_code(data, &style, cx)) -// .with_child( -// Flex::column() -// .with_children([ -// Label::new( -// "Paste this code into GitHub after", -// style.auth.prompting.hint.text.clone(), -// ) -// .aligned(), -// Label::new( -// "clicking the button below.", -// style.auth.prompting.hint.text.clone(), -// ) -// .aligned(), -// ]) -// .align_children_center() -// .contained() -// .with_style(style.auth.prompting.hint.container.clone()), -// ) -// .with_child(theme::ui::cta_button::( -// if connect_clicked { -// "Waiting for connection..." -// } else { -// "Connect to GitHub" -// }, -// style.auth.content_width, -// &style.auth.cta_button, -// cx, -// { -// let verification_uri = data.verification_uri.clone(); -// move |_, verification, cx| { -// cx.platform().open_url(&verification_uri); -// verification.connect_clicked = true; -// } -// }, -// )) -// .align_children_center() -// .into_any() -// } - -// fn render_enabled_modal( -// style: &theme::Copilot, -// cx: &mut ViewContext, -// ) -> AnyElement { -// enum DoneButton {} - -// let enabled_style = &style.auth.authorized; -// Flex::column() -// .with_child( -// Label::new("Copilot Enabled!", enabled_style.subheading.text.clone()) -// .contained() -// .with_style(enabled_style.subheading.container) -// .aligned(), -// ) -// .with_child( -// Flex::column() -// .with_children([ -// Label::new( -// "You can update your settings or", -// enabled_style.hint.text.clone(), -// ) -// .aligned(), -// Label::new( -// "sign out from the Copilot menu in", -// enabled_style.hint.text.clone(), -// ) -// .aligned(), -// Label::new("the status bar.", enabled_style.hint.text.clone()).aligned(), -// ]) -// .align_children_center() -// .contained() -// .with_style(enabled_style.hint.container), -// ) -// .with_child(theme::ui::cta_button::( -// "Done", -// style.auth.content_width, -// &style.auth.cta_button, -// cx, -// |_, _, cx| cx.remove_window(), -// )) -// .align_children_center() -// .into_any() -// } - -// fn render_unauthorized_modal( -// style: &theme::Copilot, -// cx: &mut ViewContext, -// ) -> AnyElement { -// let unauthorized_style = &style.auth.not_authorized; - -// Flex::column() -// .with_child( -// Flex::column() -// .with_children([ -// Label::new( -// "Enable Copilot by connecting", -// unauthorized_style.subheading.text.clone(), -// ) -// .aligned(), -// Label::new( -// "your existing license.", -// unauthorized_style.subheading.text.clone(), -// ) -// .aligned(), -// ]) -// .align_children_center() -// .contained() -// .with_style(unauthorized_style.subheading.container), -// ) -// .with_child( -// Flex::column() -// .with_children([ -// Label::new( -// "You must have an active copilot", -// unauthorized_style.warning.text.clone(), -// ) -// .aligned(), -// Label::new( -// "license to use it in Zed.", -// unauthorized_style.warning.text.clone(), -// ) -// .aligned(), -// ]) -// .align_children_center() -// .contained() -// .with_style(unauthorized_style.warning.container), -// ) -// .with_child(theme::ui::cta_button::( -// "Subscribe on GitHub", -// style.auth.content_width, -// &style.auth.cta_button, -// cx, -// |_, _, cx| { -// cx.remove_window(); -// cx.platform().open_url(COPILOT_SIGN_UP_URL) -// }, -// )) -// .align_children_center() -// .into_any() -// } -// } - -// impl Entity for CopilotCodeVerification { -// type Event = (); -// } - -// impl View for CopilotCodeVerification { -// fn ui_name() -> &'static str { -// "CopilotCodeVerification" -// } - -// fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { -// cx.notify() -// } - -// fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { -// cx.notify() -// } - -// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { -// enum ConnectModal {} - -// let style = theme::current(cx).clone(); - -// modal::( -// "Connect Copilot to Zed", -// &style.copilot.modal, -// cx, -// |cx| { -// Flex::column() -// .with_children([ -// theme::ui::icon(&style.copilot.auth.header).into_any(), -// match &self.status { -// Status::SigningIn { -// prompt: Some(prompt), -// } => Self::render_prompting_modal( -// self.connect_clicked, -// &prompt, -// &style.copilot, -// cx, -// ), -// Status::Unauthorized => { -// self.connect_clicked = false; -// Self::render_unauthorized_modal(&style.copilot, cx) -// } -// Status::Authorized => { -// self.connect_clicked = false; -// Self::render_enabled_modal(&style.copilot, cx) -// } -// _ => Empty::new().into_any(), -// }, -// ]) -// .align_children_center() -// }, -// ) -// .into_any() -// } -// } + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let prompt = match &self.status { + Status::SigningIn { + prompt: Some(prompt), + } => Self::render_prompting_modal(self.connect_clicked, &prompt, cx).into_any_element(), + Status::Unauthorized => { + self.connect_clicked = false; + Self::render_unauthorized_modal().into_any_element() + } + Status::Authorized => { + self.connect_clicked = false; + Self::render_enabled_modal().into_any_element() + } + _ => div().into_any_element(), + }; + div() + .id("copilot code verification") + .flex() + .flex_col() + .size_full() + .items_center() + .p_10() + .bg(cx.theme().colors().element_background) + .child(ui::Label::new("Connect Copilot to Zed")) + .child(IconElement::new(Icon::ZedXCopilot)) + .child(prompt) + } +} diff --git a/crates/diagnostics2/src/diagnostics.rs b/crates/diagnostics2/src/diagnostics.rs index dd01f90b9f..f725fb7c4f 100644 --- a/crates/diagnostics2/src/diagnostics.rs +++ b/crates/diagnostics2/src/diagnostics.rs @@ -36,7 +36,7 @@ use std::{ }; use theme::ActiveTheme; pub use toolbar_controls::ToolbarControls; -use ui::{h_stack, Color, HighlightedLabel, Icon, IconElement, Label}; +use ui::{h_stack, prelude::*, HighlightedLabel, Icon, IconElement, Label}; use util::TryFutureExt; use workspace::{ item::{BreadcrumbText, Item, ItemEvent, ItemHandle}, @@ -88,7 +88,7 @@ struct DiagnosticGroupState { block_count: usize, } -impl EventEmitter for ProjectDiagnosticsEditor {} +impl EventEmitter for ProjectDiagnosticsEditor {} impl Render for ProjectDiagnosticsEditor { type Element = Focusable
; @@ -158,7 +158,7 @@ impl ProjectDiagnosticsEditor { }); let editor_event_subscription = cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| { - Self::emit_item_event_for_editor_event(event, cx); + cx.emit(event.clone()); if event == &EditorEvent::Focused && this.path_states.is_empty() { cx.focus(&this.focus_handle); } @@ -183,40 +183,6 @@ impl ProjectDiagnosticsEditor { this } - fn emit_item_event_for_editor_event(event: &EditorEvent, cx: &mut ViewContext) { - match event { - EditorEvent::Closed => cx.emit(ItemEvent::CloseItem), - - EditorEvent::Saved | EditorEvent::TitleChanged => { - cx.emit(ItemEvent::UpdateTab); - cx.emit(ItemEvent::UpdateBreadcrumbs); - } - - EditorEvent::Reparsed => { - cx.emit(ItemEvent::UpdateBreadcrumbs); - } - - EditorEvent::SelectionsChanged { local } if *local => { - cx.emit(ItemEvent::UpdateBreadcrumbs); - } - - EditorEvent::DirtyChanged => { - cx.emit(ItemEvent::UpdateTab); - } - - EditorEvent::BufferEdited => { - cx.emit(ItemEvent::Edit); - cx.emit(ItemEvent::UpdateBreadcrumbs); - } - - EditorEvent::ExcerptsAdded { .. } | EditorEvent::ExcerptsRemoved { .. } => { - cx.emit(ItemEvent::Edit); - } - - _ => {} - } - } - fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { if let Some(existing) = workspace.item_of_type::(cx) { workspace.activate_item(&existing, cx); @@ -333,8 +299,7 @@ impl ProjectDiagnosticsEditor { this.update(&mut cx, |this, cx| { this.summary = this.project.read(cx).diagnostic_summary(false, cx); - cx.emit(ItemEvent::UpdateTab); - cx.emit(ItemEvent::UpdateBreadcrumbs); + cx.emit(EditorEvent::TitleChanged); })?; anyhow::Ok(()) } @@ -649,6 +614,12 @@ impl FocusableView for ProjectDiagnosticsEditor { } impl Item for ProjectDiagnosticsEditor { + type Event = EditorEvent; + + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + fn deactivated(&mut self, cx: &mut ViewContext) { self.editor.update(cx, |editor, cx| editor.deactivated(cx)); } diff --git a/crates/editor2/src/display_map.rs b/crates/editor2/src/display_map.rs index 1aee04dd0a..60975a7a5c 100644 --- a/crates/editor2/src/display_map.rs +++ b/crates/editor2/src/display_map.rs @@ -24,7 +24,7 @@ use lsp::DiagnosticSeverity; use std::{any::TypeId, borrow::Cow, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc}; use sum_tree::{Bias, TreeMap}; use tab_map::TabMap; -use theme::{SyntaxTheme, Theme}; +use theme::{StatusColors, SyntaxTheme, Theme}; use wrap_map::WrapMap; pub use block_map::{ @@ -513,8 +513,8 @@ impl DisplaySnapshot { self.chunks( display_rows, language_aware, - Some(editor_style.syntax.inlay_style), - Some(editor_style.syntax.suggestion_style), + Some(editor_style.inlays_style), + Some(editor_style.suggestions_style), ) .map(|chunk| { let mut highlight_style = chunk diff --git a/crates/editor2/src/display_map/block_map.rs b/crates/editor2/src/display_map/block_map.rs index 64e46549fd..cc0095bca9 100644 --- a/crates/editor2/src/display_map/block_map.rs +++ b/crates/editor2/src/display_map/block_map.rs @@ -993,7 +993,7 @@ mod tests { use super::*; use crate::display_map::inlay_map::InlayMap; use crate::display_map::{fold_map::FoldMap, tab_map::TabMap, wrap_map::WrapMap}; - use gpui::{div, font, px, Element, Platform as _}; + use gpui::{div, font, px, Element}; use multi_buffer::MultiBuffer; use rand::prelude::*; use settings::SettingsStore; @@ -1185,11 +1185,7 @@ mod tests { fn test_blocks_on_wrapped_lines(cx: &mut gpui::TestAppContext) { cx.update(|cx| init_test(cx)); - let font_id = cx - .test_platform - .text_system() - .font_id(&font("Helvetica")) - .unwrap(); + let font_id = cx.text_system().font_id(&font("Helvetica")).unwrap(); let text = "one two three\nfour five six\nseven eight"; diff --git a/crates/editor2/src/display_map/wrap_map.rs b/crates/editor2/src/display_map/wrap_map.rs index a2ac0ec849..ca9db7754b 100644 --- a/crates/editor2/src/display_map/wrap_map.rs +++ b/crates/editor2/src/display_map/wrap_map.rs @@ -1032,7 +1032,7 @@ mod tests { display_map::{fold_map::FoldMap, inlay_map::InlayMap, tab_map::TabMap}, MultiBuffer, }; - use gpui::{font, px, test::observe, Platform}; + use gpui::{font, px, test::observe}; use rand::prelude::*; use settings::SettingsStore; use smol::stream::StreamExt; diff --git a/crates/editor2/src/editor.rs b/crates/editor2/src/editor.rs index cdaa42972e..796fc68fe6 100644 --- a/crates/editor2/src/editor.rs +++ b/crates/editor2/src/editor.rs @@ -92,6 +92,7 @@ use std::{ ops::{ControlFlow, Deref, DerefMut, Range, RangeInclusive}, path::Path, sync::Arc, + sync::Weak, time::{Duration, Instant}, }; pub use sum_tree::Bias; @@ -420,6 +421,25 @@ pub fn init(cx: &mut AppContext) { }, ) .detach(); + + cx.on_action(move |_: &workspace::NewFile, cx| { + let app_state = cx.global::>(); + if let Some(app_state) = app_state.upgrade() { + workspace::open_new(&app_state, cx, |workspace, cx| { + Editor::new_file(workspace, &Default::default(), cx) + }) + .detach(); + } + }); + cx.on_action(move |_: &workspace::NewWindow, cx| { + let app_state = cx.global::>(); + if let Some(app_state) = app_state.upgrade() { + workspace::open_new(&app_state, cx, |workspace, cx| { + Editor::new_file(workspace, &Default::default(), cx) + }) + .detach(); + } + }); } trait InvalidationRegion { @@ -479,6 +499,8 @@ pub struct EditorStyle { pub scrollbar_width: Pixels, pub syntax: Arc, pub diagnostic_style: DiagnosticStyle, + pub inlays_style: HighlightStyle, + pub suggestions_style: HighlightStyle, } type CompletionId = usize; @@ -1675,8 +1697,7 @@ impl Editor { if let Some(project) = project.as_ref() { if buffer.read(cx).is_singleton() { project_subscriptions.push(cx.observe(project, |_, _, cx| { - cx.emit(ItemEvent::UpdateTab); - cx.emit(ItemEvent::UpdateBreadcrumbs); + cx.emit(EditorEvent::TitleChanged); })); } project_subscriptions.push(cx.subscribe(project, |editor, _, event, cx| { @@ -1966,14 +1987,14 @@ impl Editor { cx.notify(); } - // pub fn set_cursor_shape(&mut self, cursor_shape: CursorShape, cx: &mut ViewContext) { - // self.cursor_shape = cursor_shape; - // cx.notify(); - // } + pub fn set_cursor_shape(&mut self, cursor_shape: CursorShape, cx: &mut ViewContext) { + self.cursor_shape = cursor_shape; + cx.notify(); + } - // pub fn set_collapse_matches(&mut self, collapse_matches: bool) { - // self.collapse_matches = collapse_matches; - // } + pub fn set_collapse_matches(&mut self, collapse_matches: bool) { + self.collapse_matches = collapse_matches; + } pub fn range_for_match(&self, range: &Range) -> Range { if self.collapse_matches { @@ -1982,56 +2003,47 @@ impl Editor { range.clone() } - // pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext) { - // if self.display_map.read(cx).clip_at_line_ends != clip { - // self.display_map - // .update(cx, |map, _| map.clip_at_line_ends = clip); - // } - // } + pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext) { + if self.display_map.read(cx).clip_at_line_ends != clip { + self.display_map + .update(cx, |map, _| map.clip_at_line_ends = clip); + } + } - // pub fn set_keymap_context_layer( - // &mut self, - // context: KeymapContext, - // cx: &mut ViewContext, - // ) { - // self.keymap_context_layers - // .insert(TypeId::of::(), context); - // cx.notify(); - // } + pub fn set_keymap_context_layer( + &mut self, + context: KeyContext, + cx: &mut ViewContext, + ) { + self.keymap_context_layers + .insert(TypeId::of::(), context); + cx.notify(); + } - // pub fn remove_keymap_context_layer(&mut self, cx: &mut ViewContext) { - // self.keymap_context_layers.remove(&TypeId::of::()); - // cx.notify(); - // } + pub fn remove_keymap_context_layer(&mut self, cx: &mut ViewContext) { + self.keymap_context_layers.remove(&TypeId::of::()); + cx.notify(); + } - // pub fn set_input_enabled(&mut self, input_enabled: bool) { - // self.input_enabled = input_enabled; - // } + pub fn set_input_enabled(&mut self, input_enabled: bool) { + self.input_enabled = input_enabled; + } - // pub fn set_autoindent(&mut self, autoindent: bool) { - // if autoindent { - // self.autoindent_mode = Some(AutoindentMode::EachLine); - // } else { - // self.autoindent_mode = None; - // } - // } + pub fn set_autoindent(&mut self, autoindent: bool) { + if autoindent { + self.autoindent_mode = Some(AutoindentMode::EachLine); + } else { + self.autoindent_mode = None; + } + } - // pub fn read_only(&self) -> bool { - // self.read_only - // } + pub fn read_only(&self) -> bool { + self.read_only + } - // pub fn set_read_only(&mut self, read_only: bool) { - // self.read_only = read_only; - // } - - // pub fn set_field_editor_style( - // &mut self, - // style: Option>, - // cx: &mut ViewContext, - // ) { - // self.get_field_editor_theme = style; - // cx.notify(); - // } + pub fn set_read_only(&mut self, read_only: bool) { + self.read_only = read_only; + } fn selections_did_change( &mut self, @@ -2146,10 +2158,6 @@ impl Editor { cx.emit(SearchEvent::ActiveMatchChanged) } - if local { - cx.emit(ItemEvent::UpdateBreadcrumbs); - } - cx.notify(); } @@ -7639,6 +7647,18 @@ impl Editor { .editor_style .diagnostic_style .clone(), + // todo!("what about the rest of the highlight style parts for inlays and suggestions?") + inlays_style: HighlightStyle { + color: Some(cx.theme().status().hint), + font_weight: Some(FontWeight::BOLD), + fade_out: Some(0.6), + ..HighlightStyle::default() + }, + suggestions_style: HighlightStyle { + color: Some(cx.theme().status().predictive), + fade_out: Some(0.6), + ..HighlightStyle::default() + }, }, )) .into_any_element() @@ -8589,8 +8609,6 @@ impl Editor { self.update_visible_copilot_suggestion(cx); } cx.emit(EditorEvent::BufferEdited); - cx.emit(ItemEvent::Edit); - cx.emit(ItemEvent::UpdateBreadcrumbs); cx.emit(SearchEvent::MatchesInvalidated); if *sigleton_buffer_edited { @@ -8638,20 +8656,14 @@ impl Editor { self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone() }) } - multi_buffer::Event::Reparsed => { - cx.emit(ItemEvent::UpdateBreadcrumbs); - } - multi_buffer::Event::DirtyChanged => { - cx.emit(ItemEvent::UpdateTab); - } - multi_buffer::Event::Saved - | multi_buffer::Event::FileHandleChanged - | multi_buffer::Event::Reloaded => { - cx.emit(ItemEvent::UpdateTab); - cx.emit(ItemEvent::UpdateBreadcrumbs); + multi_buffer::Event::Reparsed => cx.emit(EditorEvent::Reparsed), + multi_buffer::Event::DirtyChanged => cx.emit(EditorEvent::DirtyChanged), + multi_buffer::Event::Saved => cx.emit(EditorEvent::Saved), + multi_buffer::Event::FileHandleChanged | multi_buffer::Event::Reloaded => { + cx.emit(EditorEvent::TitleChanged) } multi_buffer::Event::DiffBaseChanged => cx.emit(EditorEvent::DiffBaseChanged), - multi_buffer::Event::Closed => cx.emit(ItemEvent::CloseItem), + multi_buffer::Event::Closed => cx.emit(EditorEvent::Closed), multi_buffer::Event::DiagnosticsUpdated => { self.refresh_active_diagnostics(cx); } @@ -9287,7 +9299,7 @@ impl Render for Editor { color: cx.theme().colors().text, font_family: settings.buffer_font.family.clone(), font_features: settings.buffer_font.features, - font_size: settings.buffer_font_size.into(), + font_size: settings.buffer_font_size(cx).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, line_height: relative(settings.buffer_line_height.value()), @@ -9312,6 +9324,19 @@ impl Render for Editor { scrollbar_width: px(12.), syntax: cx.theme().syntax().clone(), diagnostic_style: cx.theme().diagnostic_style(), + // TODO kb find `HighlightStyle` usages + // todo!("what about the rest of the highlight style parts?") + inlays_style: HighlightStyle { + color: Some(cx.theme().status().hint), + font_weight: Some(FontWeight::BOLD), + fade_out: Some(0.6), + ..HighlightStyle::default() + }, + suggestions_style: HighlightStyle { + color: Some(cx.theme().status().predictive), + fade_out: Some(0.6), + ..HighlightStyle::default() + }, }, ) } diff --git a/crates/editor2/src/editor_tests.rs b/crates/editor2/src/editor_tests.rs index 424da8987e..2548fc40a4 100644 --- a/crates/editor2/src/editor_tests.rs +++ b/crates/editor2/src/editor_tests.rs @@ -12,7 +12,7 @@ use futures::StreamExt; use gpui::{ div, serde_json::{self, json}, - Div, Flatten, Platform, TestAppContext, VisualTestContext, WindowBounds, WindowOptions, + Div, Flatten, TestAppContext, VisualTestContext, WindowBounds, WindowOptions, }; use indoc::indoc; use language::{ @@ -32,7 +32,7 @@ use util::{ test::{marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker}, }; use workspace::{ - item::{FollowEvent, FollowableEvents, FollowableItem, Item, ItemHandle}, + item::{FollowEvent, FollowableItem, Item, ItemHandle}, NavigationEntry, ViewId, }; @@ -345,7 +345,12 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { ); editor.update(cx, |view, cx| { - view.update_selection(DisplayPoint::new(3, 3), 0, gpui::Point::::zero(), cx); + view.update_selection( + DisplayPoint::new(3, 3), + 0, + gpui::Point::::default(), + cx, + ); }); assert_eq!( @@ -356,7 +361,12 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { ); editor.update(cx, |view, cx| { - view.update_selection(DisplayPoint::new(1, 1), 0, gpui::Point::::zero(), cx); + view.update_selection( + DisplayPoint::new(1, 1), + 0, + gpui::Point::::default(), + cx, + ); }); assert_eq!( @@ -368,7 +378,12 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { editor.update(cx, |view, cx| { view.end_selection(cx); - view.update_selection(DisplayPoint::new(3, 3), 0, gpui::Point::::zero(), cx); + view.update_selection( + DisplayPoint::new(3, 3), + 0, + gpui::Point::::default(), + cx, + ); }); assert_eq!( @@ -380,7 +395,12 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) { editor.update(cx, |view, cx| { view.begin_selection(DisplayPoint::new(3, 3), true, 1, cx); - view.update_selection(DisplayPoint::new(0, 0), 0, gpui::Point::::zero(), cx); + view.update_selection( + DisplayPoint::new(0, 0), + 0, + gpui::Point::::default(), + cx, + ); }); assert_eq!( @@ -423,7 +443,12 @@ fn test_canceling_pending_selection(cx: &mut TestAppContext) { }); view.update(cx, |view, cx| { - view.update_selection(DisplayPoint::new(3, 3), 0, gpui::Point::::zero(), cx); + view.update_selection( + DisplayPoint::new(3, 3), + 0, + gpui::Point::::default(), + cx, + ); assert_eq!( view.selections.display_ranges(cx), [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)] @@ -432,7 +457,12 @@ fn test_canceling_pending_selection(cx: &mut TestAppContext) { view.update(cx, |view, cx| { view.cancel(&Cancel, cx); - view.update_selection(DisplayPoint::new(1, 1), 0, gpui::Point::::zero(), cx); + view.update_selection( + DisplayPoint::new(1, 1), + 0, + gpui::Point::::default(), + cx, + ); assert_eq!( view.selections.display_ranges(cx), [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)] @@ -643,11 +673,21 @@ fn test_cancel(cx: &mut TestAppContext) { view.update(cx, |view, cx| { view.begin_selection(DisplayPoint::new(3, 4), false, 1, cx); - view.update_selection(DisplayPoint::new(1, 1), 0, gpui::Point::::zero(), cx); + view.update_selection( + DisplayPoint::new(1, 1), + 0, + gpui::Point::::default(), + cx, + ); view.end_selection(cx); view.begin_selection(DisplayPoint::new(0, 1), true, 1, cx); - view.update_selection(DisplayPoint::new(0, 3), 0, gpui::Point::::zero(), cx); + view.update_selection( + DisplayPoint::new(0, 3), + 0, + gpui::Point::::default(), + cx, + ); view.end_selection(cx); assert_eq!( view.selections.display_ranges(cx), @@ -3238,9 +3278,7 @@ async fn test_clipboard(cx: &mut gpui::TestAppContext) { the lazy dog"}); cx.update_editor(|e, cx| e.copy(&Copy, cx)); assert_eq!( - cx.test_platform - .read_from_clipboard() - .map(|item| item.text().to_owned()), + cx.read_from_clipboard().map(|item| item.text().to_owned()), Some("fox jumps over\n".to_owned()) ); @@ -6478,7 +6516,7 @@ async fn test_following(cx: &mut gpui::TestAppContext) { cx.subscribe( &follower.root_view(cx).unwrap(), move |_, _, event: &EditorEvent, cx| { - if matches!(event.to_follow_event(), Some(FollowEvent::Unfollow)) { + if matches!(Editor::to_follow_event(event), Some(FollowEvent::Unfollow)) { *is_still_following.borrow_mut() = false; } diff --git a/crates/editor2/src/element.rs b/crates/editor2/src/element.rs index d7b9d0bb40..9447c3370b 100644 --- a/crates/editor2/src/element.rs +++ b/crates/editor2/src/element.rs @@ -485,7 +485,7 @@ impl EditorElement { let modifiers = event.modifiers; if editor.has_pending_selection() && event.pressed_button == Some(MouseButton::Left) { let point_for_position = position_map.point_for_position(text_bounds, event.position); - let mut scroll_delta = gpui::Point::::zero(); + let mut scroll_delta = gpui::Point::::default(); let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0); let top = text_bounds.origin.y + vertical_margin; let bottom = text_bounds.lower_left().y - vertical_margin; @@ -511,7 +511,7 @@ impl EditorElement { position: point_for_position.previous_valid, goal_column: point_for_position.exact_unclipped.column(), scroll_position: (position_map.snapshot.scroll_position() + scroll_delta) - .clamp(&gpui::Point::zero(), &position_map.scroll_max), + .clamp(&gpui::Point::default(), &position_map.scroll_max), }, cx, ); @@ -2803,35 +2803,48 @@ impl Element for EditorElement { let focus_handle = editor.focus_handle(cx); let dispatch_context = self.editor.read(cx).dispatch_context(cx); - cx.with_key_dispatch(dispatch_context, Some(focus_handle.clone()), |_, cx| { - self.register_actions(cx); - self.register_key_listeners(cx); + cx.with_key_dispatch( + Some(dispatch_context), + Some(focus_handle.clone()), + |_, cx| { + self.register_actions(cx); + self.register_key_listeners(cx); - // We call with_z_index to establish a new stacking context. - cx.with_z_index(0, |cx| { - cx.with_content_mask(Some(ContentMask { bounds }), |cx| { - // Paint mouse listeners at z-index 0 so any elements we paint on top of the editor - // take precedence. - cx.with_z_index(0, |cx| { - self.paint_mouse_listeners(bounds, gutter_bounds, text_bounds, &layout, cx); + // We call with_z_index to establish a new stacking context. + cx.with_z_index(0, |cx| { + cx.with_content_mask(Some(ContentMask { bounds }), |cx| { + // Paint mouse listeners at z-index 0 so any elements we paint on top of the editor + // take precedence. + cx.with_z_index(0, |cx| { + self.paint_mouse_listeners( + bounds, + gutter_bounds, + text_bounds, + &layout, + cx, + ); + }); + let input_handler = + ElementInputHandler::new(bounds, self.editor.clone(), cx); + cx.handle_input(&focus_handle, input_handler); + + self.paint_background(gutter_bounds, text_bounds, &layout, cx); + if layout.gutter_size.width > Pixels::ZERO { + self.paint_gutter(gutter_bounds, &mut layout, cx); + } + self.paint_text(text_bounds, &mut layout, cx); + + if !layout.blocks.is_empty() { + cx.with_z_index(1, |cx| { + cx.with_element_id(Some("editor_blocks"), |cx| { + self.paint_blocks(bounds, &mut layout, cx); + }) + }) + } }); - let input_handler = ElementInputHandler::new(bounds, self.editor.clone(), cx); - cx.handle_input(&focus_handle, input_handler); - - self.paint_background(gutter_bounds, text_bounds, &layout, cx); - if layout.gutter_size.width > Pixels::ZERO { - self.paint_gutter(gutter_bounds, &mut layout, cx); - } - self.paint_text(text_bounds, &mut layout, cx); - - if !layout.blocks.is_empty() { - cx.with_element_id(Some("editor_blocks"), |cx| { - self.paint_blocks(bounds, &mut layout, cx); - }) - } }); - }); - }) + }, + ) } } @@ -3448,7 +3461,6 @@ mod tests { DisplayPoint::new(4, 0)..DisplayPoint::new(6, 0) ); assert_eq!(local_selections[0].head, DisplayPoint::new(5, 0)); - dbg!("Hi"); // moves cursor on buffer boundary back two lines // and doesn't allow selection to bleed through assert_eq!( diff --git a/crates/editor2/src/items.rs b/crates/editor2/src/items.rs index 93bb37c622..2b46c29e6b 100644 --- a/crates/editor2/src/items.rs +++ b/crates/editor2/src/items.rs @@ -32,10 +32,10 @@ use std::{ }; use text::Selection; use theme::{ActiveTheme, Theme}; -use ui::{Color, Label}; +use ui::{h_stack, prelude::*, Label}; use util::{paths::PathExt, paths::FILE_ROW_COLUMN_DELIMITER, ResultExt, TryFutureExt}; use workspace::{ - item::{BreadcrumbText, FollowEvent, FollowableEvents, FollowableItemHandle}, + item::{BreadcrumbText, FollowEvent, FollowableItemHandle}, StatusItemView, }; use workspace::{ @@ -46,27 +46,7 @@ use workspace::{ pub const MAX_TAB_TITLE_LEN: usize = 24; -impl FollowableEvents for EditorEvent { - fn to_follow_event(&self) -> Option { - match self { - EditorEvent::Edited => Some(FollowEvent::Unfollow), - EditorEvent::SelectionsChanged { local } - | EditorEvent::ScrollPositionChanged { local, .. } => { - if *local { - Some(FollowEvent::Unfollow) - } else { - None - } - } - _ => None, - } - } -} - -impl EventEmitter for Editor {} - impl FollowableItem for Editor { - type FollowableEvent = EditorEvent; fn remote_id(&self) -> Option { self.remote_id } @@ -241,9 +221,24 @@ impl FollowableItem for Editor { })) } + fn to_follow_event(event: &EditorEvent) -> Option { + match event { + EditorEvent::Edited => Some(FollowEvent::Unfollow), + EditorEvent::SelectionsChanged { local } + | EditorEvent::ScrollPositionChanged { local, .. } => { + if *local { + Some(FollowEvent::Unfollow) + } else { + None + } + } + _ => None, + } + } + fn add_event_to_update_proto( &self, - event: &Self::FollowableEvent, + event: &EditorEvent, update: &mut Option, cx: &WindowContext, ) -> bool { @@ -528,6 +523,8 @@ fn deserialize_anchor(buffer: &MultiBufferSnapshot, anchor: proto::EditorAnchor) } impl Item for Editor { + type Event = EditorEvent; + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { if let Ok(data) = data.downcast::() { let newest_selection = self.selections.newest::(cx); @@ -586,28 +583,25 @@ impl Item for Editor { fn tab_content(&self, detail: Option, cx: &WindowContext) -> AnyElement { let theme = cx.theme(); - AnyElement::new( - div() - .flex() - .flex_row() - .items_center() - .gap_2() - .child(Label::new(self.title(cx).to_string())) - .children(detail.and_then(|detail| { - let path = path_for_buffer(&self.buffer, detail, false, cx)?; - let description = path.to_string_lossy(); + let description = detail.and_then(|detail| { + let path = path_for_buffer(&self.buffer, detail, false, cx)?; + let description = path.to_string_lossy(); + let description = description.trim(); - Some( - div().child( - Label::new(util::truncate_and_trailoff( - &description, - MAX_TAB_TITLE_LEN, - )) - .color(Color::Muted), - ), - ) - })), - ) + if description.is_empty() { + return None; + } + + Some(util::truncate_and_trailoff(&description, MAX_TAB_TITLE_LEN)) + }); + + h_stack() + .gap_2() + .child(Label::new(self.title(cx).to_string())) + .when_some(description, |this, description| { + this.child(Label::new(description).color(Color::Muted)) + }) + .into_any_element() } fn for_each_project_item( @@ -841,6 +835,40 @@ impl Item for Editor { Some("Editor") } + fn to_item_events(event: &EditorEvent, mut f: impl FnMut(ItemEvent)) { + match event { + EditorEvent::Closed => f(ItemEvent::CloseItem), + + EditorEvent::Saved | EditorEvent::TitleChanged => { + f(ItemEvent::UpdateTab); + f(ItemEvent::UpdateBreadcrumbs); + } + + EditorEvent::Reparsed => { + f(ItemEvent::UpdateBreadcrumbs); + } + + EditorEvent::SelectionsChanged { local } if *local => { + f(ItemEvent::UpdateBreadcrumbs); + } + + EditorEvent::DirtyChanged => { + f(ItemEvent::UpdateTab); + } + + EditorEvent::BufferEdited => { + f(ItemEvent::Edit); + f(ItemEvent::UpdateBreadcrumbs); + } + + EditorEvent::ExcerptsAdded { .. } | EditorEvent::ExcerptsRemoved { .. } => { + f(ItemEvent::Edit); + } + + _ => {} + } + } + fn deserialize( project: Model, _workspace: WeakView, @@ -911,7 +939,7 @@ impl SearchableItem for Editor { fn update_matches(&mut self, matches: Vec>, cx: &mut ViewContext) { self.highlight_background::( matches, - |theme| theme.title_bar_background, // todo: update theme + |theme| theme.search_match_background, cx, ); } diff --git a/crates/go_to_line2/src/go_to_line.rs b/crates/go_to_line2/src/go_to_line.rs index 5ad95c1f6e..aff9942c26 100644 --- a/crates/go_to_line2/src/go_to_line.rs +++ b/crates/go_to_line2/src/go_to_line.rs @@ -6,7 +6,7 @@ use gpui::{ }; use text::{Bias, Point}; use theme::ActiveTheme; -use ui::{h_stack, v_stack, Color, Label, StyledExt}; +use ui::{h_stack, prelude::*, v_stack, Label}; use util::paths::FILE_ROW_COLUMN_DELIMITER; actions!(Toggle); diff --git a/crates/gpui2/src/app.rs b/crates/gpui2/src/app.rs index fec6f150f6..9293302938 100644 --- a/crates/gpui2/src/app.rs +++ b/crates/gpui2/src/app.rs @@ -15,10 +15,10 @@ use smol::future::FutureExt; pub use test_context::*; use crate::{ - current_platform, image_cache::ImageCache, Action, ActionRegistry, Any, AnyView, - AnyWindowHandle, AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context, + current_platform, image_cache::ImageCache, init_app_menus, Action, ActionRegistry, Any, + AnyView, AnyWindowHandle, AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context, DispatchPhase, DisplayId, Entity, EventEmitter, FocusEvent, FocusHandle, FocusId, - ForegroundExecutor, KeyBinding, Keymap, LayoutId, PathPromptOptions, Pixels, Platform, + ForegroundExecutor, KeyBinding, Keymap, LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, Render, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement, TextSystem, View, ViewContext, Window, WindowContext, WindowHandle, WindowId, @@ -39,7 +39,10 @@ use std::{ sync::{atomic::Ordering::SeqCst, Arc}, time::Duration, }; -use util::http::{self, HttpClient}; +use util::{ + http::{self, HttpClient}, + ResultExt, +}; /// Temporary(?) wrapper around RefCell to help us debug any double borrows. /// Strongly consider removing after stabilization. @@ -201,7 +204,7 @@ pub struct AppContext { pub(crate) windows: SlotMap>, pub(crate) keymap: Arc>, pub(crate) global_action_listeners: - HashMap>>, + HashMap>>, pending_effects: VecDeque, pub(crate) pending_notifications: HashSet, pub(crate) pending_global_notifications: HashSet, @@ -275,6 +278,8 @@ impl AppContext { }), }); + init_app_menus(platform.as_ref(), &mut *app.borrow_mut()); + platform.on_quit(Box::new({ let cx = app.clone(); move || { @@ -425,6 +430,10 @@ impl AppContext { .collect() } + pub fn active_window(&self) -> Option { + self.platform.active_window() + } + /// Opens a new window with the given option and the root view returned by the given function. /// The function is invoked with a `WindowContext`, which can be used to interact with window-specific /// functionality. @@ -851,7 +860,6 @@ impl AppContext { } /// Remove the global of the given type from the app context. Does not notify global observers. - #[cfg(any(test, feature = "test-support"))] pub fn remove_global(&mut self) -> G { let global_type = TypeId::of::(); *self @@ -962,9 +970,9 @@ impl AppContext { self.global_action_listeners .entry(TypeId::of::()) .or_default() - .push(Box::new(move |action, phase, cx| { + .push(Rc::new(move |action, phase, cx| { if phase == DispatchPhase::Bubble { - let action = action.as_any().downcast_ref().unwrap(); + let action = action.downcast_ref().unwrap(); listener(action, cx) } })); @@ -1015,6 +1023,90 @@ impl AppContext { activate(); subscription } + + pub(crate) fn clear_pending_keystrokes(&mut self) { + for window in self.windows() { + window + .update(self, |_, cx| { + cx.window + .current_frame + .dispatch_tree + .clear_pending_keystrokes() + }) + .ok(); + } + } + + pub fn is_action_available(&mut self, action: &dyn Action) -> bool { + if let Some(window) = self.active_window() { + if let Ok(window_action_available) = + window.update(self, |_, cx| cx.is_action_available(action)) + { + return window_action_available; + } + } + + self.global_action_listeners + .contains_key(&action.as_any().type_id()) + } + + pub fn set_menus(&mut self, menus: Vec) { + self.platform.set_menus(menus, &self.keymap.lock()); + } + + pub fn dispatch_action(&mut self, action: &dyn Action) { + if let Some(active_window) = self.active_window() { + active_window + .update(self, |_, cx| cx.dispatch_action(action.boxed_clone())) + .log_err(); + } else { + self.propagate_event = true; + + if let Some(mut global_listeners) = self + .global_action_listeners + .remove(&action.as_any().type_id()) + { + for listener in &global_listeners { + listener(action.as_any(), DispatchPhase::Capture, self); + if !self.propagate_event { + break; + } + } + + global_listeners.extend( + self.global_action_listeners + .remove(&action.as_any().type_id()) + .unwrap_or_default(), + ); + + self.global_action_listeners + .insert(action.as_any().type_id(), global_listeners); + } + + if self.propagate_event { + if let Some(mut global_listeners) = self + .global_action_listeners + .remove(&action.as_any().type_id()) + { + for listener in global_listeners.iter().rev() { + listener(action.as_any(), DispatchPhase::Bubble, self); + if !self.propagate_event { + break; + } + } + + global_listeners.extend( + self.global_action_listeners + .remove(&action.as_any().type_id()) + .unwrap_or_default(), + ); + + self.global_action_listeners + .insert(action.as_any().type_id(), global_listeners); + } + } + } + } } impl Context for AppContext { diff --git a/crates/gpui2/src/app/test_context.rs b/crates/gpui2/src/app/test_context.rs index a9403de9bc..9f0c7e6aca 100644 --- a/crates/gpui2/src/app/test_context.rs +++ b/crates/gpui2/src/app/test_context.rs @@ -1,9 +1,10 @@ use crate::{ div, Action, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, - BackgroundExecutor, Bounds, Context, Div, Entity, EventEmitter, ForegroundExecutor, InputEvent, - KeyDownEvent, Keystroke, Model, ModelContext, Pixels, PlatformWindow, Point, Render, Result, - Size, Task, TestDispatcher, TestPlatform, TestWindow, TestWindowHandlers, View, ViewContext, - VisualContext, WindowBounds, WindowContext, WindowHandle, WindowOptions, + BackgroundExecutor, Bounds, ClipboardItem, Context, Div, Entity, EventEmitter, + ForegroundExecutor, InputEvent, KeyDownEvent, Keystroke, Model, ModelContext, Pixels, Platform, + PlatformWindow, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestWindow, + TestWindowHandlers, TextSystem, View, ViewContext, VisualContext, WindowBounds, WindowContext, + WindowHandle, WindowOptions, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt}; @@ -16,6 +17,7 @@ pub struct TestAppContext { pub foreground_executor: ForegroundExecutor, pub dispatcher: TestDispatcher, pub test_platform: Rc, + text_system: Arc, } impl Context for TestAppContext { @@ -82,6 +84,7 @@ impl TestAppContext { let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); let asset_source = Arc::new(()); let http_client = util::http::FakeHttpClient::with_404_response(); + let text_system = Arc::new(TextSystem::new(platform.text_system())); Self { app: AppContext::new(platform.clone(), asset_source, http_client), @@ -89,6 +92,7 @@ impl TestAppContext { foreground_executor, dispatcher: dispatcher.clone(), test_platform: platform, + text_system, } } @@ -155,6 +159,18 @@ impl TestAppContext { (view, Box::leak(cx)) } + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + pub fn write_to_clipboard(&self, item: ClipboardItem) { + self.test_platform.write_to_clipboard(item) + } + + pub fn read_from_clipboard(&self) -> Option { + self.test_platform.read_from_clipboard() + } + pub fn simulate_new_path_selection( &self, select_path: impl FnOnce(&std::path::Path) -> Option, @@ -529,6 +545,10 @@ pub struct VisualTestContext<'a> { } impl<'a> VisualTestContext<'a> { + pub fn update(&mut self, f: impl FnOnce(&mut WindowContext) -> R) -> R { + self.cx.update_window(self.window, |_, cx| f(cx)).unwrap() + } + pub fn from_window(window: AnyWindowHandle, cx: &'a mut TestAppContext) -> Self { Self { cx, window } } diff --git a/crates/gpui2/src/element.rs b/crates/gpui2/src/element.rs index b876e375da..a67276b0bc 100644 --- a/crates/gpui2/src/element.rs +++ b/crates/gpui2/src/element.rs @@ -388,39 +388,6 @@ impl DrawableElement { } } -// impl Element for DrawableElement { -// type State = ::State; - -// fn layout( -// &mut self, -// element_state: Option, -// cx: &mut WindowContext, -// ) -> (LayoutId, Self::State) { - -// } - -// fn paint( -// self, -// bounds: Bounds, -// element_state: &mut Self::State, -// cx: &mut WindowContext, -// ) { -// todo!() -// } -// } - -// impl RenderOnce for DrawableElement { -// type Element = Self; - -// fn element_id(&self) -> Option { -// self.element.as_ref()?.element_id() -// } - -// fn render_once(self) -> Self::Element { -// self -// } -// } - impl ElementObject for Option> where E: Element, diff --git a/crates/gpui2/src/elements/canvas.rs b/crates/gpui2/src/elements/canvas.rs index 4761b04f3f..287a3b4b5a 100644 --- a/crates/gpui2/src/elements/canvas.rs +++ b/crates/gpui2/src/elements/canvas.rs @@ -1,9 +1,11 @@ -use crate::{Bounds, Element, IntoElement, Pixels, StyleRefinement, Styled, WindowContext}; +use refineable::Refineable as _; + +use crate::{Bounds, Element, IntoElement, Pixels, Style, StyleRefinement, Styled, WindowContext}; pub fn canvas(callback: impl 'static + FnOnce(Bounds, &mut WindowContext)) -> Canvas { Canvas { paint_callback: Box::new(callback), - style: Default::default(), + style: StyleRefinement::default(), } } @@ -32,7 +34,9 @@ impl Element for Canvas { _: Option, cx: &mut WindowContext, ) -> (crate::LayoutId, Self::State) { - let layout_id = cx.request_layout(&self.style.clone().into(), []); + let mut style = Style::default(); + style.refine(&self.style); + let layout_id = cx.request_layout(&style, []); (layout_id, ()) } diff --git a/crates/gpui2/src/elements/div.rs b/crates/gpui2/src/elements/div.rs index ce457fc693..10fd7dda0a 100644 --- a/crates/gpui2/src/elements/div.rs +++ b/crates/gpui2/src/elements/div.rs @@ -55,7 +55,7 @@ pub trait InteractiveElement: Sized + Element { E: Debug, { if let Some(key_context) = key_context.try_into().log_err() { - self.interactivity().key_context = key_context; + self.interactivity().key_context = Some(key_context); } self } @@ -559,6 +559,8 @@ pub type KeyDownListener = Box; +pub type DragEventListener = Box; + pub type ActionListener = Box; pub fn div() -> Div { @@ -722,7 +724,7 @@ impl DivState { pub struct Interactivity { pub element_id: Option, - pub key_context: KeyContext, + pub key_context: Option, pub focusable: bool, pub tracked_focus_handle: Option, pub scroll_handle: Option, @@ -751,7 +753,7 @@ pub struct Interactivity { pub tooltip_builder: Option, } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct InteractiveBounds { pub bounds: Bounds, pub stacking_order: StackingOrder, @@ -899,11 +901,14 @@ impl Interactivity { .active_drag .take() .expect("checked for type drag state type above"); + listener(drag.view.clone(), cx); cx.notify(); cx.stop_propagation(); } } + } else { + cx.active_drag = None; } } }); @@ -1238,7 +1243,7 @@ impl Default for Interactivity { fn default() -> Self { Self { element_id: None, - key_context: KeyContext::default(), + key_context: None, focusable: false, tracked_focus_handle: None, scroll_handle: None, @@ -1324,7 +1329,7 @@ impl GroupBounds { } pub struct Focusable { - element: E, + pub element: E, } impl FocusableElement for Focusable {} diff --git a/crates/gpui2/src/elements/overlay.rs b/crates/gpui2/src/elements/overlay.rs index 764bdfabcd..7d4b909637 100644 --- a/crates/gpui2/src/elements/overlay.rs +++ b/crates/gpui2/src/elements/overlay.rs @@ -102,7 +102,7 @@ impl Element for Overlay { let mut desired = self.anchor_corner.get_bounds(origin, size); let limits = Bounds { - origin: Point::zero(), + origin: Point::default(), size: cx.viewport_size(), }; diff --git a/crates/gpui2/src/geometry.rs b/crates/gpui2/src/geometry.rs index 20afd2d288..50f680f493 100644 --- a/crates/gpui2/src/geometry.rs +++ b/crates/gpui2/src/geometry.rs @@ -8,6 +8,18 @@ use std::{ ops::{Add, Div, Mul, MulAssign, Sub}, }; +/// Describes a location in a 2D cartesian coordinate space. +/// +/// It holds two public fields, `x` and `y`, which represent the coordinates in the space. +/// The type `T` for the coordinates can be any type that implements `Default`, `Clone`, and `Debug`. +/// +/// # Examples +/// +/// ``` +/// # use zed::Point; +/// let point = Point { x: 10, y: 20 }; +/// println!("{:?}", point); // Outputs: Point { x: 10, y: 20 } +/// ``` #[derive(Refineable, Default, Add, AddAssign, Sub, SubAssign, Copy, Debug, PartialEq, Eq, Hash)] #[refineable(Debug)] #[repr(C)] @@ -16,19 +28,66 @@ pub struct Point { pub y: T, } +/// Constructs a new `Point` with the given x and y coordinates. +/// +/// # Arguments +/// +/// * `x` - The x coordinate of the point. +/// * `y` - The y coordinate of the point. +/// +/// # Returns +/// +/// Returns a `Point` with the specified coordinates. +/// +/// # Examples +/// +/// ``` +/// # use zed::Point; +/// let p = point(10, 20); +/// assert_eq!(p.x, 10); +/// assert_eq!(p.y, 20); +/// ``` pub fn point(x: T, y: T) -> Point { Point { x, y } } impl Point { + /// Creates a new `Point` with the specified `x` and `y` coordinates. + /// + /// # Arguments + /// + /// * `x` - The horizontal coordinate of the point. + /// * `y` - The vertical coordinate of the point. + /// + /// # Examples + /// + /// ``` + /// let p = Point::new(10, 20); + /// assert_eq!(p.x, 10); + /// assert_eq!(p.y, 20); + /// ``` pub const fn new(x: T, y: T) -> Self { Self { x, y } } - pub fn zero() -> Self { - Self::new(T::default(), T::default()) - } - + /// Transforms the point to a `Point` by applying the given function to both coordinates. + /// + /// This method allows for converting a `Point` to a `Point` by specifying a closure + /// that defines how to convert between the two types. The closure is applied to both the `x` + /// and `y` coordinates, resulting in a new point of the desired type. + /// + /// # Arguments + /// + /// * `f` - A closure that takes a value of type `T` and returns a value of type `U`. + /// + /// # Examples + /// + /// ``` + /// # use zed::Point; + /// let p = Point { x: 3, y: 4 }; + /// let p_float = p.map(|coord| coord as f32); + /// assert_eq!(p_float, Point { x: 3.0, y: 4.0 }); + /// ``` pub fn map(&self, f: impl Fn(T) -> U) -> Point { Point { x: f(self.x.clone()), @@ -38,6 +97,21 @@ impl Point { } impl Point { + /// Scales the point by a given factor, which is typically derived from the resolution + /// of a target display to ensure proper sizing of UI elements. + /// + /// # Arguments + /// + /// * `factor` - The scaling factor to apply to both the x and y coordinates. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Point, Pixels, ScaledPixels}; + /// let p = Point { x: Pixels(10.0), y: Pixels(20.0) }; + /// let scaled_p = p.scale(1.5); + /// assert_eq!(scaled_p, Point { x: ScaledPixels(15.0), y: ScaledPixels(30.0) }); + /// ``` pub fn scale(&self, factor: f32) -> Point { Point { x: self.x.scale(factor), @@ -45,6 +119,16 @@ impl Point { } } + /// Calculates the Euclidean distance from the origin (0, 0) to this point. + /// + /// # Examples + /// + /// ``` + /// # use zed::Point; + /// # use zed::Pixels; + /// let p = Point { x: Pixels(3.0), y: Pixels(4.0) }; + /// assert_eq!(p.magnitude(), 5.0); + /// ``` pub fn magnitude(&self) -> f64 { ((self.x.0.powi(2) + self.y.0.powi(2)) as f64).sqrt() } @@ -95,14 +179,29 @@ impl Point where T: PartialOrd + Clone + Default + Debug, { + /// Returns a new point with the maximum values of each dimension from `self` and `other`. + /// + /// # Arguments + /// + /// * `other` - A reference to another `Point` to compare with `self`. + /// + /// # Examples + /// + /// ``` + /// # use zed::Point; + /// let p1 = Point { x: 3, y: 7 }; + /// let p2 = Point { x: 5, y: 2 }; + /// let max_point = p1.max(&p2); + /// assert_eq!(max_point, Point { x: 5, y: 7 }); + /// ``` pub fn max(&self, other: &Self) -> Self { Point { - x: if self.x >= other.x { + x: if self.x > other.x { self.x.clone() } else { other.x.clone() }, - y: if self.y >= other.y { + y: if self.y > other.y { self.y.clone() } else { other.y.clone() @@ -110,6 +209,21 @@ where } } + /// Returns a new point with the minimum values of each dimension from `self` and `other`. + /// + /// # Arguments + /// + /// * `other` - A reference to another `Point` to compare with `self`. + /// + /// # Examples + /// + /// ``` + /// # use zed::Point; + /// let p1 = Point { x: 3, y: 7 }; + /// let p2 = Point { x: 5, y: 2 }; + /// let min_point = p1.min(&p2); + /// assert_eq!(min_point, Point { x: 3, y: 2 }); + /// ``` pub fn min(&self, other: &Self) -> Self { Point { x: if self.x <= other.x { @@ -125,6 +239,32 @@ where } } + /// Clamps the point to a specified range. + /// + /// Given a minimum point and a maximum point, this method constrains the current point + /// such that its coordinates do not exceed the range defined by the minimum and maximum points. + /// If the current point's coordinates are less than the minimum, they are set to the minimum. + /// If they are greater than the maximum, they are set to the maximum. + /// + /// # Arguments + /// + /// * `min` - A reference to a `Point` representing the minimum allowable coordinates. + /// * `max` - A reference to a `Point` representing the maximum allowable coordinates. + /// + /// # Examples + /// + /// ``` + /// # use zed::Point; + /// let p = Point { x: 10, y: 20 }; + /// let min = Point { x: 0, y: 5 }; + /// let max = Point { x: 15, y: 25 }; + /// let clamped_p = p.clamp(&min, &max); + /// assert_eq!(clamped_p, Point { x: 10, y: 20 }); + /// + /// let p_out_of_bounds = Point { x: -5, y: 30 }; + /// let clamped_p_out_of_bounds = p_out_of_bounds.clamp(&min, &max); + /// assert_eq!(clamped_p_out_of_bounds, Point { x: 0, y: 25 }); + /// ``` pub fn clamp(&self, min: &Self, max: &Self) -> Self { self.max(min).min(max) } @@ -139,6 +279,10 @@ impl Clone for Point { } } +/// A structure representing a two-dimensional size with width and height in a given unit. +/// +/// This struct is generic over the type `T`, which can be any type that implements `Clone`, `Default`, and `Debug`. +/// It is commonly used to specify dimensions for elements in a UI, such as a window or element. #[derive(Refineable, Default, Clone, Copy, PartialEq, Div, Hash, Serialize, Deserialize)] #[refineable(Debug)] #[repr(C)] @@ -147,6 +291,21 @@ pub struct Size { pub height: T, } +/// Constructs a new `Size` with the provided width and height. +/// +/// # Arguments +/// +/// * `width` - The width component of the `Size`. +/// * `height` - The height component of the `Size`. +/// +/// # Examples +/// +/// ``` +/// # use zed::Size; +/// let my_size = size(10, 20); +/// assert_eq!(my_size.width, 10); +/// assert_eq!(my_size.height, 20); +/// ``` pub fn size(width: T, height: T) -> Size where T: Clone + Default + Debug, @@ -158,6 +317,24 @@ impl Size where T: Clone + Default + Debug, { + /// Applies a function to the width and height of the size, producing a new `Size`. + /// + /// This method allows for converting a `Size` to a `Size` by specifying a closure + /// that defines how to convert between the two types. The closure is applied to both the `width` + /// and `height`, resulting in a new size of the desired type. + /// + /// # Arguments + /// + /// * `f` - A closure that takes a value of type `T` and returns a value of type `U`. + /// + /// # Examples + /// + /// ``` + /// # use zed::Size; + /// let my_size = Size { width: 10, height: 20 }; + /// let my_new_size = my_size.map(|dimension| dimension as f32 * 1.5); + /// assert_eq!(my_new_size, Size { width: 15.0, height: 30.0 }); + /// ``` pub fn map(&self, f: impl Fn(T) -> U) -> Size where U: Clone + Default + Debug, @@ -170,6 +347,24 @@ where } impl Size { + /// Scales the size by a given factor. + /// + /// This method multiplies both the width and height by the provided scaling factor, + /// resulting in a new `Size` that is proportionally larger or smaller + /// depending on the factor. + /// + /// # Arguments + /// + /// * `factor` - The scaling factor to apply to the width and height. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Size, Pixels, ScaledPixels}; + /// let size = Size { width: Pixels(100.0), height: Pixels(50.0) }; + /// let scaled_size = size.scale(2.0); + /// assert_eq!(scaled_size, Size { width: ScaledPixels(200.0), height: ScaledPixels(100.0) }); + /// ``` pub fn scale(&self, factor: f32) -> Size { Size { width: self.width.scale(factor), @@ -182,6 +377,21 @@ impl Size where T: PartialOrd + Clone + Default + Debug, { + /// Returns a new `Size` with the maximum width and height from `self` and `other`. + /// + /// # Arguments + /// + /// * `other` - A reference to another `Size` to compare with `self`. + /// + /// # Examples + /// + /// ``` + /// # use zed::Size; + /// let size1 = Size { width: 30, height: 40 }; + /// let size2 = Size { width: 50, height: 20 }; + /// let max_size = size1.max(&size2); + /// assert_eq!(max_size, Size { width: 50, height: 40 }); + /// ``` pub fn max(&self, other: &Self) -> Self { Size { width: if self.width >= other.width { @@ -286,6 +496,14 @@ impl From> for Size { } impl Size { + /// Returns a `Size` with both width and height set to fill the available space. + /// + /// This function creates a `Size` instance where both the width and height are set to `Length::Definite(DefiniteLength::Fraction(1.0))`, + /// which represents 100% of the available space in both dimensions. + /// + /// # Returns + /// + /// A `Size` that will fill the available space when used in a layout. pub fn full() -> Self { Self { width: relative(1.).into(), @@ -294,16 +512,16 @@ impl Size { } } -impl Size { - pub fn zero() -> Self { - Self { - width: px(0.).into(), - height: px(0.).into(), - } - } -} - impl Size { + /// Returns a `Size` with both width and height set to `auto`, which allows the layout engine to determine the size. + /// + /// This function creates a `Size` instance where both the width and height are set to `Length::Auto`, + /// indicating that their size should be computed based on the layout context, such as the content size or + /// available space. + /// + /// # Returns + /// + /// A `Size` with width and height set to `Length::Auto`. pub fn auto() -> Self { Self { width: Length::Auto, @@ -312,6 +530,23 @@ impl Size { } } +/// Represents a rectangular area in a 2D space with an origin point and a size. +/// +/// The `Bounds` struct is generic over a type `T` which represents the type of the coordinate system. +/// The origin is represented as a `Point` which defines the upper-left corner of the rectangle, +/// and the size is represented as a `Size` which defines the width and height of the rectangle. +/// +/// # Examples +/// +/// ``` +/// # use zed::{Bounds, Point, Size}; +/// let origin = Point { x: 0, y: 0 }; +/// let size = Size { width: 10, height: 20 }; +/// let bounds = Bounds::new(origin, size); +/// +/// assert_eq!(bounds.origin, origin); +/// assert_eq!(bounds.size, size); +/// ``` #[derive(Refineable, Clone, Default, Debug, Eq, PartialEq)] #[refineable(Debug)] #[repr(C)] @@ -324,6 +559,33 @@ impl Bounds where T: Clone + Debug + Sub + Default, { + /// Constructs a `Bounds` from two corner points: the upper-left and lower-right corners. + /// + /// This function calculates the origin and size of the `Bounds` based on the provided corner points. + /// The origin is set to the upper-left corner, and the size is determined by the difference between + /// the x and y coordinates of the lower-right and upper-left points. + /// + /// # Arguments + /// + /// * `upper_left` - A `Point` representing the upper-left corner of the rectangle. + /// * `lower_right` - A `Point` representing the lower-right corner of the rectangle. + /// + /// # Returns + /// + /// Returns a `Bounds` that encompasses the area defined by the two corner points. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point}; + /// let upper_left = Point { x: 0, y: 0 }; + /// let lower_right = Point { x: 10, y: 10 }; + /// let bounds = Bounds::from_corners(upper_left, lower_right); + /// + /// assert_eq!(bounds.origin, upper_left); + /// assert_eq!(bounds.size.width, 10); + /// assert_eq!(bounds.size.height, 10); + /// ``` pub fn from_corners(upper_left: Point, lower_right: Point) -> Self { let origin = Point { x: upper_left.x.clone(), @@ -336,6 +598,16 @@ where Bounds { origin, size } } + /// Creates a new `Bounds` with the specified origin and size. + /// + /// # Arguments + /// + /// * `origin` - A `Point` representing the origin of the bounds. + /// * `size` - A `Size` representing the size of the bounds. + /// + /// # Returns + /// + /// Returns a `Bounds` that has the given origin and size. pub fn new(origin: Point, size: Size) -> Self { Bounds { origin, size } } @@ -345,6 +617,39 @@ impl Bounds where T: Clone + Debug + PartialOrd + Add + Sub + Default + Half, { + /// Checks if this `Bounds` intersects with another `Bounds`. + /// + /// Two `Bounds` instances intersect if they overlap in the 2D space they occupy. + /// This method checks if there is any overlapping area between the two bounds. + /// + /// # Arguments + /// + /// * `other` - A reference to another `Bounds` to check for intersection with. + /// + /// # Returns + /// + /// Returns `true` if there is any intersection between the two bounds, `false` otherwise. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let bounds1 = Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 10, height: 10 }, + /// }; + /// let bounds2 = Bounds { + /// origin: Point { x: 5, y: 5 }, + /// size: Size { width: 10, height: 10 }, + /// }; + /// let bounds3 = Bounds { + /// origin: Point { x: 20, y: 20 }, + /// size: Size { width: 10, height: 10 }, + /// }; + /// + /// assert_eq!(bounds1.intersects(&bounds2), true); // Overlapping bounds + /// assert_eq!(bounds1.intersects(&bounds3), false); // Non-overlapping bounds + /// ``` pub fn intersects(&self, other: &Bounds) -> bool { let my_lower_right = self.lower_right(); let their_lower_right = other.lower_right(); @@ -355,6 +660,32 @@ where && my_lower_right.y > other.origin.y } + /// Dilates the bounds by a specified amount in all directions. + /// + /// This method expands the bounds by the given `amount`, increasing the size + /// and adjusting the origin so that the bounds grow outwards equally in all directions. + /// The resulting bounds will have its width and height increased by twice the `amount` + /// (since it grows in both directions), and the origin will be moved by `-amount` + /// in both the x and y directions. + /// + /// # Arguments + /// + /// * `amount` - The amount by which to dilate the bounds. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let mut bounds = Bounds { + /// origin: Point { x: 10, y: 10 }, + /// size: Size { width: 10, height: 10 }, + /// }; + /// bounds.dilate(5); + /// assert_eq!(bounds, Bounds { + /// origin: Point { x: 5, y: 5 }, + /// size: Size { width: 20, height: 20 }, + /// }); + /// ``` pub fn dilate(&mut self, amount: T) { self.origin.x = self.origin.x.clone() - amount.clone(); self.origin.y = self.origin.y.clone() - amount.clone(); @@ -363,6 +694,27 @@ where self.size.height = self.size.height.clone() + double_amount; } + /// Returns the center point of the bounds. + /// + /// Calculates the center by taking the origin's x and y coordinates and adding half the width and height + /// of the bounds, respectively. The center is represented as a `Point` where `T` is the type of the + /// coordinate system. + /// + /// # Returns + /// + /// A `Point` representing the center of the bounds. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let bounds = Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 10, height: 20 }, + /// }; + /// let center = bounds.center(); + /// assert_eq!(center, Point { x: 5, y: 10 }); + /// ``` pub fn center(&self) -> Point { Point { x: self.origin.x.clone() + self.size.width.clone().half(), @@ -372,12 +724,78 @@ where } impl + Sub> Bounds { + /// Calculates the intersection of two `Bounds` objects. + /// + /// This method computes the overlapping region of two `Bounds`. If the bounds do not intersect, + /// the resulting `Bounds` will have a size with width and height of zero. + /// + /// # Arguments + /// + /// * `other` - A reference to another `Bounds` to intersect with. + /// + /// # Returns + /// + /// Returns a `Bounds` representing the intersection area. If there is no intersection, + /// the returned `Bounds` will have a size with width and height of zero. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let bounds1 = Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 10, height: 10 }, + /// }; + /// let bounds2 = Bounds { + /// origin: Point { x: 5, y: 5 }, + /// size: Size { width: 10, height: 10 }, + /// }; + /// let intersection = bounds1.intersect(&bounds2); + /// + /// assert_eq!(intersection, Bounds { + /// origin: Point { x: 5, y: 5 }, + /// size: Size { width: 5, height: 5 }, + /// }); + /// ``` pub fn intersect(&self, other: &Self) -> Self { let upper_left = self.origin.max(&other.origin); let lower_right = self.lower_right().min(&other.lower_right()); Self::from_corners(upper_left, lower_right) } + /// Computes the union of two `Bounds`. + /// + /// This method calculates the smallest `Bounds` that contains both the current `Bounds` and the `other` `Bounds`. + /// The resulting `Bounds` will have an origin that is the minimum of the origins of the two `Bounds`, + /// and a size that encompasses the furthest extents of both `Bounds`. + /// + /// # Arguments + /// + /// * `other` - A reference to another `Bounds` to create a union with. + /// + /// # Returns + /// + /// Returns a `Bounds` representing the union of the two `Bounds`. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let bounds1 = Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 10, height: 10 }, + /// }; + /// let bounds2 = Bounds { + /// origin: Point { x: 5, y: 5 }, + /// size: Size { width: 15, height: 15 }, + /// }; + /// let union_bounds = bounds1.union(&bounds2); + /// + /// assert_eq!(union_bounds, Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 20, height: 20 }, + /// }); + /// ``` pub fn union(&self, other: &Self) -> Self { let top_left = self.origin.min(&other.origin); let bottom_right = self.lower_right().max(&other.lower_right()); @@ -432,22 +850,59 @@ impl Bounds where T: Add + Clone + Default + Debug, { + /// Returns the top edge of the bounds. + /// + /// # Returns + /// + /// A value of type `T` representing the y-coordinate of the top edge of the bounds. pub fn top(&self) -> T { self.origin.y.clone() } + /// Returns the bottom edge of the bounds. + /// + /// # Returns + /// + /// A value of type `T` representing the y-coordinate of the bottom edge of the bounds. pub fn bottom(&self) -> T { self.origin.y.clone() + self.size.height.clone() } + /// Returns the left edge of the bounds. + /// + /// # Returns + /// + /// A value of type `T` representing the x-coordinate of the left edge of the bounds. pub fn left(&self) -> T { self.origin.x.clone() } + /// Returns the right edge of the bounds. + /// + /// # Returns + /// + /// A value of type `T` representing the x-coordinate of the right edge of the bounds. pub fn right(&self) -> T { self.origin.x.clone() + self.size.width.clone() } + /// Returns the upper-right corner point of the bounds. + /// + /// # Returns + /// + /// A `Point` representing the upper-right corner of the bounds. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let bounds = Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 10, height: 20 }, + /// }; + /// let upper_right = bounds.upper_right(); + /// assert_eq!(upper_right, Point { x: 10, y: 0 }); + /// ``` pub fn upper_right(&self) -> Point { Point { x: self.origin.x.clone() + self.size.width.clone(), @@ -455,6 +910,23 @@ where } } + /// Returns the lower-right corner point of the bounds. + /// + /// # Returns + /// + /// A `Point` representing the lower-right corner of the bounds. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let bounds = Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 10, height: 20 }, + /// }; + /// let lower_right = bounds.lower_right(); + /// assert_eq!(lower_right, Point { x: 10, y: 20 }); + /// ``` pub fn lower_right(&self) -> Point { Point { x: self.origin.x.clone() + self.size.width.clone(), @@ -462,6 +934,23 @@ where } } + /// Returns the lower-left corner point of the bounds. + /// + /// # Returns + /// + /// A `Point` representing the lower-left corner of the bounds. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let bounds = Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 10, height: 20 }, + /// }; + /// let lower_left = bounds.lower_left(); + /// assert_eq!(lower_left, Point { x: 0, y: 20 }); + /// ``` pub fn lower_left(&self) -> Point { Point { x: self.origin.x.clone(), @@ -474,6 +963,35 @@ impl Bounds where T: Add + PartialOrd + Clone + Default + Debug, { + /// Checks if the given point is within the bounds. + /// + /// This method determines whether a point lies inside the rectangle defined by the bounds, + /// including the edges. The point is considered inside if its x-coordinate is greater than + /// or equal to the left edge and less than or equal to the right edge, and its y-coordinate + /// is greater than or equal to the top edge and less than or equal to the bottom edge of the bounds. + /// + /// # Arguments + /// + /// * `point` - A reference to a `Point` that represents the point to check. + /// + /// # Returns + /// + /// Returns `true` if the point is within the bounds, `false` otherwise. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Point, Bounds}; + /// let bounds = Bounds { + /// origin: Point { x: 0, y: 0 }, + /// size: Size { width: 10, height: 10 }, + /// }; + /// let inside_point = Point { x: 5, y: 5 }; + /// let outside_point = Point { x: 15, y: 15 }; + /// + /// assert!(bounds.contains_point(&inside_point)); + /// assert!(!bounds.contains_point(&outside_point)); + /// ``` pub fn contains_point(&self, point: &Point) -> bool { point.x >= self.origin.x && point.x <= self.origin.x.clone() + self.size.width.clone() @@ -481,6 +999,34 @@ where && point.y <= self.origin.y.clone() + self.size.height.clone() } + /// Applies a function to the origin and size of the bounds, producing a new `Bounds`. + /// + /// This method allows for converting a `Bounds` to a `Bounds` by specifying a closure + /// that defines how to convert between the two types. The closure is applied to the `origin` and + /// `size` fields, resulting in new bounds of the desired type. + /// + /// # Arguments + /// + /// * `f` - A closure that takes a value of type `T` and returns a value of type `U`. + /// + /// # Returns + /// + /// Returns a new `Bounds` with the origin and size mapped by the provided function. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size}; + /// let bounds = Bounds { + /// origin: Point { x: 10.0, y: 10.0 }, + /// size: Size { width: 10.0, height: 20.0 }, + /// }; + /// let new_bounds = bounds.map(|value| value as f64 * 1.5); + /// + /// assert_eq!(new_bounds, Bounds { + /// origin: Point { x: 15.0, y: 15.0 }, + /// size: Size { width: 15.0, height: 30.0 }, + /// }); pub fn map(&self, f: impl Fn(T) -> U) -> Bounds where U: Clone + Default + Debug, @@ -493,6 +1039,36 @@ where } impl Bounds { + /// Scales the bounds by a given factor, typically used to adjust for display scaling. + /// + /// This method multiplies the origin and size of the bounds by the provided scaling factor, + /// resulting in a new `Bounds` that is proportionally larger or smaller + /// depending on the scaling factor. This can be used to ensure that the bounds are properly + /// scaled for different display densities. + /// + /// # Arguments + /// + /// * `factor` - The scaling factor to apply to the origin and size, typically the display's scaling factor. + /// + /// # Returns + /// + /// Returns a new `Bounds` that represents the scaled bounds. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Bounds, Point, Size, Pixels}; + /// let bounds = Bounds { + /// origin: Point { x: Pixels(10.0), y: Pixels(20.0) }, + /// size: Size { width: Pixels(30.0), height: Pixels(40.0) }, + /// }; + /// let display_scale_factor = 2.0; + /// let scaled_bounds = bounds.scale(display_scale_factor); + /// assert_eq!(scaled_bounds, Bounds { + /// origin: Point { x: ScaledPixels(20.0), y: ScaledPixels(40.0) }, + /// size: Size { width: ScaledPixels(60.0), height: ScaledPixels(80.0) }, + /// }); + /// ``` pub fn scale(&self, factor: f32) -> Bounds { Bounds { origin: self.origin.scale(factor), @@ -503,6 +1079,26 @@ impl Bounds { impl Copy for Bounds {} +/// Represents the edges of a box in a 2D space, such as padding or margin. +/// +/// Each field represents the size of the edge on one side of the box: `top`, `right`, `bottom`, and `left`. +/// +/// # Examples +/// +/// ``` +/// # use zed::Edges; +/// let edges = Edges { +/// top: 10.0, +/// right: 20.0, +/// bottom: 30.0, +/// left: 40.0, +/// }; +/// +/// assert_eq!(edges.top, 10.0); +/// assert_eq!(edges.right, 20.0); +/// assert_eq!(edges.bottom, 30.0); +/// assert_eq!(edges.left, 40.0); +/// ``` #[derive(Refineable, Clone, Default, Debug, Eq, PartialEq)] #[refineable(Debug)] #[repr(C)] @@ -545,6 +1141,30 @@ where impl Copy for Edges {} impl Edges { + /// Constructs `Edges` where all sides are set to the same specified value. + /// + /// This function creates an `Edges` instance with the `top`, `right`, `bottom`, and `left` fields all initialized + /// to the same value provided as an argument. This is useful when you want to have uniform edges around a box, + /// such as padding or margin with the same size on all sides. + /// + /// # Arguments + /// + /// * `value` - The value to set for all four sides of the edges. + /// + /// # Returns + /// + /// An `Edges` instance with all sides set to the given value. + /// + /// # Examples + /// + /// ``` + /// # use zed::Edges; + /// let uniform_edges = Edges::all(10.0); + /// assert_eq!(uniform_edges.top, 10.0); + /// assert_eq!(uniform_edges.right, 10.0); + /// assert_eq!(uniform_edges.bottom, 10.0); + /// assert_eq!(uniform_edges.left, 10.0); + /// ``` pub fn all(value: T) -> Self { Self { top: value.clone(), @@ -554,6 +1174,28 @@ impl Edges { } } + /// Applies a function to each field of the `Edges`, producing a new `Edges`. + /// + /// This method allows for converting an `Edges` to an `Edges` by specifying a closure + /// that defines how to convert between the two types. The closure is applied to each field + /// (`top`, `right`, `bottom`, `left`), resulting in new edges of the desired type. + /// + /// # Arguments + /// + /// * `f` - A closure that takes a reference to a value of type `T` and returns a value of type `U`. + /// + /// # Returns + /// + /// Returns a new `Edges` with each field mapped by the provided function. + /// + /// # Examples + /// + /// ``` + /// # use zed::Edges; + /// let edges = Edges { top: 10, right: 20, bottom: 30, left: 40 }; + /// let edges_float = edges.map(|&value| value as f32 * 1.1); + /// assert_eq!(edges_float, Edges { top: 11.0, right: 22.0, bottom: 33.0, left: 44.0 }); + /// ``` pub fn map(&self, f: impl Fn(&T) -> U) -> Edges where U: Clone + Default + Debug, @@ -566,6 +1208,33 @@ impl Edges { } } + /// Checks if any of the edges satisfy a given predicate. + /// + /// This method applies a predicate function to each field of the `Edges` and returns `true` if any field satisfies the predicate. + /// + /// # Arguments + /// + /// * `predicate` - A closure that takes a reference to a value of type `T` and returns a `bool`. + /// + /// # Returns + /// + /// Returns `true` if the predicate returns `true` for any of the edge values, `false` otherwise. + /// + /// # Examples + /// + /// ``` + /// # use zed::Edges; + /// let edges = Edges { + /// top: 10, + /// right: 0, + /// bottom: 5, + /// left: 0, + /// }; + /// + /// assert!(edges.any(|value| *value == 0)); + /// assert!(edges.any(|value| *value > 0)); + /// assert!(!edges.any(|value| *value > 10)); + /// ``` pub fn any bool>(&self, predicate: F) -> bool { predicate(&self.top) || predicate(&self.right) @@ -575,6 +1244,24 @@ impl Edges { } impl Edges { + /// Sets the edges of the `Edges` struct to `auto`, which is a special value that allows the layout engine to automatically determine the size of the edges. + /// + /// This is typically used in layout contexts where the exact size of the edges is not important, or when the size should be calculated based on the content or container. + /// + /// # Returns + /// + /// Returns an `Edges` with all edges set to `Length::Auto`. + /// + /// # Examples + /// + /// ``` + /// # use zed::Edges; + /// let auto_edges = Edges::auto(); + /// assert_eq!(auto_edges.top, Length::Auto); + /// assert_eq!(auto_edges.right, Length::Auto); + /// assert_eq!(auto_edges.bottom, Length::Auto); + /// assert_eq!(auto_edges.left, Length::Auto); + /// ``` pub fn auto() -> Self { Self { top: Length::Auto, @@ -584,6 +1271,25 @@ impl Edges { } } + /// Sets the edges of the `Edges` struct to zero, which means no size or thickness. + /// + /// This is typically used when you want to specify that a box (like a padding or margin area) + /// should have no edges, effectively making it non-existent or invisible in layout calculations. + /// + /// # Returns + /// + /// Returns an `Edges` with all edges set to zero length. + /// + /// # Examples + /// + /// ``` + /// # use zed::Edges; + /// let no_edges = Edges::zero(); + /// assert_eq!(no_edges.top, Length::Definite(DefiniteLength::from(Pixels(0.)))); + /// assert_eq!(no_edges.right, Length::Definite(DefiniteLength::from(Pixels(0.)))); + /// assert_eq!(no_edges.bottom, Length::Definite(DefiniteLength::from(Pixels(0.)))); + /// assert_eq!(no_edges.left, Length::Definite(DefiniteLength::from(Pixels(0.)))); + /// ``` pub fn zero() -> Self { Self { top: px(0.).into(), @@ -595,6 +1301,25 @@ impl Edges { } impl Edges { + /// Sets the edges of the `Edges` struct to zero, which means no size or thickness. + /// + /// This is typically used when you want to specify that a box (like a padding or margin area) + /// should have no edges, effectively making it non-existent or invisible in layout calculations. + /// + /// # Returns + /// + /// Returns an `Edges` with all edges set to zero length. + /// + /// # Examples + /// + /// ``` + /// # use zed::Edges; + /// let no_edges = Edges::zero(); + /// assert_eq!(no_edges.top, DefiniteLength::from(zed::px(0.))); + /// assert_eq!(no_edges.right, DefiniteLength::from(zed::px(0.))); + /// assert_eq!(no_edges.bottom, DefiniteLength::from(zed::px(0.))); + /// assert_eq!(no_edges.left, DefiniteLength::from(zed::px(0.))); + /// ``` pub fn zero() -> Self { Self { top: px(0.).into(), @@ -604,6 +1329,42 @@ impl Edges { } } + /// Converts the `DefiniteLength` to `Pixels` based on the parent size and the REM size. + /// + /// This method allows for a `DefiniteLength` value to be converted into pixels, taking into account + /// the size of the parent element (for percentage-based lengths) and the size of a rem unit (for rem-based lengths). + /// + /// # Arguments + /// + /// * `parent_size` - `Size` representing the size of the parent element. + /// * `rem_size` - `Pixels` representing the size of one REM unit. + /// + /// # Returns + /// + /// Returns an `Edges` representing the edges with lengths converted to pixels. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Edges, DefiniteLength, px, AbsoluteLength, Size}; + /// let edges = Edges { + /// top: DefiniteLength::Absolute(AbsoluteLength::Pixels(px(10.0))), + /// right: DefiniteLength::Fraction(0.5), + /// bottom: DefiniteLength::Absolute(AbsoluteLength::Rems(rems(2.0))), + /// left: DefiniteLength::Fraction(0.25), + /// }; + /// let parent_size = Size { + /// width: AbsoluteLength::Pixels(px(200.0)), + /// height: AbsoluteLength::Pixels(px(100.0)), + /// }; + /// let rem_size = px(16.0); + /// let edges_in_pixels = edges.to_pixels(parent_size, rem_size); + /// + /// assert_eq!(edges_in_pixels.top, px(10.0)); // Absolute length in pixels + /// assert_eq!(edges_in_pixels.right, px(100.0)); // 50% of parent width + /// assert_eq!(edges_in_pixels.bottom, px(32.0)); // 2 rems + /// assert_eq!(edges_in_pixels.left, px(50.0)); // 25% of parent width + /// ``` pub fn to_pixels(&self, parent_size: Size, rem_size: Pixels) -> Edges { Edges { top: self.top.to_pixels(parent_size.height, rem_size), @@ -615,6 +1376,25 @@ impl Edges { } impl Edges { + /// Sets the edges of the `Edges` struct to zero, which means no size or thickness. + /// + /// This is typically used when you want to specify that a box (like a padding or margin area) + /// should have no edges, effectively making it non-existent or invisible in layout calculations. + /// + /// # Returns + /// + /// Returns an `Edges` with all edges set to zero length. + /// + /// # Examples + /// + /// ``` + /// # use zed::Edges; + /// let no_edges = Edges::zero(); + /// assert_eq!(no_edges.top, AbsoluteLength::Pixels(Pixels(0.0))); + /// assert_eq!(no_edges.right, AbsoluteLength::Pixels(Pixels(0.0))); + /// assert_eq!(no_edges.bottom, AbsoluteLength::Pixels(Pixels(0.0))); + /// assert_eq!(no_edges.left, AbsoluteLength::Pixels(Pixels(0.0))); + /// ``` pub fn zero() -> Self { Self { top: px(0.).into(), @@ -624,6 +1404,37 @@ impl Edges { } } + /// Converts the `AbsoluteLength` to `Pixels` based on the `rem_size`. + /// + /// If the `AbsoluteLength` is already in pixels, it simply returns the corresponding `Pixels` value. + /// If the `AbsoluteLength` is in rems, it multiplies the number of rems by the `rem_size` to convert it to pixels. + /// + /// # Arguments + /// + /// * `rem_size` - The size of one rem unit in pixels. + /// + /// # Returns + /// + /// Returns an `Edges` representing the edges with lengths converted to pixels. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Edges, AbsoluteLength, Pixels, px}; + /// let edges = Edges { + /// top: AbsoluteLength::Pixels(px(10.0)), + /// right: AbsoluteLength::Rems(rems(1.0)), + /// bottom: AbsoluteLength::Pixels(px(20.0)), + /// left: AbsoluteLength::Rems(rems(2.0)), + /// }; + /// let rem_size = px(16.0); + /// let edges_in_pixels = edges.to_pixels(rem_size); + /// + /// assert_eq!(edges_in_pixels.top, px(10.0)); // Already in pixels + /// assert_eq!(edges_in_pixels.right, px(16.0)); // 1 rem converted to pixels + /// assert_eq!(edges_in_pixels.bottom, px(20.0)); // Already in pixels + /// assert_eq!(edges_in_pixels.left, px(32.0)); // 2 rems converted to pixels + /// ``` pub fn to_pixels(&self, rem_size: Pixels) -> Edges { Edges { top: self.top.to_pixels(rem_size), @@ -635,6 +1446,34 @@ impl Edges { } impl Edges { + /// Scales the `Edges` by a given factor, returning `Edges`. + /// + /// This method is typically used for adjusting the edge sizes for different display densities or scaling factors. + /// + /// # Arguments + /// + /// * `factor` - The scaling factor to apply to each edge. + /// + /// # Returns + /// + /// Returns a new `Edges` where each edge is the result of scaling the original edge by the given factor. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Edges, Pixels}; + /// let edges = Edges { + /// top: Pixels(10.0), + /// right: Pixels(20.0), + /// bottom: Pixels(30.0), + /// left: Pixels(40.0), + /// }; + /// let scaled_edges = edges.scale(2.0); + /// assert_eq!(scaled_edges.top, ScaledPixels(20.0)); + /// assert_eq!(scaled_edges.right, ScaledPixels(40.0)); + /// assert_eq!(scaled_edges.bottom, ScaledPixels(60.0)); + /// assert_eq!(scaled_edges.left, ScaledPixels(80.0)); + /// ``` pub fn scale(&self, factor: f32) -> Edges { Edges { top: self.top.scale(factor), @@ -645,6 +1484,10 @@ impl Edges { } } +/// Represents the corners of a box in a 2D space, such as border radius. +/// +/// Each field represents the size of the corner on one side of the box: `top_left`, `top_right`, `bottom_right`, and `bottom_left`. +/// ``` #[derive(Refineable, Clone, Default, Debug, Eq, PartialEq)] #[refineable(Debug)] #[repr(C)] @@ -659,6 +1502,30 @@ impl Corners where T: Clone + Default + Debug, { + /// Constructs `Corners` where all sides are set to the same specified value. + /// + /// This function creates a `Corners` instance with the `top_left`, `top_right`, `bottom_right`, and `bottom_left` fields all initialized + /// to the same value provided as an argument. This is useful when you want to have uniform corners around a box, + /// such as a uniform border radius on a rectangle. + /// + /// # Arguments + /// + /// * `value` - The value to set for all four corners. + /// + /// # Returns + /// + /// An `Corners` instance with all corners set to the given value. + /// + /// # Examples + /// + /// ``` + /// # use zed::Corners; + /// let uniform_corners = Corners::all(5.0); + /// assert_eq!(uniform_corners.top_left, 5.0); + /// assert_eq!(uniform_corners.top_right, 5.0); + /// assert_eq!(uniform_corners.bottom_right, 5.0); + /// assert_eq!(uniform_corners.bottom_left, 5.0); + /// ``` pub fn all(value: T) -> Self { Self { top_left: value.clone(), @@ -670,6 +1537,42 @@ where } impl Corners { + /// Converts the `AbsoluteLength` to `Pixels` based on the provided size and rem size, ensuring the resulting + /// `Pixels` do not exceed half of the maximum of the provided size's width and height. + /// + /// This method is particularly useful when dealing with corner radii, where the radius in pixels should not + /// exceed half the size of the box it applies to, to avoid the corners overlapping. + /// + /// # Arguments + /// + /// * `size` - The `Size` against which the maximum allowable radius is determined. + /// * `rem_size` - The size of one REM unit in pixels, used for conversion if the `AbsoluteLength` is in REMs. + /// + /// # Returns + /// + /// Returns a `Corners` instance with each corner's length converted to pixels and clamped to the + /// maximum allowable radius based on the provided size. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Corners, AbsoluteLength, Pixels, Size}; + /// let corners = Corners { + /// top_left: AbsoluteLength::Pixels(Pixels(15.0)), + /// top_right: AbsoluteLength::Rems(Rems(1.0)), + /// bottom_right: AbsoluteLength::Pixels(Pixels(20.0)), + /// bottom_left: AbsoluteLength::Rems(Rems(2.0)), + /// }; + /// let size = Size { width: Pixels(100.0), height: Pixels(50.0) }; + /// let rem_size = Pixels(16.0); + /// let corners_in_pixels = corners.to_pixels(size, rem_size); + /// + /// // The resulting corners should not exceed half the size of the smallest dimension (50.0 / 2.0 = 25.0). + /// assert_eq!(corners_in_pixels.top_left, Pixels(15.0)); + /// assert_eq!(corners_in_pixels.top_right, Pixels(16.0)); // 1 rem converted to pixels + /// assert_eq!(corners_in_pixels.bottom_right, Pixels(20.0).min(Pixels(25.0))); // Clamped to 25.0 + /// assert_eq!(corners_in_pixels.bottom_left, Pixels(32.0).min(Pixels(25.0))); // 2 rems converted to pixels and clamped + /// ``` pub fn to_pixels(&self, size: Size, rem_size: Pixels) -> Corners { let max = size.width.max(size.height) / 2.; Corners { @@ -682,6 +1585,34 @@ impl Corners { } impl Corners { + /// Scales the `Corners` by a given factor, returning `Corners`. + /// + /// This method is typically used for adjusting the corner sizes for different display densities or scaling factors. + /// + /// # Arguments + /// + /// * `factor` - The scaling factor to apply to each corner. + /// + /// # Returns + /// + /// Returns a new `Corners` where each corner is the result of scaling the original corner by the given factor. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Corners, Pixels}; + /// let corners = Corners { + /// top_left: Pixels(10.0), + /// top_right: Pixels(20.0), + /// bottom_right: Pixels(30.0), + /// bottom_left: Pixels(40.0), + /// }; + /// let scaled_corners = corners.scale(2.0); + /// assert_eq!(scaled_corners.top_left, ScaledPixels(20.0)); + /// assert_eq!(scaled_corners.top_right, ScaledPixels(40.0)); + /// assert_eq!(scaled_corners.bottom_right, ScaledPixels(60.0)); + /// assert_eq!(scaled_corners.bottom_left, ScaledPixels(80.0)); + /// ``` pub fn scale(&self, factor: f32) -> Corners { Corners { top_left: self.top_left.scale(factor), @@ -693,6 +1624,38 @@ impl Corners { } impl Corners { + /// Applies a function to each field of the `Corners`, producing a new `Corners`. + /// + /// This method allows for converting a `Corners` to a `Corners` by specifying a closure + /// that defines how to convert between the two types. The closure is applied to each field + /// (`top_left`, `top_right`, `bottom_right`, `bottom_left`), resulting in new corners of the desired type. + /// + /// # Arguments + /// + /// * `f` - A closure that takes a reference to a value of type `T` and returns a value of type `U`. + /// + /// # Returns + /// + /// Returns a new `Corners` with each field mapped by the provided function. + /// + /// # Examples + /// + /// ``` + /// # use zed::{Corners, Pixels}; + /// let corners = Corners { + /// top_left: Pixels(10.0), + /// top_right: Pixels(20.0), + /// bottom_right: Pixels(30.0), + /// bottom_left: Pixels(40.0), + /// }; + /// let corners_in_rems = corners.map(|&px| Rems(px.0 / 16.0)); + /// assert_eq!(corners_in_rems, Corners { + /// top_left: Rems(0.625), + /// top_right: Rems(1.25), + /// bottom_right: Rems(1.875), + /// bottom_left: Rems(2.5), + /// }); + /// ``` pub fn map(&self, f: impl Fn(&T) -> U) -> Corners where U: Clone + Default + Debug, @@ -737,6 +1700,28 @@ where impl Copy for Corners where T: Copy + Clone + Default + Debug {} +/// Represents a length in pixels, the base unit of measurement in the UI framework. +/// +/// `Pixels` is a value type that represents an absolute length in pixels, which is used +/// for specifying sizes, positions, and distances in the UI. It is the fundamental unit +/// of measurement for all visual elements and layout calculations. +/// +/// The inner value is an `f32`, allowing for sub-pixel precision which can be useful for +/// anti-aliasing and animations. However, when applied to actual pixel grids, the value +/// is typically rounded to the nearest integer. +/// +/// # Examples +/// +/// ``` +/// use zed::Pixels; +/// +/// // Define a length of 10 pixels +/// let length = Pixels(10.0); +/// +/// // Define a length and scale it by a factor of 2 +/// let scaled_length = length.scale(2.0); +/// assert_eq!(scaled_length, Pixels(20.0)); +/// ``` #[derive( Clone, Copy, @@ -815,29 +1800,68 @@ impl MulAssign for Pixels { } impl Pixels { + /// Represents zero pixels. pub const ZERO: Pixels = Pixels(0.0); + /// The maximum value that can be represented by `Pixels`. pub const MAX: Pixels = Pixels(f32::MAX); + /// Floors the `Pixels` value to the nearest whole number. + /// + /// # Returns + /// + /// Returns a new `Pixels` instance with the floored value. pub fn floor(&self) -> Self { Self(self.0.floor()) } + /// Rounds the `Pixels` value to the nearest whole number. + /// + /// # Returns + /// + /// Returns a new `Pixels` instance with the rounded value. pub fn round(&self) -> Self { Self(self.0.round()) } + /// Returns the ceiling of the `Pixels` value to the nearest whole number. + /// + /// # Returns + /// + /// Returns a new `Pixels` instance with the ceiling value. pub fn ceil(&self) -> Self { Self(self.0.ceil()) } + /// Scales the `Pixels` value by a given factor, producing `ScaledPixels`. + /// + /// This method is used when adjusting pixel values for display scaling factors, + /// such as high DPI (dots per inch) or Retina displays, where the pixel density is higher and + /// thus requires scaling to maintain visual consistency and readability. + /// + /// The resulting `ScaledPixels` represent the scaled value which can be used for rendering + /// calculations where display scaling is considered. pub fn scale(&self, factor: f32) -> ScaledPixels { ScaledPixels(self.0 * factor) } + /// Raises the `Pixels` value to a given power. + /// + /// # Arguments + /// + /// * `exponent` - The exponent to raise the `Pixels` value by. + /// + /// # Returns + /// + /// Returns a new `Pixels` instance with the value raised to the given exponent. pub fn pow(&self, exponent: f32) -> Self { Self(self.0.powf(exponent)) } + /// Returns the absolute value of the `Pixels`. + /// + /// # Returns + /// + /// A new `Pixels` instance with the absolute value of the original `Pixels`. pub fn abs(&self) -> Self { Self(self.0.abs()) } @@ -925,6 +1949,13 @@ impl From for Pixels { } } +/// Represents physical pixels on the display. +/// +/// `DevicePixels` is a unit of measurement that refers to the actual pixels on a device's screen. +/// This type is used when precise pixel manipulation is required, such as rendering graphics or +/// interfacing with hardware that operates on the pixel level. Unlike logical pixels that may be +/// affected by the device's scale factor, `DevicePixels` always correspond to real pixels on the +/// display. #[derive( Add, AddAssign, Clone, Copy, Default, Div, Eq, Hash, Ord, PartialEq, PartialOrd, Sub, SubAssign, )] @@ -932,6 +1963,28 @@ impl From for Pixels { pub struct DevicePixels(pub(crate) i32); impl DevicePixels { + /// Converts the `DevicePixels` value to the number of bytes needed to represent it in memory. + /// + /// This function is useful when working with graphical data that needs to be stored in a buffer, + /// such as images or framebuffers, where each pixel may be represented by a specific number of bytes. + /// + /// # Arguments + /// + /// * `bytes_per_pixel` - The number of bytes used to represent a single pixel. + /// + /// # Returns + /// + /// The number of bytes required to represent the `DevicePixels` value in memory. + /// + /// # Examples + /// + /// ``` + /// # use zed::DevicePixels; + /// let pixels = DevicePixels(10); // 10 device pixels + /// let bytes_per_pixel = 4; // Assume each pixel is represented by 4 bytes (e.g., RGBA) + /// let total_bytes = pixels.to_bytes(bytes_per_pixel); + /// assert_eq!(total_bytes, 40); // 10 pixels * 4 bytes/pixel = 40 bytes + /// ``` pub fn to_bytes(&self, bytes_per_pixel: u8) -> u32 { self.0 as u32 * bytes_per_pixel as u32 } @@ -991,15 +2044,32 @@ impl From for DevicePixels { } } +/// Represents scaled pixels that take into account the device's scale factor. +/// +/// `ScaledPixels` are used to ensure that UI elements appear at the correct size on devices +/// with different pixel densities. When a device has a higher scale factor (such as Retina displays), +/// a single logical pixel may correspond to multiple physical pixels. By using `ScaledPixels`, +/// dimensions and positions can be specified in a way that scales appropriately across different +/// display resolutions. #[derive(Clone, Copy, Default, Add, AddAssign, Sub, SubAssign, Div, PartialEq, PartialOrd)] #[repr(transparent)] pub struct ScaledPixels(pub(crate) f32); impl ScaledPixels { + /// Floors the `ScaledPixels` value to the nearest whole number. + /// + /// # Returns + /// + /// Returns a new `ScaledPixels` instance with the floored value. pub fn floor(&self) -> Self { Self(self.0.floor()) } + /// Rounds the `ScaledPixels` value to the nearest whole number. + /// + /// # Returns + /// + /// Returns a new `ScaledPixels` instance with the rounded value. pub fn ceil(&self) -> Self { Self(self.0.ceil()) } @@ -1031,6 +2101,12 @@ impl From for f64 { } } +/// Represents pixels in a global coordinate space, which can span across multiple displays. +/// +/// `GlobalPixels` is used when dealing with a coordinate system that is not limited to a single +/// display's boundaries. This type is particularly useful in multi-monitor setups where +/// positioning and measurements need to be consistent and relative to a "global" origin point +/// rather than being relative to any individual display. #[derive(Clone, Copy, Default, Add, AddAssign, Sub, SubAssign, Div, PartialEq, PartialOrd)] #[repr(transparent)] pub struct GlobalPixels(pub(crate) f32); @@ -1065,6 +2141,14 @@ impl sqlez::bindable::Bind for GlobalPixels { } } +/// Represents a length in rems, a unit based on the font-size of the window, which can be assigned with [WindowContext::set_rem_size]. +/// +/// Rems are used for defining lengths that are scalable and consistent across different UI elements. +/// The value of `1rem` is typically equal to the font-size of the root element (often the `` element in browsers), +/// making it a flexible unit that adapts to the user's text size preferences. In this framework, `rems` serve a similar +/// purpose, allowing for scalable and accessible design that can adjust to different display settings or user preferences. +/// +/// For example, if the root element's font-size is `16px`, then `1rem` equals `16px`. A length of `2rems` would then be `32px`. #[derive(Clone, Copy, Default, Add, Sub, Mul, Div, Neg)] pub struct Rems(pub f32); @@ -1082,17 +2166,26 @@ impl Debug for Rems { } } +/// Represents an absolute length in pixels or rems. +/// +/// `AbsoluteLength` can be either a fixed number of pixels, which is an absolute measurement not +/// affected by the current font size, or a number of rems, which is relative to the font size of +/// the root element. It is used for specifying dimensions that are either independent of or +/// related to the typographic scale. #[derive(Clone, Copy, Debug, Neg)] pub enum AbsoluteLength { + /// A length in pixels. Pixels(Pixels), + /// A length in rems. Rems(Rems), } impl AbsoluteLength { + /// Checks if the absolute length is zero. pub fn is_zero(&self) -> bool { match self { - AbsoluteLength::Pixels(px) => px.0 == 0., - AbsoluteLength::Rems(rems) => rems.0 == 0., + AbsoluteLength::Pixels(px) => px.0 == 0.0, + AbsoluteLength::Rems(rems) => rems.0 == 0.0, } } } @@ -1110,6 +2203,27 @@ impl From for AbsoluteLength { } impl AbsoluteLength { + /// Converts an `AbsoluteLength` to `Pixels` based on a given `rem_size`. + /// + /// # Arguments + /// + /// * `rem_size` - The size of one rem in pixels. + /// + /// # Returns + /// + /// Returns the `AbsoluteLength` as `Pixels`. + /// + /// # Examples + /// + /// ``` + /// # use zed::{AbsoluteLength, Pixels}; + /// let length_in_pixels = AbsoluteLength::Pixels(Pixels(42.0)); + /// let length_in_rems = AbsoluteLength::Rems(Rems(2.0)); + /// let rem_size = Pixels(16.0); + /// + /// assert_eq!(length_in_pixels.to_pixels(rem_size), Pixels(42.0)); + /// assert_eq!(length_in_rems.to_pixels(rem_size), Pixels(32.0)); + /// ``` pub fn to_pixels(&self, rem_size: Pixels) -> Pixels { match self { AbsoluteLength::Pixels(pixels) => *pixels, @@ -1125,14 +2239,47 @@ impl Default for AbsoluteLength { } /// A non-auto length that can be defined in pixels, rems, or percent of parent. +/// +/// This enum represents lengths that have a specific value, as opposed to lengths that are automatically +/// determined by the context. It includes absolute lengths in pixels or rems, and relative lengths as a +/// fraction of the parent's size. #[derive(Clone, Copy, Neg)] pub enum DefiniteLength { + /// An absolute length specified in pixels or rems. Absolute(AbsoluteLength), - /// A fraction of the parent's size between 0 and 1. + /// A relative length specified as a fraction of the parent's size, between 0 and 1. Fraction(f32), } impl DefiniteLength { + /// Converts the `DefiniteLength` to `Pixels` based on a given `base_size` and `rem_size`. + /// + /// If the `DefiniteLength` is an absolute length, it will be directly converted to `Pixels`. + /// If it is a fraction, the fraction will be multiplied by the `base_size` to get the length in pixels. + /// + /// # Arguments + /// + /// * `base_size` - The base size in `AbsoluteLength` to which the fraction will be applied. + /// * `rem_size` - The size of one rem in pixels, used to convert rems to pixels. + /// + /// # Returns + /// + /// Returns the `DefiniteLength` as `Pixels`. + /// + /// # Examples + /// + /// ``` + /// # use zed::{DefiniteLength, AbsoluteLength, Pixels, px, rems}; + /// let length_in_pixels = DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))); + /// let length_in_rems = DefiniteLength::Absolute(AbsoluteLength::Rems(rems(2.0))); + /// let length_as_fraction = DefiniteLength::Fraction(0.5); + /// let base_size = AbsoluteLength::Pixels(px(100.0)); + /// let rem_size = px(16.0); + /// + /// assert_eq!(length_in_pixels.to_pixels(base_size, rem_size), Pixels(42.0)); + /// assert_eq!(length_in_rems.to_pixels(base_size, rem_size), Pixels(32.0)); + /// assert_eq!(length_as_fraction.to_pixels(base_size, rem_size), Pixels(50.0)); + /// ``` pub fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Pixels { match self { DefiniteLength::Absolute(size) => size.to_pixels(rem_size), @@ -1180,7 +2327,9 @@ impl Default for DefiniteLength { /// A length that can be defined in pixels, rems, percent of parent, or auto. #[derive(Clone, Copy)] pub enum Length { + /// A definite length specified either in pixels, rems, or as a fraction of the parent's size. Definite(DefiniteLength), + /// An automatic length that is determined by the context in which it is used. Auto, } @@ -1193,6 +2342,18 @@ impl Debug for Length { } } +/// Constructs a `DefiniteLength` representing a relative fraction of a parent size. +/// +/// This function creates a `DefiniteLength` that is a specified fraction of a parent's dimension. +/// The fraction should be a floating-point number between 0.0 and 1.0, where 1.0 represents 100% of the parent's size. +/// +/// # Arguments +/// +/// * `fraction` - The fraction of the parent's size, between 0.0 and 1.0. +/// +/// # Returns +/// +/// A `DefiniteLength` representing the relative length as a fraction of the parent's size. pub fn relative(fraction: f32) -> DefiniteLength { DefiniteLength::Fraction(fraction).into() } @@ -1202,14 +2363,43 @@ pub fn phi() -> DefiniteLength { relative(1.61803398875) } +/// Constructs a `Rems` value representing a length in rems. +/// +/// # Arguments +/// +/// * `rems` - The number of rems for the length. +/// +/// # Returns +/// +/// A `Rems` representing the specified number of rems. pub fn rems(rems: f32) -> Rems { Rems(rems) } +/// Constructs a `Pixels` value representing a length in pixels. +/// +/// # Arguments +/// +/// * `pixels` - The number of pixels for the length. +/// +/// # Returns +/// +/// A `Pixels` representing the specified number of pixels. pub const fn px(pixels: f32) -> Pixels { Pixels(pixels) } +/// Returns a `Length` representing an automatic length. +/// +/// The `auto` length is often used in layout calculations where the length should be determined +/// by the layout context itself rather than being explicitly set. This is commonly used in CSS +/// for properties like `width`, `height`, `margin`, `padding`, etc., where `auto` can be used +/// to instruct the layout engine to calculate the size based on other factors like the size of the +/// container or the intrinsic size of the content. +/// +/// # Returns +/// +/// A `Length` variant set to `Auto`. pub fn auto() -> Length { Length::Auto } @@ -1250,7 +2440,17 @@ impl From<()> for Length { } } +/// Provides a trait for types that can calculate half of their value. +/// +/// The `Half` trait is used for types that can be evenly divided, returning a new instance of the same type +/// representing half of the original value. This is commonly used for types that represent measurements or sizes, +/// such as lengths or pixels, where halving is a frequent operation during layout calculations or animations. pub trait Half { + /// Returns half of the current value. + /// + /// # Returns + /// + /// A new instance of the implementing type, representing half of the original value. fn half(&self) -> Self; } @@ -1290,7 +2490,18 @@ impl Half for GlobalPixels { } } +/// A trait for checking if a value is zero. +/// +/// This trait provides a method to determine if a value is considered to be zero. +/// It is implemented for various numeric and length-related types where the concept +/// of zero is applicable. This can be useful for comparisons, optimizations, or +/// determining if an operation has a neutral effect. pub trait IsZero { + /// Determines if the value is zero. + /// + /// # Returns + /// + /// Returns `true` if the value is zero, `false` otherwise. fn is_zero(&self) -> bool; } diff --git a/crates/gpui2/src/key_dispatch.rs b/crates/gpui2/src/key_dispatch.rs index 4838b1a612..7b8d506d03 100644 --- a/crates/gpui2/src/key_dispatch.rs +++ b/crates/gpui2/src/key_dispatch.rs @@ -28,7 +28,7 @@ pub(crate) struct DispatchTree { pub(crate) struct DispatchNode { pub key_listeners: SmallVec<[KeyListener; 2]>, pub action_listeners: SmallVec<[DispatchActionListener; 16]>, - pub context: KeyContext, + pub context: Option, parent: Option, } @@ -61,7 +61,7 @@ impl DispatchTree { self.keystroke_matchers.clear(); } - pub fn push_node(&mut self, context: KeyContext) { + pub fn push_node(&mut self, context: Option) { let parent = self.node_stack.last().copied(); let node_id = DispatchNodeId(self.nodes.len()); self.nodes.push(DispatchNode { @@ -69,34 +69,34 @@ impl DispatchTree { ..Default::default() }); self.node_stack.push(node_id); - if !context.is_empty() { - self.active_node().context = context.clone(); + if let Some(context) = context { + self.active_node().context = Some(context.clone()); self.context_stack.push(context); } } pub fn pop_node(&mut self) { let node_id = self.node_stack.pop().unwrap(); - if !self.nodes[node_id.0].context.is_empty() { + if self.nodes[node_id.0].context.is_some() { self.context_stack.pop(); } } - pub fn clear_keystroke_matchers(&mut self) { + pub fn clear_pending_keystrokes(&mut self) { self.keystroke_matchers.clear(); } /// Preserve keystroke matchers from previous frames to support multi-stroke /// bindings across multiple frames. - pub fn preserve_keystroke_matchers(&mut self, old_tree: &mut Self, focus_id: Option) { + pub fn preserve_pending_keystrokes(&mut self, old_tree: &mut Self, focus_id: Option) { if let Some(node_id) = focus_id.and_then(|focus_id| self.focusable_node_id(focus_id)) { let dispatch_path = self.dispatch_path(node_id); self.context_stack.clear(); for node_id in dispatch_path { let node = self.node(node_id); - if !node.context.is_empty() { - self.context_stack.push(node.context.clone()); + if let Some(context) = node.context.clone() { + self.context_stack.push(context); } if let Some((context_stack, matcher)) = old_tree @@ -148,21 +148,33 @@ impl DispatchTree { false } - pub fn available_actions(&self, target: FocusId) -> Vec> { + pub fn available_actions(&self, target: DispatchNodeId) -> Vec> { let mut actions = Vec::new(); - if let Some(node) = self.focusable_node_ids.get(&target) { - for node_id in self.dispatch_path(*node) { - let node = &self.nodes[node_id.0]; - for DispatchActionListener { action_type, .. } in &node.action_listeners { - // Intentionally silence these errors without logging. - // If an action cannot be built by default, it's not available. - actions.extend(self.action_registry.build_action_type(action_type).ok()); - } + for node_id in self.dispatch_path(target) { + let node = &self.nodes[node_id.0]; + for DispatchActionListener { action_type, .. } in &node.action_listeners { + // Intentionally silence these errors without logging. + // If an action cannot be built by default, it's not available. + actions.extend(self.action_registry.build_action_type(action_type).ok()); } } actions } + pub fn is_action_available(&self, action: &dyn Action, target: DispatchNodeId) -> bool { + for node_id in self.dispatch_path(target) { + let node = &self.nodes[node_id.0]; + if node + .action_listeners + .iter() + .any(|listener| listener.action_type == action.as_any().type_id()) + { + return true; + } + } + false + } + pub fn bindings_for_action( &self, action: &dyn Action, @@ -236,6 +248,11 @@ impl DispatchTree { self.focusable_node_ids.get(&target).copied() } + pub fn root_node_id(&self) -> DispatchNodeId { + debug_assert!(!self.nodes.is_empty()); + DispatchNodeId(0) + } + fn active_node_id(&self) -> DispatchNodeId { *self.node_stack.last().unwrap() } diff --git a/crates/gpui2/src/platform.rs b/crates/gpui2/src/platform.rs index c345ea0643..006640af4f 100644 --- a/crates/gpui2/src/platform.rs +++ b/crates/gpui2/src/platform.rs @@ -1,3 +1,4 @@ +mod app_menu; mod keystroke; #[cfg(target_os = "macos")] mod mac; @@ -5,10 +6,10 @@ mod mac; mod test; use crate::{ - point, size, AnyWindowHandle, BackgroundExecutor, Bounds, DevicePixels, Font, FontId, - FontMetrics, FontRun, ForegroundExecutor, GlobalPixels, GlyphId, InputEvent, LineLayout, - Pixels, Point, RenderGlyphParams, RenderImageParams, RenderSvgParams, Result, Scene, - SharedString, Size, TaskLabel, + point, size, Action, AnyWindowHandle, BackgroundExecutor, Bounds, DevicePixels, Font, FontId, + FontMetrics, FontRun, ForegroundExecutor, GlobalPixels, GlyphId, InputEvent, Keymap, + LineLayout, Pixels, Point, RenderGlyphParams, RenderImageParams, RenderSvgParams, Result, + Scene, SharedString, Size, TaskLabel, }; use anyhow::{anyhow, bail}; use async_task::Runnable; @@ -32,6 +33,7 @@ use std::{ }; use uuid::Uuid; +pub use app_menu::*; pub use keystroke::*; #[cfg(target_os = "macos")] pub use mac::*; @@ -44,7 +46,7 @@ pub(crate) fn current_platform() -> Rc { Rc::new(MacPlatform::new()) } -pub trait Platform: 'static { +pub(crate) trait Platform: 'static { fn background_executor(&self) -> BackgroundExecutor; fn foreground_executor(&self) -> ForegroundExecutor; fn text_system(&self) -> Arc; @@ -59,7 +61,7 @@ pub trait Platform: 'static { fn displays(&self) -> Vec>; fn display(&self, id: DisplayId) -> Option>; - fn main_window(&self) -> Option; + fn active_window(&self) -> Option; fn open_window( &self, handle: AnyWindowHandle, @@ -90,6 +92,11 @@ pub trait Platform: 'static { fn on_reopen(&self, callback: Box); fn on_event(&self, callback: Box bool>); + fn set_menus(&self, menus: Vec, keymap: &Keymap); + fn on_app_menu_action(&self, callback: Box); + fn on_will_open_app_menu(&self, callback: Box); + fn on_validate_app_menu_command(&self, callback: Box bool>); + fn os_name(&self) -> &'static str; fn os_version(&self) -> Result; fn app_version(&self) -> Result; @@ -138,6 +145,7 @@ pub trait PlatformWindow { fn mouse_position(&self) -> Point; fn as_any_mut(&mut self) -> &mut dyn Any; fn set_input_handler(&mut self, input_handler: Box); + fn clear_input_handler(&mut self); fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str]) -> oneshot::Receiver; fn activate(&self); fn set_title(&mut self, title: &str); diff --git a/crates/gpui2/src/platform/app_menu.rs b/crates/gpui2/src/platform/app_menu.rs new file mode 100644 index 0000000000..10fe2cf33a --- /dev/null +++ b/crates/gpui2/src/platform/app_menu.rs @@ -0,0 +1,77 @@ +use crate::{Action, AppContext, Platform}; +use util::ResultExt; + +pub struct Menu<'a> { + pub name: &'a str, + pub items: Vec>, +} + +pub enum MenuItem<'a> { + Separator, + Submenu(Menu<'a>), + Action { + name: &'a str, + action: Box, + os_action: Option, + }, +} + +impl<'a> MenuItem<'a> { + pub fn separator() -> Self { + Self::Separator + } + + pub fn submenu(menu: Menu<'a>) -> Self { + Self::Submenu(menu) + } + + pub fn action(name: &'a str, action: impl Action) -> Self { + Self::Action { + name, + action: Box::new(action), + os_action: None, + } + } + + pub fn os_action(name: &'a str, action: impl Action, os_action: OsAction) -> Self { + Self::Action { + name, + action: Box::new(action), + os_action: Some(os_action), + } + } +} + +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum OsAction { + Cut, + Copy, + Paste, + SelectAll, + Undo, + Redo, +} + +pub(crate) fn init_app_menus(platform: &dyn Platform, cx: &mut AppContext) { + platform.on_will_open_app_menu(Box::new({ + let cx = cx.to_async(); + move || { + cx.update(|cx| cx.clear_pending_keystrokes()).ok(); + } + })); + + platform.on_validate_app_menu_command(Box::new({ + let cx = cx.to_async(); + move |action| { + cx.update(|cx| cx.is_action_available(action)) + .unwrap_or(false) + } + })); + + platform.on_app_menu_action(Box::new({ + let cx = cx.to_async(); + move |action| { + cx.update(|cx| cx.dispatch_action(action)).log_err(); + } + })); +} diff --git a/crates/gpui2/src/platform/mac/display_linker.rs b/crates/gpui2/src/platform/mac/display_linker.rs index b63cf24e26..d8f5a675a5 100644 --- a/crates/gpui2/src/platform/mac/display_linker.rs +++ b/crates/gpui2/src/platform/mac/display_linker.rs @@ -7,6 +7,7 @@ use std::{ use crate::DisplayId; use collections::HashMap; use parking_lot::Mutex; +pub use sys::CVSMPTETime as SmtpeTime; pub use sys::CVTimeStamp as VideoTimestamp; pub(crate) struct MacDisplayLinker { @@ -153,7 +154,7 @@ mod sys { kCVTimeStampTopField | kCVTimeStampBottomField; #[repr(C)] - #[derive(Clone, Copy)] + #[derive(Clone, Copy, Default)] pub struct CVSMPTETime { pub subframes: i16, pub subframe_divisor: i16, diff --git a/crates/gpui2/src/platform/mac/platform.rs b/crates/gpui2/src/platform/mac/platform.rs index 314f055811..2deea545e1 100644 --- a/crates/gpui2/src/platform/mac/platform.rs +++ b/crates/gpui2/src/platform/mac/platform.rs @@ -1,18 +1,19 @@ -use super::BoolExt; +use super::{events::key_to_native, BoolExt}; use crate::{ - AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, - InputEvent, MacDispatcher, MacDisplay, MacDisplayLinker, MacTextSystem, MacWindow, - PathPromptOptions, Platform, PlatformDisplay, PlatformTextSystem, PlatformWindow, Result, - SemanticVersion, VideoTimestamp, WindowOptions, + Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, + ForegroundExecutor, InputEvent, Keymap, MacDispatcher, MacDisplay, MacDisplayLinker, + MacTextSystem, MacWindow, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, + PlatformTextSystem, PlatformWindow, Result, SemanticVersion, VideoTimestamp, WindowOptions, }; use anyhow::anyhow; use block::ConcreteBlock; use cocoa::{ appkit::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - NSModalResponse, NSOpenPanel, NSPasteboard, NSPasteboardTypeString, NSSavePanel, NSWindow, + NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard, + NSPasteboardTypeString, NSSavePanel, NSWindow, }, - base::{id, nil, BOOL, YES}, + base::{id, nil, selector, BOOL, YES}, foundation::{ NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL, @@ -155,12 +156,12 @@ pub struct MacPlatformState { reopen: Option>, quit: Option>, event: Option bool>>, - // menu_command: Option>, - // validate_menu_command: Option bool>>, + menu_command: Option>, + validate_menu_command: Option bool>>, will_open_menu: Option>, + menu_actions: Vec>, open_urls: Option)>>, finish_launching: Option>, - // menu_actions: Vec>, } impl MacPlatform { @@ -179,12 +180,12 @@ impl MacPlatform { reopen: None, quit: None, event: None, + menu_command: None, + validate_menu_command: None, will_open_menu: None, + menu_actions: Default::default(), open_urls: None, finish_launching: None, - // menu_command: None, - // validate_menu_command: None, - // menu_actions: Default::default(), })) } @@ -200,151 +201,153 @@ impl MacPlatform { } } - // unsafe fn create_menu_bar( - // &self, - // menus: Vec, - // delegate: id, - // actions: &mut Vec>, - // keystroke_matcher: &KeymapMatcher, - // ) -> id { - // let application_menu = NSMenu::new(nil).autorelease(); - // application_menu.setDelegate_(delegate); + unsafe fn create_menu_bar( + &self, + menus: Vec, + delegate: id, + actions: &mut Vec>, + keymap: &Keymap, + ) -> id { + let application_menu = NSMenu::new(nil).autorelease(); + application_menu.setDelegate_(delegate); - // for menu_config in menus { - // let menu = NSMenu::new(nil).autorelease(); - // menu.setTitle_(ns_string(menu_config.name)); - // menu.setDelegate_(delegate); + for menu_config in menus { + let menu = NSMenu::new(nil).autorelease(); + menu.setTitle_(ns_string(menu_config.name)); + menu.setDelegate_(delegate); - // for item_config in menu_config.items { - // menu.addItem_(self.create_menu_item( - // item_config, - // delegate, - // actions, - // keystroke_matcher, - // )); - // } + for item_config in menu_config.items { + menu.addItem_(self.create_menu_item(item_config, delegate, actions, keymap)); + } - // let menu_item = NSMenuItem::new(nil).autorelease(); - // menu_item.setSubmenu_(menu); - // application_menu.addItem_(menu_item); + let menu_item = NSMenuItem::new(nil).autorelease(); + menu_item.setSubmenu_(menu); + application_menu.addItem_(menu_item); - // if menu_config.name == "Window" { - // let app: id = msg_send![APP_CLASS, sharedApplication]; - // app.setWindowsMenu_(menu); - // } - // } + if menu_config.name == "Window" { + let app: id = msg_send![APP_CLASS, sharedApplication]; + app.setWindowsMenu_(menu); + } + } - // application_menu - // } + application_menu + } - // unsafe fn create_menu_item( - // &self, - // item: MenuItem, - // delegate: id, - // actions: &mut Vec>, - // keystroke_matcher: &KeymapMatcher, - // ) -> id { - // match item { - // MenuItem::Separator => NSMenuItem::separatorItem(nil), - // MenuItem::Action { - // name, - // action, - // os_action, - // } => { - // // TODO - // let keystrokes = keystroke_matcher - // .bindings_for_action(action.id()) - // .find(|binding| binding.action().eq(action.as_ref())) - // .map(|binding| binding.keystrokes()); - // let selector = match os_action { - // Some(crate::OsAction::Cut) => selector("cut:"), - // Some(crate::OsAction::Copy) => selector("copy:"), - // Some(crate::OsAction::Paste) => selector("paste:"), - // Some(crate::OsAction::SelectAll) => selector("selectAll:"), - // Some(crate::OsAction::Undo) => selector("undo:"), - // Some(crate::OsAction::Redo) => selector("redo:"), - // None => selector("handleGPUIMenuItem:"), - // }; + unsafe fn create_menu_item( + &self, + item: MenuItem, + delegate: id, + actions: &mut Vec>, + keymap: &Keymap, + ) -> id { + match item { + MenuItem::Separator => NSMenuItem::separatorItem(nil), + MenuItem::Action { + name, + action, + os_action, + } => { + let keystrokes = keymap + .bindings_for_action(action.type_id()) + .find(|binding| binding.action().partial_eq(action.as_ref())) + .map(|binding| binding.keystrokes()); - // let item; - // if let Some(keystrokes) = keystrokes { - // if keystrokes.len() == 1 { - // let keystroke = &keystrokes[0]; - // let mut mask = NSEventModifierFlags::empty(); - // for (modifier, flag) in &[ - // (keystroke.cmd, NSEventModifierFlags::NSCommandKeyMask), - // (keystroke.ctrl, NSEventModifierFlags::NSControlKeyMask), - // (keystroke.alt, NSEventModifierFlags::NSAlternateKeyMask), - // (keystroke.shift, NSEventModifierFlags::NSShiftKeyMask), - // ] { - // if *modifier { - // mask |= *flag; - // } - // } + let selector = match os_action { + Some(crate::OsAction::Cut) => selector("cut:"), + Some(crate::OsAction::Copy) => selector("copy:"), + Some(crate::OsAction::Paste) => selector("paste:"), + Some(crate::OsAction::SelectAll) => selector("selectAll:"), + Some(crate::OsAction::Undo) => selector("undo:"), + Some(crate::OsAction::Redo) => selector("redo:"), + None => selector("handleGPUIMenuItem:"), + }; - // item = NSMenuItem::alloc(nil) - // .initWithTitle_action_keyEquivalent_( - // ns_string(name), - // selector, - // ns_string(key_to_native(&keystroke.key).as_ref()), - // ) - // .autorelease(); - // item.setKeyEquivalentModifierMask_(mask); - // } - // // For multi-keystroke bindings, render the keystroke as part of the title. - // else { - // use std::fmt::Write; + let item; + if let Some(keystrokes) = keystrokes { + if keystrokes.len() == 1 { + let keystroke = &keystrokes[0]; + let mut mask = NSEventModifierFlags::empty(); + for (modifier, flag) in &[ + ( + keystroke.modifiers.command, + NSEventModifierFlags::NSCommandKeyMask, + ), + ( + keystroke.modifiers.control, + NSEventModifierFlags::NSControlKeyMask, + ), + ( + keystroke.modifiers.alt, + NSEventModifierFlags::NSAlternateKeyMask, + ), + ( + keystroke.modifiers.shift, + NSEventModifierFlags::NSShiftKeyMask, + ), + ] { + if *modifier { + mask |= *flag; + } + } - // let mut name = format!("{name} ["); - // for (i, keystroke) in keystrokes.iter().enumerate() { - // if i > 0 { - // name.push(' '); - // } - // write!(&mut name, "{}", keystroke).unwrap(); - // } - // name.push(']'); + item = NSMenuItem::alloc(nil) + .initWithTitle_action_keyEquivalent_( + ns_string(name), + selector, + ns_string(key_to_native(&keystroke.key).as_ref()), + ) + .autorelease(); + item.setKeyEquivalentModifierMask_(mask); + } + // For multi-keystroke bindings, render the keystroke as part of the title. + else { + use std::fmt::Write; - // item = NSMenuItem::alloc(nil) - // .initWithTitle_action_keyEquivalent_( - // ns_string(&name), - // selector, - // ns_string(""), - // ) - // .autorelease(); - // } - // } else { - // item = NSMenuItem::alloc(nil) - // .initWithTitle_action_keyEquivalent_( - // ns_string(name), - // selector, - // ns_string(""), - // ) - // .autorelease(); - // } + let mut name = format!("{name} ["); + for (i, keystroke) in keystrokes.iter().enumerate() { + if i > 0 { + name.push(' '); + } + write!(&mut name, "{}", keystroke).unwrap(); + } + name.push(']'); - // let tag = actions.len() as NSInteger; - // let _: () = msg_send![item, setTag: tag]; - // actions.push(action); - // item - // } - // MenuItem::Submenu(Menu { name, items }) => { - // let item = NSMenuItem::new(nil).autorelease(); - // let submenu = NSMenu::new(nil).autorelease(); - // submenu.setDelegate_(delegate); - // for item in items { - // submenu.addItem_(self.create_menu_item( - // item, - // delegate, - // actions, - // keystroke_matcher, - // )); - // } - // item.setSubmenu_(submenu); - // item.setTitle_(ns_string(name)); - // item - // } - // } - // } + item = NSMenuItem::alloc(nil) + .initWithTitle_action_keyEquivalent_( + ns_string(&name), + selector, + ns_string(""), + ) + .autorelease(); + } + } else { + item = NSMenuItem::alloc(nil) + .initWithTitle_action_keyEquivalent_( + ns_string(name), + selector, + ns_string(""), + ) + .autorelease(); + } + + let tag = actions.len() as NSInteger; + let _: () = msg_send![item, setTag: tag]; + actions.push(action); + item + } + MenuItem::Submenu(Menu { name, items }) => { + let item = NSMenuItem::new(nil).autorelease(); + let submenu = NSMenu::new(nil).autorelease(); + submenu.setDelegate_(delegate); + for item in items { + submenu.addItem_(self.create_menu_item(item, delegate, actions, keymap)); + } + item.setSubmenu_(submenu); + item.setTitle_(ns_string(name)); + item + } + } + } } impl Platform for MacPlatform { @@ -479,8 +482,8 @@ impl Platform for MacPlatform { MacDisplay::find_by_id(id).map(|screen| Rc::new(screen) as Rc<_>) } - fn main_window(&self) -> Option { - MacWindow::main_window() + fn active_window(&self) -> Option { + MacWindow::active_window() } fn open_window( @@ -631,6 +634,18 @@ impl Platform for MacPlatform { self.0.lock().event = Some(callback); } + fn on_app_menu_action(&self, callback: Box) { + self.0.lock().menu_command = Some(callback); + } + + fn on_will_open_app_menu(&self, callback: Box) { + self.0.lock().will_open_menu = Some(callback); + } + + fn on_validate_app_menu_command(&self, callback: Box bool>) { + self.0.lock().validate_menu_command = Some(callback); + } + fn os_name(&self) -> &'static str { "macOS" } @@ -673,6 +688,15 @@ impl Platform for MacPlatform { } } + fn set_menus(&self, menus: Vec, keymap: &Keymap) { + unsafe { + let app: id = msg_send![APP_CLASS, sharedApplication]; + let mut state = self.0.lock(); + let actions = &mut state.menu_actions; + app.setMainMenu_(self.create_menu_bar(menus, app.delegate(), actions, keymap)); + } + } + fn local_timezone(&self) -> UtcOffset { unsafe { let local_timezone: id = msg_send![class!(NSTimeZone), localTimeZone]; @@ -681,32 +705,6 @@ impl Platform for MacPlatform { } } - // fn on_menu_command(&self, callback: Box) { - // self.0.lock().menu_command = Some(callback); - // } - - // fn on_will_open_menu(&self, callback: Box) { - // self.0.lock().will_open_menu = Some(callback); - // } - - // fn on_validate_menu_command(&self, callback: Box bool>) { - // self.0.lock().validate_menu_command = Some(callback); - // } - - // fn set_menus(&self, menus: Vec, keystroke_matcher: &KeymapMatcher) { - // unsafe { - // let app: id = msg_send![APP_CLASS, sharedApplication]; - // let mut state = self.0.lock(); - // let actions = &mut state.menu_actions; - // app.setMainMenu_(self.create_menu_bar( - // menus, - // app.delegate(), - // actions, - // keystroke_matcher, - // )); - // } - // } - fn path_for_auxiliary_executable(&self, name: &str) -> Result { unsafe { let bundle: id = NSBundle::mainBundle(); @@ -956,7 +954,7 @@ unsafe fn path_from_objc(path: id) -> PathBuf { PathBuf::from(path) } -unsafe fn get_foreground_platform(object: &mut Object) -> &MacPlatform { +unsafe fn get_mac_platform(object: &mut Object) -> &MacPlatform { let platform_ptr: *mut c_void = *object.get_ivar(MAC_PLATFORM_IVAR); assert!(!platform_ptr.is_null()); &*(platform_ptr as *const MacPlatform) @@ -965,7 +963,7 @@ unsafe fn get_foreground_platform(object: &mut Object) -> &MacPlatform { extern "C" fn send_event(this: &mut Object, _sel: Sel, native_event: id) { unsafe { if let Some(event) = InputEvent::from_native(native_event, None) { - let platform = get_foreground_platform(this); + let platform = get_mac_platform(this); if let Some(callback) = platform.0.lock().event.as_mut() { if !callback(event) { return; @@ -981,7 +979,7 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { let app: id = msg_send![APP_CLASS, sharedApplication]; app.setActivationPolicy_(NSApplicationActivationPolicyRegular); - let platform = get_foreground_platform(this); + let platform = get_mac_platform(this); let callback = platform.0.lock().finish_launching.take(); if let Some(callback) = callback { callback(); @@ -991,7 +989,7 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { extern "C" fn should_handle_reopen(this: &mut Object, _: Sel, _: id, has_open_windows: bool) { if !has_open_windows { - let platform = unsafe { get_foreground_platform(this) }; + let platform = unsafe { get_mac_platform(this) }; if let Some(callback) = platform.0.lock().reopen.as_mut() { callback(); } @@ -999,21 +997,21 @@ extern "C" fn should_handle_reopen(this: &mut Object, _: Sel, _: id, has_open_wi } extern "C" fn did_become_active(this: &mut Object, _: Sel, _: id) { - let platform = unsafe { get_foreground_platform(this) }; + let platform = unsafe { get_mac_platform(this) }; if let Some(callback) = platform.0.lock().become_active.as_mut() { callback(); } } extern "C" fn did_resign_active(this: &mut Object, _: Sel, _: id) { - let platform = unsafe { get_foreground_platform(this) }; + let platform = unsafe { get_mac_platform(this) }; if let Some(callback) = platform.0.lock().resign_active.as_mut() { callback(); } } extern "C" fn will_terminate(this: &mut Object, _: Sel, _: id) { - let platform = unsafe { get_foreground_platform(this) }; + let platform = unsafe { get_mac_platform(this) }; if let Some(callback) = platform.0.lock().quit.as_mut() { callback(); } @@ -1035,49 +1033,47 @@ extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) { }) .collect::>() }; - let platform = unsafe { get_foreground_platform(this) }; + let platform = unsafe { get_mac_platform(this) }; if let Some(callback) = platform.0.lock().open_urls.as_mut() { callback(urls); } } -extern "C" fn handle_menu_item(__this: &mut Object, _: Sel, __item: id) { - todo!() - // unsafe { - // let platform = get_foreground_platform(this); - // let mut platform = platform.0.lock(); - // if let Some(mut callback) = platform.menu_command.take() { - // let tag: NSInteger = msg_send![item, tag]; - // let index = tag as usize; - // if let Some(action) = platform.menu_actions.get(index) { - // callback(action.as_ref()); - // } - // platform.menu_command = Some(callback); - // } - // } +extern "C" fn handle_menu_item(this: &mut Object, _: Sel, item: id) { + unsafe { + let platform = get_mac_platform(this); + let mut platform = platform.0.lock(); + if let Some(mut callback) = platform.menu_command.take() { + let tag: NSInteger = msg_send![item, tag]; + let index = tag as usize; + if let Some(action) = platform.menu_actions.get(index) { + callback(action.as_ref()); + } + platform.menu_command = Some(callback); + } + } } -extern "C" fn validate_menu_item(__this: &mut Object, _: Sel, __item: id) -> bool { - todo!() - // unsafe { - // let mut result = false; - // let platform = get_foreground_platform(this); - // let mut platform = platform.0.lock(); - // if let Some(mut callback) = platform.validate_menu_command.take() { - // let tag: NSInteger = msg_send![item, tag]; - // let index = tag as usize; - // if let Some(action) = platform.menu_actions.get(index) { - // result = callback(action.as_ref()); - // } - // platform.validate_menu_command = Some(callback); - // } - // result - // } +extern "C" fn validate_menu_item(this: &mut Object, _: Sel, item: id) -> bool { + unsafe { + let mut result = false; + let platform = get_mac_platform(this); + let mut platform = platform.0.lock(); + if let Some(mut callback) = platform.validate_menu_command.take() { + let tag: NSInteger = msg_send![item, tag]; + let index = tag as usize; + if let Some(action) = platform.menu_actions.get(index) { + result = callback(action.as_ref()); + } + platform.validate_menu_command = Some(callback); + } + result + } } extern "C" fn menu_will_open(this: &mut Object, _: Sel, _: id) { unsafe { - let platform = get_foreground_platform(this); + let platform = get_mac_platform(this); let mut platform = platform.0.lock(); if let Some(mut callback) = platform.will_open_menu.take() { callback(); diff --git a/crates/gpui2/src/platform/mac/window.rs b/crates/gpui2/src/platform/mac/window.rs index 5b72c10851..03ba635327 100644 --- a/crates/gpui2/src/platform/mac/window.rs +++ b/crates/gpui2/src/platform/mac/window.rs @@ -662,7 +662,7 @@ impl MacWindow { } } - pub fn main_window() -> Option { + pub fn active_window() -> Option { unsafe { let app = NSApplication::sharedApplication(nil); let main_window: id = msg_send![app, mainWindow]; @@ -750,6 +750,10 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().input_handler = Some(input_handler); } + fn clear_input_handler(&mut self) { + self.0.as_ref().lock().input_handler = None; + } + fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str]) -> oneshot::Receiver { // macOs applies overrides to modal window buttons after they are added. // Two most important for this logic are: diff --git a/crates/gpui2/src/platform/test/display.rs b/crates/gpui2/src/platform/test/display.rs index 78d75296e6..95f1daf8e9 100644 --- a/crates/gpui2/src/platform/test/display.rs +++ b/crates/gpui2/src/platform/test/display.rs @@ -15,7 +15,7 @@ impl TestDisplay { id: DisplayId(1), uuid: uuid::Uuid::new_v4(), bounds: Bounds::from_corners( - Point::zero(), + Point::default(), Point::new(GlobalPixels(1920.), GlobalPixels(1080.)), ), } diff --git a/crates/gpui2/src/platform/test/platform.rs b/crates/gpui2/src/platform/test/platform.rs index fa4b6e18c5..876120b626 100644 --- a/crates/gpui2/src/platform/test/platform.rs +++ b/crates/gpui2/src/platform/test/platform.rs @@ -1,6 +1,6 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, - Platform, PlatformDisplay, PlatformTextSystem, TestDisplay, TestWindow, WindowOptions, + Keymap, Platform, PlatformDisplay, PlatformTextSystem, TestDisplay, TestWindow, WindowOptions, }; use anyhow::{anyhow, Result}; use collections::VecDeque; @@ -127,7 +127,7 @@ impl Platform for TestPlatform { self.displays().iter().find(|d| d.id() == id).cloned() } - fn main_window(&self) -> Option { + fn active_window(&self) -> Option { unimplemented!() } @@ -147,18 +147,25 @@ impl Platform for TestPlatform { fn set_display_link_output_callback( &self, _display_id: DisplayId, - _callback: Box, + mut callback: Box, ) { - unimplemented!() + let timestamp = crate::VideoTimestamp { + version: 0, + video_time_scale: 0, + video_time: 0, + host_time: 0, + rate_scalar: 0.0, + video_refresh_period: 0, + smpte_time: crate::SmtpeTime::default(), + flags: 0, + reserved: 0, + }; + callback(×tamp, ×tamp) } - fn start_display_link(&self, _display_id: DisplayId) { - unimplemented!() - } + fn start_display_link(&self, _display_id: DisplayId) {} - fn stop_display_link(&self, _display_id: DisplayId) { - unimplemented!() - } + fn stop_display_link(&self, _display_id: DisplayId) {} fn open_url(&self, _url: &str) { unimplemented!() @@ -205,6 +212,14 @@ impl Platform for TestPlatform { unimplemented!() } + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + + fn on_app_menu_action(&self, _callback: Box) {} + + fn on_will_open_app_menu(&self, _callback: Box) {} + + fn on_validate_app_menu_command(&self, _callback: Box bool>) {} + fn os_name(&self) -> &'static str { "test" } diff --git a/crates/gpui2/src/platform/test/window.rs b/crates/gpui2/src/platform/test/window.rs index b1bfebad06..9b7ad72472 100644 --- a/crates/gpui2/src/platform/test/window.rs +++ b/crates/gpui2/src/platform/test/window.rs @@ -66,11 +66,11 @@ impl PlatformWindow for TestWindow { } fn titlebar_height(&self) -> Pixels { - todo!() + unimplemented!() } fn appearance(&self) -> WindowAppearance { - todo!() + unimplemented!() } fn display(&self) -> std::rc::Rc { @@ -78,7 +78,7 @@ impl PlatformWindow for TestWindow { } fn mouse_position(&self) -> Point { - Point::zero() + Point::default() } fn as_any_mut(&mut self) -> &mut dyn std::any::Any { @@ -89,6 +89,10 @@ impl PlatformWindow for TestWindow { self.input_handler = Some(Arc::new(Mutex::new(input_handler))); } + fn clear_input_handler(&mut self) { + self.input_handler = None; + } + fn prompt( &self, _level: crate::PromptLevel, @@ -99,7 +103,7 @@ impl PlatformWindow for TestWindow { } fn activate(&self) { - todo!() + unimplemented!() } fn set_title(&mut self, title: &str) { @@ -107,23 +111,23 @@ impl PlatformWindow for TestWindow { } fn set_edited(&mut self, _edited: bool) { - todo!() + unimplemented!() } fn show_character_palette(&self) { - todo!() + unimplemented!() } fn minimize(&self) { - todo!() + unimplemented!() } fn zoom(&self) { - todo!() + unimplemented!() } fn toggle_full_screen(&self) { - todo!() + unimplemented!() } fn on_input(&self, callback: Box bool>) { @@ -139,7 +143,7 @@ impl PlatformWindow for TestWindow { } fn on_fullscreen(&self, _callback: Box) { - todo!() + unimplemented!() } fn on_moved(&self, callback: Box) { @@ -147,19 +151,19 @@ impl PlatformWindow for TestWindow { } fn on_should_close(&self, _callback: Box bool>) { - todo!() + unimplemented!() } fn on_close(&self, _callback: Box) { - todo!() + unimplemented!() } fn on_appearance_changed(&self, _callback: Box) { - todo!() + unimplemented!() } fn is_topmost_for_position(&self, _position: crate::Point) -> bool { - todo!() + unimplemented!() } fn draw(&self, scene: crate::Scene) { @@ -223,7 +227,7 @@ impl PlatformAtlas for TestAtlas { }, tile_id: TileId(tile_id), bounds: crate::Bounds { - origin: Point::zero(), + origin: Point::default(), size, }, }, diff --git a/crates/gpui2/src/style.rs b/crates/gpui2/src/style.rs index 9254eaeb85..5c511b41a4 100644 --- a/crates/gpui2/src/style.rs +++ b/crates/gpui2/src/style.rs @@ -385,7 +385,7 @@ impl Default for Style { min_size: Size::auto(), max_size: Size::auto(), aspect_ratio: None, - gap: Size::zero(), + gap: Size::default(), // Aligment align_items: None, align_self: None, diff --git a/crates/gpui2/src/window.rs b/crates/gpui2/src/window.rs index 8eb14769bf..455472a349 100644 --- a/crates/gpui2/src/window.rs +++ b/crates/gpui2/src/window.rs @@ -430,7 +430,7 @@ impl<'a> WindowContext<'a> { self.window .current_frame .dispatch_tree - .clear_keystroke_matchers(); + .clear_pending_keystrokes(); self.app.push_effect(Effect::FocusChanged { window_handle: self.window.handle, focused: Some(focus_id), @@ -453,19 +453,21 @@ impl<'a> WindowContext<'a> { } pub fn dispatch_action(&mut self, action: Box) { - if let Some(focus_handle) = self.focused() { - self.defer(move |cx| { - if let Some(node_id) = cx - .window - .current_frame - .dispatch_tree - .focusable_node_id(focus_handle.id) - { - cx.propagate_event = true; - cx.dispatch_action_on_node(node_id, action); - } - }) - } + let focus_handle = self.focused(); + + self.defer(move |cx| { + let node_id = focus_handle + .and_then(|handle| { + cx.window + .current_frame + .dispatch_tree + .focusable_node_id(handle.id) + }) + .unwrap_or_else(|| cx.window.current_frame.dispatch_tree.root_node_id()); + + cx.propagate_event = true; + cx.dispatch_action_on_node(node_id, action); + }) } /// Schedules the given function to be run at the end of the current effect cycle, allowing entities @@ -802,6 +804,22 @@ impl<'a> WindowContext<'a> { ); } + pub fn is_action_available(&self, action: &dyn Action) -> bool { + let target = self + .focused() + .and_then(|focused_handle| { + self.window + .current_frame + .dispatch_tree + .focusable_node_id(focused_handle.id) + }) + .unwrap_or_else(|| self.window.current_frame.dispatch_tree.root_node_id()); + self.window + .current_frame + .dispatch_tree + .is_action_available(action, target) + } + /// The position of the mouse relative to the window. pub fn mouse_position(&self) -> Point { self.window.mouse_position @@ -1154,8 +1172,19 @@ impl<'a> WindowContext<'a> { self.start_frame(); self.with_z_index(0, |cx| { - let available_space = cx.window.viewport_size.map(Into::into); - root_view.draw(Point::zero(), available_space, cx); + cx.with_key_dispatch(Some(KeyContext::default()), None, |_, cx| { + for (action_type, action_listeners) in &cx.app.global_action_listeners { + for action_listener in action_listeners.iter().cloned() { + cx.window.current_frame.dispatch_tree.on_action( + *action_type, + Rc::new(move |action, phase, cx| action_listener(action, phase, cx)), + ) + } + } + + let available_space = cx.window.viewport_size.map(Into::into); + root_view.draw(Point::default(), available_space, cx); + }) }); if let Some(active_drag) = self.app.active_drag.take() { @@ -1163,8 +1192,8 @@ impl<'a> WindowContext<'a> { let offset = cx.mouse_position() - active_drag.cursor_offset; let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); active_drag.view.draw(offset, available_space, cx); - cx.active_drag = Some(active_drag); }); + self.active_drag = Some(active_drag); } else if let Some(active_tooltip) = self.app.active_tooltip.take() { self.with_z_index(1, |cx| { let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); @@ -1177,7 +1206,7 @@ impl<'a> WindowContext<'a> { self.window .current_frame .dispatch_tree - .preserve_keystroke_matchers( + .preserve_pending_keystrokes( &mut self.window.previous_frame.dispatch_tree, self.window.focus, ); @@ -1199,6 +1228,7 @@ impl<'a> WindowContext<'a> { /// Rotate the current frame and the previous frame, then clear the current frame. /// We repopulate all state in the current frame during each paint. fn start_frame(&mut self) { + self.window.platform_window.clear_input_handler(); self.text_system().start_frame(); let window = &mut *self.window; @@ -1338,75 +1368,79 @@ impl<'a> WindowContext<'a> { } fn dispatch_key_event(&mut self, event: &dyn Any) { - if let Some(node_id) = self.window.focus.and_then(|focus_id| { - self.window - .current_frame - .dispatch_tree - .focusable_node_id(focus_id) - }) { - let dispatch_path = self - .window - .current_frame - .dispatch_tree - .dispatch_path(node_id); + let node_id = self + .window + .focus + .and_then(|focus_id| { + self.window + .current_frame + .dispatch_tree + .focusable_node_id(focus_id) + }) + .unwrap_or_else(|| self.window.current_frame.dispatch_tree.root_node_id()); - let mut actions: Vec> = Vec::new(); + let dispatch_path = self + .window + .current_frame + .dispatch_tree + .dispatch_path(node_id); - // Capture phase - let mut context_stack: SmallVec<[KeyContext; 16]> = SmallVec::new(); - self.propagate_event = true; + let mut actions: Vec> = Vec::new(); - for node_id in &dispatch_path { - let node = self.window.current_frame.dispatch_tree.node(*node_id); + // Capture phase + let mut context_stack: SmallVec<[KeyContext; 16]> = SmallVec::new(); + self.propagate_event = true; - if !node.context.is_empty() { - context_stack.push(node.context.clone()); - } + for node_id in &dispatch_path { + let node = self.window.current_frame.dispatch_tree.node(*node_id); - for key_listener in node.key_listeners.clone() { - key_listener(event, DispatchPhase::Capture, self); - if !self.propagate_event { - return; - } - } + if let Some(context) = node.context.clone() { + context_stack.push(context); } - // Bubble phase - for node_id in dispatch_path.iter().rev() { - // Handle low level key events - let node = self.window.current_frame.dispatch_tree.node(*node_id); - for key_listener in node.key_listeners.clone() { - key_listener(event, DispatchPhase::Bubble, self); - if !self.propagate_event { - return; - } - } - - // Match keystrokes - let node = self.window.current_frame.dispatch_tree.node(*node_id); - if !node.context.is_empty() { - if let Some(key_down_event) = event.downcast_ref::() { - if let Some(found) = self - .window - .current_frame - .dispatch_tree - .dispatch_key(&key_down_event.keystroke, &context_stack) - { - actions.push(found.boxed_clone()) - } - } - - context_stack.pop(); - } - } - - for action in actions { - self.dispatch_action_on_node(node_id, action); + for key_listener in node.key_listeners.clone() { + key_listener(event, DispatchPhase::Capture, self); if !self.propagate_event { return; } } } + + // Bubble phase + for node_id in dispatch_path.iter().rev() { + // Handle low level key events + let node = self.window.current_frame.dispatch_tree.node(*node_id); + for key_listener in node.key_listeners.clone() { + key_listener(event, DispatchPhase::Bubble, self); + if !self.propagate_event { + return; + } + } + + // Match keystrokes + let node = self.window.current_frame.dispatch_tree.node(*node_id); + if node.context.is_some() { + if let Some(key_down_event) = event.downcast_ref::() { + if let Some(found) = self + .window + .current_frame + .dispatch_tree + .dispatch_key(&key_down_event.keystroke, &context_stack) + { + actions.push(found.boxed_clone()) + } + } + + context_stack.pop(); + } + } + + for action in actions { + self.dispatch_action_on_node(node_id, action); + if !self.propagate_event { + return; + } + } } fn dispatch_action_on_node(&mut self, node_id: DispatchNodeId, action: Box) { @@ -1490,14 +1524,21 @@ impl<'a> WindowContext<'a> { } pub fn available_actions(&self) -> Vec> { - if let Some(focus_id) = self.window.focus { - self.window - .current_frame - .dispatch_tree - .available_actions(focus_id) - } else { - Vec::new() - } + let node_id = self + .window + .focus + .and_then(|focus_id| { + self.window + .current_frame + .dispatch_tree + .focusable_node_id(focus_id) + }) + .unwrap_or_else(|| self.window.current_frame.dispatch_tree.root_node_id()); + + self.window + .current_frame + .dispatch_tree + .available_actions(node_id) } pub fn bindings_for_action(&self, action: &dyn Action) -> Vec { @@ -1523,7 +1564,7 @@ impl<'a> WindowContext<'a> { let context_stack = dispatch_tree .dispatch_path(node_id) .into_iter() - .map(|node_id| dispatch_tree.node(node_id).context.clone()) + .filter_map(|node_id| dispatch_tree.node(node_id).context.clone()) .collect(); dispatch_tree.bindings_for_action(action, &context_stack) } @@ -1553,7 +1594,7 @@ impl<'a> WindowContext<'a> { //========== ELEMENT RELATED FUNCTIONS =========== pub fn with_key_dispatch( &mut self, - context: KeyContext, + context: Option, focus_handle: Option, f: impl FnOnce(Option, &mut Self) -> R, ) -> R { @@ -2816,3 +2857,9 @@ impl From<(&'static str, EntityId)> for ElementId { ElementId::NamedInteger(name.into(), id.as_u64() as usize) } } + +impl From<(&'static str, usize)> for ElementId { + fn from((name, id): (&'static str, usize)) -> Self { + ElementId::NamedInteger(name.into(), id) + } +} diff --git a/crates/language2/src/highlight_map.rs b/crates/language2/src/highlight_map.rs index 8e7a35233c..270ac259c9 100644 --- a/crates/language2/src/highlight_map.rs +++ b/crates/language2/src/highlight_map.rs @@ -95,8 +95,6 @@ mod tests { .iter() .map(|(name, color)| (name.to_string(), (*color).into())) .collect(), - inlay_style: HighlightStyle::default(), - suggestion_style: HighlightStyle::default(), }; let capture_names = &[ diff --git a/crates/node_runtime/Cargo.toml b/crates/node_runtime/Cargo.toml index faa837fb67..0b4e7a262d 100644 --- a/crates/node_runtime/Cargo.toml +++ b/crates/node_runtime/Cargo.toml @@ -10,7 +10,7 @@ doctest = false [dependencies] util = { path = "../util" } -async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] } +async-compression.workspace = true async-tar = "0.4.2" futures.workspace = true async-trait.workspace = true diff --git a/crates/project_panel2/src/project_panel.rs b/crates/project_panel2/src/project_panel.rs index 81a7c779ca..41a5c544a9 100644 --- a/crates/project_panel2/src/project_panel.rs +++ b/crates/project_panel2/src/project_panel.rs @@ -55,7 +55,6 @@ pub struct ProjectPanel { clipboard_entry: Option, _dragged_entry_destination: Option>, _workspace: WeakView, - has_focus: bool, width: Option, pending_serialization: Task>, } @@ -172,7 +171,6 @@ impl ProjectPanel { let focus_handle = cx.focus_handle(); cx.on_focus(&focus_handle, Self::focus_in).detach(); - cx.on_blur(&focus_handle, Self::focus_out).detach(); cx.subscribe(&project, |this, project, event, cx| match event { project::Event::ActiveEntryChanged(Some(entry_id)) => { @@ -238,7 +236,6 @@ impl ProjectPanel { // context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), _dragged_entry_destination: None, _workspace: workspace.weak_handle(), - has_focus: false, width: None, pending_serialization: Task::ready(None), }; @@ -356,16 +353,11 @@ impl ProjectPanel { } fn focus_in(&mut self, cx: &mut ViewContext) { - if !self.has_focus { - self.has_focus = true; + if !self.focus_handle.contains_focused(cx) { cx.emit(Event::Focus); } } - fn focus_out(&mut self, _: &mut ViewContext) { - self.has_focus = false; - } - fn deploy_context_menu( &mut self, position: Point, @@ -1557,10 +1549,6 @@ impl Panel for ProjectPanel { Box::new(ToggleFocus) } - fn has_focus(&self, _: &WindowContext) -> bool { - self.has_focus - } - fn persistent_name() -> &'static str { "Project Panel" } diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index d648e83f8f..074ef7d2f3 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -13,7 +13,7 @@ use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView, Workspac pub struct QuickActionBar { buffer_search_bar: ViewHandle, active_item: Option>, - _inlay_hints_enabled_subscription: Option, + inlay_hints_enabled_subscription: Option, workspace: WeakViewHandle, } @@ -22,7 +22,7 @@ impl QuickActionBar { Self { buffer_search_bar, active_item: None, - _inlay_hints_enabled_subscription: None, + inlay_hints_enabled_subscription: None, workspace: workspace.weak_handle(), } } @@ -161,12 +161,12 @@ impl ToolbarItemView for QuickActionBar { match active_pane_item { Some(active_item) => { self.active_item = Some(active_item.boxed_clone()); - self._inlay_hints_enabled_subscription.take(); + self.inlay_hints_enabled_subscription.take(); if let Some(editor) = active_item.downcast::() { let mut inlay_hints_enabled = editor.read(cx).inlay_hints_enabled(); let mut supports_inlay_hints = editor.read(cx).supports_inlay_hints(cx); - self._inlay_hints_enabled_subscription = + self.inlay_hints_enabled_subscription = Some(cx.observe(&editor, move |_, editor, cx| { let editor = editor.read(cx); let new_inlay_hints_enabled = editor.inlay_hints_enabled(); diff --git a/crates/quick_action_bar2/Cargo.toml b/crates/quick_action_bar2/Cargo.toml new file mode 100644 index 0000000000..678e5c16f1 --- /dev/null +++ b/crates/quick_action_bar2/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "quick_action_bar2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/quick_action_bar.rs" +doctest = false + +[dependencies] +assistant = { package = "assistant2", path = "../assistant2" } +editor = { package = "editor2", path = "../editor2" } +gpui = { package = "gpui2", path = "../gpui2" } +search = { package = "search2", path = "../search2" } +workspace = { package = "workspace2", path = "../workspace2" } +ui = { package = "ui2", path = "../ui2" } + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } diff --git a/crates/quick_action_bar2/src/quick_action_bar.rs b/crates/quick_action_bar2/src/quick_action_bar.rs new file mode 100644 index 0000000000..e933689e62 --- /dev/null +++ b/crates/quick_action_bar2/src/quick_action_bar.rs @@ -0,0 +1,193 @@ +use assistant::{AssistantPanel, InlineAssist}; +use editor::Editor; + +use gpui::{ + Action, ClickEvent, Div, ElementId, EventEmitter, InteractiveElement, ParentElement, Render, + Stateful, Styled, Subscription, View, ViewContext, WeakView, +}; +use search::BufferSearchBar; +use ui::{prelude::*, ButtonSize, ButtonStyle, Icon, IconButton, IconSize, Tooltip}; +use workspace::{ + item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, +}; + +pub struct QuickActionBar { + buffer_search_bar: View, + active_item: Option>, + _inlay_hints_enabled_subscription: Option, + workspace: WeakView, +} + +impl QuickActionBar { + pub fn new(buffer_search_bar: View, workspace: &Workspace) -> Self { + Self { + buffer_search_bar, + active_item: None, + _inlay_hints_enabled_subscription: None, + workspace: workspace.weak_handle(), + } + } + + fn active_editor(&self) -> Option> { + self.active_item + .as_ref() + .and_then(|item| item.downcast::()) + } +} + +impl Render for QuickActionBar { + type Element = Stateful
; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let Some(editor) = self.active_editor() else { + return div().id("empty quick action bar"); + }; + + let inlay_hints_button = Some(QuickActionBarButton::new( + "toggle inlay hints", + Icon::InlayHint, + editor.read(cx).inlay_hints_enabled(), + Box::new(editor::ToggleInlayHints), + "Toggle Inlay Hints", + { + let editor = editor.clone(); + move |_, cx| { + editor.update(cx, |editor, cx| { + editor.toggle_inlay_hints(&editor::ToggleInlayHints, cx); + }); + } + }, + )) + .filter(|_| editor.read(cx).supports_inlay_hints(cx)); + + let search_button = Some(QuickActionBarButton::new( + "toggle buffer search", + Icon::MagnifyingGlass, + !self.buffer_search_bar.read(cx).is_dismissed(), + Box::new(search::buffer_search::Deploy { focus: false }), + "Buffer Search", + { + let buffer_search_bar = self.buffer_search_bar.clone(); + move |_, cx| { + buffer_search_bar.update(cx, |search_bar, cx| search_bar.toggle(cx)); + } + }, + )) + .filter(|_| editor.is_singleton(cx)); + + let assistant_button = QuickActionBarButton::new( + "toggle inline assistant", + Icon::MagicWand, + false, + Box::new(InlineAssist), + "Inline Assist", + { + let workspace = self.workspace.clone(); + move |_, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + AssistantPanel::inline_assist(workspace, &InlineAssist, cx); + }); + } + } + }, + ); + + h_stack() + .id("quick action bar") + .p_1() + .gap_2() + .children(inlay_hints_button) + .children(search_button) + .child(assistant_button) + } +} + +impl EventEmitter for QuickActionBar {} + +#[derive(IntoElement)] +struct QuickActionBarButton { + id: ElementId, + icon: Icon, + toggled: bool, + action: Box, + tooltip: SharedString, + on_click: Box, +} + +impl QuickActionBarButton { + fn new( + id: impl Into, + icon: Icon, + toggled: bool, + action: Box, + tooltip: impl Into, + on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static, + ) -> Self { + Self { + id: id.into(), + icon, + toggled, + action, + tooltip: tooltip.into(), + on_click: Box::new(on_click), + } + } +} + +impl RenderOnce for QuickActionBarButton { + type Rendered = IconButton; + + fn render(self, _: &mut WindowContext) -> Self::Rendered { + let tooltip = self.tooltip.clone(); + let action = self.action.boxed_clone(); + + IconButton::new(self.id.clone(), self.icon) + .size(ButtonSize::Compact) + .icon_size(IconSize::Small) + .style(ButtonStyle::Subtle) + .selected(self.toggled) + .tooltip(move |cx| Tooltip::for_action(tooltip.clone(), &*action, cx)) + .on_click(move |event, cx| (self.on_click)(event, cx)) + } +} + +impl ToolbarItemView for QuickActionBar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) -> ToolbarItemLocation { + match active_pane_item { + Some(active_item) => { + self.active_item = Some(active_item.boxed_clone()); + self._inlay_hints_enabled_subscription.take(); + + if let Some(editor) = active_item.downcast::() { + let mut inlay_hints_enabled = editor.read(cx).inlay_hints_enabled(); + let mut supports_inlay_hints = editor.read(cx).supports_inlay_hints(cx); + self._inlay_hints_enabled_subscription = + Some(cx.observe(&editor, move |_, editor, cx| { + let editor = editor.read(cx); + let new_inlay_hints_enabled = editor.inlay_hints_enabled(); + let new_supports_inlay_hints = editor.supports_inlay_hints(cx); + let should_notify = inlay_hints_enabled != new_inlay_hints_enabled + || supports_inlay_hints != new_supports_inlay_hints; + inlay_hints_enabled = new_inlay_hints_enabled; + supports_inlay_hints = new_supports_inlay_hints; + if should_notify { + cx.notify() + } + })); + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + None => { + self.active_item = None; + ToolbarItemLocation::Hidden + } + } + } +} diff --git a/crates/recent_projects2/Cargo.toml b/crates/recent_projects2/Cargo.toml new file mode 100644 index 0000000000..3d10c147e0 --- /dev/null +++ b/crates/recent_projects2/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "recent_projects2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/recent_projects.rs" +doctest = false + +[dependencies] +db = { path = "../db" } +editor = { package = "editor2", path = "../editor2" } +fuzzy = { package = "fuzzy2", path = "../fuzzy2" } +gpui = { package = "gpui2", path = "../gpui2" } +language = { package = "language2", path = "../language2" } +picker = { package = "picker2", path = "../picker2" } +settings = { package = "settings2", path = "../settings2" } +text = { package = "text2", path = "../text2" } +util = { path = "../util"} +theme = { package = "theme2", path = "../theme2" } +ui = { package = "ui2", path = "../ui2" } +workspace = { package = "workspace2", path = "../workspace2" } + +futures.workspace = true +ordered-float.workspace = true +postage.workspace = true +smol.workspace = true + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } diff --git a/crates/recent_projects2/src/highlighted_workspace_location.rs b/crates/recent_projects2/src/highlighted_workspace_location.rs new file mode 100644 index 0000000000..a4057d2f4b --- /dev/null +++ b/crates/recent_projects2/src/highlighted_workspace_location.rs @@ -0,0 +1,131 @@ +use std::path::Path; + +use fuzzy::StringMatch; +use ui::{prelude::*, HighlightedLabel}; +use util::paths::PathExt; +use workspace::WorkspaceLocation; + +#[derive(IntoElement)] +pub struct HighlightedText { + pub text: String, + pub highlight_positions: Vec, + char_count: usize, +} + +impl HighlightedText { + fn join(components: impl Iterator, separator: &str) -> Self { + let mut char_count = 0; + let separator_char_count = separator.chars().count(); + let mut text = String::new(); + let mut highlight_positions = Vec::new(); + for component in components { + if char_count != 0 { + text.push_str(separator); + char_count += separator_char_count; + } + + highlight_positions.extend( + component + .highlight_positions + .iter() + .map(|position| position + char_count), + ); + text.push_str(&component.text); + char_count += component.text.chars().count(); + } + + Self { + text, + highlight_positions, + char_count, + } + } +} + +impl RenderOnce for HighlightedText { + type Rendered = HighlightedLabel; + + fn render(self, _cx: &mut WindowContext) -> Self::Rendered { + HighlightedLabel::new(self.text, self.highlight_positions) + } +} + +pub struct HighlightedWorkspaceLocation { + pub names: HighlightedText, + pub paths: Vec, +} + +impl HighlightedWorkspaceLocation { + pub fn new(string_match: &StringMatch, location: &WorkspaceLocation) -> Self { + let mut path_start_offset = 0; + let (names, paths): (Vec<_>, Vec<_>) = location + .paths() + .iter() + .map(|path| { + let path = path.compact(); + let highlighted_text = Self::highlights_for_path( + path.as_ref(), + &string_match.positions, + path_start_offset, + ); + + path_start_offset += highlighted_text.1.char_count; + + highlighted_text + }) + .unzip(); + + Self { + names: HighlightedText::join(names.into_iter().filter_map(|name| name), ", "), + paths, + } + } + + // Compute the highlighted text for the name and path + fn highlights_for_path( + path: &Path, + match_positions: &Vec, + path_start_offset: usize, + ) -> (Option, HighlightedText) { + let path_string = path.to_string_lossy(); + let path_char_count = path_string.chars().count(); + // Get the subset of match highlight positions that line up with the given path. + // Also adjusts them to start at the path start + let path_positions = match_positions + .iter() + .copied() + .skip_while(|position| *position < path_start_offset) + .take_while(|position| *position < path_start_offset + path_char_count) + .map(|position| position - path_start_offset) + .collect::>(); + + // Again subset the highlight positions to just those that line up with the file_name + // again adjusted to the start of the file_name + let file_name_text_and_positions = path.file_name().map(|file_name| { + let text = file_name.to_string_lossy(); + let char_count = text.chars().count(); + let file_name_start = path_char_count - char_count; + let highlight_positions = path_positions + .iter() + .copied() + .skip_while(|position| *position < file_name_start) + .take_while(|position| *position < file_name_start + char_count) + .map(|position| position - file_name_start) + .collect::>(); + HighlightedText { + text: text.to_string(), + highlight_positions, + char_count, + } + }); + + ( + file_name_text_and_positions, + HighlightedText { + text: path_string.to_string(), + highlight_positions: path_positions, + char_count: path_char_count, + }, + ) + } +} diff --git a/crates/recent_projects2/src/recent_projects.rs b/crates/recent_projects2/src/recent_projects.rs new file mode 100644 index 0000000000..f6c2e0a2a2 --- /dev/null +++ b/crates/recent_projects2/src/recent_projects.rs @@ -0,0 +1,233 @@ +mod highlighted_workspace_location; + +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + actions, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, Result, Task, + View, ViewContext, WeakView, +}; +use highlighted_workspace_location::HighlightedWorkspaceLocation; +use ordered_float::OrderedFloat; +use picker::{Picker, PickerDelegate}; +use std::sync::Arc; +use ui::{prelude::*, ListItem}; +use util::paths::PathExt; +use workspace::{ + notifications::simple_message_notification::MessageNotification, Workspace, WorkspaceLocation, + WORKSPACE_DB, +}; + +actions!(OpenRecent); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(RecentProjects::register).detach(); +} + +pub struct RecentProjects { + picker: View>, +} + +impl RecentProjects { + fn new(delegate: RecentProjectsDelegate, cx: &mut ViewContext) -> Self { + Self { + picker: cx.build_view(|cx| Picker::new(delegate, cx)), + } + } + + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(|workspace, _: &OpenRecent, cx| { + let Some(recent_projects) = workspace.active_modal::(cx) else { + if let Some(handler) = Self::open(workspace, cx) { + handler.detach_and_log_err(cx); + } + return; + }; + + recent_projects.update(cx, |recent_projects, cx| { + recent_projects + .picker + .update(cx, |picker, cx| picker.cycle_selection(cx)) + }); + }); + } + + fn open(_: &mut Workspace, cx: &mut ViewContext) -> Option>> { + Some(cx.spawn(|workspace, mut cx| async move { + let workspace_locations: Vec<_> = cx + .background_executor() + .spawn(async { + WORKSPACE_DB + .recent_workspaces_on_disk() + .await + .unwrap_or_default() + .into_iter() + .map(|(_, location)| location) + .collect() + }) + .await; + + workspace.update(&mut cx, |workspace, cx| { + if !workspace_locations.is_empty() { + let weak_workspace = cx.view().downgrade(); + workspace.toggle_modal(cx, |cx| { + let delegate = + RecentProjectsDelegate::new(weak_workspace, workspace_locations, true); + + RecentProjects::new(delegate, cx) + }); + } else { + workspace.show_notification(0, cx, |cx| { + cx.build_view(|_| MessageNotification::new("No recent projects to open.")) + }) + } + })?; + Ok(()) + })) + } +} + +impl EventEmitter for RecentProjects {} + +impl FocusableView for RecentProjects { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl Render for RecentProjects { + type Element = Div; + + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + v_stack().w_96().child(self.picker.clone()) + } +} + +pub struct RecentProjectsDelegate { + workspace: WeakView, + workspace_locations: Vec, + selected_match_index: usize, + matches: Vec, + render_paths: bool, +} + +impl RecentProjectsDelegate { + fn new( + workspace: WeakView, + workspace_locations: Vec, + render_paths: bool, + ) -> Self { + Self { + workspace, + workspace_locations, + selected_match_index: 0, + matches: Default::default(), + render_paths, + } + } +} + +impl PickerDelegate for RecentProjectsDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self) -> Arc { + "Recent Projects...".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_match_index + } + + fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { + self.selected_match_index = ix; + } + + fn update_matches( + &mut self, + query: String, + cx: &mut ViewContext>, + ) -> gpui::Task<()> { + let query = query.trim_start(); + let smart_case = query.chars().any(|c| c.is_uppercase()); + let candidates = self + .workspace_locations + .iter() + .enumerate() + .map(|(id, location)| { + let combined_string = location + .paths() + .iter() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""); + StringMatchCandidate::new(id, combined_string) + }) + .collect::>(); + self.matches = smol::block_on(fuzzy::match_strings( + candidates.as_slice(), + query, + smart_case, + 100, + &Default::default(), + cx.background_executor().clone(), + )); + self.matches.sort_unstable_by_key(|m| m.candidate_id); + + self.selected_match_index = self + .matches + .iter() + .enumerate() + .rev() + .max_by_key(|(_, m)| OrderedFloat(m.score)) + .map(|(ix, _)| ix) + .unwrap_or(0); + Task::ready(()) + } + + fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { + if let Some((selected_match, workspace)) = self + .matches + .get(self.selected_index()) + .zip(self.workspace.upgrade()) + { + let workspace_location = &self.workspace_locations[selected_match.candidate_id]; + workspace + .update(cx, |workspace, cx| { + workspace + .open_workspace_for_paths(workspace_location.paths().as_ref().clone(), cx) + }) + .detach_and_log_err(cx); + self.dismissed(cx); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + let Some(r#match) = self.matches.get(ix) else { + return None; + }; + + let highlighted_location = HighlightedWorkspaceLocation::new( + &r#match, + &self.workspace_locations[r#match.candidate_id], + ); + + Some( + ListItem::new(ix).inset(true).selected(selected).child( + v_stack() + .child(highlighted_location.names) + .when(self.render_paths, |this| { + this.children(highlighted_location.paths) + }), + ), + ) + } +} diff --git a/crates/search2/src/buffer_search.rs b/crates/search2/src/buffer_search.rs index b3d6006113..da32f51194 100644 --- a/crates/search2/src/buffer_search.rs +++ b/crates/search2/src/buffer_search.rs @@ -10,15 +10,15 @@ use collections::HashMap; use editor::{Editor, EditorMode}; use futures::channel::oneshot; use gpui::{ - actions, div, red, Action, AppContext, Div, EventEmitter, InteractiveElement as _, IntoElement, - ParentElement as _, Render, Styled, Subscription, Task, View, ViewContext, VisualContext as _, - WeakView, WindowContext, + actions, div, red, Action, AppContext, Div, EventEmitter, FocusableView, + InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled, Subscription, Task, + View, ViewContext, VisualContext as _, WeakView, WindowContext, }; use project::search::SearchQuery; use serde::Deserialize; use std::{any::Any, sync::Arc}; -use ui::{h_stack, Icon, IconButton, IconElement}; +use ui::{h_stack, Clickable, Icon, IconButton, IconElement}; use util::ResultExt; use workspace::{ item::ItemHandle, @@ -161,16 +161,6 @@ impl Render for BufferSearchBar { Some(ui::Label::new(message)) }); - let nav_button_for_direction = |icon, direction| { - render_nav_button( - icon, - self.active_match_index.is_some(), - cx.listener(move |this, _, cx| match direction { - Direction::Prev => this.select_prev_match(&Default::default(), cx), - Direction::Next => this.select_next_match(&Default::default(), cx), - }), - ) - }; let should_show_replace_input = self.replace_enabled && supported_options.replacement; let replace_all = should_show_replace_input .then(|| super::render_replace_button(ReplaceAll, ui::Icon::ReplaceAll)); @@ -237,20 +227,32 @@ impl Render for BufferSearchBar { h_stack() .gap_0p5() .flex_none() - .child(self.render_action_button()) + .child(self.render_action_button(cx)) .children(match_count) - .child(nav_button_for_direction( + .child(render_nav_button( ui::Icon::ChevronLeft, - Direction::Prev, + self.active_match_index.is_some(), + cx.listener(move |this, _, cx| { + this.select_prev_match(&Default::default(), cx); + }), )) - .child(nav_button_for_direction( + .child(render_nav_button( ui::Icon::ChevronRight, - Direction::Next, + self.active_match_index.is_some(), + cx.listener(move |this, _, cx| { + this.select_next_match(&Default::default(), cx); + }), )), ) } } +impl FocusableView for BufferSearchBar { + fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { + self.query_editor.focus_handle(cx) + } +} + impl ToolbarItemView for BufferSearchBar { fn set_active_pane_item( &mut self, @@ -311,13 +313,7 @@ impl BufferSearchBar { pane.update(cx, |this, cx| { this.toolbar().update(cx, |this, cx| { if let Some(search_bar) = this.item_of_type::() { - search_bar.update(cx, |this, cx| { - if this.is_dismissed() { - this.show(cx); - } else { - this.dismiss(&Dismiss, cx); - } - }); + search_bar.update(cx, |this, cx| this.toggle(cx)); return; } let view = cx.build_view(|cx| BufferSearchBar::new(cx)); @@ -481,6 +477,14 @@ impl BufferSearchBar { false } + pub fn toggle(&mut self, cx: &mut ViewContext) { + if self.is_dismissed() { + self.show(cx); + } else { + self.dismiss(&Dismiss, cx); + } + } + pub fn show(&mut self, cx: &mut ViewContext) -> bool { if self.active_searchable_item.is_none() { return false; @@ -582,12 +586,14 @@ impl BufferSearchBar { self.update_matches(cx) } - fn render_action_button(&self) -> impl IntoElement { + fn render_action_button(&self, cx: &mut ViewContext) -> impl IntoElement { // let tooltip_style = theme.tooltip.clone(); // let style = theme.search.action_button.clone(); - IconButton::new(0, ui::Icon::SelectAll).action(Box::new(SelectAllMatches)) + IconButton::new("select-all", ui::Icon::SelectAll).on_click(cx.listener(|this, _, cx| { + this.select_all_matches(&SelectAllMatches, cx); + })) } pub fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext) { diff --git a/crates/settings2/src/settings_file.rs b/crates/settings2/src/settings_file.rs index c28e281895..46450a9c28 100644 --- a/crates/settings2/src/settings_file.rs +++ b/crates/settings2/src/settings_file.rs @@ -124,6 +124,17 @@ pub fn update_settings_file( pub fn load_default_keymap(cx: &mut AppContext) { for path in ["keymaps/default.json", "keymaps/vim.json"] { + // TODO: Remove this conditional when we're ready to add Vim support. + // Right now we're avoiding loading the Vim keymap to silence the warnings + // about invalid action bindings. + if path.contains("vim") { + let _: Option<()> = Err(format!( + "TODO: Skipping {path} until we're ready to add Vim support" + )) + .log_err(); + continue; + } + KeymapFile::load_asset(path, cx).unwrap(); } @@ -132,39 +143,3 @@ pub fn load_default_keymap(cx: &mut AppContext) { // KeymapFile::load_asset(asset_path, cx).unwrap(); // } } - -pub fn handle_keymap_file_changes( - mut user_keymap_file_rx: mpsc::UnboundedReceiver, - cx: &mut AppContext, -) { - cx.spawn(move |cx| async move { - // let mut settings_subscription = None; - while let Some(user_keymap_content) = user_keymap_file_rx.next().await { - if let Some(keymap_content) = KeymapFile::parse(&user_keymap_content).log_err() { - cx.update(|cx| reload_keymaps(cx, &keymap_content)).ok(); - - // todo!() - // let mut old_base_keymap = cx.read(|cx| *settings::get::(cx)); - // drop(settings_subscription); - // settings_subscription = Some(cx.update(|cx| { - // cx.observe_global::(move |cx| { - // let new_base_keymap = *settings::get::(cx); - // if new_base_keymap != old_base_keymap { - // old_base_keymap = new_base_keymap.clone(); - // reload_keymaps(cx, &keymap_content); - // } - // }) - // })); - } - } - }) - .detach(); -} - -fn reload_keymaps(cx: &mut AppContext, keymap_content: &KeymapFile) { - // todo!() - // cx.clear_bindings(); - load_default_keymap(cx); - keymap_content.clone().add_to_cx(cx).log_err(); - // cx.set_menus(menus::menus()); -} diff --git a/crates/terminal2/src/mappings/colors.rs b/crates/terminal2/src/mappings/colors.rs index d3c8443cbf..adcc0d8080 100644 --- a/crates/terminal2/src/mappings/colors.rs +++ b/crates/terminal2/src/mappings/colors.rs @@ -1,118 +1,5 @@ -// todo!() use alacritty_terminal::term::color::Rgb as AlacRgb; -// use gpui::color::Color; -// use theme2::TerminalStyle; -///Converts a 2, 8, or 24 bit color ANSI color to the GPUI equivalent -// pub fn convert_color(alac_color: &AnsiColor, style: &TerminalStyle) -> Color { -// match alac_color { -// //Named and theme defined colors -// alacritty_terminal::ansi::Color::Named(n) => match n { -// alacritty_terminal::ansi::NamedColor::Black => style.black, -// alacritty_terminal::ansi::NamedColor::Red => style.red, -// alacritty_terminal::ansi::NamedColor::Green => style.green, -// alacritty_terminal::ansi::NamedColor::Yellow => style.yellow, -// alacritty_terminal::ansi::NamedColor::Blue => style.blue, -// alacritty_terminal::ansi::NamedColor::Magenta => style.magenta, -// alacritty_terminal::ansi::NamedColor::Cyan => style.cyan, -// alacritty_terminal::ansi::NamedColor::White => style.white, -// alacritty_terminal::ansi::NamedColor::BrightBlack => style.bright_black, -// alacritty_terminal::ansi::NamedColor::BrightRed => style.bright_red, -// alacritty_terminal::ansi::NamedColor::BrightGreen => style.bright_green, -// alacritty_terminal::ansi::NamedColor::BrightYellow => style.bright_yellow, -// alacritty_terminal::ansi::NamedColor::BrightBlue => style.bright_blue, -// alacritty_terminal::ansi::NamedColor::BrightMagenta => style.bright_magenta, -// alacritty_terminal::ansi::NamedColor::BrightCyan => style.bright_cyan, -// alacritty_terminal::ansi::NamedColor::BrightWhite => style.bright_white, -// alacritty_terminal::ansi::NamedColor::Foreground => style.foreground, -// alacritty_terminal::ansi::NamedColor::Background => style.background, -// alacritty_terminal::ansi::NamedColor::Cursor => style.cursor, -// alacritty_terminal::ansi::NamedColor::DimBlack => style.dim_black, -// alacritty_terminal::ansi::NamedColor::DimRed => style.dim_red, -// alacritty_terminal::ansi::NamedColor::DimGreen => style.dim_green, -// alacritty_terminal::ansi::NamedColor::DimYellow => style.dim_yellow, -// alacritty_terminal::ansi::NamedColor::DimBlue => style.dim_blue, -// alacritty_terminal::ansi::NamedColor::DimMagenta => style.dim_magenta, -// alacritty_terminal::ansi::NamedColor::DimCyan => style.dim_cyan, -// alacritty_terminal::ansi::NamedColor::DimWhite => style.dim_white, -// alacritty_terminal::ansi::NamedColor::BrightForeground => style.bright_foreground, -// alacritty_terminal::ansi::NamedColor::DimForeground => style.dim_foreground, -// }, -// //'True' colors -// alacritty_terminal::ansi::Color::Spec(rgb) => Color::new(rgb.r, rgb.g, rgb.b, u8::MAX), -// //8 bit, indexed colors -// alacritty_terminal::ansi::Color::Indexed(i) => get_color_at_index(&(*i as usize), style), -// } -// } - -/// TODO: Move this -///Converts an 8 bit ANSI color to it's GPUI equivalent. -///Accepts usize for compatibility with the alacritty::Colors interface, -///Other than that use case, should only be called with values in the [0,255] range -// pub fn get_color_at_index(index: &usize, style: &TerminalStyle) -> Color { -// match index { -// //0-15 are the same as the named colors above -// 0 => style.black, -// 1 => style.red, -// 2 => style.green, -// 3 => style.yellow, -// 4 => style.blue, -// 5 => style.magenta, -// 6 => style.cyan, -// 7 => style.white, -// 8 => style.bright_black, -// 9 => style.bright_red, -// 10 => style.bright_green, -// 11 => style.bright_yellow, -// 12 => style.bright_blue, -// 13 => style.bright_magenta, -// 14 => style.bright_cyan, -// 15 => style.bright_white, -// //16-231 are mapped to their RGB colors on a 0-5 range per channel -// 16..=231 => { -// let (r, g, b) = rgb_for_index(&(*index as u8)); //Split the index into it's ANSI-RGB components -// let step = (u8::MAX as f32 / 5.).floor() as u8; //Split the RGB range into 5 chunks, with floor so no overflow -// Color::new(r * step, g * step, b * step, u8::MAX) //Map the ANSI-RGB components to an RGB color -// } -// //232-255 are a 24 step grayscale from black to white -// 232..=255 => { -// let i = *index as u8 - 232; //Align index to 0..24 -// let step = (u8::MAX as f32 / 24.).floor() as u8; //Split the RGB grayscale values into 24 chunks -// Color::new(i * step, i * step, i * step, u8::MAX) //Map the ANSI-grayscale components to the RGB-grayscale -// } -// //For compatibility with the alacritty::Colors interface -// 256 => style.foreground, -// 257 => style.background, -// 258 => style.cursor, -// 259 => style.dim_black, -// 260 => style.dim_red, -// 261 => style.dim_green, -// 262 => style.dim_yellow, -// 263 => style.dim_blue, -// 264 => style.dim_magenta, -// 265 => style.dim_cyan, -// 266 => style.dim_white, -// 267 => style.bright_foreground, -// 268 => style.black, //'Dim Background', non-standard color -// _ => Color::new(0, 0, 0, 255), -// } -// } -///Generates the rgb channels in [0, 5] for a given index into the 6x6x6 ANSI color cube -///See: [8 bit ansi color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit). -/// -///Wikipedia gives a formula for calculating the index for a given color: -/// -///index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5) -/// -///This function does the reverse, calculating the r, g, and b components from a given index. -// fn rgb_for_index(i: &u8) -> (u8, u8, u8) { -// debug_assert!((&16..=&231).contains(&i)); -// let i = i - 16; -// let r = (i - (i % 36)) / 36; -// let g = ((i % 36) - (i % 6)) / 6; -// let b = (i % 36) % 6; -// (r, g, b) -// } use gpui::Rgba; //Convenience method to convert from a GPUI color to an alacritty Rgb @@ -123,15 +10,3 @@ pub fn to_alac_rgb(color: impl Into) -> AlacRgb { let b = ((color.b * color.a) * 255.) as u8; AlacRgb::new(r, g, b) } - -// #[cfg(test)] -// mod tests { -// #[test] -// fn test_rgb_for_index() { -// //Test every possible value in the color cube -// for i in 16..=231 { -// let (r, g, b) = crate::mappings::colors::rgb_for_index(&(i as u8)); -// assert_eq!(i, 16 + 36 * r + 6 * g + b); -// } -// } -// } diff --git a/crates/terminal2/src/mappings/mouse.rs b/crates/terminal2/src/mappings/mouse.rs index edced3156f..a32d83d28d 100644 --- a/crates/terminal2/src/mappings/mouse.rs +++ b/crates/terminal2/src/mappings/mouse.rs @@ -186,9 +186,9 @@ pub fn mouse_side( } pub fn grid_point(pos: Point, cur_size: TerminalSize, display_offset: usize) -> AlacPoint { - let col = GridCol((cur_size.cell_width / pos.x) as usize); + let col = GridCol((pos.x / cur_size.cell_width) as usize); let col = min(col, cur_size.last_column()); - let line = (cur_size.line_height / pos.y) as i32; + let line = (pos.y / cur_size.line_height) as i32; let line = min(line, cur_size.bottommost_line().0); AlacPoint::new(GridLine(line - display_offset as i32), col) } diff --git a/crates/terminal2/src/terminal2.rs b/crates/terminal2/src/terminal2.rs index 9f94339504..6036d65d6e 100644 --- a/crates/terminal2/src/terminal2.rs +++ b/crates/terminal2/src/terminal2.rs @@ -1103,7 +1103,12 @@ impl Terminal { } } - pub fn mouse_drag(&mut self, e: MouseMoveEvent, origin: Point, region: Bounds) { + pub fn mouse_drag( + &mut self, + e: &MouseMoveEvent, + origin: Point, + region: Bounds, + ) { let position = e.position - origin; self.last_mouse_position = Some(position); @@ -1129,7 +1134,7 @@ impl Terminal { } } - fn drag_line_delta(&mut self, e: MouseMoveEvent, region: Bounds) -> Option { + fn drag_line_delta(&mut self, e: &MouseMoveEvent, region: Bounds) -> Option { //TODO: Why do these need to be doubled? Probably the same problem that the IME has let top = region.origin.y + (self.last_content.size.line_height * 2.); let bottom = region.lower_left().y - (self.last_content.size.line_height * 2.); @@ -1229,7 +1234,7 @@ impl Terminal { } ///Scroll the terminal - pub fn scroll_wheel(&mut self, e: ScrollWheelEvent, origin: Point) { + pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, origin: Point) { let mouse_mode = self.mouse_mode(e.shift); if let Some(scroll_lines) = self.determine_scroll_lines(&e, mouse_mode) { diff --git a/crates/terminal2/src/terminal_settings.rs b/crates/terminal2/src/terminal_settings.rs index 16ec286922..1038c6d061 100644 --- a/crates/terminal2/src/terminal_settings.rs +++ b/crates/terminal2/src/terminal_settings.rs @@ -1,4 +1,4 @@ -use gpui::{AppContext, FontFeatures, Pixels}; +use gpui::{px, AbsoluteLength, AppContext, FontFeatures, Pixels}; use schemars::JsonSchema; use serde_derive::{Deserialize, Serialize}; use std::{collections::HashMap, path::PathBuf}; @@ -114,12 +114,13 @@ pub enum TerminalLineHeight { } impl TerminalLineHeight { - pub fn value(&self) -> f32 { - match self { + pub fn value(&self) -> AbsoluteLength { + let value = match self { TerminalLineHeight::Comfortable => 1.618, TerminalLineHeight::Standard => 1.3, TerminalLineHeight::Custom(line_height) => f32::max(*line_height, 1.), - } + }; + px(value).into() } } diff --git a/crates/terminal_view2/Cargo.toml b/crates/terminal_view2/Cargo.toml index 12e2c06504..9654bed7f5 100644 --- a/crates/terminal_view2/Cargo.toml +++ b/crates/terminal_view2/Cargo.toml @@ -21,6 +21,7 @@ workspace = { package = "workspace2", path = "../workspace2" } db = { package = "db2", path = "../db2" } procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false } terminal = { package = "terminal2", path = "../terminal2" } +ui = { package = "ui2", path = "../ui2" } smallvec.workspace = true smol.workspace = true mio-extras = "2.0.6" diff --git a/crates/terminal_view2/src/terminal_element.rs b/crates/terminal_view2/src/terminal_element.rs index 363dd90287..4a37ca91b7 100644 --- a/crates/terminal_view2/src/terminal_element.rs +++ b/crates/terminal_view2/src/terminal_element.rs @@ -1,970 +1,1168 @@ -// #![allow(unused)] // todo!() - -// use editor::{Cursor, HighlightedRange, HighlightedRangeLine}; -// use gpui::{ -// point, transparent_black, AnyElement, AppContext, Bounds, Component, CursorStyle, Element, -// ElementId, FontStyle, FontWeight, HighlightStyle, Hsla, IntoElement, IsZero, LayoutId, -// ModelContext, Overlay, Pixels, Point, Quad, TextRun, TextStyle, TextSystem, Underline, -// ViewContext, WeakModel, WindowContext, -// }; -// use itertools::Itertools; -// use language::CursorShape; -// use ordered_float::OrderedFloat; -// use settings::Settings; -// use terminal::{ -// alacritty_terminal::{ -// ansi::{Color as AnsiColor, Color::Named, CursorShape as AlacCursorShape, NamedColor}, -// grid::Dimensions, -// index::Point as AlacPoint, -// term::{cell::Flags, TermMode}, -// }, -// terminal_settings::TerminalSettings, -// IndexedCell, Terminal, TerminalContent, TerminalSize, -// }; -// use theme::ThemeSettings; - -// use std::mem; -// use std::{fmt::Debug, ops::RangeInclusive}; - -// use crate::TerminalView; - -// ///The information generated during layout that is necessary for painting -// pub struct LayoutState { -// cells: Vec, -// rects: Vec, -// relative_highlighted_ranges: Vec<(RangeInclusive, Hsla)>, -// cursor: Option, -// background_color: Hsla, -// size: TerminalSize, -// mode: TermMode, -// display_offset: usize, -// hyperlink_tooltip: Option, -// gutter: f32, -// } - -// ///Helper struct for converting data between alacritty's cursor points, and displayed cursor points -// struct DisplayCursor { -// line: i32, -// col: usize, -// } - -// impl DisplayCursor { -// fn from(cursor_point: AlacPoint, display_offset: usize) -> Self { -// Self { -// line: cursor_point.line.0 + display_offset as i32, -// col: cursor_point.column.0, -// } -// } - -// pub fn line(&self) -> i32 { -// self.line -// } - -// pub fn col(&self) -> usize { -// self.col -// } -// } - -// #[derive(Clone, Debug, Default)] -// struct LayoutCell { -// point: AlacPoint, -// text: Line, -// } - -// impl LayoutCell { -// fn new(point: AlacPoint, text: Line) -> LayoutCell { -// LayoutCell { point, text } -// } - -// fn paint( -// &self, -// origin: Point, -// layout: &LayoutState, -// _visible_bounds: Bounds, -// _view: &mut TerminalView, -// cx: &mut WindowContext, -// ) { -// let pos = { -// let point = self.point; - -// Point::new( -// (origin.x + point.column as f32 * layout.size.cell_width).floor(), -// origin.y + point.line as f32 * layout.size.line_height, -// ) -// }; - -// self.text.paint(pos, layout.size.line_height, cx); -// } -// } - -// #[derive(Clone, Debug, Default)] -// struct LayoutRect { -// point: AlacPoint, -// num_of_cells: usize, -// color: Hsla, -// } - -// impl LayoutRect { -// fn new(point: AlacPoint, num_of_cells: usize, color: Hsla) -> LayoutRect { -// LayoutRect { -// point, -// num_of_cells, -// color, -// } -// } - -// fn extend(&self) -> Self { -// LayoutRect { -// point: self.point, -// num_of_cells: self.num_of_cells + 1, -// color: self.color, -// } -// } - -// fn paint( -// &self, -// origin: Point, -// layout: &LayoutState, -// _view: &mut TerminalView, -// cx: &mut ViewContext, -// ) { -// let position = { -// let alac_point = self.point; -// point( -// (origin.x + alac_point.column as f32 * layout.size.cell_width).floor(), -// origin.y + alac_point.line as f32 * layout.size.line_height, -// ) -// }; -// let size = point( -// (layout.size.cell_width * self.num_of_cells as f32).ceil(), -// layout.size.line_height, -// ) -// .into(); - -// cx.paint_quad( -// Bounds::new(position, size), -// Default::default(), -// self.color, -// Default::default(), -// transparent_black(), -// ); -// } -// } - -// ///The GPUI element that paints the terminal. -// ///We need to keep a reference to the view for mouse events, do we need it for any other terminal stuff, or can we move that to connection? -// pub struct TerminalElement { -// terminal: WeakModel, -// focused: bool, -// cursor_visible: bool, -// can_navigate_to_selected_word: bool, -// } - -// impl TerminalElement { -// pub fn new( -// terminal: WeakModel, -// focused: bool, -// cursor_visible: bool, -// can_navigate_to_selected_word: bool, -// ) -> TerminalElement { -// TerminalElement { -// terminal, -// focused, -// cursor_visible, -// can_navigate_to_selected_word, -// } -// } - -// //Vec> -> Clip out the parts of the ranges - -// fn layout_grid( -// grid: &Vec, -// text_style: &TextStyle, -// terminal_theme: &TerminalStyle, -// text_system: &TextSystem, -// hyperlink: Option<(HighlightStyle, &RangeInclusive)>, -// cx: &mut WindowContext<'_>, -// ) -> (Vec, Vec) { -// let mut cells = vec![]; -// let mut rects = vec![]; - -// let mut cur_rect: Option = None; -// let mut cur_alac_color = None; - -// let linegroups = grid.into_iter().group_by(|i| i.point.line); -// for (line_index, (_, line)) in linegroups.into_iter().enumerate() { -// for cell in line { -// let mut fg = cell.fg; -// let mut bg = cell.bg; -// if cell.flags.contains(Flags::INVERSE) { -// mem::swap(&mut fg, &mut bg); -// } - -// //Expand background rect range -// { -// if matches!(bg, Named(NamedColor::Background)) { -// //Continue to next cell, resetting variables if necessary -// cur_alac_color = None; -// if let Some(rect) = cur_rect { -// rects.push(rect); -// cur_rect = None -// } -// } else { -// match cur_alac_color { -// Some(cur_color) => { -// if bg == cur_color { -// cur_rect = cur_rect.take().map(|rect| rect.extend()); -// } else { -// cur_alac_color = Some(bg); -// if cur_rect.is_some() { -// rects.push(cur_rect.take().unwrap()); -// } -// cur_rect = Some(LayoutRect::new( -// AlacPoint::new( -// line_index as i32, -// cell.point.column.0 as i32, -// ), -// 1, -// convert_color(&bg, &terminal_theme), -// )); -// } -// } -// None => { -// cur_alac_color = Some(bg); -// cur_rect = Some(LayoutRect::new( -// AlacPoint::new(line_index as i32, cell.point.column.0 as i32), -// 1, -// convert_color(&bg, &terminal_theme), -// )); -// } -// } -// } -// } - -// //Layout current cell text -// { -// let cell_text = &cell.c.to_string(); -// if !is_blank(&cell) { -// let cell_style = TerminalElement::cell_style( -// &cell, -// fg, -// terminal_theme, -// text_style, -// text_system, -// hyperlink, -// ); - -// let layout_cell = text_system.layout_line( -// cell_text, -// text_style.font_size.to_pixels(cx.rem_size()), -// &[(cell_text.len(), cell_style)], -// )?; - -// cells.push(LayoutCell::new( -// AlacPoint::new(line_index as i32, cell.point.column.0 as i32), -// layout_cell, -// )) -// }; -// } -// } - -// if cur_rect.is_some() { -// rects.push(cur_rect.take().unwrap()); -// } -// } -// (cells, rects) -// } - -// // Compute the cursor position and expected block width, may return a zero width if x_for_index returns -// // the same position for sequential indexes. Use em_width instead -// fn shape_cursor( -// cursor_point: DisplayCursor, -// size: TerminalSize, -// text_fragment: &Line, -// ) -> Option<(Point, Pixels)> { -// if cursor_point.line() < size.total_lines() as i32 { -// let cursor_width = if text_fragment.width == Pixels::ZERO { -// size.cell_width() -// } else { -// text_fragment.width -// }; - -// //Cursor should always surround as much of the text as possible, -// //hence when on pixel boundaries round the origin down and the width up -// Some(( -// point( -// (cursor_point.col() as f32 * size.cell_width()).floor(), -// (cursor_point.line() as f32 * size.line_height()).floor(), -// ), -// cursor_width.ceil(), -// )) -// } else { -// None -// } -// } - -// ///Convert the Alacritty cell styles to GPUI text styles and background color -// fn cell_style( -// indexed: &IndexedCell, -// fg: terminal::alacritty_terminal::ansi::Color, -// style: &TerminalStyle, -// text_style: &TextStyle, -// text_system: &TextSystem, -// hyperlink: Option<(HighlightStyle, &RangeInclusive)>, -// ) -> TextRun { -// let flags = indexed.cell.flags; -// let fg = convert_color(&fg, &style); - -// let mut underline = flags -// .intersects(Flags::ALL_UNDERLINES) -// .then(|| Underline { -// color: fg, -// thickness: Pixels::from(1.0).scale(1.0), -// order: todo!(), -// bounds: todo!(), -// content_mask: todo!(), -// wavy: flags.contains(Flags::UNDERCURL), -// }) -// .unwrap_or_default(); - -// if indexed.cell.hyperlink().is_some() { -// if underline.thickness.is_zero() { -// underline.thickness = Pixels::from(1.0).scale(1.0); -// } -// } - -// let mut properties = Properties::new(); -// if indexed.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) { -// properties = *properties.weight(FontWeight::BOLD); -// } -// if indexed.flags.intersects(Flags::ITALIC) { -// properties = *properties.style(FontStyle::Italic); -// } - -// let font_id = text_system -// .select_font(text_style.font_family, &properties) -// .unwrap_or(text_style.font_id); - -// let mut result = TextRun { -// color: fg, -// font_id, -// underline, -// }; - -// if let Some((style, range)) = hyperlink { -// if range.contains(&indexed.point) { -// if let Some(underline) = style.underline { -// result.underline = Some(underline); -// } - -// if let Some(color) = style.color { -// result.color = color; -// } -// } -// } - -// result -// } - -// // todo!() -// // fn generic_button_handler( -// // connection: WeakModel, -// // origin: Point, -// // f: impl Fn(&mut Terminal, Point, E, &mut ModelContext), -// // ) -> impl Fn(E, &mut TerminalView, &mut EventContext) { -// // move |event, _: &mut TerminalView, cx| { -// // cx.focus_parent(); -// // if let Some(conn_handle) = connection.upgrade() { -// // conn_handle.update(cx, |terminal, cx| { -// // f(terminal, origin, event, cx); - -// // cx.notify(); -// // }) -// // } -// // } -// // } - -// fn attach_mouse_handlers( -// &self, -// origin: Point, -// visible_bounds: Bounds, -// mode: TermMode, -// cx: &mut ViewContext, -// ) { -// // todo!() -// // let connection = self.terminal; - -// // let mut region = MouseRegion::new::(cx.view_id(), 0, visible_bounds); - -// // // Terminal Emulator controlled behavior: -// // region = region -// // // Start selections -// // .on_down(MouseButton::Left, move |event, v: &mut TerminalView, cx| { -// // let terminal_view = cx.handle(); -// // cx.focus(&terminal_view); -// // v.context_menu.update(cx, |menu, _cx| menu.delay_cancel()); -// // if let Some(conn_handle) = connection.upgrade() { -// // conn_handle.update(cx, |terminal, cx| { -// // terminal.mouse_down(&event, origin); - -// // cx.notify(); -// // }) -// // } -// // }) -// // // Update drag selections -// // .on_drag(MouseButton::Left, move |event, _: &mut TerminalView, cx| { -// // if event.end { -// // return; -// // } - -// // if cx.is_self_focused() { -// // if let Some(conn_handle) = connection.upgrade() { -// // conn_handle.update(cx, |terminal, cx| { -// // terminal.mouse_drag(event, origin); -// // cx.notify(); -// // }) -// // } -// // } -// // }) -// // // Copy on up behavior -// // .on_up( -// // MouseButton::Left, -// // TerminalElement::generic_button_handler( -// // connection, -// // origin, -// // move |terminal, origin, e, cx| { -// // terminal.mouse_up(&e, origin, cx); -// // }, -// // ), -// // ) -// // // Context menu -// // .on_click( -// // MouseButton::Right, -// // move |event, view: &mut TerminalView, cx| { -// // let mouse_mode = if let Some(conn_handle) = connection.upgrade() { -// // conn_handle.update(cx, |terminal, _cx| terminal.mouse_mode(event.shift)) -// // } else { -// // // If we can't get the model handle, probably can't deploy the context menu -// // true -// // }; -// // if !mouse_mode { -// // view.deploy_context_menu(event.position, cx); -// // } -// // }, -// // ) -// // .on_move(move |event, _: &mut TerminalView, cx| { -// // if cx.is_self_focused() { -// // if let Some(conn_handle) = connection.upgrade() { -// // conn_handle.update(cx, |terminal, cx| { -// // terminal.mouse_move(&event, origin); -// // cx.notify(); -// // }) -// // } -// // } -// // }) -// // .on_scroll(move |event, _: &mut TerminalView, cx| { -// // if let Some(conn_handle) = connection.upgrade() { -// // conn_handle.update(cx, |terminal, cx| { -// // terminal.scroll_wheel(event, origin); -// // cx.notify(); -// // }) -// // } -// // }); - -// // // Mouse mode handlers: -// // // All mouse modes need the extra click handlers -// // if mode.intersects(TermMode::MOUSE_MODE) { -// // region = region -// // .on_down( -// // MouseButton::Right, -// // TerminalElement::generic_button_handler( -// // connection, -// // origin, -// // move |terminal, origin, e, _cx| { -// // terminal.mouse_down(&e, origin); -// // }, -// // ), -// // ) -// // .on_down( -// // MouseButton::Middle, -// // TerminalElement::generic_button_handler( -// // connection, -// // origin, -// // move |terminal, origin, e, _cx| { -// // terminal.mouse_down(&e, origin); -// // }, -// // ), -// // ) -// // .on_up( -// // MouseButton::Right, -// // TerminalElement::generic_button_handler( -// // connection, -// // origin, -// // move |terminal, origin, e, cx| { -// // terminal.mouse_up(&e, origin, cx); -// // }, -// // ), -// // ) -// // .on_up( -// // MouseButton::Middle, -// // TerminalElement::generic_button_handler( -// // connection, -// // origin, -// // move |terminal, origin, e, cx| { -// // terminal.mouse_up(&e, origin, cx); -// // }, -// // ), -// // ) -// // } - -// // cx.scene().push_mouse_region(region); -// } -// } - -// impl Element for TerminalElement { -// type State = LayoutState; - -// fn layout( -// &mut self, -// element_state: Option, -// cx: &mut WindowContext<'_>, -// ) -> (LayoutId, Self::State) { -// let settings = ThemeSettings::get_global(cx); -// let terminal_settings = TerminalSettings::get_global(cx); - -// //Setup layout information -// let terminal_theme = settings.theme.terminal.clone(); //TODO: Try to minimize this clone. -// let link_style = settings.theme.editor.link_definition; -// let tooltip_style = settings.theme.tooltip.clone(); - -// let text_system = cx.text_system(); -// let font_size = font_size(&terminal_settings, cx).unwrap_or(settings.buffer_font_size(cx)); -// let font_family_name = terminal_settings -// .font_family -// .as_ref() -// .unwrap_or(&settings.buffer_font_family_name); -// let font_features = terminal_settings -// .font_features -// .as_ref() -// .unwrap_or(&settings.buffer_font_features); -// let family_id = text_system -// .load_family(&[font_family_name], &font_features) -// .log_err() -// .unwrap_or(settings.buffer_font_family); -// let font_id = text_system -// .select_font(family_id, &Default::default()) -// .unwrap(); - -// let text_style = TextStyle { -// color: settings.theme.editor.text_color, -// font_family_id: family_id, -// font_family_name: text_system.family_name(family_id).unwrap(), -// font_id, -// font_size, -// font_properties: Default::default(), -// underline: Default::default(), -// soft_wrap: false, -// font_family: todo!(), -// font_features: todo!(), -// line_height: todo!(), -// font_weight: todo!(), -// font_style: todo!(), -// background_color: todo!(), -// white_space: todo!(), -// }; -// let selection_color = settings.theme.editor.selection.selection; -// let match_color = settings.theme.search.match_background; -// let gutter; -// let dimensions = { -// let line_height = text_style.font_size * terminal_settings.line_height.value(); -// let cell_width = text_system.em_advance(text_style.font_id, text_style.font_size); -// gutter = cell_width; - -// let size = constraint.max - point(gutter, 0.); -// TerminalSize::new(line_height, cell_width, size) -// }; - -// let search_matches = if let Some(terminal_model) = self.terminal.upgrade() { -// terminal_model.read(cx).matches.clone() -// } else { -// Default::default() -// }; - -// let background_color = terminal_theme.background; -// let terminal_handle = self.terminal.upgrade().unwrap(); - -// let last_hovered_word = terminal_handle.update(cx, |terminal, cx| { -// terminal.set_size(dimensions); -// terminal.try_sync(cx); -// if self.can_navigate_to_selected_word && terminal.can_navigate_to_selected_word() { -// terminal.last_content.last_hovered_word.clone() -// } else { -// None -// } -// }); - -// let hyperlink_tooltip = last_hovered_word.clone().map(|hovered_word| { -// let mut tooltip = Overlay::new( -// Empty::new() -// .contained() -// .constrained() -// .with_width(dimensions.width()) -// .with_height(dimensions.height()) -// .with_tooltip::( -// hovered_word.id, -// hovered_word.word, -// None, -// tooltip_style, -// cx, -// ), -// ) -// .with_position_mode(gpui::OverlayPositionMode::Local) -// .into_any(); - -// tooltip.layout( -// SizeConstraint::new(Point::zero(), cx.window_size()), -// view_state, -// cx, -// ); -// tooltip -// }); - -// let TerminalContent { -// cells, -// mode, -// display_offset, -// cursor_char, -// selection, -// cursor, -// .. -// } = { &terminal_handle.read(cx).last_content }; - -// // searches, highlights to a single range representations -// let mut relative_highlighted_ranges = Vec::new(); -// for search_match in search_matches { -// relative_highlighted_ranges.push((search_match, match_color)) -// } -// if let Some(selection) = selection { -// relative_highlighted_ranges.push((selection.start..=selection.end, selection_color)); -// } - -// // then have that representation be converted to the appropriate highlight data structure - -// let (cells, rects) = TerminalElement::layout_grid( -// cells, -// &text_style, -// &terminal_theme, -// &cx.text_system(), -// last_hovered_word -// .as_ref() -// .map(|last_hovered_word| (link_style, &last_hovered_word.word_match)), -// cx, -// ); - -// //Layout cursor. Rectangle is used for IME, so we should lay it out even -// //if we don't end up showing it. -// let cursor = if let AlacCursorShape::Hidden = cursor.shape { -// None -// } else { -// let cursor_point = DisplayCursor::from(cursor.point, *display_offset); -// let cursor_text = { -// let str_trxt = cursor_char.to_string(); - -// let color = if self.focused { -// terminal_theme.background -// } else { -// terminal_theme.foreground -// }; - -// cx.text_system().layout_line( -// &str_trxt, -// text_style.font_size, -// &[( -// str_trxt.len(), -// TextRun { -// font_id: text_style.font_id, -// color, -// underline: Default::default(), -// }, -// )], -// )? -// }; - -// let focused = self.focused; -// TerminalElement::shape_cursor(cursor_point, dimensions, &cursor_text).map( -// move |(cursor_position, block_width)| { -// let (shape, text) = match cursor.shape { -// AlacCursorShape::Block if !focused => (CursorShape::Hollow, None), -// AlacCursorShape::Block => (CursorShape::Block, Some(cursor_text)), -// AlacCursorShape::Underline => (CursorShape::Underscore, None), -// AlacCursorShape::Beam => (CursorShape::Bar, None), -// AlacCursorShape::HollowBlock => (CursorShape::Hollow, None), -// //This case is handled in the if wrapping the whole cursor layout -// AlacCursorShape::Hidden => unreachable!(), -// }; - -// Cursor::new( -// cursor_position, -// block_width, -// dimensions.line_height, -// terminal_theme.cursor, -// shape, -// text, -// ) -// }, -// ) -// }; - -// //Done! -// ( -// constraint.max, -// Self::State { -// cells, -// cursor, -// background_color, -// size: dimensions, -// rects, -// relative_highlighted_ranges, -// mode: *mode, -// display_offset: *display_offset, -// hyperlink_tooltip, -// gutter, -// }, -// ) -// } - -// fn paint( -// self, -// bounds: Bounds, -// element_state: &mut Self::State, -// cx: &mut WindowContext<'_>, -// ) { -// // todo!() -// // let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); - -// // //Setup element stuff -// // let clip_bounds = Some(visible_bounds); - -// // cx.paint_layer(clip_bounds, |cx| { -// // let origin = bounds.origin + point(element_state.gutter, 0.); - -// // // Elements are ephemeral, only at paint time do we know what could be clicked by a mouse -// // self.attach_mouse_handlers(origin, visible_bounds, element_state.mode, cx); - -// // cx.scene().push_cursor_region(gpui::CursorRegion { -// // bounds, -// // style: if element_state.hyperlink_tooltip.is_some() { -// // CursorStyle::AlacPointingHand -// // } else { -// // CursorStyle::IBeam -// // }, -// // }); - -// // cx.paint_layer(clip_bounds, |cx| { -// // //Start with a background color -// // cx.scene().push_quad(Quad { -// // bounds, -// // background: Some(element_state.background_color), -// // border: Default::default(), -// // corner_radii: Default::default(), -// // }); - -// // for rect in &element_state.rects { -// // rect.paint(origin, element_state, view_state, cx); -// // } -// // }); - -// // //Draw Highlighted Backgrounds -// // cx.paint_layer(clip_bounds, |cx| { -// // for (relative_highlighted_range, color) in -// // element_state.relative_highlighted_ranges.iter() -// // { -// // if let Some((start_y, highlighted_range_lines)) = to_highlighted_range_lines( -// // relative_highlighted_range, -// // element_state, -// // origin, -// // ) { -// // let hr = HighlightedRange { -// // start_y, //Need to change this -// // line_height: element_state.size.line_height, -// // lines: highlighted_range_lines, -// // color: color.clone(), -// // //Copied from editor. TODO: move to theme or something -// // corner_radius: 0.15 * element_state.size.line_height, -// // }; -// // hr.paint(bounds, cx); -// // } -// // } -// // }); - -// // //Draw the text cells -// // cx.paint_layer(clip_bounds, |cx| { -// // for cell in &element_state.cells { -// // cell.paint(origin, element_state, visible_bounds, view_state, cx); -// // } -// // }); - -// // //Draw cursor -// // if self.cursor_visible { -// // if let Some(cursor) = &element_state.cursor { -// // cx.paint_layer(clip_bounds, |cx| { -// // cursor.paint(origin, cx); -// // }) -// // } -// // } - -// // if let Some(element) = &mut element_state.hyperlink_tooltip { -// // element.paint(origin, visible_bounds, view_state, cx) -// // } -// // }); -// } - -// // todo!() remove? -// // fn metadata(&self) -> Option<&dyn std::any::Any> { -// // None -// // } - -// // fn debug( -// // &self, -// // _: Bounds, -// // _: &Self::State, -// // _: &Self::PaintState, -// // _: &TerminalView, -// // _: &gpui::ViewContext, -// // ) -> gpui::serde_json::Value { -// // json!({ -// // "type": "TerminalElement", -// // }) -// // } - -// // fn rect_for_text_range( -// // &self, -// // _: Range, -// // bounds: Bounds, -// // _: Bounds, -// // layout: &Self::State, -// // _: &Self::PaintState, -// // _: &TerminalView, -// // _: &gpui::ViewContext, -// // ) -> Option> { -// // // Use the same origin that's passed to `Cursor::paint` in the paint -// // // method bove. -// // let mut origin = bounds.origin() + point(layout.size.cell_width, 0.); - -// // // TODO - Why is it necessary to move downward one line to get correct -// // // positioning? I would think that we'd want the same rect that is -// // // painted for the cursor. -// // origin += point(0., layout.size.line_height); - -// // Some(layout.cursor.as_ref()?.bounding_rect(origin)) -// // } -// } - -// impl IntoElement for TerminalElement { -// type Element = Self; - -// fn element_id(&self) -> Option { -// todo!() -// } - -// fn into_element(self) -> Self::Element { -// self -// } -// } - -// fn is_blank(cell: &IndexedCell) -> bool { -// if cell.c != ' ' { -// return false; -// } - -// if cell.bg != AnsiColor::Named(NamedColor::Background) { -// return false; -// } - -// if cell.hyperlink().is_some() { -// return false; -// } - -// if cell -// .flags -// .intersects(Flags::ALL_UNDERLINES | Flags::INVERSE | Flags::STRIKEOUT) -// { -// return false; -// } - -// return true; -// } - -// fn to_highlighted_range_lines( -// range: &RangeInclusive, -// layout: &LayoutState, -// origin: Point, -// ) -> Option<(Pixels, Vec)> { -// // Step 1. Normalize the points to be viewport relative. -// // When display_offset = 1, here's how the grid is arranged: -// //-2,0 -2,1... -// //--- Viewport top -// //-1,0 -1,1... -// //--------- Terminal Top -// // 0,0 0,1... -// // 1,0 1,1... -// //--- Viewport Bottom -// // 2,0 2,1... -// //--------- Terminal Bottom - -// // Normalize to viewport relative, from terminal relative. -// // lines are i32s, which are negative above the top left corner of the terminal -// // If the user has scrolled, we use the display_offset to tell us which offset -// // of the grid data we should be looking at. But for the rendering step, we don't -// // want negatives. We want things relative to the 'viewport' (the area of the grid -// // which is currently shown according to the display offset) -// let unclamped_start = AlacPoint::new( -// range.start().line + layout.display_offset, -// range.start().column, -// ); -// let unclamped_end = -// AlacPoint::new(range.end().line + layout.display_offset, range.end().column); - -// // Step 2. Clamp range to viewport, and return None if it doesn't overlap -// if unclamped_end.line.0 < 0 || unclamped_start.line.0 > layout.size.num_lines() as i32 { -// return None; -// } - -// let clamped_start_line = unclamped_start.line.0.max(0) as usize; -// let clamped_end_line = unclamped_end.line.0.min(layout.size.num_lines() as i32) as usize; -// //Convert the start of the range to pixels -// let start_y = origin.y + clamped_start_line as f32 * layout.size.line_height; - -// // Step 3. Expand ranges that cross lines into a collection of single-line ranges. -// // (also convert to pixels) -// let mut highlighted_range_lines = Vec::new(); -// for line in clamped_start_line..=clamped_end_line { -// let mut line_start = 0; -// let mut line_end = layout.size.columns(); - -// if line == clamped_start_line { -// line_start = unclamped_start.column.0 as usize; -// } -// if line == clamped_end_line { -// line_end = unclamped_end.column.0 as usize + 1; //+1 for inclusive -// } - -// highlighted_range_lines.push(HighlightedRangeLine { -// start_x: origin.x + line_start as f32 * layout.size.cell_width, -// end_x: origin.x + line_end as f32 * layout.size.cell_width, -// }); -// } - -// Some((start_y, highlighted_range_lines)) -// } - -// fn font_size(terminal_settings: &TerminalSettings, cx: &mut AppContext) -> Option { -// terminal_settings -// .font_size -// .map(|size| theme::adjusted_font_size(size, cx)) -// } - -// // mappings::colors::convert_color -// fn convert_color(fg: &terminal::alacritty_terminal::ansi::Color, style: &TerminalStyle) -> Hsla { -// todo!() -// } +use editor::{Cursor, HighlightedRange, HighlightedRangeLine}; +use gpui::{ + black, div, point, px, red, relative, transparent_black, AnyElement, AsyncWindowContext, + AvailableSpace, Bounds, DispatchPhase, Element, ElementId, FocusHandle, Font, FontStyle, + FontWeight, HighlightStyle, Hsla, InteractiveElement, InteractiveElementState, IntoElement, + LayoutId, Model, ModelContext, ModifiersChangedEvent, MouseButton, Pixels, + PlatformInputHandler, Point, Rgba, ShapedLine, Size, StatefulInteractiveElement, Styled, + TextRun, TextStyle, TextSystem, UnderlineStyle, View, WhiteSpace, WindowContext, +}; +use itertools::Itertools; +use language::CursorShape; +use settings::Settings; +use terminal::{ + alacritty_terminal::ansi::NamedColor, + alacritty_terminal::{ + ansi::{Color as AnsiColor, Color::Named, CursorShape as AlacCursorShape}, + grid::Dimensions, + index::Point as AlacPoint, + term::{cell::Flags, TermMode}, + }, + terminal_settings::TerminalSettings, + IndexedCell, Terminal, TerminalContent, TerminalSize, +}; +use theme::{ActiveTheme, Theme, ThemeSettings}; +use ui::Tooltip; + +use std::mem; +use std::{fmt::Debug, ops::RangeInclusive}; + +use crate::TerminalView; + +///The information generated during layout that is necessary for painting +pub struct LayoutState { + cells: Vec, + rects: Vec, + relative_highlighted_ranges: Vec<(RangeInclusive, Hsla)>, + cursor: Option, + background_color: Hsla, + size: TerminalSize, + mode: TermMode, + display_offset: usize, + hyperlink_tooltip: Option, + gutter: Pixels, +} + +///Helper struct for converting data between alacritty's cursor points, and displayed cursor points +struct DisplayCursor { + line: i32, + col: usize, +} + +impl DisplayCursor { + fn from(cursor_point: AlacPoint, display_offset: usize) -> Self { + Self { + line: cursor_point.line.0 + display_offset as i32, + col: cursor_point.column.0, + } + } + + pub fn line(&self) -> i32 { + self.line + } + + pub fn col(&self) -> usize { + self.col + } +} + +#[derive(Debug, Default)] +struct LayoutCell { + point: AlacPoint, + text: gpui::ShapedLine, +} + +impl LayoutCell { + fn new(point: AlacPoint, text: gpui::ShapedLine) -> LayoutCell { + LayoutCell { point, text } + } + + fn paint( + &self, + origin: Point, + layout: &LayoutState, + _visible_bounds: Bounds, + cx: &mut WindowContext, + ) { + let pos = { + let point = self.point; + + Point::new( + (origin.x + point.column as f32 * layout.size.cell_width).floor(), + origin.y + point.line as f32 * layout.size.line_height, + ) + }; + + self.text.paint(pos, layout.size.line_height, cx).ok(); + } +} + +#[derive(Clone, Debug, Default)] +struct LayoutRect { + point: AlacPoint, + num_of_cells: usize, + color: Hsla, +} + +impl LayoutRect { + fn new(point: AlacPoint, num_of_cells: usize, color: Hsla) -> LayoutRect { + LayoutRect { + point, + num_of_cells, + color, + } + } + + fn extend(&self) -> Self { + LayoutRect { + point: self.point, + num_of_cells: self.num_of_cells + 1, + color: self.color, + } + } + + fn paint(&self, origin: Point, layout: &LayoutState, cx: &mut WindowContext) { + let position = { + let alac_point = self.point; + point( + (origin.x + alac_point.column as f32 * layout.size.cell_width).floor(), + origin.y + alac_point.line as f32 * layout.size.line_height, + ) + }; + let size = point( + (layout.size.cell_width * self.num_of_cells as f32).ceil(), + layout.size.line_height, + ) + .into(); + + cx.paint_quad( + Bounds::new(position, size), + Default::default(), + self.color, + Default::default(), + transparent_black(), + ); + } +} + +///The GPUI element that paints the terminal. +///We need to keep a reference to the view for mouse events, do we need it for any other terminal stuff, or can we move that to connection? +pub struct TerminalElement { + terminal: Model, + terminal_view: View, + focus: FocusHandle, + focused: bool, + cursor_visible: bool, + can_navigate_to_selected_word: bool, + interactivity: gpui::Interactivity, +} + +impl InteractiveElement for TerminalElement { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + &mut self.interactivity + } +} + +impl StatefulInteractiveElement for TerminalElement {} + +impl TerminalElement { + pub fn new( + terminal: Model, + terminal_view: View, + focus: FocusHandle, + focused: bool, + cursor_visible: bool, + can_navigate_to_selected_word: bool, + ) -> TerminalElement { + TerminalElement { + terminal, + terminal_view, + focused, + focus: focus.clone(), + cursor_visible, + can_navigate_to_selected_word, + interactivity: Default::default(), + } + .track_focus(&focus) + .element + } + + //Vec> -> Clip out the parts of the ranges + + fn layout_grid( + grid: &Vec, + text_style: &TextStyle, + // terminal_theme: &TerminalStyle, + text_system: &TextSystem, + hyperlink: Option<(HighlightStyle, &RangeInclusive)>, + cx: &WindowContext<'_>, + ) -> (Vec, Vec) { + let theme = cx.theme(); + let mut cells = vec![]; + let mut rects = vec![]; + + let mut cur_rect: Option = None; + let mut cur_alac_color = None; + + let linegroups = grid.into_iter().group_by(|i| i.point.line); + for (line_index, (_, line)) in linegroups.into_iter().enumerate() { + for cell in line { + let mut fg = cell.fg; + let mut bg = cell.bg; + if cell.flags.contains(Flags::INVERSE) { + mem::swap(&mut fg, &mut bg); + } + + //Expand background rect range + { + if matches!(bg, Named(NamedColor::Background)) { + //Continue to next cell, resetting variables if necessary + cur_alac_color = None; + if let Some(rect) = cur_rect { + rects.push(rect); + cur_rect = None + } + } else { + match cur_alac_color { + Some(cur_color) => { + if bg == cur_color { + cur_rect = cur_rect.take().map(|rect| rect.extend()); + } else { + cur_alac_color = Some(bg); + if cur_rect.is_some() { + rects.push(cur_rect.take().unwrap()); + } + cur_rect = Some(LayoutRect::new( + AlacPoint::new( + line_index as i32, + cell.point.column.0 as i32, + ), + 1, + convert_color(&bg, theme), + )); + } + } + None => { + cur_alac_color = Some(bg); + cur_rect = Some(LayoutRect::new( + AlacPoint::new(line_index as i32, cell.point.column.0 as i32), + 1, + convert_color(&bg, &theme), + )); + } + } + } + } + + //Layout current cell text + { + let cell_text = cell.c.to_string(); + if !is_blank(&cell) { + let cell_style = TerminalElement::cell_style( + &cell, + fg, + theme, + text_style, + text_system, + hyperlink, + ); + + let layout_cell = text_system + .shape_line( + cell_text.into(), + text_style.font_size.to_pixels(cx.rem_size()), + &[cell_style], + ) + .unwrap(); + + cells.push(LayoutCell::new( + AlacPoint::new(line_index as i32, cell.point.column.0 as i32), + layout_cell, + )) + }; + } + } + + if cur_rect.is_some() { + rects.push(cur_rect.take().unwrap()); + } + } + (cells, rects) + } + + // Compute the cursor position and expected block width, may return a zero width if x_for_index returns + // the same position for sequential indexes. Use em_width instead + fn shape_cursor( + cursor_point: DisplayCursor, + size: TerminalSize, + text_fragment: &ShapedLine, + ) -> Option<(Point, Pixels)> { + if cursor_point.line() < size.total_lines() as i32 { + let cursor_width = if text_fragment.width == Pixels::ZERO { + size.cell_width() + } else { + text_fragment.width + }; + + //Cursor should always surround as much of the text as possible, + //hence when on pixel boundaries round the origin down and the width up + Some(( + point( + (cursor_point.col() as f32 * size.cell_width()).floor(), + (cursor_point.line() as f32 * size.line_height()).floor(), + ), + cursor_width.ceil(), + )) + } else { + None + } + } + + ///Convert the Alacritty cell styles to GPUI text styles and background color + fn cell_style( + indexed: &IndexedCell, + fg: terminal::alacritty_terminal::ansi::Color, + // bg: terminal::alacritty_terminal::ansi::Color, + colors: &Theme, + text_style: &TextStyle, + text_system: &TextSystem, + hyperlink: Option<(HighlightStyle, &RangeInclusive)>, + ) -> TextRun { + let flags = indexed.cell.flags; + let fg = convert_color(&fg, &colors); + // let bg = convert_color(&bg, &colors); + + let underline = (flags.intersects(Flags::ALL_UNDERLINES) + || indexed.cell.hyperlink().is_some()) + .then(|| UnderlineStyle { + color: Some(fg), + thickness: Pixels::from(1.0), + wavy: flags.contains(Flags::UNDERCURL), + }); + + let weight = if flags.intersects(Flags::BOLD | Flags::DIM_BOLD) { + FontWeight::BOLD + } else { + FontWeight::NORMAL + }; + + let style = if flags.intersects(Flags::ITALIC) { + FontStyle::Italic + } else { + FontStyle::Normal + }; + + let mut result = TextRun { + len: indexed.c.len_utf8() as usize, + color: fg, + background_color: None, + font: Font { + weight, + style, + ..text_style.font() + }, + underline, + }; + + if let Some((style, range)) = hyperlink { + if range.contains(&indexed.point) { + if let Some(underline) = style.underline { + result.underline = Some(underline); + } + + if let Some(color) = style.color { + result.color = color; + } + } + } + + result + } + + fn compute_layout(&self, bounds: Bounds, cx: &mut WindowContext) -> LayoutState { + let settings = ThemeSettings::get_global(cx).clone(); + + let buffer_font_size = settings.buffer_font_size(cx); + + let terminal_settings = TerminalSettings::get_global(cx); + let font_family = terminal_settings + .font_family + .as_ref() + .map(|string| string.clone().into()) + .unwrap_or(settings.buffer_font.family); + + let font_features = terminal_settings + .font_features + .clone() + .unwrap_or(settings.buffer_font.features.clone()); + + let line_height = terminal_settings.line_height.value(); + let font_size = terminal_settings.font_size.clone(); + + let font_size = + font_size.map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)); + + let settings = ThemeSettings::get_global(cx); + let theme = cx.theme().clone(); + + let link_style = HighlightStyle { + color: Some(gpui::blue()), + font_weight: None, + font_style: None, + background_color: None, + underline: Some(UnderlineStyle { + thickness: px(1.0), + color: Some(gpui::red()), + wavy: false, + }), + fade_out: None, + }; + + let text_style = TextStyle { + font_family, + font_features, + font_size: font_size.into(), + font_style: FontStyle::Normal, + line_height: line_height.into(), + background_color: None, + white_space: WhiteSpace::Normal, + // These are going to be overridden per-cell + underline: None, + color: theme.colors().text, + font_weight: FontWeight::NORMAL, + }; + + let text_system = cx.text_system(); + let selection_color = theme.players().local(); + let match_color = theme.colors().search_match_background; + let gutter; + let dimensions = { + let rem_size = cx.rem_size(); + let font_pixels = text_style.font_size.to_pixels(rem_size); + let line_height = font_pixels * line_height.to_pixels(rem_size); + let font_id = cx.text_system().font_id(&text_style.font()).unwrap(); + + // todo!(do we need to keep this unwrap?) + let cell_width = text_system + .advance(font_id, font_pixels, 'm') + .unwrap() + .width; + gutter = cell_width; + + let mut size = bounds.size.clone(); + size.width -= gutter; + + TerminalSize::new(line_height, cell_width, size) + }; + + let search_matches = self.terminal.read(cx).matches.clone(); + + let background_color = theme.colors().background; + + let last_hovered_word = self.terminal.update(cx, |terminal, cx| { + terminal.set_size(dimensions); + terminal.try_sync(cx); + if self.can_navigate_to_selected_word && terminal.can_navigate_to_selected_word() { + terminal.last_content.last_hovered_word.clone() + } else { + None + } + }); + + let hyperlink_tooltip = last_hovered_word.clone().map(|hovered_word| { + div() + .size_full() + .id("terminal-element") + .tooltip(move |cx| Tooltip::text(hovered_word.word.clone(), cx)) + }); + + let TerminalContent { + cells, + mode, + display_offset, + cursor_char, + selection, + cursor, + .. + } = &self.terminal.read(cx).last_content; + + // searches, highlights to a single range representations + let mut relative_highlighted_ranges = Vec::new(); + for search_match in search_matches { + relative_highlighted_ranges.push((search_match, match_color)) + } + if let Some(selection) = selection { + relative_highlighted_ranges + .push((selection.start..=selection.end, selection_color.cursor)); + } + + // then have that representation be converted to the appropriate highlight data structure + + let (cells, rects) = TerminalElement::layout_grid( + cells, + &text_style, + &cx.text_system(), + last_hovered_word + .as_ref() + .map(|last_hovered_word| (link_style, &last_hovered_word.word_match)), + cx, + ); + + //Layout cursor. Rectangle is used for IME, so we should lay it out even + //if we don't end up showing it. + let cursor = if let AlacCursorShape::Hidden = cursor.shape { + None + } else { + let cursor_point = DisplayCursor::from(cursor.point, *display_offset); + let cursor_text = { + let str_trxt = cursor_char.to_string(); + + let color = if self.focused { + theme.players().local().background + } else { + theme.players().local().cursor + }; + + let len = str_trxt.len(); + cx.text_system() + .shape_line( + str_trxt.into(), + text_style.font_size.to_pixels(cx.rem_size()), + &[TextRun { + len, + font: text_style.font(), + color, + background_color: None, + underline: Default::default(), + }], + ) + //todo!(do we need to keep this unwrap?) + .unwrap() + }; + + let focused = self.focused; + TerminalElement::shape_cursor(cursor_point, dimensions, &cursor_text).map( + move |(cursor_position, block_width)| { + let (shape, text) = match cursor.shape { + AlacCursorShape::Block if !focused => (CursorShape::Hollow, None), + AlacCursorShape::Block => (CursorShape::Block, Some(cursor_text)), + AlacCursorShape::Underline => (CursorShape::Underscore, None), + AlacCursorShape::Beam => (CursorShape::Bar, None), + AlacCursorShape::HollowBlock => (CursorShape::Hollow, None), + //This case is handled in the if wrapping the whole cursor layout + AlacCursorShape::Hidden => unreachable!(), + }; + + Cursor::new( + cursor_position, + block_width, + dimensions.line_height, + theme.players().local().cursor, + shape, + text, + ) + }, + ) + }; + + //Done! + LayoutState { + cells, + cursor, + background_color, + size: dimensions, + rects, + relative_highlighted_ranges, + mode: *mode, + display_offset: *display_offset, + hyperlink_tooltip: None, // todo!(tooltips) + gutter, + } + } + + fn generic_button_handler( + connection: Model, + origin: Point, + focus_handle: FocusHandle, + f: impl Fn(&mut Terminal, Point, &E, &mut ModelContext), + ) -> impl Fn(&E, &mut WindowContext) { + move |event, cx| { + cx.focus(&focus_handle); + connection.update(cx, |terminal, cx| { + f(terminal, origin, event, cx); + + cx.notify(); + }) + } + } + + fn register_key_listeners(&self, cx: &mut WindowContext) { + cx.on_key_event({ + let this = self.terminal.clone(); + move |event: &ModifiersChangedEvent, phase, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + let handled = + this.update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); + + if handled { + cx.notify(); + } + } + }); + } + + fn register_mouse_listeners( + self, + origin: Point, + mode: TermMode, + bounds: Bounds, + cx: &mut WindowContext, + ) -> Self { + let focus = self.focus.clone(); + let connection = self.terminal.clone(); + + let mut this = self + .on_mouse_down(MouseButton::Left, { + let connection = connection.clone(); + let focus = focus.clone(); + move |e, cx| { + cx.focus(&focus); + //todo!(context menu) + // v.context_menu.update(cx, |menu, _cx| menu.delay_cancel()); + connection.update(cx, |terminal, cx| { + terminal.mouse_down(&e, origin); + + cx.notify(); + }) + } + }) + .on_mouse_move({ + let connection = connection.clone(); + let focus = focus.clone(); + move |e, cx| { + if e.pressed_button.is_some() { + if focus.is_focused(cx) { + connection.update(cx, |terminal, cx| { + terminal.mouse_drag(e, origin, bounds); + cx.notify(); + }) + } + } + } + }) + .on_mouse_up( + MouseButton::Left, + TerminalElement::generic_button_handler( + connection.clone(), + origin, + focus.clone(), + move |terminal, origin, e, cx| { + terminal.mouse_up(&e, origin, cx); + }, + ), + ) + .on_click({ + let connection = connection.clone(); + move |e, cx| { + if e.down.button == MouseButton::Right { + let mouse_mode = connection.update(cx, |terminal, _cx| { + terminal.mouse_mode(e.down.modifiers.shift) + }); + + if !mouse_mode { + //todo!(context menu) + // view.deploy_context_menu(e.position, cx); + } + } + } + }) + .on_mouse_move({ + let connection = connection.clone(); + let focus = focus.clone(); + move |e, cx| { + if focus.is_focused(cx) { + connection.update(cx, |terminal, cx| { + terminal.mouse_move(&e, origin); + cx.notify(); + }) + } + } + }) + .on_scroll_wheel({ + let connection = connection.clone(); + move |e, cx| { + connection.update(cx, |terminal, cx| { + terminal.scroll_wheel(e, origin); + cx.notify(); + }) + } + }); + + // Mouse mode handlers: + // All mouse modes need the extra click handlers + if mode.intersects(TermMode::MOUSE_MODE) { + this = this + .on_mouse_down( + MouseButton::Right, + TerminalElement::generic_button_handler( + connection.clone(), + origin, + focus.clone(), + move |terminal, origin, e, _cx| { + terminal.mouse_down(&e, origin); + }, + ), + ) + .on_mouse_down( + MouseButton::Middle, + TerminalElement::generic_button_handler( + connection.clone(), + origin, + focus.clone(), + move |terminal, origin, e, _cx| { + terminal.mouse_down(&e, origin); + }, + ), + ) + .on_mouse_up( + MouseButton::Right, + TerminalElement::generic_button_handler( + connection.clone(), + origin, + focus.clone(), + move |terminal, origin, e, cx| { + terminal.mouse_up(&e, origin, cx); + }, + ), + ) + .on_mouse_up( + MouseButton::Middle, + TerminalElement::generic_button_handler( + connection, + origin, + focus, + move |terminal, origin, e, cx| { + terminal.mouse_up(&e, origin, cx); + }, + ), + ) + } + + this + } +} + +impl Element for TerminalElement { + type State = InteractiveElementState; + + fn layout( + &mut self, + element_state: Option, + cx: &mut WindowContext<'_>, + ) -> (LayoutId, Self::State) { + let (layout_id, interactive_state) = + self.interactivity + .layout(element_state, cx, |mut style, cx| { + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + let layout_id = cx.request_layout(&style, None); + + layout_id + }); + + (layout_id, interactive_state) + } + + fn paint( + mut self, + bounds: Bounds, + state: &mut Self::State, + cx: &mut WindowContext<'_>, + ) { + let mut layout = self.compute_layout(bounds, cx); + + let theme = cx.theme(); + + let dispatch_context = self.terminal_view.read(cx).dispatch_context(cx); + self.interactivity().key_context = Some(dispatch_context); + cx.paint_quad( + bounds, + Default::default(), + layout.background_color, + Default::default(), + Hsla::default(), + ); + let origin = bounds.origin + Point::new(layout.gutter, px(0.)); + + let terminal_input_handler = TerminalInputHandler { + cx: cx.to_async(), + terminal: self.terminal.clone(), + cursor_bounds: layout + .cursor + .as_ref() + .map(|cursor| cursor.bounding_rect(origin)), + }; + + let mut this = self.register_mouse_listeners(origin, layout.mode, bounds, cx); + + let interactivity = mem::take(&mut this.interactivity); + + interactivity.paint(bounds, bounds.size, state, cx, |_, _, cx| { + cx.handle_input(&this.focus, terminal_input_handler); + + this.register_key_listeners(cx); + + for rect in &layout.rects { + rect.paint(origin, &layout, cx); + } + + cx.with_z_index(1, |cx| { + for (relative_highlighted_range, color) in layout.relative_highlighted_ranges.iter() + { + if let Some((start_y, highlighted_range_lines)) = + to_highlighted_range_lines(relative_highlighted_range, &layout, origin) + { + let hr = HighlightedRange { + start_y, //Need to change this + line_height: layout.size.line_height, + lines: highlighted_range_lines, + color: color.clone(), + //Copied from editor. TODO: move to theme or something + corner_radius: 0.15 * layout.size.line_height, + }; + hr.paint(bounds, cx); + } + } + }); + + cx.with_z_index(2, |cx| { + for cell in &layout.cells { + cell.paint(origin, &layout, bounds, cx); + } + }); + + if this.cursor_visible { + cx.with_z_index(3, |cx| { + if let Some(cursor) = &layout.cursor { + cursor.paint(origin, cx); + } + }); + } + + if let Some(element) = layout.hyperlink_tooltip.take() { + let width: AvailableSpace = bounds.size.width.into(); + let height: AvailableSpace = bounds.size.height.into(); + element.draw(origin, Size { width, height }, cx) + } + }); + } +} + +impl IntoElement for TerminalElement { + type Element = Self; + + fn element_id(&self) -> Option { + Some("terminal".into()) + } + + fn into_element(self) -> Self::Element { + self + } +} + +struct TerminalInputHandler { + cx: AsyncWindowContext, + terminal: Model, + cursor_bounds: Option>, +} + +impl PlatformInputHandler for TerminalInputHandler { + fn selected_text_range(&mut self) -> Option> { + self.cx + .update(|_, cx| { + if self + .terminal + .read(cx) + .last_content + .mode + .contains(TermMode::ALT_SCREEN) + { + None + } else { + Some(0..0) + } + }) + .ok() + .flatten() + } + + fn marked_text_range(&mut self) -> Option> { + None + } + + fn text_for_range(&mut self, range_utf16: std::ops::Range) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + _replacement_range: Option>, + text: &str, + ) { + self.cx + .update(|_, cx| { + self.terminal.update(cx, |terminal, _| { + terminal.input(text.into()); + }) + }) + .ok(); + } + + fn replace_and_mark_text_in_range( + &mut self, + _range_utf16: Option>, + _new_text: &str, + _new_selected_range: Option>, + ) { + } + + fn unmark_text(&mut self) {} + + fn bounds_for_range(&mut self, _range_utf16: std::ops::Range) -> Option> { + self.cursor_bounds + } +} + +fn is_blank(cell: &IndexedCell) -> bool { + if cell.c != ' ' { + return false; + } + + if cell.bg != AnsiColor::Named(NamedColor::Background) { + return false; + } + + if cell.hyperlink().is_some() { + return false; + } + + if cell + .flags + .intersects(Flags::ALL_UNDERLINES | Flags::INVERSE | Flags::STRIKEOUT) + { + return false; + } + + return true; +} + +fn to_highlighted_range_lines( + range: &RangeInclusive, + layout: &LayoutState, + origin: Point, +) -> Option<(Pixels, Vec)> { + // Step 1. Normalize the points to be viewport relative. + // When display_offset = 1, here's how the grid is arranged: + //-2,0 -2,1... + //--- Viewport top + //-1,0 -1,1... + //--------- Terminal Top + // 0,0 0,1... + // 1,0 1,1... + //--- Viewport Bottom + // 2,0 2,1... + //--------- Terminal Bottom + + // Normalize to viewport relative, from terminal relative. + // lines are i32s, which are negative above the top left corner of the terminal + // If the user has scrolled, we use the display_offset to tell us which offset + // of the grid data we should be looking at. But for the rendering step, we don't + // want negatives. We want things relative to the 'viewport' (the area of the grid + // which is currently shown according to the display offset) + let unclamped_start = AlacPoint::new( + range.start().line + layout.display_offset, + range.start().column, + ); + let unclamped_end = + AlacPoint::new(range.end().line + layout.display_offset, range.end().column); + + // Step 2. Clamp range to viewport, and return None if it doesn't overlap + if unclamped_end.line.0 < 0 || unclamped_start.line.0 > layout.size.num_lines() as i32 { + return None; + } + + let clamped_start_line = unclamped_start.line.0.max(0) as usize; + let clamped_end_line = unclamped_end.line.0.min(layout.size.num_lines() as i32) as usize; + //Convert the start of the range to pixels + let start_y = origin.y + clamped_start_line as f32 * layout.size.line_height; + + // Step 3. Expand ranges that cross lines into a collection of single-line ranges. + // (also convert to pixels) + let mut highlighted_range_lines = Vec::new(); + for line in clamped_start_line..=clamped_end_line { + let mut line_start = 0; + let mut line_end = layout.size.columns(); + + if line == clamped_start_line { + line_start = unclamped_start.column.0 as usize; + } + if line == clamped_end_line { + line_end = unclamped_end.column.0 as usize + 1; //+1 for inclusive + } + + highlighted_range_lines.push(HighlightedRangeLine { + start_x: origin.x + line_start as f32 * layout.size.cell_width, + end_x: origin.x + line_end as f32 * layout.size.cell_width, + }); + } + + Some((start_y, highlighted_range_lines)) +} + +///Converts a 2, 8, or 24 bit color ANSI color to the GPUI equivalent +fn convert_color(fg: &terminal::alacritty_terminal::ansi::Color, theme: &Theme) -> Hsla { + let colors = theme.colors(); + match fg { + //Named and theme defined colors + terminal::alacritty_terminal::ansi::Color::Named(n) => match n { + NamedColor::Black => colors.terminal_ansi_black, + NamedColor::Red => colors.terminal_ansi_red, + NamedColor::Green => colors.terminal_ansi_green, + NamedColor::Yellow => colors.terminal_ansi_yellow, + NamedColor::Blue => colors.terminal_ansi_blue, + NamedColor::Magenta => colors.terminal_ansi_magenta, + NamedColor::Cyan => colors.terminal_ansi_cyan, + NamedColor::White => colors.terminal_ansi_white, + NamedColor::BrightBlack => colors.terminal_ansi_bright_black, + NamedColor::BrightRed => colors.terminal_ansi_bright_red, + NamedColor::BrightGreen => colors.terminal_ansi_bright_green, + NamedColor::BrightYellow => colors.terminal_ansi_bright_yellow, + NamedColor::BrightBlue => colors.terminal_ansi_bright_blue, + NamedColor::BrightMagenta => colors.terminal_ansi_bright_magenta, + NamedColor::BrightCyan => colors.terminal_ansi_bright_cyan, + NamedColor::BrightWhite => colors.terminal_ansi_bright_white, + NamedColor::Foreground => colors.text, + NamedColor::Background => colors.background, + NamedColor::Cursor => theme.players().local().cursor, + + // todo!(more colors) + NamedColor::DimBlack => red(), + NamedColor::DimRed => red(), + NamedColor::DimGreen => red(), + NamedColor::DimYellow => red(), + NamedColor::DimBlue => red(), + NamedColor::DimMagenta => red(), + NamedColor::DimCyan => red(), + NamedColor::DimWhite => red(), + NamedColor::BrightForeground => red(), + NamedColor::DimForeground => red(), + }, + //'True' colors + terminal::alacritty_terminal::ansi::Color::Spec(rgb) => rgba_color(rgb.r, rgb.g, rgb.b), + //8 bit, indexed colors + terminal::alacritty_terminal::ansi::Color::Indexed(i) => { + get_color_at_index(&(*i as usize), theme) + } + } +} + +///Converts an 8 bit ANSI color to it's GPUI equivalent. +///Accepts usize for compatibility with the alacritty::Colors interface, +///Other than that use case, should only be called with values in the [0,255] range +pub fn get_color_at_index(index: &usize, theme: &Theme) -> Hsla { + let colors = theme.colors(); + + match index { + //0-15 are the same as the named colors above + 0 => colors.terminal_ansi_black, + 1 => colors.terminal_ansi_red, + 2 => colors.terminal_ansi_green, + 3 => colors.terminal_ansi_yellow, + 4 => colors.terminal_ansi_blue, + 5 => colors.terminal_ansi_magenta, + 6 => colors.terminal_ansi_cyan, + 7 => colors.terminal_ansi_white, + 8 => colors.terminal_ansi_bright_black, + 9 => colors.terminal_ansi_bright_red, + 10 => colors.terminal_ansi_bright_green, + 11 => colors.terminal_ansi_bright_yellow, + 12 => colors.terminal_ansi_bright_blue, + 13 => colors.terminal_ansi_bright_magenta, + 14 => colors.terminal_ansi_bright_cyan, + 15 => colors.terminal_ansi_bright_white, + //16-231 are mapped to their RGB colors on a 0-5 range per channel + 16..=231 => { + let (r, g, b) = rgb_for_index(&(*index as u8)); //Split the index into it's ANSI-RGB components + let step = (u8::MAX as f32 / 5.).floor() as u8; //Split the RGB range into 5 chunks, with floor so no overflow + rgba_color(r * step, g * step, b * step) //Map the ANSI-RGB components to an RGB color + } + //232-255 are a 24 step grayscale from black to white + 232..=255 => { + let i = *index as u8 - 232; //Align index to 0..24 + let step = (u8::MAX as f32 / 24.).floor() as u8; //Split the RGB grayscale values into 24 chunks + rgba_color(i * step, i * step, i * step) //Map the ANSI-grayscale components to the RGB-grayscale + } + //For compatibility with the alacritty::Colors interface + 256 => colors.text, + 257 => colors.background, + 258 => theme.players().local().cursor, + + // todo!(more colors) + 259 => red(), //style.dim_black, + 260 => red(), //style.dim_red, + 261 => red(), //style.dim_green, + 262 => red(), //style.dim_yellow, + 263 => red(), //style.dim_blue, + 264 => red(), //style.dim_magenta, + 265 => red(), //style.dim_cyan, + 266 => red(), //style.dim_white, + 267 => red(), //style.bright_foreground, + 268 => colors.terminal_ansi_black, //'Dim Background', non-standard color + + _ => black(), + } +} + +///Generates the rgb channels in [0, 5] for a given index into the 6x6x6 ANSI color cube +///See: [8 bit ansi color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit). +/// +///Wikipedia gives a formula for calculating the index for a given color: +/// +///index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5) +/// +///This function does the reverse, calculating the r, g, and b components from a given index. +fn rgb_for_index(i: &u8) -> (u8, u8, u8) { + debug_assert!((&16..=&231).contains(&i)); + let i = i - 16; + let r = (i - (i % 36)) / 36; + let g = ((i % 36) - (i % 6)) / 6; + let b = (i % 36) % 6; + (r, g, b) +} + +fn rgba_color(r: u8, g: u8, b: u8) -> Hsla { + Rgba { + r: (r as f32 / 255.) as f32, + g: (g as f32 / 255.) as f32, + b: (b as f32 / 255.) as f32, + a: 1., + } + .into() +} + +#[cfg(test)] +mod tests { + use crate::terminal_element::rgb_for_index; + + #[test] + fn test_rgb_for_index() { + //Test every possible value in the color cube + for i in 16..=231 { + let (r, g, b) = rgb_for_index(&(i as u8)); + assert_eq!(i, 16 + 36 * r + 6 * g + b); + } + } +} diff --git a/crates/terminal_view2/src/terminal_panel.rs b/crates/terminal_view2/src/terminal_panel.rs index b6582b07b1..b8db12381b 100644 --- a/crates/terminal_view2/src/terminal_panel.rs +++ b/crates/terminal_view2/src/terminal_panel.rs @@ -4,8 +4,8 @@ use crate::TerminalView; use db::kvp::KEY_VALUE_STORE; use gpui::{ actions, div, serde_json, AppContext, AsyncWindowContext, Div, Entity, EventEmitter, - FocusHandle, FocusableView, ParentElement, Render, Subscription, Task, View, ViewContext, - VisualContext, WeakView, WindowContext, + FocusHandle, FocusableView, ParentElement, Render, Styled, Subscription, Task, View, + ViewContext, VisualContext, WeakView, WindowContext, }; use project::Fs; use serde::{Deserialize, Serialize}; @@ -339,7 +339,7 @@ impl Render for TerminalPanel { type Element = Div; fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { - div().child(self.pane.clone()) + div().size_full().child(self.pane.clone()) } } @@ -415,10 +415,6 @@ impl Panel for TerminalPanel { } } - fn has_focus(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).has_focus(cx) - } - fn persistent_name() -> &'static str { "TerminalPanel" } diff --git a/crates/terminal_view2/src/terminal_view.rs b/crates/terminal_view2/src/terminal_view.rs index e184fa6876..75b019f2ef 100644 --- a/crates/terminal_view2/src/terminal_view.rs +++ b/crates/terminal_view2/src/terminal_view.rs @@ -9,10 +9,9 @@ pub mod terminal_panel; // use crate::terminal_element::TerminalElement; use editor::{scroll::autoscroll::Autoscroll, Editor}; use gpui::{ - actions, div, Action, AnyElement, AppContext, Div, Element, EventEmitter, FocusEvent, - FocusHandle, Focusable, FocusableElement, FocusableView, InputHandler, InteractiveElement, - KeyDownEvent, Keystroke, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Render, - SharedString, Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext, + actions, div, Action, AnyElement, AppContext, Div, EventEmitter, FocusEvent, FocusHandle, + Focusable, FocusableElement, FocusableView, KeyContext, KeyDownEvent, Keystroke, Model, + MouseButton, MouseDownEvent, Pixels, Render, Subscription, Task, View, VisualContext, WeakView, }; use language::Bias; use persistence::TERMINAL_DB; @@ -25,13 +24,14 @@ use terminal::{ terminal_settings::{TerminalBlink, TerminalSettings, WorkingDirectory}, Event, MaybeNavigationTarget, Terminal, }; +use terminal_element::TerminalElement; +use ui::{h_stack, prelude::*, ContextMenu, Icon, IconElement, Label}; use util::{paths::PathLikeWithPosition, ResultExt}; use workspace::{ item::{BreadcrumbText, Item, ItemEvent}, notifications::NotifyResultExt, register_deserializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem}, - ui::{ContextMenu, Icon, IconElement, Label}, CloseActiveItem, NewCenterTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, }; @@ -90,6 +90,7 @@ pub struct TerminalView { blink_epoch: usize, can_navigate_to_selected_word: bool, workspace_id: WorkspaceId, + _subscriptions: Vec, } impl EventEmitter for TerminalView {} @@ -261,6 +262,20 @@ impl TerminalView { }) .detach(); + let focus = cx.focus_handle(); + // let focus_in = cx.on_focus_in(&focus, |this, cx| { + // this.has_new_content = false; + // this.terminal.read(cx).focus_in(); + // this.blink_cursors(this.blink_epoch, cx); + // cx.notify(); + // }); + // let focus_out = cx.on_focus_out(&focus, |this, cx| { + // this.terminal.update(cx, |terminal, _| { + // terminal.focus_out(); + // }); + // cx.notify(); + // }); + Self { terminal, has_new_content: true, @@ -273,6 +288,7 @@ impl TerminalView { blink_epoch: 0, can_navigate_to_selected_word: false, workspace_id, + _subscriptions: vec![/*focus_in, focus_out*/], } } @@ -302,8 +318,7 @@ impl TerminalView { menu.action("Clear", Box::new(Clear)) .action("Close", Box::new(CloseActiveItem { save_intent: None })) })); - dbg!(&position); - // todo!() + // todo!(context menus) // self.context_menu // .show(position, AnchorCorner::TopLeft, menu_entries, cx); // cx.notify(); @@ -448,6 +463,81 @@ impl TerminalView { }); } } + + fn dispatch_context(&self, cx: &AppContext) -> KeyContext { + let mut dispatch_context = KeyContext::default(); + dispatch_context.add("Terminal"); + + let mode = self.terminal.read(cx).last_content.mode; + dispatch_context.set( + "screen", + if mode.contains(TermMode::ALT_SCREEN) { + "alt" + } else { + "normal" + }, + ); + + if mode.contains(TermMode::APP_CURSOR) { + dispatch_context.add("DECCKM"); + } + if mode.contains(TermMode::APP_KEYPAD) { + dispatch_context.add("DECPAM"); + } else { + dispatch_context.add("DECPNM"); + } + if mode.contains(TermMode::SHOW_CURSOR) { + dispatch_context.add("DECTCEM"); + } + if mode.contains(TermMode::LINE_WRAP) { + dispatch_context.add("DECAWM"); + } + if mode.contains(TermMode::ORIGIN) { + dispatch_context.add("DECOM"); + } + if mode.contains(TermMode::INSERT) { + dispatch_context.add("IRM"); + } + //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html + if mode.contains(TermMode::LINE_FEED_NEW_LINE) { + dispatch_context.add("LNM"); + } + if mode.contains(TermMode::FOCUS_IN_OUT) { + dispatch_context.add("report_focus"); + } + if mode.contains(TermMode::ALTERNATE_SCROLL) { + dispatch_context.add("alternate_scroll"); + } + if mode.contains(TermMode::BRACKETED_PASTE) { + dispatch_context.add("bracketed_paste"); + } + if mode.intersects(TermMode::MOUSE_MODE) { + dispatch_context.add("any_mouse_reporting"); + } + { + let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) { + "click" + } else if mode.contains(TermMode::MOUSE_DRAG) { + "drag" + } else if mode.contains(TermMode::MOUSE_MOTION) { + "motion" + } else { + "off" + }; + dispatch_context.set("mouse_reporting", mouse_reporting); + } + { + let format = if mode.contains(TermMode::SGR_MOUSE) { + "sgr" + } else if mode.contains(TermMode::UTF8_MOUSE) { + "utf8" + } else { + "normal" + }; + dispatch_context.set("mouse_format", format); + }; + dispatch_context + } } fn possible_open_targets( @@ -532,17 +622,20 @@ impl Render for TerminalView { type Element = Focusable
; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { - let terminal_handle = self.terminal.clone().downgrade(); + let terminal_handle = self.terminal.clone(); + let this_view = cx.view().clone(); let self_id = cx.entity_id(); let focused = self.focus_handle.is_focused(cx); div() + .size_full() .relative() .child( div() .z_index(0) .absolute() + .size_full() .on_key_down(cx.listener(Self::key_down)) .on_action(cx.listener(TerminalView::send_text)) .on_action(cx.listener(TerminalView::send_keystroke)) @@ -551,15 +644,14 @@ impl Render for TerminalView { .on_action(cx.listener(TerminalView::clear)) .on_action(cx.listener(TerminalView::show_character_palette)) .on_action(cx.listener(TerminalView::select_all)) - // todo!() - .child( - "TERMINAL HERE", // TerminalElement::new( - // terminal_handle, - // focused, - // self.should_show_cursor(focused, cx), - // self.can_navigate_to_selected_word, - // ) - ) + .child(TerminalElement::new( + terminal_handle, + this_view, + self.focus_handle.clone(), + focused, + self.should_show_cursor(focused, cx), + self.can_navigate_to_selected_word, + )) .on_mouse_down( MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, cx| { @@ -579,163 +671,9 @@ impl Render for TerminalView { } } -// impl View for TerminalView { -//todo!() -// fn modifiers_changed( -// &mut self, -// event: &ModifiersChangedEvent, -// cx: &mut ViewContext, -// ) -> bool { -// let handled = self -// .terminal() -// .update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); -// if handled { -// cx.notify(); -// } -// handled -// } -// } - -// todo!() -// fn update_keymap_context(&self, keymap: &mut KeymapContext, cx: &gpui::AppContext) { -// Self::reset_to_default_keymap_context(keymap); - -// let mode = self.terminal.read(cx).last_content.mode; -// keymap.add_key( -// "screen", -// if mode.contains(TermMode::ALT_SCREEN) { -// "alt" -// } else { -// "normal" -// }, -// ); - -// if mode.contains(TermMode::APP_CURSOR) { -// keymap.add_identifier("DECCKM"); -// } -// if mode.contains(TermMode::APP_KEYPAD) { -// keymap.add_identifier("DECPAM"); -// } else { -// keymap.add_identifier("DECPNM"); -// } -// if mode.contains(TermMode::SHOW_CURSOR) { -// keymap.add_identifier("DECTCEM"); -// } -// if mode.contains(TermMode::LINE_WRAP) { -// keymap.add_identifier("DECAWM"); -// } -// if mode.contains(TermMode::ORIGIN) { -// keymap.add_identifier("DECOM"); -// } -// if mode.contains(TermMode::INSERT) { -// keymap.add_identifier("IRM"); -// } -// //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html -// if mode.contains(TermMode::LINE_FEED_NEW_LINE) { -// keymap.add_identifier("LNM"); -// } -// if mode.contains(TermMode::FOCUS_IN_OUT) { -// keymap.add_identifier("report_focus"); -// } -// if mode.contains(TermMode::ALTERNATE_SCROLL) { -// keymap.add_identifier("alternate_scroll"); -// } -// if mode.contains(TermMode::BRACKETED_PASTE) { -// keymap.add_identifier("bracketed_paste"); -// } -// if mode.intersects(TermMode::MOUSE_MODE) { -// keymap.add_identifier("any_mouse_reporting"); -// } -// { -// let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) { -// "click" -// } else if mode.contains(TermMode::MOUSE_DRAG) { -// "drag" -// } else if mode.contains(TermMode::MOUSE_MOTION) { -// "motion" -// } else { -// "off" -// }; -// keymap.add_key("mouse_reporting", mouse_reporting); -// } -// { -// let format = if mode.contains(TermMode::SGR_MOUSE) { -// "sgr" -// } else if mode.contains(TermMode::UTF8_MOUSE) { -// "utf8" -// } else { -// "normal" -// }; -// keymap.add_key("mouse_format", format); -// } -// } - -impl InputHandler for TerminalView { - fn text_for_range( - &mut self, - range: std::ops::Range, - cx: &mut ViewContext, - ) -> Option { - todo!() - } - - fn selected_text_range( - &mut self, - cx: &mut ViewContext, - ) -> Option> { - if self - .terminal - .read(cx) - .last_content - .mode - .contains(TermMode::ALT_SCREEN) - { - None - } else { - Some(0..0) - } - } - - fn marked_text_range(&self, cx: &mut ViewContext) -> Option> { - todo!() - } - - fn unmark_text(&mut self, cx: &mut ViewContext) { - todo!() - } - - fn replace_text_in_range( - &mut self, - _: Option>, - text: &str, - cx: &mut ViewContext, - ) { - self.terminal.update(cx, |terminal, _| { - terminal.input(text.into()); - }); - } - - fn replace_and_mark_text_in_range( - &mut self, - range: Option>, - new_text: &str, - new_selected_range: Option>, - cx: &mut ViewContext, - ) { - todo!() - } - - fn bounds_for_range( - &mut self, - range_utf16: std::ops::Range, - element_bounds: gpui::Bounds, - cx: &mut ViewContext, - ) -> Option> { - todo!() - } -} - impl Item for TerminalView { + type Event = ItemEvent; + fn tab_tooltip_text(&self, cx: &AppContext) -> Option { Some(self.terminal().read(cx).title().into()) } @@ -743,7 +681,8 @@ impl Item for TerminalView { fn tab_content(&self, _detail: Option, cx: &WindowContext) -> AnyElement { let title = self.terminal().read(cx).title(); - div() + h_stack() + .gap_2() .child(IconElement::new(Icon::Terminal)) .child(Label::new(title)) .into_any() @@ -776,7 +715,7 @@ impl Item for TerminalView { false } - // todo!() + // todo!(search) // fn as_searchable(&self, handle: &View) -> Option> { // Some(Box::new(handle.clone())) // } @@ -806,22 +745,23 @@ impl Item for TerminalView { let window = cx.window_handle(); cx.spawn(|pane, mut cx| async move { let cwd = None; - // todo!() - // TERMINAL_DB - // .get_working_directory(item_id, workspace_id) - // .log_err() - // .flatten() - // .or_else(|| { - // cx.read(|cx| { - // let strategy = TerminalSettings::get_global(cx).working_directory.clone(); - // workspace - // .upgrade() - // .map(|workspace| { - // get_working_directory(workspace.read(cx), cx, strategy) - // }) - // .flatten() - // }) - // }); + TERMINAL_DB + .get_working_directory(item_id, workspace_id) + .log_err() + .flatten() + .or_else(|| { + cx.update(|_, cx| { + let strategy = TerminalSettings::get_global(cx).working_directory.clone(); + workspace + .upgrade() + .map(|workspace| { + get_working_directory(workspace.read(cx), cx, strategy) + }) + .flatten() + }) + .ok() + .flatten() + }); let terminal = project.update(&mut cx, |project, cx| { project.create_terminal(cwd, window, cx) @@ -833,16 +773,19 @@ impl Item for TerminalView { } fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { - // todo!() - // cx.background() - // .spawn(TERMINAL_DB.update_workspace_id( - // workspace.database_id(), - // self.workspace_id, - // cx.view_id(), - // )) - // .detach(); + cx.background_executor() + .spawn(TERMINAL_DB.update_workspace_id( + workspace.database_id(), + self.workspace_id, + cx.entity_id().as_u64(), + )) + .detach(); self.workspace_id = workspace.database_id(); } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + f(*event) + } } impl SearchableItem for TerminalView { diff --git a/crates/theme2/src/default_colors.rs b/crates/theme2/src/default_colors.rs index 4a47bc0536..c01e0d8c80 100644 --- a/crates/theme2/src/default_colors.rs +++ b/crates/theme2/src/default_colors.rs @@ -5,7 +5,7 @@ use crate::ColorScale; use crate::{SystemColors, ThemeColors}; pub(crate) fn neutral() -> ColorScaleSet { - slate() + sand() } impl ThemeColors { @@ -29,12 +29,12 @@ impl ThemeColors { element_disabled: neutral().light_alpha().step_3(), drop_target_background: blue().light_alpha().step_2(), ghost_element_background: system.transparent, - ghost_element_hover: neutral().light_alpha().step_4(), - ghost_element_active: neutral().light_alpha().step_5(), + ghost_element_hover: neutral().light_alpha().step_3(), + ghost_element_active: neutral().light_alpha().step_4(), ghost_element_selected: neutral().light_alpha().step_5(), ghost_element_disabled: neutral().light_alpha().step_3(), - text: yellow().light().step_9(), - text_muted: neutral().light().step_11(), + text: neutral().light().step_12(), + text_muted: neutral().light().step_10(), text_placeholder: neutral().light().step_10(), text_disabled: neutral().light().step_9(), text_accent: blue().light().step_11(), @@ -49,17 +49,19 @@ impl ThemeColors { tab_bar_background: neutral().light().step_2(), tab_active_background: neutral().light().step_1(), tab_inactive_background: neutral().light().step_2(), + search_match_background: neutral().light().step_2(), // todo!(this was inserted by Mikayla) + editor_background: neutral().light().step_1(), editor_gutter_background: neutral().light().step_1(), // todo!("pick the right colors") editor_subheader_background: neutral().light().step_2(), editor_active_line_background: neutral().light_alpha().step_3(), - editor_line_number: neutral().light_alpha().step_3(), // todo!("pick the right colors") - editor_active_line_number: neutral().light_alpha().step_3(), // todo!("pick the right colors") - editor_highlighted_line_background: neutral().light_alpha().step_4(), // todo!("pick the right colors") - editor_invisible: neutral().light_alpha().step_4(), // todo!("pick the right colors") - editor_wrap_guide: neutral().light_alpha().step_4(), // todo!("pick the right colors") - editor_active_wrap_guide: neutral().light_alpha().step_4(), // todo!("pick the right colors") - editor_document_highlight_read_background: neutral().light_alpha().step_4(), // todo!("pick the right colors") + editor_line_number: neutral().light().step_10(), + editor_active_line_number: neutral().light().step_11(), + editor_highlighted_line_background: neutral().light_alpha().step_3(), + editor_invisible: neutral().light().step_10(), + editor_wrap_guide: neutral().light_alpha().step_7(), + editor_active_wrap_guide: neutral().light_alpha().step_8(), // todo!("pick the right colors") + editor_document_highlight_read_background: neutral().light_alpha().step_3(), // todo!("pick the right colors") editor_document_highlight_write_background: neutral().light_alpha().step_4(), // todo!("pick the right colors") terminal_background: neutral().light().step_1(), terminal_ansi_black: black().light().step_12(), @@ -121,6 +123,8 @@ impl ThemeColors { tab_bar_background: neutral().dark().step_2(), tab_active_background: neutral().dark().step_1(), tab_inactive_background: neutral().dark().step_2(), + search_match_background: neutral().dark().step_2(), // todo!(this was inserted by Mikayla) + editor_background: neutral().dark().step_1(), editor_gutter_background: neutral().dark().step_1(), editor_subheader_background: neutral().dark().step_3(), diff --git a/crates/theme2/src/default_theme.rs b/crates/theme2/src/default_theme.rs index 8502f433f4..ab953b121a 100644 --- a/crates/theme2/src/default_theme.rs +++ b/crates/theme2/src/default_theme.rs @@ -1,47 +1,51 @@ +use std::sync::Arc; + use crate::{ + default_color_scales, one_themes::{one_dark, one_family}, - Theme, ThemeFamily, + Appearance, PlayerColors, StatusColors, SyntaxTheme, SystemColors, Theme, ThemeColors, + ThemeFamily, ThemeStyles, }; -// fn zed_pro_daylight() -> Theme { -// Theme { -// id: "zed_pro_daylight".to_string(), -// name: "Zed Pro Daylight".into(), -// appearance: Appearance::Light, -// styles: ThemeStyles { -// system: SystemColors::default(), -// colors: ThemeColors::light(), -// status: StatusColors::light(), -// player: PlayerColors::light(), -// syntax: Arc::new(SyntaxTheme::light()), -// }, -// } -// } +fn zed_pro_daylight() -> Theme { + Theme { + id: "zed_pro_daylight".to_string(), + name: "Zed Pro Daylight".into(), + appearance: Appearance::Light, + styles: ThemeStyles { + system: SystemColors::default(), + colors: ThemeColors::light(), + status: StatusColors::light(), + player: PlayerColors::light(), + syntax: Arc::new(SyntaxTheme::light()), + }, + } +} -// pub(crate) fn zed_pro_moonlight() -> Theme { -// Theme { -// id: "zed_pro_moonlight".to_string(), -// name: "Zed Pro Moonlight".into(), -// appearance: Appearance::Dark, -// styles: ThemeStyles { -// system: SystemColors::default(), -// colors: ThemeColors::dark(), -// status: StatusColors::dark(), -// player: PlayerColors::dark(), -// syntax: Arc::new(SyntaxTheme::dark()), -// }, -// } -// } +pub(crate) fn zed_pro_moonlight() -> Theme { + Theme { + id: "zed_pro_moonlight".to_string(), + name: "Zed Pro Moonlight".into(), + appearance: Appearance::Dark, + styles: ThemeStyles { + system: SystemColors::default(), + colors: ThemeColors::dark(), + status: StatusColors::dark(), + player: PlayerColors::dark(), + syntax: Arc::new(SyntaxTheme::dark()), + }, + } +} -// pub fn zed_pro_family() -> ThemeFamily { -// ThemeFamily { -// id: "zed_pro".to_string(), -// name: "Zed Pro".into(), -// author: "Zed Team".into(), -// themes: vec![zed_pro_daylight(), zed_pro_moonlight()], -// scales: default_color_scales(), -// } -// } +pub fn zed_pro_family() -> ThemeFamily { + ThemeFamily { + id: "zed_pro".to_string(), + name: "Zed Pro".into(), + author: "Zed Team".into(), + themes: vec![zed_pro_daylight(), zed_pro_moonlight()], + scales: default_color_scales(), + } +} impl Default for ThemeFamily { fn default() -> Self { diff --git a/crates/theme2/src/one_themes.rs b/crates/theme2/src/one_themes.rs index e1fb5f1bed..29b01dbbf9 100644 --- a/crates/theme2/src/one_themes.rs +++ b/crates/theme2/src/one_themes.rs @@ -75,6 +75,8 @@ pub(crate) fn one_dark() -> Theme { tab_bar_background: bg, tab_inactive_background: bg, tab_active_background: editor, + search_match_background: bg, // todo!(this was inserted by Mikayla) + editor_background: editor, editor_gutter_background: editor, editor_subheader_background: bg, @@ -92,6 +94,7 @@ pub(crate) fn one_dark() -> Theme { 0.2, ), editor_document_highlight_write_background: gpui::red(), + terminal_background: bg, // todo!("Use one colors for terminal") terminal_ansi_black: crate::black().dark().step_12(), @@ -191,8 +194,6 @@ pub(crate) fn one_dark() -> Theme { ("variable.special".into(), red.into()), ("variant".into(), HighlightStyle::default()), ], - inlay_style: HighlightStyle::default(), - suggestion_style: HighlightStyle::default(), }), }, } diff --git a/crates/theme2/src/registry.rs b/crates/theme2/src/registry.rs index b50eb831dd..cb7814cb6f 100644 --- a/crates/theme2/src/registry.rs +++ b/crates/theme2/src/registry.rs @@ -6,8 +6,8 @@ use gpui::{HighlightStyle, SharedString}; use refineable::Refineable; use crate::{ - one_themes::one_family, Appearance, PlayerColors, StatusColors, SyntaxTheme, SystemColors, - Theme, ThemeColors, ThemeFamily, ThemeStyles, UserTheme, UserThemeFamily, + one_themes::one_family, zed_pro_family, Appearance, PlayerColors, StatusColors, SyntaxTheme, + SystemColors, Theme, ThemeColors, ThemeFamily, ThemeStyles, UserTheme, UserThemeFamily, }; pub struct ThemeRegistry { @@ -117,7 +117,7 @@ impl Default for ThemeRegistry { themes: HashMap::default(), }; - this.insert_theme_families([one_family()]); + this.insert_theme_families([zed_pro_family(), one_family()]); this } diff --git a/crates/theme2/src/settings.rs b/crates/theme2/src/settings.rs index 15b578d4b0..624b14fe33 100644 --- a/crates/theme2/src/settings.rs +++ b/crates/theme2/src/settings.rs @@ -27,7 +27,7 @@ pub struct ThemeSettings { } #[derive(Default)] -pub struct AdjustedBufferFontSize(Option); +pub struct AdjustedBufferFontSize(Pixels); #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct ThemeSettingsContent { @@ -69,12 +69,10 @@ impl BufferLineHeight { } impl ThemeSettings { - pub fn buffer_font_size(&self, cx: &mut AppContext) -> Pixels { - let font_size = *cx - .default_global::() - .0 - .get_or_insert(self.buffer_font_size.into()); - font_size.max(MIN_FONT_SIZE) + pub fn buffer_font_size(&self, cx: &AppContext) -> Pixels { + cx.try_global::() + .map_or(self.buffer_font_size, |size| size.0) + .max(MIN_FONT_SIZE) } pub fn line_height(&self) -> f32 { @@ -83,9 +81,9 @@ impl ThemeSettings { } pub fn adjusted_font_size(size: Pixels, cx: &mut AppContext) -> Pixels { - if let Some(adjusted_size) = cx.default_global::().0 { + if let Some(AdjustedBufferFontSize(adjusted_size)) = cx.try_global::() { let buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size; - let delta = adjusted_size - buffer_font_size; + let delta = *adjusted_size - buffer_font_size; size + delta } else { size @@ -95,18 +93,19 @@ pub fn adjusted_font_size(size: Pixels, cx: &mut AppContext) -> Pixels { pub fn adjust_font_size(cx: &mut AppContext, f: fn(&mut Pixels)) { let buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size; - let adjusted_size = cx - .default_global::() - .0 - .get_or_insert(buffer_font_size); - f(adjusted_size); - *adjusted_size = (*adjusted_size).max(MIN_FONT_SIZE - buffer_font_size); + let mut adjusted_size = cx + .try_global::() + .map_or(buffer_font_size, |adjusted_size| adjusted_size.0); + + f(&mut adjusted_size); + adjusted_size = adjusted_size.max(MIN_FONT_SIZE); + cx.set_global(AdjustedBufferFontSize(adjusted_size)); cx.refresh(); } pub fn reset_font_size(cx: &mut AppContext) { if cx.has_global::() { - cx.global_mut::().0 = None; + cx.remove_global::(); cx.refresh(); } } diff --git a/crates/theme2/src/styles/colors.rs b/crates/theme2/src/styles/colors.rs index 1d4917ac00..27d891ce94 100644 --- a/crates/theme2/src/styles/colors.rs +++ b/crates/theme2/src/styles/colors.rs @@ -114,6 +114,7 @@ pub struct ThemeColors { pub tab_bar_background: Hsla, pub tab_inactive_background: Hsla, pub tab_active_background: Hsla, + pub search_match_background: Hsla, // pub panel_background: Hsla, // pub pane_focused_border: Hsla, // /// The color of the scrollbar thumb. diff --git a/crates/theme2/src/styles/syntax.rs b/crates/theme2/src/styles/syntax.rs index 8675d30e3a..0f35bf60a7 100644 --- a/crates/theme2/src/styles/syntax.rs +++ b/crates/theme2/src/styles/syntax.rs @@ -8,12 +8,6 @@ use crate::{ #[derive(Clone, Default)] pub struct SyntaxTheme { pub highlights: Vec<(String, HighlightStyle)>, - // todo!("Remove this in favor of StatusColor.hint") - // If this should be overridable we should move it to ThemeColors - pub inlay_style: HighlightStyle, - // todo!("Remove this in favor of StatusColor.prediction") - // If this should be overridable we should move it to ThemeColors - pub suggestion_style: HighlightStyle, } impl SyntaxTheme { @@ -22,8 +16,8 @@ impl SyntaxTheme { highlights: vec![ ("attribute".into(), cyan().light().step_11().into()), ("boolean".into(), tomato().light().step_11().into()), - ("comment".into(), neutral().light().step_11().into()), - ("comment.doc".into(), iris().light().step_12().into()), + ("comment".into(), neutral().light().step_10().into()), + ("comment.doc".into(), iris().light().step_11().into()), ("constant".into(), red().light().step_9().into()), ("constructor".into(), red().light().step_9().into()), ("embedded".into(), red().light().step_9().into()), @@ -32,11 +26,11 @@ impl SyntaxTheme { ("enum".into(), red().light().step_9().into()), ("function".into(), red().light().step_9().into()), ("hint".into(), red().light().step_9().into()), - ("keyword".into(), orange().light().step_11().into()), + ("keyword".into(), orange().light().step_9().into()), ("label".into(), red().light().step_9().into()), ("link_text".into(), red().light().step_9().into()), ("link_uri".into(), red().light().step_9().into()), - ("number".into(), red().light().step_9().into()), + ("number".into(), purple().light().step_10().into()), ("operator".into(), red().light().step_9().into()), ("predictive".into(), red().light().step_9().into()), ("preproc".into(), red().light().step_9().into()), @@ -49,16 +43,16 @@ impl SyntaxTheme { ), ( "punctuation.delimiter".into(), - neutral().light().step_11().into(), + neutral().light().step_10().into(), ), ( "punctuation.list_marker".into(), blue().light().step_11().into(), ), ("punctuation.special".into(), red().light().step_9().into()), - ("string".into(), jade().light().step_11().into()), + ("string".into(), jade().light().step_9().into()), ("string.escape".into(), red().light().step_9().into()), - ("string.regex".into(), tomato().light().step_11().into()), + ("string.regex".into(), tomato().light().step_9().into()), ("string.special".into(), red().light().step_9().into()), ( "string.special.symbol".into(), @@ -67,13 +61,11 @@ impl SyntaxTheme { ("tag".into(), red().light().step_9().into()), ("text.literal".into(), red().light().step_9().into()), ("title".into(), red().light().step_9().into()), - ("type".into(), red().light().step_9().into()), + ("type".into(), cyan().light().step_9().into()), ("variable".into(), red().light().step_9().into()), ("variable.special".into(), red().light().step_9().into()), ("variant".into(), red().light().step_9().into()), ], - inlay_style: tomato().light().step_1().into(), // todo!("nate: use a proper style") - suggestion_style: orange().light().step_1().into(), // todo!("nate: use proper style") } } @@ -132,8 +124,6 @@ impl SyntaxTheme { ("variable.special".into(), red().dark().step_11().into()), ("variant".into(), red().dark().step_11().into()), ], - inlay_style: neutral().dark().step_11().into(), // todo!("nate: use a proper style") - suggestion_style: orange().dark().step_11().into(), // todo!("nate: use a proper style") } } @@ -152,8 +142,6 @@ impl SyntaxTheme { ) }) .collect(), - inlay_style: HighlightStyle::default(), - suggestion_style: HighlightStyle::default(), } } diff --git a/crates/ui2/src/components.rs b/crates/ui2/src/components.rs index 17271de48d..583b30a2e0 100644 --- a/crates/ui2/src/components.rs +++ b/crates/ui2/src/components.rs @@ -5,6 +5,7 @@ mod context_menu; mod disclosure; mod divider; mod icon; +mod indicator; mod keybinding; mod label; mod list; @@ -24,6 +25,7 @@ pub use context_menu::*; pub use disclosure::*; pub use divider::*; pub use icon::*; +pub use indicator::*; pub use keybinding::*; pub use label::*; pub use list::*; diff --git a/crates/ui2/src/components/button/button_like.rs b/crates/ui2/src/components/button/button_like.rs index 4bef6bff77..1a33eb2845 100644 --- a/crates/ui2/src/components/button/button_like.rs +++ b/crates/ui2/src/components/button/button_like.rs @@ -359,11 +359,7 @@ impl RenderOnce for ButtonLike { }, ) .when_some(self.tooltip, |this, tooltip| { - if !self.selected { - this.tooltip(move |cx| tooltip(cx)) - } else { - this - } + this.tooltip(move |cx| tooltip(cx)) }) .children(self.children) } diff --git a/crates/ui2/src/components/button/icon_button.rs b/crates/ui2/src/components/button/icon_button.rs index 94431ef642..f49120e90c 100644 --- a/crates/ui2/src/components/button/icon_button.rs +++ b/crates/ui2/src/components/button/icon_button.rs @@ -1,4 +1,4 @@ -use gpui::{Action, AnyView, DefiniteLength}; +use gpui::{AnyView, DefiniteLength}; use crate::prelude::*; use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize}; @@ -39,10 +39,6 @@ impl IconButton { self.selected_icon = icon.into(); self } - - pub fn action(self, action: Box) -> Self { - self.on_click(move |_event, cx| cx.dispatch_action(action.boxed_clone())) - } } impl Disableable for IconButton { diff --git a/crates/ui2/src/components/icon.rs b/crates/ui2/src/components/icon.rs index a993a54e15..a5b09782f5 100644 --- a/crates/ui2/src/components/icon.rs +++ b/crates/ui2/src/components/icon.rs @@ -1,15 +1,26 @@ -use gpui::{rems, svg, IntoElement, Svg}; +use gpui::{rems, svg, IntoElement, Rems, Svg}; use strum::EnumIter; use crate::prelude::*; #[derive(Default, PartialEq, Copy, Clone)] pub enum IconSize { + XSmall, Small, #[default] Medium, } +impl IconSize { + pub fn rems(self) -> Rems { + match self { + IconSize::XSmall => rems(12. / 16.), + IconSize::Small => rems(14. / 16.), + IconSize::Medium => rems(16. / 16.), + } + } +} + #[derive(Debug, PartialEq, Copy, Clone, EnumIter)] pub enum Icon { Ai, @@ -81,6 +92,8 @@ pub enum Icon { Shift, Option, Return, + Update, + ZedXCopilot, } impl Icon { @@ -109,6 +122,7 @@ impl Icon { Icon::Close => "icons/x.svg", Icon::Collab => "icons/user_group_16.svg", Icon::Copilot => "icons/copilot.svg", + Icon::CopilotInit => "icons/copilot_init.svg", Icon::CopilotError => "icons/copilot_error.svg", Icon::CopilotDisabled => "icons/copilot_disabled.svg", @@ -155,6 +169,8 @@ impl Icon { Icon::Shift => "icons/shift.svg", Icon::Option => "icons/option.svg", Icon::Return => "icons/return.svg", + Icon::Update => "icons/update.svg", + Icon::ZedXCopilot => "icons/zed_x_copilot.svg", } } } @@ -170,13 +186,8 @@ impl RenderOnce for IconElement { type Rendered = Svg; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - let svg_size = match self.size { - IconSize::Small => rems(14. / 16.), - IconSize::Medium => rems(16. / 16.), - }; - svg() - .size(svg_size) + .size(self.size.rems()) .flex_none() .path(self.path) .text_color(self.color.color(cx)) diff --git a/crates/ui2/src/components/indicator.rs b/crates/ui2/src/components/indicator.rs new file mode 100644 index 0000000000..4a94650dfc --- /dev/null +++ b/crates/ui2/src/components/indicator.rs @@ -0,0 +1,60 @@ +use gpui::{Div, Position}; + +use crate::prelude::*; + +#[derive(Default)] +pub enum IndicatorStyle { + #[default] + Dot, + Bar, +} + +#[derive(IntoElement)] +pub struct Indicator { + position: Position, + style: IndicatorStyle, + color: Color, +} + +impl Indicator { + pub fn dot() -> Self { + Self { + position: Position::Relative, + style: IndicatorStyle::Dot, + color: Color::Default, + } + } + + pub fn bar() -> Self { + Self { + position: Position::Relative, + style: IndicatorStyle::Dot, + color: Color::Default, + } + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + pub fn absolute(mut self) -> Self { + self.position = Position::Absolute; + self + } +} + +impl RenderOnce for Indicator { + type Rendered = Div; + + fn render(self, cx: &mut WindowContext) -> Self::Rendered { + div() + .flex_none() + .map(|this| match self.style { + IndicatorStyle::Dot => this.w_1p5().h_1p5().rounded_full(), + IndicatorStyle::Bar => this.w_full().h_1p5().rounded_t_md(), + }) + .when(self.position == Position::Absolute, |this| this.absolute()) + .bg(self.color.color(cx)) + } +} diff --git a/crates/ui2/src/components/label.rs b/crates/ui2/src/components/label.rs index 7aeda3e850..bda97be649 100644 --- a/crates/ui2/src/components/label.rs +++ b/crates/ui2/src/components/label.rs @@ -1,180 +1,7 @@ -use std::ops::Range; +mod highlighted_label; +mod label; +mod label_like; -use crate::prelude::*; -use crate::styled_ext::StyledExt; -use gpui::{relative, Div, HighlightStyle, IntoElement, StyledText, WindowContext}; - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] -pub enum LabelSize { - #[default] - Default, - Small, -} - -#[derive(Default, PartialEq, Copy, Clone)] -pub enum LineHeightStyle { - #[default] - TextLabel, - /// Sets the line height to 1 - UILabel, -} - -#[derive(IntoElement, Clone)] -pub struct Label { - label: SharedString, - size: LabelSize, - line_height_style: LineHeightStyle, - color: Color, - strikethrough: bool, -} - -impl RenderOnce for Label { - type Rendered = Div; - - fn render(self, cx: &mut WindowContext) -> Self::Rendered { - div() - .when(self.strikethrough, |this| { - this.relative().child( - div() - .absolute() - .top_1_2() - .w_full() - .h_px() - .bg(Color::Hidden.color(cx)), - ) - }) - .map(|this| match self.size { - LabelSize::Default => this.text_ui(), - LabelSize::Small => this.text_ui_sm(), - }) - .when(self.line_height_style == LineHeightStyle::UILabel, |this| { - this.line_height(relative(1.)) - }) - .text_color(self.color.color(cx)) - .child(self.label.clone()) - } -} - -impl Label { - pub fn new(label: impl Into) -> Self { - Self { - label: label.into(), - size: LabelSize::Default, - line_height_style: LineHeightStyle::default(), - color: Color::Default, - strikethrough: false, - } - } - - pub fn size(mut self, size: LabelSize) -> Self { - self.size = size; - self - } - - pub fn color(mut self, color: Color) -> Self { - self.color = color; - self - } - - pub fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self { - self.line_height_style = line_height_style; - self - } - - pub fn set_strikethrough(mut self, strikethrough: bool) -> Self { - self.strikethrough = strikethrough; - self - } -} - -#[derive(IntoElement)] -pub struct HighlightedLabel { - label: SharedString, - size: LabelSize, - color: Color, - highlight_indices: Vec, - strikethrough: bool, -} - -impl RenderOnce for HighlightedLabel { - type Rendered = Div; - - fn render(self, cx: &mut WindowContext) -> Self::Rendered { - let highlight_color = cx.theme().colors().text_accent; - - let mut highlight_indices = self.highlight_indices.iter().copied().peekable(); - let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); - - while let Some(start_ix) = highlight_indices.next() { - let mut end_ix = start_ix; - - loop { - end_ix = end_ix + self.label[end_ix..].chars().next().unwrap().len_utf8(); - if let Some(&next_ix) = highlight_indices.peek() { - if next_ix == end_ix { - end_ix = next_ix; - highlight_indices.next(); - continue; - } - } - break; - } - - highlights.push(( - start_ix..end_ix, - HighlightStyle { - color: Some(highlight_color), - ..Default::default() - }, - )); - } - - div() - .flex() - .when(self.strikethrough, |this| { - this.relative().child( - div() - .absolute() - .top_px() - .my_auto() - .w_full() - .h_px() - .bg(Color::Hidden.color(cx)), - ) - }) - .map(|this| match self.size { - LabelSize::Default => this.text_ui(), - LabelSize::Small => this.text_ui_sm(), - }) - .child(StyledText::new(self.label).with_highlights(&cx.text_style(), highlights)) - } -} - -impl HighlightedLabel { - /// shows a label with the given characters highlighted. - /// characters are identified by utf8 byte position. - pub fn new(label: impl Into, highlight_indices: Vec) -> Self { - Self { - label: label.into(), - size: LabelSize::Default, - color: Color::Default, - highlight_indices, - strikethrough: false, - } - } - - pub fn size(mut self, size: LabelSize) -> Self { - self.size = size; - self - } - - pub fn color(mut self, color: Color) -> Self { - self.color = color; - self - } - - pub fn set_strikethrough(mut self, strikethrough: bool) -> Self { - self.strikethrough = strikethrough; - self - } -} +pub use highlighted_label::*; +pub use label::*; +pub use label_like::*; diff --git a/crates/ui2/src/components/label/highlighted_label.rs b/crates/ui2/src/components/label/highlighted_label.rs new file mode 100644 index 0000000000..a7fbb0d816 --- /dev/null +++ b/crates/ui2/src/components/label/highlighted_label.rs @@ -0,0 +1,86 @@ +use std::ops::Range; + +use gpui::{HighlightStyle, StyledText}; + +use crate::{prelude::*, LabelCommon, LabelLike, LabelSize, LineHeightStyle}; + +#[derive(IntoElement)] +pub struct HighlightedLabel { + base: LabelLike, + label: SharedString, + highlight_indices: Vec, +} + +impl HighlightedLabel { + /// Constructs a label with the given characters highlighted. + /// Characters are identified by UTF-8 byte position. + pub fn new(label: impl Into, highlight_indices: Vec) -> Self { + Self { + base: LabelLike::new(), + label: label.into(), + highlight_indices, + } + } +} + +impl LabelCommon for HighlightedLabel { + fn size(mut self, size: LabelSize) -> Self { + self.base = self.base.size(size); + self + } + + fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self { + self.base = self.base.line_height_style(line_height_style); + self + } + + fn color(mut self, color: Color) -> Self { + self.base = self.base.color(color); + self + } + + fn strikethrough(mut self, strikethrough: bool) -> Self { + self.base = self.base.strikethrough(strikethrough); + self + } +} + +impl RenderOnce for HighlightedLabel { + type Rendered = LabelLike; + + fn render(self, cx: &mut WindowContext) -> Self::Rendered { + let highlight_color = cx.theme().colors().text_accent; + + let mut highlight_indices = self.highlight_indices.iter().copied().peekable(); + let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + + while let Some(start_ix) = highlight_indices.next() { + let mut end_ix = start_ix; + + loop { + end_ix = end_ix + self.label[end_ix..].chars().next().unwrap().len_utf8(); + if let Some(&next_ix) = highlight_indices.peek() { + if next_ix == end_ix { + end_ix = next_ix; + highlight_indices.next(); + continue; + } + } + break; + } + + highlights.push(( + start_ix..end_ix, + HighlightStyle { + color: Some(highlight_color), + ..Default::default() + }, + )); + } + + let mut text_style = cx.text_style().clone(); + text_style.color = self.base.color.color(cx); + + LabelLike::new().child(StyledText::new(self.label).with_highlights(&text_style, highlights)) + } +} diff --git a/crates/ui2/src/components/label/label.rs b/crates/ui2/src/components/label/label.rs new file mode 100644 index 0000000000..8272340888 --- /dev/null +++ b/crates/ui2/src/components/label/label.rs @@ -0,0 +1,48 @@ +use gpui::WindowContext; + +use crate::{prelude::*, LabelCommon, LabelLike, LabelSize, LineHeightStyle}; + +#[derive(IntoElement)] +pub struct Label { + base: LabelLike, + label: SharedString, +} + +impl Label { + pub fn new(label: impl Into) -> Self { + Self { + base: LabelLike::new(), + label: label.into(), + } + } +} + +impl LabelCommon for Label { + fn size(mut self, size: LabelSize) -> Self { + self.base = self.base.size(size); + self + } + + fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self { + self.base = self.base.line_height_style(line_height_style); + self + } + + fn color(mut self, color: Color) -> Self { + self.base = self.base.color(color); + self + } + + fn strikethrough(mut self, strikethrough: bool) -> Self { + self.base = self.base.strikethrough(strikethrough); + self + } +} + +impl RenderOnce for Label { + type Rendered = LabelLike; + + fn render(self, _cx: &mut WindowContext) -> Self::Rendered { + self.base.child(self.label) + } +} diff --git a/crates/ui2/src/components/label/label_like.rs b/crates/ui2/src/components/label/label_like.rs new file mode 100644 index 0000000000..72a48adea4 --- /dev/null +++ b/crates/ui2/src/components/label/label_like.rs @@ -0,0 +1,102 @@ +use gpui::{relative, AnyElement, Div, Styled}; +use smallvec::SmallVec; + +use crate::prelude::*; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] +pub enum LabelSize { + #[default] + Default, + Small, +} + +#[derive(Default, PartialEq, Copy, Clone)] +pub enum LineHeightStyle { + #[default] + TextLabel, + /// Sets the line height to 1 + UILabel, +} + +pub trait LabelCommon { + fn size(self, size: LabelSize) -> Self; + fn line_height_style(self, line_height_style: LineHeightStyle) -> Self; + fn color(self, color: Color) -> Self; + fn strikethrough(self, strikethrough: bool) -> Self; +} + +#[derive(IntoElement)] +pub struct LabelLike { + size: LabelSize, + line_height_style: LineHeightStyle, + pub(crate) color: Color, + strikethrough: bool, + children: SmallVec<[AnyElement; 2]>, +} + +impl LabelLike { + pub fn new() -> Self { + Self { + size: LabelSize::Default, + line_height_style: LineHeightStyle::default(), + color: Color::Default, + strikethrough: false, + children: SmallVec::new(), + } + } +} + +impl LabelCommon for LabelLike { + fn size(mut self, size: LabelSize) -> Self { + self.size = size; + self + } + + fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self { + self.line_height_style = line_height_style; + self + } + + fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + fn strikethrough(mut self, strikethrough: bool) -> Self { + self.strikethrough = strikethrough; + self + } +} + +impl ParentElement for LabelLike { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { + &mut self.children + } +} + +impl RenderOnce for LabelLike { + type Rendered = Div; + + fn render(self, cx: &mut WindowContext) -> Self::Rendered { + div() + .when(self.strikethrough, |this| { + this.relative().child( + div() + .absolute() + .top_1_2() + .w_full() + .h_px() + .bg(Color::Hidden.color(cx)), + ) + }) + .map(|this| match self.size { + LabelSize::Default => this.text_ui(), + LabelSize::Small => this.text_ui_sm(), + }) + .when(self.line_height_style == LineHeightStyle::UILabel, |this| { + this.line_height(relative(1.)) + }) + .text_color(self.color.color(cx)) + .children(self.children) + } +} diff --git a/crates/ui2/src/components/stories/icon_button.rs b/crates/ui2/src/components/stories/icon_button.rs index 3c4d68f8af..583f453d18 100644 --- a/crates/ui2/src/components/stories/icon_button.rs +++ b/crates/ui2/src/components/stories/icon_button.rs @@ -51,5 +51,13 @@ impl Render for IconButtonStory { .tooltip(|cx| Tooltip::text("Open messages", cx)), ), ) + .child(Story::label("Selected with `tooltip`")) + .child( + div().w_8().child( + IconButton::new("selected_with_tooltip", Icon::InlayHint) + .selected(true) + .tooltip(|cx| Tooltip::text("Toggle inlay hints", cx)), + ), + ) } } diff --git a/crates/ui2/src/components/stories/label.rs b/crates/ui2/src/components/stories/label.rs index 2417bee6e1..e026d388fd 100644 --- a/crates/ui2/src/components/stories/label.rs +++ b/crates/ui2/src/components/stories/label.rs @@ -23,5 +23,9 @@ impl Render for LabelStory { "Héllo, world!", vec![0, 1, 3, 8, 9, 13], )) + .child(Story::label("Highlighted with `color`")) + .child( + HighlightedLabel::new("Hello, world!", vec![0, 1, 2, 7, 8, 12]).color(Color::Error), + ) } } diff --git a/crates/ui2/src/components/tooltip.rs b/crates/ui2/src/components/tooltip.rs index cc17a610f4..7c502ac5cb 100644 --- a/crates/ui2/src/components/tooltip.rs +++ b/crates/ui2/src/components/tooltip.rs @@ -84,6 +84,7 @@ impl Render for Tooltip { .px_2() .child( h_stack() + .gap_2() .child(self.title.clone()) .when_some(self.key_binding.clone(), |this, key_binding| { this.justify_between().child(key_binding) diff --git a/crates/ui2/src/prelude.rs b/crates/ui2/src/prelude.rs index 38065b6275..a71efa4dc3 100644 --- a/crates/ui2/src/prelude.rs +++ b/crates/ui2/src/prelude.rs @@ -9,5 +9,5 @@ pub use crate::disableable::*; pub use crate::fixed::*; pub use crate::selectable::*; pub use crate::{h_stack, v_stack}; -pub use crate::{ButtonCommon, Color, StyledExt}; +pub use crate::{ButtonCommon, Color, LabelCommon, StyledExt}; pub use theme::ActiveTheme; diff --git a/crates/ui2/src/styled_ext.rs b/crates/ui2/src/styled_ext.rs index e567830d6c..0f882b4963 100644 --- a/crates/ui2/src/styled_ext.rs +++ b/crates/ui2/src/styled_ext.rs @@ -70,8 +70,7 @@ pub trait StyledExt: Styled + Sized { /// or other places that text needs to match the user's buffer font size. fn text_buffer(self, cx: &mut WindowContext) -> Self { let settings = ThemeSettings::get_global(cx); - - self.text_size(settings.buffer_font_size) + self.text_size(settings.buffer_font_size(cx)) } /// The [`Surface`](ui2::ElevationIndex::Surface) elevation level, located above the app background, is the standard level for all elements diff --git a/crates/util/src/arc_cow.rs b/crates/util/src/arc_cow.rs index 86b998ff06..c6afabbbaa 100644 --- a/crates/util/src/arc_cow.rs +++ b/crates/util/src/arc_cow.rs @@ -44,12 +44,18 @@ impl<'a, T: ?Sized> From<&'a T> for ArcCow<'a, T> { } } -impl From> for ArcCow<'_, T> { +impl From> for ArcCow<'_, T> { fn from(s: Arc) -> Self { Self::Owned(s) } } +impl From<&'_ Arc> for ArcCow<'_, T> { + fn from(s: &'_ Arc) -> Self { + Self::Owned(s.clone()) + } +} + impl From for ArcCow<'_, str> { fn from(value: String) -> Self { Self::Owned(value.into()) diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 8eee654331..7bfec95317 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -101,7 +101,7 @@ pub fn init(cx: &mut AppContext) { // will be initialized as disabled by default, so we filter its commands // out when starting up. cx.update_default_global::(|filter, _| { - filter.filtered_namespaces.insert("vim"); + filter.hidden_namespaces.insert("vim"); }); cx.update_global(|vim: &mut Vim, cx: &mut AppContext| { vim.set_enabled(settings::get::(cx).0, cx) @@ -477,9 +477,9 @@ impl Vim { cx.update_default_global::(|filter, _| { if self.enabled { - filter.filtered_namespaces.remove("vim"); + filter.hidden_namespaces.remove("vim"); } else { - filter.filtered_namespaces.insert("vim"); + filter.hidden_namespaces.insert("vim"); } }); diff --git a/crates/welcome2/src/welcome.rs b/crates/welcome2/src/welcome.rs index 441c2bf696..db348ab0a1 100644 --- a/crates/welcome2/src/welcome.rs +++ b/crates/welcome2/src/welcome.rs @@ -259,6 +259,8 @@ impl FocusableView for WelcomePage { } impl Item for WelcomePage { + type Event = ItemEvent; + fn tab_content(&self, _: Option, _: &WindowContext) -> AnyElement { "Welcome to Zed!".into_any() } @@ -278,4 +280,8 @@ impl Item for WelcomePage { _settings_subscription: cx.observe_global::(move |_, cx| cx.notify()), })) } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { + f(*event) + } } diff --git a/crates/workspace2/src/dock.rs b/crates/workspace2/src/dock.rs index 437e7c0192..a0a90293d6 100644 --- a/crates/workspace2/src/dock.rs +++ b/crates/workspace2/src/dock.rs @@ -26,6 +26,7 @@ pub trait Panel: FocusableView + EventEmitter { fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext); fn size(&self, cx: &WindowContext) -> f32; fn set_size(&mut self, size: Option, cx: &mut ViewContext); + // todo!("We should have a icon tooltip method, rather than using persistant_name") fn icon(&self, cx: &WindowContext) -> Option; fn toggle_action(&self) -> Box; fn icon_label(&self, _: &WindowContext) -> Option { @@ -36,7 +37,6 @@ pub trait Panel: FocusableView + EventEmitter { } fn set_zoomed(&mut self, _zoomed: bool, _cx: &mut ViewContext) {} fn set_active(&mut self, _active: bool, _cx: &mut ViewContext) {} - fn has_focus(&self, cx: &WindowContext) -> bool; } pub trait PanelHandle: Send + Sync { @@ -53,7 +53,6 @@ pub trait PanelHandle: Send + Sync { fn icon(&self, cx: &WindowContext) -> Option; fn toggle_action(&self, cx: &WindowContext) -> Box; fn icon_label(&self, cx: &WindowContext) -> Option; - fn has_focus(&self, cx: &WindowContext) -> bool; fn focus_handle(&self, cx: &AppContext) -> FocusHandle; fn to_any(&self) -> AnyView; } @@ -114,10 +113,6 @@ where self.read(cx).icon_label(cx) } - fn has_focus(&self, cx: &WindowContext) -> bool { - self.read(cx).has_focus(cx) - } - fn to_any(&self) -> AnyView { self.clone().into() } @@ -319,7 +314,7 @@ impl Dock { } PanelEvent::ZoomIn => { this.set_panel_zoomed(&panel.to_any(), true, cx); - if !panel.has_focus(cx) { + if !panel.focus_handle(cx).contains_focused(cx) { cx.focus_view(&panel); } workspace @@ -729,7 +724,10 @@ impl Render for PanelButtons { .trigger( IconButton::new(name, icon) .selected(is_active_button) - .action(action.boxed_clone()) + .on_click({ + let action = action.boxed_clone(); + move |_, cx| cx.dispatch_action(action.boxed_clone()) + }) .tooltip(move |cx| { Tooltip::for_action(tooltip.clone(), &*action, cx) }), @@ -760,7 +758,7 @@ pub mod test { pub position: DockPosition, pub zoomed: bool, pub active: bool, - pub has_focus: bool, + pub focus_handle: FocusHandle, pub size: f32, } actions!(ToggleTestPanel); @@ -768,12 +766,12 @@ pub mod test { impl EventEmitter for TestPanel {} impl TestPanel { - pub fn new(position: DockPosition) -> Self { + pub fn new(position: DockPosition, cx: &mut WindowContext) -> Self { Self { position, zoomed: false, active: false, - has_focus: false, + focus_handle: cx.focus_handle(), size: 300., } } @@ -832,15 +830,11 @@ pub mod test { fn set_active(&mut self, active: bool, _cx: &mut ViewContext) { self.active = active; } - - fn has_focus(&self, _cx: &WindowContext) -> bool { - self.has_focus - } } impl FocusableView for TestPanel { - fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - unimplemented!() + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() } } } diff --git a/crates/workspace2/src/item.rs b/crates/workspace2/src/item.rs index e7cdb2f861..536ebd980e 100644 --- a/crates/workspace2/src/item.rs +++ b/crates/workspace2/src/item.rs @@ -78,7 +78,7 @@ impl Settings for ItemSettings { } } -#[derive(Eq, PartialEq, Hash, Debug)] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug)] pub enum ItemEvent { CloseItem, UpdateTab, @@ -92,7 +92,9 @@ pub struct BreadcrumbText { pub highlights: Option, HighlightStyle)>>, } -pub trait Item: FocusableView + EventEmitter { +pub trait Item: FocusableView + EventEmitter { + type Event; + fn deactivated(&mut self, _: &mut ViewContext) {} fn workspace_deactivated(&mut self, _: &mut ViewContext) {} fn navigate(&mut self, _: Box, _: &mut ViewContext) -> bool { @@ -155,6 +157,8 @@ pub trait Item: FocusableView + EventEmitter { unimplemented!("reload() must be implemented if can_save() returns true") } + fn to_item_events(event: &Self::Event, f: impl FnMut(ItemEvent)); + fn act_as_type<'a>( &'a self, type_id: TypeId, @@ -206,12 +210,12 @@ pub trait Item: FocusableView + EventEmitter { } pub trait ItemHandle: 'static + Send { - fn focus_handle(&self, cx: &WindowContext) -> FocusHandle; fn subscribe_to_item_events( &self, cx: &mut WindowContext, - handler: Box, + handler: Box, ) -> gpui::Subscription; + fn focus_handle(&self, cx: &WindowContext) -> FocusHandle; fn tab_tooltip_text(&self, cx: &AppContext) -> Option; fn tab_description(&self, detail: usize, cx: &AppContext) -> Option; fn tab_content(&self, detail: Option, cx: &WindowContext) -> AnyElement; @@ -285,20 +289,20 @@ impl dyn ItemHandle { } impl ItemHandle for View { - fn focus_handle(&self, cx: &WindowContext) -> FocusHandle { - self.focus_handle(cx) - } - fn subscribe_to_item_events( &self, cx: &mut WindowContext, - handler: Box, + handler: Box, ) -> gpui::Subscription { cx.subscribe(self, move |_, event, cx| { - handler(event, cx); + T::to_item_events(event, |item_event| handler(item_event, cx)); }) } + fn focus_handle(&self, cx: &WindowContext) -> FocusHandle { + self.focus_handle(cx) + } + fn tab_tooltip_text(&self, cx: &AppContext) -> Option { self.read(cx).tab_tooltip_text(cx) } @@ -461,7 +465,7 @@ impl ItemHandle for View { } } - match event { + T::to_item_events(event, |event| match event { ItemEvent::CloseItem => { pane.update(cx, |pane, cx| { pane.close_item_by_id(item.item_id(), crate::SaveIntent::Close, cx) @@ -489,7 +493,7 @@ impl ItemHandle for View { } _ => {} - } + }); })); cx.on_blur(&self.focus_handle(cx), move |workspace, cx| { @@ -655,12 +659,7 @@ pub enum FollowEvent { Unfollow, } -pub trait FollowableEvents { - fn to_follow_event(&self) -> Option; -} - pub trait FollowableItem: Item { - type FollowableEvent: FollowableEvents; fn remote_id(&self) -> Option; fn to_state_proto(&self, cx: &WindowContext) -> Option; fn from_state_proto( @@ -670,9 +669,10 @@ pub trait FollowableItem: Item { state: &mut Option, cx: &mut WindowContext, ) -> Option>>>; + fn to_follow_event(event: &Self::Event) -> Option; fn add_event_to_update_proto( &self, - event: &Self::FollowableEvent, + event: &Self::Event, update: &mut Option, cx: &WindowContext, ) -> bool; @@ -683,7 +683,6 @@ pub trait FollowableItem: Item { cx: &mut ViewContext, ) -> Task>; fn is_project_item(&self, cx: &WindowContext) -> bool; - fn set_leader_peer_id(&mut self, leader_peer_id: Option, cx: &mut ViewContext); } @@ -739,10 +738,7 @@ impl FollowableItemHandle for View { } fn to_follow_event(&self, event: &dyn Any) -> Option { - event - .downcast_ref() - .map(T::FollowableEvent::to_follow_event) - .flatten() + T::to_follow_event(event.downcast_ref()?) } fn apply_update_proto( @@ -929,6 +925,12 @@ pub mod test { } impl Item for TestItem { + type Event = ItemEvent; + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + f(*event) + } + fn tab_description(&self, detail: usize, _: &AppContext) -> Option { self.tab_descriptions.as_ref().and_then(|descriptions| { let description = *descriptions.get(detail).or_else(|| descriptions.last())?; diff --git a/crates/workspace2/src/pane.rs b/crates/workspace2/src/pane.rs index ca2c4c2161..495819f608 100644 --- a/crates/workspace2/src/pane.rs +++ b/crates/workspace2/src/pane.rs @@ -1,5 +1,5 @@ use crate::{ - item::{Item, ItemHandle, ItemSettings, WeakItemHandle}, + item::{ClosePosition, Item, ItemHandle, ItemSettings, WeakItemHandle}, toolbar::Toolbar, workspace_settings::{AutosaveSetting, WorkspaceSettings}, NewCenterTerminal, NewFile, NewSearch, SplitDirection, ToggleZoom, Workspace, @@ -27,7 +27,8 @@ use std::{ }; use ui::{ - h_stack, prelude::*, right_click_menu, Color, Icon, IconButton, IconElement, Label, Tooltip, + h_stack, prelude::*, right_click_menu, ButtonSize, Color, Icon, IconButton, IconSize, + Indicator, Label, Tooltip, }; use ui::{v_stack, ContextMenu}; use util::truncate_and_remove_front; @@ -1418,22 +1419,7 @@ impl Pane { cx: &mut ViewContext<'_, Pane>, ) -> impl IntoElement { let label = item.tab_content(Some(detail), cx); - let close_icon = || { - let id = item.item_id(); - - div() - .id(ix) - .invisible() - .group_hover("", |style| style.visible()) - .child( - IconButton::new("close_tab", Icon::Close).on_click(cx.listener( - move |pane, _, cx| { - pane.close_item_by_id(id, SaveIntent::Close, cx) - .detach_and_log_err(cx); - }, - )), - ) - }; + let close_side = &ItemSettings::get_global(cx).close_position; let (text_color, tab_bg, tab_hover_bg, tab_active_bg) = match ix == self.active_item_index { false => ( @@ -1450,102 +1436,129 @@ impl Pane { ), }; - let close_right = ItemSettings::get_global(cx).close_position.right(); let is_active = ix == self.active_item_index; + let indicator = { + let indicator_color = match (item.has_conflict(cx), item.is_dirty(cx)) { + (true, _) => Some(Color::Warning), + (_, true) => Some(Color::Accent), + (false, false) => None, + }; + + h_stack() + .w_3() + .h_3() + .justify_center() + .absolute() + .map(|this| match close_side { + ClosePosition::Left => this.right_1(), + ClosePosition::Right => this.left_1(), + }) + .when_some(indicator_color, |this, indicator_color| { + this.child(Indicator::dot().color(indicator_color)) + }) + }; + + let close_button = { + let id = item.item_id(); + + h_stack() + .invisible() + .w_3() + .h_3() + .justify_center() + .absolute() + .map(|this| match close_side { + ClosePosition::Left => this.left_1(), + ClosePosition::Right => this.right_1(), + }) + .group_hover("", |style| style.visible()) + .child( + // TODO: Fix button size + IconButton::new("close tab", Icon::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(move |pane, _, cx| { + pane.close_item_by_id(id, SaveIntent::Close, cx) + .detach_and_log_err(cx); + })), + ) + }; + let tab = div() - .group("") - .id(ix) - .cursor_pointer() - .when_some(item.tab_tooltip_text(cx), |div, text| { - div.tooltip(move |cx| cx.build_view(|cx| Tooltip::new(text.clone())).into()) - }) - .on_click(cx.listener(move |v: &mut Self, e, cx| v.activate_item(ix, true, true, cx))) - // .on_drag(move |pane, cx| pane.render_tab(ix, item.boxed_clone(), detail, cx)) - // .drag_over::(|d| d.bg(cx.theme().colors().element_drop_target)) - // .on_drop(|_view, state: View, cx| { - // eprintln!("{:?}", state.read(cx)); - // }) - .flex() - .items_center() - .justify_center() - // todo!("Nate - I need to do some work to balance all the items in the tab once things stablize") - .map(|this| { - if close_right { - this.pl_3().pr_1() - } else { - this.pr_1().pr_3() - } - }) - .py_1() - .bg(tab_bg) .border_color(cx.theme().colors().border) - .text_color(if is_active { - cx.theme().colors().text - } else { - cx.theme().colors().text_muted - }) + .bg(tab_bg) + // 30px @ 16px/rem + .h(rems(1.875)) .map(|this| { + let is_first_item = ix == 0; let is_last_item = ix == self.items.len() - 1; match ix.cmp(&self.active_item_index) { - cmp::Ordering::Less => this.border_l().mr_px(), - cmp::Ordering::Greater => { - if is_last_item { - this.mr_px().ml_px() + cmp::Ordering::Less => { + if is_first_item { + this.pl_px().pr_px().border_b() } else { - this.border_r().ml_px() + this.border_l().pr_px().border_b() + } + } + cmp::Ordering::Greater => { + if is_last_item { + this.pr_px().pl_px().border_b() + } else { + this.border_r().pl_px().border_b() + } + } + cmp::Ordering::Equal => { + if is_first_item { + this.pl_px().border_r().pb_px() + } else { + this.border_l().border_r().pb_px() } } - cmp::Ordering::Equal => this.border_l().border_r(), } }) - // .hover(|h| h.bg(tab_hover_bg)) - // .active(|a| a.bg(tab_active_bg)) .child( - div() - .flex() - .items_center() + h_stack() + .group("") + .id(ix) + .relative() + .h_full() + .cursor_pointer() + .when_some(item.tab_tooltip_text(cx), |div, text| { + div.tooltip(move |cx| cx.build_view(|cx| Tooltip::new(text.clone())).into()) + }) + .on_click( + cx.listener(move |v: &mut Self, e, cx| v.activate_item(ix, true, true, cx)), + ) + // .on_drag(move |pane, cx| pane.render_tab(ix, item.boxed_clone(), detail, cx)) + // .drag_over::(|d| d.bg(cx.theme().colors().element_drop_target)) + // .on_drop(|_view, state: View, cx| { + // eprintln!("{:?}", state.read(cx)); + // }) + .px_5() + // .hover(|h| h.bg(tab_hover_bg)) + // .active(|a| a.bg(tab_active_bg)) .gap_1() .text_color(text_color) - .children( - item.has_conflict(cx) - .then(|| { - div().border().border_color(gpui::red()).child( - IconElement::new(Icon::ExclamationTriangle) - .size(ui::IconSize::Small) - .color(Color::Warning), - ) - }) - .or(item.is_dirty(cx).then(|| { - div().border().border_color(gpui::red()).child( - IconElement::new(Icon::ExclamationTriangle) - .size(ui::IconSize::Small) - .color(Color::Info), - ) - })), - ) - .children((!close_right).then(|| close_icon())) - .child(label) - .children(close_right.then(|| close_icon())), + .child(indicator) + .child(close_button) + .child(label), ); right_click_menu(ix).trigger(tab).menu(|cx| { ContextMenu::build(cx, |menu, cx| { - menu.action( - "Close Active Item", - CloseActiveItem { save_intent: None }.boxed_clone(), - ) - .action("Close Inactive Items", CloseInactiveItems.boxed_clone()) - .action("Close Clean Items", CloseCleanItems.boxed_clone()) - .action("Close Items To The Left", CloseItemsToTheLeft.boxed_clone()) - .action( - "Close Items To The Right", - CloseItemsToTheRight.boxed_clone(), - ) - .action( - "Close All Items", - CloseAllItems { save_intent: None }.boxed_clone(), - ) + menu.action("Close", CloseActiveItem { save_intent: None }.boxed_clone()) + .action("Close Others", CloseInactiveItems.boxed_clone()) + .separator() + .action("Close Left", CloseItemsToTheLeft.boxed_clone()) + .action("Close Right", CloseItemsToTheRight.boxed_clone()) + .separator() + .action("Close Clean", CloseCleanItems.boxed_clone()) + .action( + "Close All", + CloseAllItems { save_intent: None }.boxed_clone(), + ) }) }) } @@ -1565,116 +1578,118 @@ impl Pane { // Left Side .child( h_stack() - .px_2() .flex() .flex_none() .gap_1() + .px_1() + .border_b() + .border_r() + .border_color(cx.theme().colors().border) // Nav Buttons .child( - div().border().border_color(gpui::red()).child( - IconButton::new("navigate_backward", Icon::ArrowLeft) - .on_click({ - let view = cx.view().clone(); - move |_, cx| view.update(cx, Self::navigate_backward) - }) - .disabled(!self.can_navigate_backward()), - ), + IconButton::new("navigate_backward", Icon::ArrowLeft) + .icon_size(IconSize::Small) + .on_click({ + let view = cx.view().clone(); + move |_, cx| view.update(cx, Self::navigate_backward) + }) + .disabled(!self.can_navigate_backward()), ) .child( - div().border().border_color(gpui::red()).child( - IconButton::new("navigate_forward", Icon::ArrowRight) - .on_click({ - let view = cx.view().clone(); - move |_, cx| view.update(cx, Self::navigate_backward) - }) - .disabled(!self.can_navigate_forward()), - ), + IconButton::new("navigate_forward", Icon::ArrowRight) + .icon_size(IconSize::Small) + .on_click({ + let view = cx.view().clone(); + move |_, cx| view.update(cx, Self::navigate_backward) + }) + .disabled(!self.can_navigate_forward()), ), ) .child( - div().flex_1().h_full().child( - div().id("tabs").flex().overflow_x_scroll().children( - self.items - .iter() - .enumerate() - .zip(self.tab_details(cx)) - .map(|((ix, item), detail)| self.render_tab(ix, item, detail, cx)), + div() + .relative() + .flex_1() + .h_full() + .overflow_hidden_x() + .child( + div() + .absolute() + .top_0() + .left_0() + .z_index(1) + .size_full() + .border_b() + .border_color(cx.theme().colors().border), + ) + .child( + h_stack().id("tabs").z_index(2).children( + self.items + .iter() + .enumerate() + .zip(self.tab_details(cx)) + .map(|((ix, item), detail)| self.render_tab(ix, item, detail, cx)), + ), ), - ), ) // Right Side .child( - div() - .px_1() + h_stack() .flex() .flex_none() - .gap_2() - // Nav Buttons + .gap_1() + .px_1() + .border_b() + .border_l() + .border_color(cx.theme().colors().border) .child( div() .flex() .items_center() .gap_px() .child( - div() - .bg(gpui::blue()) - .border() - .border_color(gpui::red()) - .child(IconButton::new("plus", Icon::Plus).on_click( - cx.listener(|this, _, cx| { - let menu = ContextMenu::build(cx, |menu, cx| { - menu.action("New File", NewFile.boxed_clone()) - .action( - "New Terminal", - NewCenterTerminal.boxed_clone(), - ) - .action("New Search", NewSearch.boxed_clone()) - }); - cx.subscribe( - &menu, - |this, _, event: &DismissEvent, cx| { - this.focus(cx); - this.new_item_menu = None; - }, - ) - .detach(); - this.new_item_menu = Some(menu); - }), - )) - .when_some(self.new_item_menu.as_ref(), |el, new_item_menu| { - el.child(Self::render_menu_overlay(new_item_menu)) - }), + IconButton::new("plus", Icon::Plus) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + let menu = ContextMenu::build(cx, |menu, cx| { + menu.action("New File", NewFile.boxed_clone()) + .action( + "New Terminal", + NewCenterTerminal.boxed_clone(), + ) + .action("New Search", NewSearch.boxed_clone()) + }); + cx.subscribe(&menu, |this, _, event: &DismissEvent, cx| { + this.focus(cx); + this.new_item_menu = None; + }) + .detach(); + this.new_item_menu = Some(menu); + })), ) + .when_some(self.new_item_menu.as_ref(), |el, new_item_menu| { + el.child(Self::render_menu_overlay(new_item_menu)) + }) .child( - div() - .border() - .border_color(gpui::red()) - .child(IconButton::new("split", Icon::Split).on_click( - cx.listener(|this, _, cx| { - let menu = ContextMenu::build(cx, |menu, cx| { - menu.action("Split Right", SplitRight.boxed_clone()) - .action("Split Left", SplitLeft.boxed_clone()) - .action("Split Up", SplitUp.boxed_clone()) - .action("Split Down", SplitDown.boxed_clone()) - }); - cx.subscribe( - &menu, - |this, _, event: &DismissEvent, cx| { - this.focus(cx); - this.split_item_menu = None; - }, - ) - .detach(); - this.split_item_menu = Some(menu); - }), - )) - .when_some( - self.split_item_menu.as_ref(), - |el, split_item_menu| { - el.child(Self::render_menu_overlay(split_item_menu)) - }, - ), - ), + IconButton::new("split", Icon::Split) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + let menu = ContextMenu::build(cx, |menu, cx| { + menu.action("Split Right", SplitRight.boxed_clone()) + .action("Split Left", SplitLeft.boxed_clone()) + .action("Split Up", SplitUp.boxed_clone()) + .action("Split Down", SplitDown.boxed_clone()) + }); + cx.subscribe(&menu, |this, _, event: &DismissEvent, cx| { + this.focus(cx); + this.split_item_menu = None; + }) + .detach(); + this.split_item_menu = Some(menu); + })), + ) + .when_some(self.split_item_menu.as_ref(), |el, split_item_menu| { + el.child(Self::render_menu_overlay(split_item_menu)) + }), ), ) } @@ -2092,6 +2107,8 @@ impl Render for Pane { v_stack() .key_context("Pane") .track_focus(&self.focus_handle) + .size_full() + .overflow_hidden() .on_focus_in({ let this = this.clone(); move |event, cx| { @@ -2159,7 +2176,6 @@ impl Render for Pane { pane.close_all_items(action, cx) .map(|task| task.detach_and_log_err(cx)); })) - .size_full() .on_action( cx.listener(|pane: &mut Self, action: &CloseActiveItem, cx| { pane.close_active_item(action, cx) diff --git a/crates/workspace2/src/shared_screen.rs b/crates/workspace2/src/shared_screen.rs index c4bcb31958..134dfc66bb 100644 --- a/crates/workspace2/src/shared_screen.rs +++ b/crates/workspace2/src/shared_screen.rs @@ -59,7 +59,6 @@ impl SharedScreen { } impl EventEmitter for SharedScreen {} -impl EventEmitter for SharedScreen {} impl FocusableView for SharedScreen { fn focus_handle(&self, _: &AppContext) -> FocusHandle { @@ -79,9 +78,12 @@ impl Render for SharedScreen { } impl Item for SharedScreen { + type Event = Event; + fn tab_tooltip_text(&self, _: &AppContext) -> Option { Some(format!("{}'s screen", self.user.github_login).into()) } + fn deactivated(&mut self, cx: &mut ViewContext) { if let Some(nav_history) = self.nav_history.as_mut() { nav_history.push::<()>(None, cx); @@ -111,4 +113,10 @@ impl Item for SharedScreen { let track = self.track.upgrade()?; Some(cx.build_view(|cx| Self::new(&track, self.peer_id, self.user.clone(), cx))) } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + Event::Close => f(ItemEvent::CloseItem), + } + } } diff --git a/crates/workspace2/src/status_bar.rs b/crates/workspace2/src/status_bar.rs index 1bc84e0411..07c48293b5 100644 --- a/crates/workspace2/src/status_bar.rs +++ b/crates/workspace2/src/status_bar.rs @@ -62,22 +62,13 @@ impl Render for StatusBar { ) .child( // Right Dock - h_stack() - .gap_1() - .child( - // Terminal - div() - .border() - .border_color(gpui::red()) - .child(IconButton::new("status-assistant", Icon::Ai)), - ) - .child( - // Terminal - div() - .border() - .border_color(gpui::red()) - .child(IconButton::new("status-chat", Icon::MessageBubbles)), - ), + h_stack().gap_1().child( + // Terminal + div() + .border() + .border_color(gpui::red()) + .child(IconButton::new("status-chat", Icon::MessageBubbles)), + ), ) .child(self.render_right_tools(cx)), ) diff --git a/crates/workspace2/src/toolbar.rs b/crates/workspace2/src/toolbar.rs index 8c554dcd67..1cc71e4d84 100644 --- a/crates/workspace2/src/toolbar.rs +++ b/crates/workspace2/src/toolbar.rs @@ -1,10 +1,10 @@ use crate::ItemHandle; use gpui::{ - div, AnyView, Div, Entity, EntityId, EventEmitter, ParentElement as _, Render, Styled, View, + AnyView, Div, Entity, EntityId, EventEmitter, ParentElement as _, Render, Styled, View, ViewContext, WindowContext, }; use ui::prelude::*; -use ui::{h_stack, v_stack, Icon, IconButton}; +use ui::{h_stack, v_stack}; pub enum ToolbarItemEvent { ChangeLocation(ToolbarItemLocation), @@ -87,25 +87,7 @@ impl Render for Toolbar { .child( h_stack() .justify_between() - // Toolbar left side - .children(self.items.iter().map(|(child, _)| child.to_any())) - // Toolbar right side - .child( - h_stack() - .p_1() - .child( - div() - .border() - .border_color(gpui::red()) - .child(IconButton::new("buffer-search", Icon::MagnifyingGlass)), - ) - .child( - div() - .border() - .border_color(gpui::red()) - .child(IconButton::new("inline-assist", Icon::MagicWand)), - ), - ), + .children(self.items.iter().map(|(child, _)| child.to_any())), ) } } diff --git a/crates/workspace2/src/workspace2.rs b/crates/workspace2/src/workspace2.rs index 6eb8c623b4..b660b8cb7f 100644 --- a/crates/workspace2/src/workspace2.rs +++ b/crates/workspace2/src/workspace2.rs @@ -45,9 +45,10 @@ use node_runtime::NodeRuntime; use notifications::{simple_message_notification::MessageNotification, NotificationHandle}; pub use pane::*; pub use pane_group::*; +use persistence::DB; pub use persistence::{ model::{ItemId, SerializedWorkspace, WorkspaceLocation}, - WorkspaceDb, DB, + WorkspaceDb, DB as WORKSPACE_DB, }; use postage::stream::Stream; use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; @@ -65,7 +66,7 @@ use std::{ time::Duration, }; use theme::{ActiveTheme, ThemeSettings}; -pub use toolbar::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; +pub use toolbar::{Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; pub use ui; // use ui::{h_stack, v_stack, StyledExt}; use util::ResultExt; @@ -213,27 +214,31 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { init_settings(cx); notifications::init(cx); - // cx.add_global_action({ - // let app_state = Arc::downgrade(&app_state); - // move |_: &Open, cx: &mut AppContext| { - // let mut paths = cx.prompt_for_paths(PathPromptOptions { - // files: true, - // directories: true, - // multiple: true, - // }); + cx.on_action(Workspace::close_global); + cx.on_action(restart); - // if let Some(app_state) = app_state.upgrade() { - // cx.spawn(move |mut cx| async move { - // if let Some(paths) = paths.recv().await.flatten() { - // cx.update(|cx| { - // open_paths(&paths, &app_state, None, cx).detach_and_log_err(cx) - // }); - // } - // }) - // .detach(); - // } - // } - // }); + cx.on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &Open, cx: &mut AppContext| { + let mut paths = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: true, + multiple: true, + }); + + if let Some(app_state) = app_state.upgrade() { + cx.spawn(move |mut cx| async move { + if let Some(paths) = paths.await.log_err().flatten() { + cx.update(|cx| { + open_paths(&paths, &app_state, None, cx).detach_and_log_err(cx) + }) + .ok(); + } + }) + .detach(); + } + } + }); } type ProjectItemBuilders = @@ -1077,7 +1082,6 @@ impl Workspace { } } - // todo!(Non-window-actions) pub fn close_global(_: &CloseWindow, cx: &mut AppContext) { cx.windows().iter().find(|window| { window @@ -1095,21 +1099,18 @@ impl Workspace { }); } - pub fn close( - &mut self, - _: &CloseWindow, - cx: &mut ViewContext, - ) -> Option>> { + pub fn close_window(&mut self, _: &CloseWindow, cx: &mut ViewContext) { let window = cx.window_handle(); let prepare = self.prepare_to_close(false, cx); - Some(cx.spawn(|_, mut cx| async move { + cx.spawn(|_, mut cx| async move { if prepare.await? { window.update(&mut cx, |_, cx| { cx.remove_window(); })?; } - Ok(()) - })) + anyhow::Ok(()) + }) + .detach_and_log_err(cx) } pub fn prepare_to_close( @@ -1543,7 +1544,7 @@ impl Workspace { if let Some(active_panel) = dock.active_panel() { if was_visible { - if active_panel.has_focus(cx) { + if active_panel.focus_handle(cx).contains_focused(cx) { focus_center = true; } } else { @@ -1590,7 +1591,9 @@ impl Workspace { /// Focus the panel of the given type if it isn't already focused. If it is /// already focused, then transfer focus back to the workspace center. pub fn toggle_panel_focus(&mut self, cx: &mut ViewContext) { - self.focus_or_unfocus_panel::(cx, |panel, cx| !panel.has_focus(cx)); + self.focus_or_unfocus_panel::(cx, |panel, cx| { + !panel.focus_handle(cx).contains_focused(cx) + }); } /// Focus or unfocus the given panel type, depending on the given callback. @@ -1682,7 +1685,7 @@ impl Workspace { if Some(dock.position()) != dock_to_reveal { if let Some(panel) = dock.active_panel() { if panel.is_zoomed(cx) { - focus_center |= panel.has_focus(cx); + focus_center |= panel.focus_handle(cx).contains_focused(cx); dock.set_open(false, cx); } } @@ -2076,6 +2079,7 @@ impl Workspace { } if &pane == self.active_pane() { self.active_item_path_changed(cx); + self.update_active_view_for_followers(cx); } } pane::Event::ChangeItemTitle => { @@ -2326,42 +2330,44 @@ impl Workspace { })) } - // pub fn follow_next_collaborator( - // &mut self, - // _: &FollowNextCollaborator, - // cx: &mut ViewContext, - // ) -> Option>> { - // let collaborators = self.project.read(cx).collaborators(); - // let next_leader_id = if let Some(leader_id) = self.leader_for_pane(&self.active_pane) { - // let mut collaborators = collaborators.keys().copied(); - // for peer_id in collaborators.by_ref() { - // if peer_id == leader_id { - // break; - // } - // } - // collaborators.next() - // } else if let Some(last_leader_id) = - // self.last_leaders_by_pane.get(&self.active_pane.downgrade()) - // { - // if collaborators.contains_key(last_leader_id) { - // Some(*last_leader_id) - // } else { - // None + // pub fn follow_next_collaborator( + // &mut self, + // _: &FollowNextCollaborator, + // cx: &mut ViewContext, + // ) { + // let collaborators = self.project.read(cx).collaborators(); + // let next_leader_id = if let Some(leader_id) = self.leader_for_pane(&self.active_pane) { + // let mut collaborators = collaborators.keys().copied(); + // for peer_id in collaborators.by_ref() { + // if peer_id == leader_id { + // break; // } + // } + // collaborators.next() + // } else if let Some(last_leader_id) = + // self.last_leaders_by_pane.get(&self.active_pane.downgrade()) + // { + // if collaborators.contains_key(last_leader_id) { + // Some(*last_leader_id) // } else { // None - // }; - - // let pane = self.active_pane.clone(); - // let Some(leader_id) = next_leader_id.or_else(|| collaborators.keys().copied().next()) - // else { - // return None; - // }; - // if Some(leader_id) == self.unfollow(&pane, cx) { - // return None; // } - // self.follow(leader_id, cx) + // } else { + // None + // }; + + // let pane = self.active_pane.clone(); + // let Some(leader_id) = next_leader_id.or_else(|| collaborators.keys().copied().next()) + // else { + // return; + // }; + // if Some(leader_id) == self.unfollow(&pane, cx) { + // return; // } + // if let Some(task) = self.follow(leader_id, cx) { + // task.detach(); + // } + // } pub fn follow( &mut self, @@ -2410,6 +2416,18 @@ impl Workspace { self.start_following(leader_id, cx) } + // // if you're already following, find the right pane and focus it. + // for (pane, state) in &self.follower_states { + // if leader_id == state.leader_id { + // cx.focus(pane); + // return None; + // } + // } + + // // Otherwise, follow. + // self.start_following(leader_id, cx) + // } + pub fn unfollow(&mut self, pane: &View, cx: &mut ViewContext) -> Option { let state = self.follower_states.remove(pane)?; let leader_id = state.leader_id; @@ -2626,8 +2644,6 @@ impl Workspace { update: proto::UpdateFollowers, cx: &mut AsyncWindowContext, ) -> Result<()> { - dbg!("process_leader_update", &update); - match update.variant.ok_or_else(|| anyhow!("invalid update"))? { proto::update_followers::Variant::UpdateActiveView(update_active_view) => { this.update(cx, |this, _| { @@ -2743,18 +2759,18 @@ impl Workspace { fn update_active_view_for_followers(&mut self, cx: &mut ViewContext) { let mut is_project_item = true; let mut update = proto::UpdateActiveView::default(); - if self.active_pane.read(cx).has_focus(cx) { - let item = self - .active_item(cx) - .and_then(|item| item.to_followable_item_handle(cx)); - if let Some(item) = item { - is_project_item = item.is_project_item(cx); - update = proto::UpdateActiveView { - id: item - .remote_id(&self.app_state.client, cx) - .map(|id| id.to_proto()), - leader_id: self.leader_for_pane(&self.active_pane), - }; + + if let Some(item) = self.active_item(cx) { + if item.focus_handle(cx).contains_focused(cx) { + if let Some(item) = item.to_followable_item_handle(cx) { + is_project_item = item.is_project_item(cx); + update = proto::UpdateActiveView { + id: item + .remote_id(&self.app_state.client, cx) + .map(|id| id.to_proto()), + leader_id: self.leader_for_pane(&self.active_pane), + }; + } } } @@ -3222,13 +3238,8 @@ impl Workspace { fn actions(&self, div: Div, cx: &mut ViewContext) -> Div { self.add_workspace_actions_listeners(div, cx) - // cx.add_async_action(Workspace::open); - // cx.add_async_action(Workspace::follow_next_collaborator); - // cx.add_async_action(Workspace::close); .on_action(cx.listener(Self::close_inactive_items_and_panes)) .on_action(cx.listener(Self::close_all_items_and_panes)) - // cx.add_global_action(Workspace::close_global); - // cx.add_global_action(restart); .on_action(cx.listener(Self::save_all)) .on_action(cx.listener(Self::add_folder_to_project)) .on_action(cx.listener(|workspace, _: &Unfollow, cx| { @@ -3277,6 +3288,9 @@ impl Workspace { workspace.close_all_docks(cx); }), ) + .on_action(cx.listener(Workspace::open)) + .on_action(cx.listener(Workspace::close_window)) + // cx.add_action(Workspace::activate_pane_at_index); // cx.add_action(|workspace: &mut Workspace, _: &ReopenClosedItem, cx| { // workspace.reopen_closed_item(cx).detach(); @@ -3881,8 +3895,6 @@ impl WorkspaceStore { let leader_id = envelope.original_sender_id()?; let update = envelope.payload; - dbg!("handle_upate_followers"); - this.update(&mut cx, |this, cx| { for workspace in &this.workspaces { workspace.update(cx, |workspace, cx| { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 245bb4cd58..f665cc36db 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.116.0" +version = "0.117.0" publish = false [lib] @@ -78,7 +78,7 @@ workspace = { path = "../workspace" } welcome = { path = "../welcome" } zed-actions = {path = "../zed-actions"} anyhow.workspace = true -async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] } +async-compression.workspace = true async-tar = "0.4.2" async-recursion = "0.3" async-trait.workspace = true diff --git a/crates/zed2/Cargo.toml b/crates/zed2/Cargo.toml index 5c8335e28b..ed7ed180f5 100644 --- a/crates/zed2/Cargo.toml +++ b/crates/zed2/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition = "2021" name = "zed2" -version = "0.109.0" +version = "2.0.0" publish = false [lib] @@ -49,14 +49,14 @@ lsp = { package = "lsp2", path = "../lsp2" } menu = { package = "menu2", path = "../menu2" } # language_tools = { path = "../language_tools" } node_runtime = { path = "../node_runtime" } -# assistant = { path = "../assistant" } +assistant = { package = "assistant2", path = "../assistant2" } outline = { package = "outline2", path = "../outline2" } # plugin_runtime = { path = "../plugin_runtime",optional = true } project = { package = "project2", path = "../project2" } project_panel = { package = "project_panel2", path = "../project_panel2" } # project_symbols = { path = "../project_symbols" } -# quick_action_bar = { path = "../quick_action_bar" } -# recent_projects = { path = "../recent_projects" } +quick_action_bar = { package = "quick_action_bar2", path = "../quick_action_bar2" } +recent_projects = { package = "recent_projects2", path = "../recent_projects2" } rope = { package = "rope2", path = "../rope2"} rpc = { package = "rpc2", path = "../rpc2" } settings = { package = "settings2", path = "../settings2" } @@ -68,13 +68,13 @@ terminal_view = { package = "terminal_view2", path = "../terminal_view2" } theme = { package = "theme2", path = "../theme2" } theme_selector = { package = "theme_selector2", path = "../theme_selector2" } util = { path = "../util" } -# semantic_index = { path = "../semantic_index" } +semantic_index = { package = "semantic_index2", path = "../semantic_index2" } # vim = { path = "../vim" } workspace = { package = "workspace2", path = "../workspace2" } welcome = { package = "welcome2", path = "../welcome2" } zed_actions = {package = "zed_actions2", path = "../zed_actions2"} anyhow.workspace = true -async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] } +async-compression.workspace = true async-tar = "0.4.2" async-recursion = "0.3" async-trait.workspace = true diff --git a/crates/zed2/src/app_menus.rs b/crates/zed2/src/app_menus.rs new file mode 100644 index 0000000000..63db41e7bd --- /dev/null +++ b/crates/zed2/src/app_menus.rs @@ -0,0 +1,175 @@ +use gpui::{Menu, MenuItem, OsAction}; + +#[cfg(target_os = "macos")] +pub fn app_menus() -> Vec> { + vec![ + Menu { + name: "Zed", + items: vec![ + MenuItem::action("About Zed…", super::About), + MenuItem::action("Check for Updates", auto_update::Check), + MenuItem::separator(), + MenuItem::submenu(Menu { + name: "Preferences", + items: vec![ + MenuItem::action("Open Settings", super::OpenSettings), + MenuItem::action("Open Key Bindings", super::OpenKeymap), + MenuItem::action("Open Default Settings", super::OpenDefaultSettings), + MenuItem::action("Open Default Key Bindings", super::OpenDefaultKeymap), + MenuItem::action("Open Local Settings", super::OpenLocalSettings), + MenuItem::action("Select Theme", theme_selector::Toggle), + ], + }), + MenuItem::action("Install CLI", install_cli::Install), + MenuItem::separator(), + MenuItem::action("Hide Zed", super::Hide), + MenuItem::action("Hide Others", super::HideOthers), + MenuItem::action("Show All", super::ShowAll), + MenuItem::action("Quit", super::Quit), + ], + }, + Menu { + name: "File", + items: vec![ + MenuItem::action("New", workspace::NewFile), + MenuItem::action("New Window", workspace::NewWindow), + MenuItem::separator(), + MenuItem::action("Open…", workspace::Open), + MenuItem::action("Open Recent...", recent_projects::OpenRecent), + MenuItem::separator(), + MenuItem::action("Add Folder to Project…", workspace::AddFolderToProject), + MenuItem::action("Save", workspace::Save { save_intent: None }), + MenuItem::action("Save As…", workspace::SaveAs), + MenuItem::action("Save All", workspace::SaveAll { save_intent: None }), + MenuItem::action( + "Close Editor", + workspace::CloseActiveItem { save_intent: None }, + ), + MenuItem::action("Close Window", workspace::CloseWindow), + ], + }, + Menu { + name: "Edit", + items: vec![ + MenuItem::os_action("Undo", editor::Undo, OsAction::Undo), + MenuItem::os_action("Redo", editor::Redo, OsAction::Redo), + MenuItem::separator(), + MenuItem::os_action("Cut", editor::Cut, OsAction::Cut), + MenuItem::os_action("Copy", editor::Copy, OsAction::Copy), + MenuItem::os_action("Paste", editor::Paste, OsAction::Paste), + MenuItem::separator(), + MenuItem::action("Find", search::buffer_search::Deploy { focus: true }), + MenuItem::action("Find In Project", workspace::NewSearch), + MenuItem::separator(), + MenuItem::action("Toggle Line Comment", editor::ToggleComments::default()), + MenuItem::action("Emoji & Symbols", editor::ShowCharacterPalette), + ], + }, + Menu { + name: "Selection", + items: vec![ + MenuItem::os_action("Select All", editor::SelectAll, OsAction::SelectAll), + MenuItem::action("Expand Selection", editor::SelectLargerSyntaxNode), + MenuItem::action("Shrink Selection", editor::SelectSmallerSyntaxNode), + MenuItem::separator(), + MenuItem::action("Add Cursor Above", editor::AddSelectionAbove), + MenuItem::action("Add Cursor Below", editor::AddSelectionBelow), + MenuItem::action( + "Select Next Occurrence", + editor::SelectNext { + replace_newest: false, + }, + ), + MenuItem::separator(), + MenuItem::action("Move Line Up", editor::MoveLineUp), + MenuItem::action("Move Line Down", editor::MoveLineDown), + MenuItem::action("Duplicate Selection", editor::DuplicateLine), + ], + }, + Menu { + name: "View", + items: vec![ + MenuItem::action("Zoom In", super::IncreaseBufferFontSize), + MenuItem::action("Zoom Out", super::DecreaseBufferFontSize), + MenuItem::action("Reset Zoom", super::ResetBufferFontSize), + MenuItem::separator(), + MenuItem::action("Toggle Left Dock", workspace::ToggleLeftDock), + MenuItem::action("Toggle Right Dock", workspace::ToggleRightDock), + MenuItem::action("Toggle Bottom Dock", workspace::ToggleBottomDock), + MenuItem::action("Close All Docks", workspace::CloseAllDocks), + MenuItem::submenu(Menu { + name: "Editor Layout", + items: vec![ + MenuItem::action("Split Up", workspace::SplitUp), + MenuItem::action("Split Down", workspace::SplitDown), + MenuItem::action("Split Left", workspace::SplitLeft), + MenuItem::action("Split Right", workspace::SplitRight), + ], + }), + MenuItem::separator(), + MenuItem::action("Project Panel", project_panel::ToggleFocus), + MenuItem::action("Command Palette", command_palette::Toggle), + MenuItem::action("Diagnostics", diagnostics::Deploy), + MenuItem::separator(), + ], + }, + Menu { + name: "Go", + items: vec![ + MenuItem::action("Back", workspace::GoBack), + MenuItem::action("Forward", workspace::GoForward), + MenuItem::separator(), + MenuItem::action("Go to File", file_finder::Toggle), + // MenuItem::action("Go to Symbol in Project", project_symbols::Toggle), + MenuItem::action("Go to Symbol in Editor", outline::Toggle), + MenuItem::action("Go to Definition", editor::GoToDefinition), + MenuItem::action("Go to Type Definition", editor::GoToTypeDefinition), + MenuItem::action("Find All References", editor::FindAllReferences), + MenuItem::action("Go to Line/Column", go_to_line::Toggle), + MenuItem::separator(), + MenuItem::action("Next Problem", editor::GoToDiagnostic), + MenuItem::action("Previous Problem", editor::GoToPrevDiagnostic), + ], + }, + Menu { + name: "Window", + items: vec![ + MenuItem::action("Minimize", super::Minimize), + MenuItem::action("Zoom", super::Zoom), + MenuItem::separator(), + ], + }, + Menu { + name: "Help", + items: vec![ + MenuItem::action("Command Palette", command_palette::Toggle), + MenuItem::separator(), + MenuItem::action("View Telemetry", crate::OpenTelemetryLog), + MenuItem::action("View Dependency Licenses", crate::OpenLicenses), + MenuItem::action("Show Welcome", workspace::Welcome), + MenuItem::separator(), + // todo!(): Needs `feedback2` crate. + // MenuItem::action("Give us feedback", feedback::feedback_editor::GiveFeedback), + // MenuItem::action( + // "Copy System Specs Into Clipboard", + // feedback::CopySystemSpecsIntoClipboard, + // ), + // MenuItem::action("File Bug Report", feedback::FileBugReport), + // MenuItem::action("Request Feature", feedback::RequestFeature), + MenuItem::separator(), + MenuItem::action( + "Documentation", + crate::OpenBrowser { + url: "https://zed.dev/docs".into(), + }, + ), + MenuItem::action( + "Zed Twitter", + crate::OpenBrowser { + url: "https://twitter.com/zeddotdev".into(), + }, + ), + ], + }, + ] +} diff --git a/crates/zed2/src/main.rs b/crates/zed2/src/main.rs index 2afbdf9fa1..b97c4f40c3 100644 --- a/crates/zed2/src/main.rs +++ b/crates/zed2/src/main.rs @@ -22,8 +22,7 @@ use node_runtime::RealNodeRuntime; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use settings::{ - default_settings, handle_keymap_file_changes, handle_settings_file_changes, watch_config_file, - Settings, SettingsStore, + default_settings, handle_settings_file_changes, watch_config_file, Settings, SettingsStore, }; use simplelog::ConfigBuilder; use smol::process::Command; @@ -51,8 +50,9 @@ use uuid::Uuid; use welcome::{show_welcome_experience, FIRST_OPEN}; use workspace::{AppState, WorkspaceStore}; use zed2::{ - build_window_options, ensure_only_instance, handle_cli_connection, initialize_workspace, - languages, Assets, IsOnlyInstance, OpenListener, OpenRequest, + app_menus, build_window_options, ensure_only_instance, handle_cli_connection, + handle_keymap_file_changes, initialize_workspace, languages, Assets, IsOnlyInstance, + OpenListener, OpenRequest, }; mod open_listener; @@ -161,11 +161,11 @@ fn main() { node_runtime.clone(), cx, ); - // assistant::init(cx); + assistant::init(cx); // component_test::init(cx); - // cx.spawn(|_| watch_languages(fs.clone(), languages.clone())) - // .detach(); + cx.spawn(|_| watch_languages(fs.clone(), languages.clone())) + .detach(); watch_file_types(fs.clone(), cx); languages.set_theme(cx.theme().clone()); @@ -186,10 +186,10 @@ fn main() { .report_app_event(telemetry_settings, event_operation); let app_state = Arc::new(AppState { - languages, + languages: languages.clone(), client: client.clone(), user_store: user_store.clone(), - fs, + fs: fs.clone(), build_window_options, workspace_store, node_runtime, @@ -200,7 +200,7 @@ fn main() { auto_update::init(http.clone(), client::ZED_SERVER_URL.clone(), cx); workspace::init(app_state.clone(), cx); - // recent_projects::init(cx); + recent_projects::init(cx); go_to_line::init(cx); file_finder::init(cx); @@ -210,7 +210,7 @@ fn main() { channel::init(&client, user_store.clone(), cx); // diagnostics::init(cx); search::init(cx); - // semantic_index::init(fs.clone(), http.clone(), languages.clone(), cx); + semantic_index::init(fs.clone(), http.clone(), languages.clone(), cx); // vim::init(cx); terminal_view::init(cx); @@ -224,7 +224,7 @@ fn main() { feedback::init(cx); welcome::init(cx); - // cx.set_menus(menus::menus()); + cx.set_menus(app_menus()); initialize_workspace(app_state.clone(), cx); if stdout_is_a_pty() { diff --git a/crates/zed2/src/zed2.rs b/crates/zed2/src/zed2.rs index b3e521850a..669b7816b3 100644 --- a/crates/zed2/src/zed2.rs +++ b/crates/zed2/src/zed2.rs @@ -1,25 +1,30 @@ #![allow(unused_variables, unused_mut)] //todo!() +mod app_menus; mod assets; pub mod languages; mod only_instance; mod open_listener; +pub use app_menus::*; pub use assets::*; +use assistant::AssistantPanel; use breadcrumbs::Breadcrumbs; use collections::VecDeque; use editor::{Editor, MultiBuffer}; use gpui::{ - actions, point, px, AppContext, Context, FocusableView, PromptLevel, TitlebarOptions, + actions, point, px, AppContext, Context, FocusableView, PromptLevel, TitlebarOptions, View, ViewContext, VisualContext, WindowBounds, WindowKind, WindowOptions, }; pub use only_instance::*; pub use open_listener::*; use anyhow::{anyhow, Context as _}; +use futures::{channel::mpsc, StreamExt}; use project_panel::ProjectPanel; -use settings::{initial_local_settings_content, Settings}; +use quick_action_bar::QuickActionBar; +use settings::{initial_local_settings_content, load_default_keymap, KeymapFile, Settings}; use std::{borrow::Cow, ops::Deref, sync::Arc}; use terminal_view::terminal_panel::TerminalPanel; use util::{ @@ -29,6 +34,7 @@ use util::{ ResultExt, }; use uuid::Uuid; +use workspace::Pane; use workspace::{ create_and_open_local_file, dock::PanelHandle, notifications::simple_message_notification::MessageNotification, open_new, AppState, NewFile, @@ -91,6 +97,8 @@ pub fn build_window_options( pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { cx.observe_new_views(move |workspace: &mut Workspace, cx| { let workspace_handle = cx.view().clone(); + let center_pane = workspace.active_pane().clone(); + initialize_pane(workspace, ¢er_pane, cx); cx.subscribe(&workspace_handle, { move |workspace, _, event, cx| { if let workspace::Event::PaneAdded(pane) = event { @@ -118,6 +126,8 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { // toolbar.add_item(syntax_tree_item, cx); }) }); + + initialize_pane(workspace, pane, cx); } } }) @@ -166,9 +176,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { cx.on_window_should_close(move |cx| { handle .update(cx, |workspace, cx| { - if let Some(task) = workspace.close(&Default::default(), cx) { - task.detach_and_log_err(cx); - } + workspace.close_window(&Default::default(), cx); false }) .unwrap_or(true) @@ -177,7 +185,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { cx.spawn(|workspace_handle, mut cx| async move { let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); - // let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); + let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); let channels_panel = collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); // let chat_panel = @@ -189,14 +197,14 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { let ( project_panel, terminal_panel, - // assistant_panel, + assistant_panel, channels_panel, // chat_panel, // notification_panel, ) = futures::try_join!( project_panel, terminal_panel, - // assistant_panel, + assistant_panel, channels_panel, // chat_panel, // notification_panel, @@ -206,25 +214,25 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { let project_panel_position = project_panel.position(cx); workspace.add_panel(project_panel, cx); workspace.add_panel(terminal_panel, cx); - // workspace.add_panel(assistant_panel, cx); + workspace.add_panel(assistant_panel, cx); workspace.add_panel(channels_panel, cx); // workspace.add_panel(chat_panel, cx); // workspace.add_panel(notification_panel, cx); - // if !was_deserialized - // && workspace - // .project() - // .read(cx) - // .visible_worktrees(cx) - // .any(|tree| { - // tree.read(cx) - // .root_entry() - // .map_or(false, |entry| entry.is_dir()) - // }) - // { - // workspace.toggle_dock(project_panel_position, cx); - // } - // cx.focus_self(); + // if !was_deserialized + // && workspace + // .project() + // .read(cx) + // .visible_worktrees(cx) + // .any(|tree| { + // tree.read(cx) + // .root_entry() + // .map_or(false, |entry| entry.is_dir()) + // }) + // { + // workspace.toggle_dock(project_panel_position, cx); + // } + cx.focus_self(); }) }) .detach(); @@ -255,14 +263,13 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { .open_urls(&[action.url.clone()]) }) .register_action(|_, action: &OpenBrowser, cx| cx.open_url(&action.url)) - //todo!(buffer font size) - // cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| { - // theme::adjust_font_size(cx, |size| *size += 1.0) - // }); - // cx.add_global_action(move |_: &DecreaseBufferFontSize, cx| { - // theme::adjust_font_size(cx, |size| *size -= 1.0) - // }); - // cx.add_global_action(move |_: &ResetBufferFontSize, cx| theme::reset_font_size(cx)); + .register_action(move |_, _: &IncreaseBufferFontSize, cx| { + theme::adjust_font_size(cx, |size| *size += px(1.0)) + }) + .register_action(move |_, _: &DecreaseBufferFontSize, cx| { + theme::adjust_font_size(cx, |size| *size -= px(1.0)) + }) + .register_action(move |_, _: &ResetBufferFontSize, cx| theme::reset_font_size(cx)) .register_action(|_, _: &install_cli::Install, cx| { cx.spawn(|_, cx| async move { install_cli::install_cli(cx.deref()) @@ -434,6 +441,36 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { .detach(); } +fn initialize_pane(workspace: &mut Workspace, pane: &View, cx: &mut ViewContext) { + pane.update(cx, |pane, cx| { + pane.toolbar().update(cx, |toolbar, cx| { + let breadcrumbs = cx.build_view(|_| Breadcrumbs::new(workspace)); + toolbar.add_item(breadcrumbs, cx); + let buffer_search_bar = cx.build_view(search::BufferSearchBar::new); + toolbar.add_item(buffer_search_bar.clone(), cx); + + let quick_action_bar = + cx.build_view(|_| QuickActionBar::new(buffer_search_bar, workspace)); + toolbar.add_item(quick_action_bar, cx); + let diagnostic_editor_controls = cx.build_view(|_| diagnostics::ToolbarControls::new()); + // toolbar.add_item(diagnostic_editor_controls, cx); + // let project_search_bar = cx.add_view(|_| ProjectSearchBar::new()); + // toolbar.add_item(project_search_bar, cx); + // let submit_feedback_button = + // cx.add_view(|_| SubmitFeedbackButton::new()); + // toolbar.add_item(submit_feedback_button, cx); + // let feedback_info_text = cx.add_view(|_| FeedbackInfoText::new()); + // toolbar.add_item(feedback_info_text, cx); + // let lsp_log_item = + // cx.add_view(|_| language_tools::LspLogToolbarItemView::new()); + // toolbar.add_item(lsp_log_item, cx); + // let syntax_tree_item = cx + // .add_view(|_| language_tools::SyntaxTreeToolbarItemView::new()); + // toolbar.add_item(syntax_tree_item, cx); + }) + }); +} + fn about(_: &mut Workspace, _: &About, cx: &mut gpui::ViewContext) { use std::fmt::Write as _; @@ -559,6 +596,42 @@ fn open_log_file(workspace: &mut Workspace, cx: &mut ViewContext) { .detach(); } +pub fn handle_keymap_file_changes( + mut user_keymap_file_rx: mpsc::UnboundedReceiver, + cx: &mut AppContext, +) { + cx.spawn(move |cx| async move { + // let mut settings_subscription = None; + while let Some(user_keymap_content) = user_keymap_file_rx.next().await { + if let Some(keymap_content) = KeymapFile::parse(&user_keymap_content).log_err() { + cx.update(|cx| reload_keymaps(cx, &keymap_content)).ok(); + + // todo!() + // let mut old_base_keymap = cx.read(|cx| *settings::get::(cx)); + // drop(settings_subscription); + // settings_subscription = Some(cx.update(|cx| { + // cx.observe_global::(move |cx| { + // let new_base_keymap = *settings::get::(cx); + // if new_base_keymap != old_base_keymap { + // old_base_keymap = new_base_keymap.clone(); + // reload_keymaps(cx, &keymap_content); + // } + // }) + // })); + } + } + }) + .detach(); +} + +fn reload_keymaps(cx: &mut AppContext, keymap_content: &KeymapFile) { + // todo!() + // cx.clear_bindings(); + load_default_keymap(cx); + keymap_content.clone().add_to_cx(cx).log_err(); + cx.set_menus(app_menus()); +} + fn open_local_settings_file( workspace: &mut Workspace, _: &OpenLocalSettings,