From 025e0019d2d0bef5222f8037aa05c70e308f3771 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:12:19 +0800 Subject: [PATCH] feat(remote): SSH configurations, SFTP deployment, run targets and remote development (#91) --- .github/workflows/ci.yml | 15 + .github/workflows/release.yml | 43 +- Cargo.lock | 127 ++- Cargo.toml | 3 + crates/ide-agent/src/lib.rs | 13 +- crates/ide-core/src/event.rs | 22 + crates/ide-core/src/lib.rs | 1 + crates/ide-core/src/remote_settings.rs | 150 +++ crates/ide-core/src/settings.rs | 10 + crates/ide-db/Cargo.toml | 3 +- crates/ide-db/src/conn.rs | 2 +- crates/ide-db/src/lib.rs | 157 +-- crates/ide-db/src/source.rs | 15 +- crates/ide-db/src/tunnel.rs | 474 --------- crates/ide-db/tests/sqlite.rs | 3 +- crates/ide-db/tests/ssh_tunnel.rs | 226 ----- crates/ide-host/Cargo.toml | 50 + crates/ide-host/src/bin/server.rs | 75 ++ crates/ide-host/src/lib.rs | 162 +++ crates/ide-host/src/ops/agent.rs | 114 +++ crates/ide-host/src/ops/analysis.rs | 280 ++++++ crates/ide-host/src/ops/build.rs | 263 +++++ crates/ide-host/src/ops/bytes.rs | 32 + crates/ide-host/src/ops/coverage.rs | 45 + crates/ide-host/src/ops/debug.rs | 357 +++++++ crates/ide-host/src/ops/doc.rs | 42 + crates/ide-host/src/ops/doc/commands.rs | 125 +++ crates/ide-host/src/ops/fs.rs | 85 ++ crates/ide-host/src/ops/history.rs | 55 ++ crates/ide-host/src/ops/java.rs | 95 ++ crates/ide-host/src/ops/lsp.rs | 357 +++++++ crates/ide-host/src/ops/mod.rs | 85 ++ crates/ide-host/src/ops/nav.rs | 176 ++++ crates/ide-host/src/ops/project.rs | 135 +++ crates/ide-host/src/ops/refactor.rs | 112 +++ crates/ide-host/src/ops/run.rs | 91 ++ crates/ide-host/src/ops/run/commands.rs | 48 + crates/ide-host/src/ops/sdk.rs | 170 ++++ crates/ide-host/src/ops/search.rs | 97 ++ crates/ide-host/src/ops/search/commands.rs | 133 +++ crates/ide-host/src/ops/spell.rs | 98 ++ crates/ide-host/src/ops/syntax.rs | 175 ++++ crates/ide-host/src/ops/term.rs | 33 + crates/ide-host/src/ops/tools.rs | 170 ++++ crates/ide-host/src/ops/vcs.rs | 24 + crates/ide-host/src/ops/vcs/commands.rs | 919 ++++++++++++++++++ crates/ide-host/src/project.rs | 767 +++++++++++++++ crates/ide-host/src/rpc.rs | 302 ++++++ .../ide-host}/src/shell_env.rs | 0 crates/ide-host/tests/rpc.rs | 151 +++ crates/ide-jsonrpc/src/lib.rs | 10 +- crates/ide-language/src/syntax/literals.rs | 7 +- crates/ide-language/src/syntax/tests.rs | 10 + crates/ide-lsp/src/launch.rs | 22 +- crates/ide-remote/Cargo.toml | 23 + crates/ide-remote/src/deploy.rs | 377 +++++++ crates/ide-remote/src/lib.rs | 12 + crates/ide-remote/src/mapping.rs | 219 +++++ crates/ide-remote/src/service.rs | 368 +++++++ crates/ide-remote/src/target.rs | 368 +++++++ crates/ide-remote/tests/remote.rs | 179 ++++ crates/ide-run/src/config.rs | 5 + crates/ide-run/src/lib.rs | 5 + crates/ide-run/src/process.rs | 11 +- crates/ide-spell/src/dict/software.txt | 48 + crates/ide-ssh/Cargo.toml | 28 + crates/ide-ssh/src/askpass.rs | 216 ++++ crates/ide-ssh/src/lib.rs | 21 + crates/ide-ssh/src/manager.rs | 192 ++++ crates/ide-ssh/src/secrets.rs | 87 ++ crates/ide-ssh/src/session.rs | 521 ++++++++++ crates/ide-ssh/src/sftp.rs | 247 +++++ crates/ide-ssh/src/shell.rs | 69 ++ crates/ide-ssh/src/target.rs | 184 ++++ crates/ide-ssh/src/testing.rs | 262 +++++ crates/ide-ssh/src/tunnel.rs | 80 ++ .../tests/fixtures/ssh/client | 0 .../tests/fixtures/ssh/host | 0 .../tests/fixtures/ssh/locked | 0 crates/ide-ssh/tests/session.rs | 256 +++++ crates/ide-terminal/src/lib.rs | 11 +- crates/ide-tools/src/lib.rs | 1 + crates/ide-tools/src/server.rs | 105 ++ crates/ide-vcs/src/cli.rs | 11 + crates/ide-vcs/src/lib.rs | 2 +- docs/plugins/themes.md | 2 +- src-tauri/Cargo.toml | 5 + src-tauri/src/commands/agent.rs | 49 +- src-tauri/src/commands/analysis.rs | 245 +---- src-tauri/src/commands/app.rs | 94 +- src-tauri/src/commands/build.rs | 198 +--- src-tauri/src/commands/bytes.rs | 19 +- src-tauri/src/commands/coverage.rs | 28 +- src-tauri/src/commands/debug.rs | 81 +- src-tauri/src/commands/deploy.rs | 182 ++++ src-tauri/src/commands/doc.rs | 109 +-- src-tauri/src/commands/fs.rs | 68 +- src-tauri/src/commands/history.rs | 32 +- src-tauri/src/commands/java.rs | 65 +- src-tauri/src/commands/lsp.rs | 133 +-- src-tauri/src/commands/mod.rs | 36 + src-tauri/src/commands/nav.rs | 117 +-- src-tauri/src/commands/refactor.rs | 56 +- src-tauri/src/commands/run.rs | 115 +-- src-tauri/src/commands/sdk.rs | 103 +- src-tauri/src/commands/search.rs | 172 +--- src-tauri/src/commands/spell.rs | 79 +- src-tauri/src/commands/ssh.rs | 96 ++ src-tauri/src/commands/syntax.rs | 79 +- src-tauri/src/commands/term.rs | 18 +- src-tauri/src/commands/tools.rs | 126 +-- src-tauri/src/commands/vcs.rs | 525 ++-------- src-tauri/src/lib.rs | 5 +- src-tauri/src/main.rs | 4 + src-tauri/src/menu.rs | 23 + src-tauri/src/remote_dev.rs | 438 +++++++++ src-tauri/src/state.rs | 822 ++-------------- src/bindings.ts | 307 +++++- src/features/agents/Agents.module.css | 8 + src/features/agents/AgentsView.tsx | 11 +- src/features/agents/store.ts | 17 +- src/features/app/closeGuard.ts | 8 +- src/features/database/SourceDialog.tsx | 196 ++-- src/features/database/model.ts | 37 +- src/features/database/sshConfig.test.ts | 45 + src/features/database/store.ts | 9 +- src/features/debugger/ui/Overlays.tsx | 5 +- src/features/deployment/Deployment.module.css | 178 ++++ src/features/deployment/Dialogs.test.tsx | 58 ++ src/features/deployment/Dialogs.tsx | 214 ++++ src/features/deployment/RemoteHostView.tsx | 190 ++++ src/features/deployment/actions.tsx | 366 +++++++ src/features/deployment/index.ts | 96 ++ src/features/deployment/model.test.ts | 155 +++ src/features/deployment/model.ts | 192 ++++ src/features/deployment/remoteHost.ts | 162 +++ src/features/deployment/ssh.ts | 32 + src/features/deployment/store.ts | 92 ++ src/features/index.ts | 6 + src/features/project-tree/index.ts | 7 + src/features/remote-dev/ConnectDialog.tsx | 167 ++++ src/features/remote-dev/RemoteDev.module.css | 74 ++ src/features/remote-dev/index.ts | 39 + src/features/remote-dev/model.ts | 53 + src/features/remote-dev/remoteDev.test.ts | 59 ++ src/features/run/ConfigChooser.tsx | 4 + src/features/run/ConfigForm.tsx | 28 + src/features/run/EditConfigsDialog.tsx | 4 + src/features/run/RunView.tsx | 11 +- src/features/run/RunWidget.tsx | 4 +- src/features/run/contextRun.ts | 2 + src/features/run/dialogs.tsx | 71 +- src/features/run/index.ts | 2 + src/features/run/remote.test.tsx | 254 +++++ src/features/run/remote.ts | 141 +++ src/features/run/store.ts | 11 +- src/features/run/types.ts | 1 + .../settings/__tests__/runTargets.test.ts | 19 + .../settings/__tests__/sshConnection.test.ts | 40 + .../settings/components/SshConfigSelect.tsx | 33 + src/features/settings/index.ts | 2 + .../settings/pages/Deployment.module.css | 169 ++++ .../settings/pages/RunTargets.module.css | 65 ++ src/features/settings/pages/deployment.tsx | 669 +++++++++++++ .../settings/pages/deploymentModel.test.ts | 76 ++ .../settings/pages/deploymentModel.ts | 81 ++ src/features/settings/pages/registry.tsx | 6 + src/features/settings/pages/runTargets.tsx | 410 ++++++++ src/features/settings/pages/ssh.tsx | 361 +++++++ src/features/settings/pages/sshConnection.ts | 32 + src/features/ssh/AskpassDialog.tsx | 68 ++ src/features/ssh/Ssh.module.css | 13 + src/features/ssh/askpass.ts | 38 + src/features/ssh/index.ts | 43 + src/features/ssh/session.ts | 64 ++ src/features/ssh/ssh.test.ts | 75 ++ src/features/terminal/TerminalView.tsx | 1 + src/features/terminal/embedded.ts | 3 +- src/features/terminal/session.ts | 15 +- src/features/welcome/ProjectsPage.tsx | 21 +- src/features/welcome/RecentList.tsx | 20 +- src/features/welcome/Welcome.module.css | 22 +- src/features/welcome/WelcomeView.tsx | 38 +- src/features/welcome/recent.ts | 66 +- src/features/welcome/session.ts | 12 +- src/platform/locales/text/zh.json | 188 +++- src/platform/locales/textCatalog.test.ts | 6 + src/platform/remote.test.ts | 33 + src/platform/remote.ts | 202 ++++ src/platform/remoteSettings.ts | 210 ++++ src/platform/tauriCore.ts | 24 + src/platform/workflowSettings.ts | 5 +- src/styles/tokens.css | 6 +- src/workbench/contributions.ts | 14 + src/workbench/toolwindows/ToolWindows.tsx | 9 +- vite.config.ts | 6 +- 196 files changed, 17920 insertions(+), 3729 deletions(-) create mode 100644 crates/ide-core/src/remote_settings.rs delete mode 100644 crates/ide-db/src/tunnel.rs delete mode 100644 crates/ide-db/tests/ssh_tunnel.rs create mode 100644 crates/ide-host/Cargo.toml create mode 100644 crates/ide-host/src/bin/server.rs create mode 100644 crates/ide-host/src/lib.rs create mode 100644 crates/ide-host/src/ops/agent.rs create mode 100644 crates/ide-host/src/ops/analysis.rs create mode 100644 crates/ide-host/src/ops/build.rs create mode 100644 crates/ide-host/src/ops/bytes.rs create mode 100644 crates/ide-host/src/ops/coverage.rs create mode 100644 crates/ide-host/src/ops/debug.rs create mode 100644 crates/ide-host/src/ops/doc.rs create mode 100644 crates/ide-host/src/ops/doc/commands.rs create mode 100644 crates/ide-host/src/ops/fs.rs create mode 100644 crates/ide-host/src/ops/history.rs create mode 100644 crates/ide-host/src/ops/java.rs create mode 100644 crates/ide-host/src/ops/lsp.rs create mode 100644 crates/ide-host/src/ops/mod.rs create mode 100644 crates/ide-host/src/ops/nav.rs create mode 100644 crates/ide-host/src/ops/project.rs create mode 100644 crates/ide-host/src/ops/refactor.rs create mode 100644 crates/ide-host/src/ops/run.rs create mode 100644 crates/ide-host/src/ops/run/commands.rs create mode 100644 crates/ide-host/src/ops/sdk.rs create mode 100644 crates/ide-host/src/ops/search.rs create mode 100644 crates/ide-host/src/ops/search/commands.rs create mode 100644 crates/ide-host/src/ops/spell.rs create mode 100644 crates/ide-host/src/ops/syntax.rs create mode 100644 crates/ide-host/src/ops/term.rs create mode 100644 crates/ide-host/src/ops/tools.rs create mode 100644 crates/ide-host/src/ops/vcs.rs create mode 100644 crates/ide-host/src/ops/vcs/commands.rs create mode 100644 crates/ide-host/src/project.rs create mode 100644 crates/ide-host/src/rpc.rs rename {src-tauri => crates/ide-host}/src/shell_env.rs (100%) create mode 100644 crates/ide-host/tests/rpc.rs create mode 100644 crates/ide-remote/Cargo.toml create mode 100644 crates/ide-remote/src/deploy.rs create mode 100644 crates/ide-remote/src/lib.rs create mode 100644 crates/ide-remote/src/mapping.rs create mode 100644 crates/ide-remote/src/service.rs create mode 100644 crates/ide-remote/src/target.rs create mode 100644 crates/ide-remote/tests/remote.rs create mode 100644 crates/ide-ssh/Cargo.toml create mode 100644 crates/ide-ssh/src/askpass.rs create mode 100644 crates/ide-ssh/src/lib.rs create mode 100644 crates/ide-ssh/src/manager.rs create mode 100644 crates/ide-ssh/src/secrets.rs create mode 100644 crates/ide-ssh/src/session.rs create mode 100644 crates/ide-ssh/src/sftp.rs create mode 100644 crates/ide-ssh/src/shell.rs create mode 100644 crates/ide-ssh/src/target.rs create mode 100644 crates/ide-ssh/src/testing.rs create mode 100644 crates/ide-ssh/src/tunnel.rs rename crates/{ide-db => ide-ssh}/tests/fixtures/ssh/client (100%) rename crates/{ide-db => ide-ssh}/tests/fixtures/ssh/host (100%) rename crates/{ide-db => ide-ssh}/tests/fixtures/ssh/locked (100%) create mode 100644 crates/ide-ssh/tests/session.rs create mode 100644 crates/ide-tools/src/server.rs create mode 100644 src-tauri/src/commands/deploy.rs create mode 100644 src-tauri/src/commands/ssh.rs create mode 100644 src-tauri/src/remote_dev.rs create mode 100644 src/features/database/sshConfig.test.ts create mode 100644 src/features/deployment/Deployment.module.css create mode 100644 src/features/deployment/Dialogs.test.tsx create mode 100644 src/features/deployment/Dialogs.tsx create mode 100644 src/features/deployment/RemoteHostView.tsx create mode 100644 src/features/deployment/actions.tsx create mode 100644 src/features/deployment/index.ts create mode 100644 src/features/deployment/model.test.ts create mode 100644 src/features/deployment/model.ts create mode 100644 src/features/deployment/remoteHost.ts create mode 100644 src/features/deployment/ssh.ts create mode 100644 src/features/deployment/store.ts create mode 100644 src/features/remote-dev/ConnectDialog.tsx create mode 100644 src/features/remote-dev/RemoteDev.module.css create mode 100644 src/features/remote-dev/index.ts create mode 100644 src/features/remote-dev/model.ts create mode 100644 src/features/remote-dev/remoteDev.test.ts create mode 100644 src/features/run/remote.test.tsx create mode 100644 src/features/run/remote.ts create mode 100644 src/features/settings/__tests__/runTargets.test.ts create mode 100644 src/features/settings/__tests__/sshConnection.test.ts create mode 100644 src/features/settings/components/SshConfigSelect.tsx create mode 100644 src/features/settings/pages/Deployment.module.css create mode 100644 src/features/settings/pages/RunTargets.module.css create mode 100644 src/features/settings/pages/deployment.tsx create mode 100644 src/features/settings/pages/deploymentModel.test.ts create mode 100644 src/features/settings/pages/deploymentModel.ts create mode 100644 src/features/settings/pages/runTargets.tsx create mode 100644 src/features/settings/pages/ssh.tsx create mode 100644 src/features/settings/pages/sshConnection.ts create mode 100644 src/features/ssh/AskpassDialog.tsx create mode 100644 src/features/ssh/Ssh.module.css create mode 100644 src/features/ssh/askpass.ts create mode 100644 src/features/ssh/index.ts create mode 100644 src/features/ssh/session.ts create mode 100644 src/features/ssh/ssh.test.ts create mode 100644 src/platform/remote.test.ts create mode 100644 src/platform/remote.ts create mode 100644 src/platform/remoteSettings.ts create mode 100644 src/platform/tauriCore.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55865182a..958afa288 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,21 @@ jobs: if: runner.os == 'Linux' run: git diff --exit-code src/bindings.ts + # The remote development server ships as a static Linux binary (release.yml): + # keep that build working. + server: + name: Remote server (linux musl) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: { targets: x86_64-unknown-linux-musl } + - uses: Swatinem/rust-cache@v2 + with: { key: server-musl } + - run: sudo apt-get update && sudo apt-get install -y musl-tools + - run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl + - run: target/x86_64-unknown-linux-musl/debug/openidea-server --version + frontend: name: Frontend runs-on: ubuntu-22.04 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e2a1765b..5701ac0a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,8 +131,49 @@ jobs: gh release upload "$TAG" "$dmg" --clobber done + # The remote development server, per host platform it can run on. The desktop + # app downloads the one for the host (ide-tools `server`) and installs it over SSH. + # Linux builds are static (musl), so they run whatever the host's glibc. + server: + needs: create-release + name: Server (${{ matrix.os }}-${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 } + - { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 } + - { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 } + - { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 } + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: { ref: "${{ env.TAG }}" } + - uses: dtolnay/rust-toolchain@stable + with: { targets: "${{ matrix.target }}" } + - uses: Swatinem/rust-cache@v2 + with: { key: "server-${{ matrix.target }}" } + - name: musl toolchain + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y musl-tools + - name: Build + run: | + # musl-tools only ships `musl-gcc`; cc-rs would look for `-linux-musl-gcc`. + if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi + cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET" + env: + TARGET: ${{ matrix.target }} + - name: Upload + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz + run: | + gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET" + if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256" + gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber + publish: - needs: [create-release, build] + needs: [create-release, build, server] runs-on: ubuntu-latest steps: - uses: actions/github-script@v7 diff --git a/Cargo.lock b/Cargo.lock index 79b698b00..d0cddcb9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3461,6 +3461,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -4061,9 +4073,8 @@ dependencies = [ "dirs 6.0.0", "futures", "ide-core", + "ide-ssh", "parking_lot", - "russh", - "russh-config", "serde", "serde_json", "specta", @@ -4102,6 +4113,47 @@ dependencies = [ "which", ] +[[package]] +name = "ide-host" +version = "0.1.18" +dependencies = [ + "dirs 6.0.0", + "dunce", + "ide-agent", + "ide-analysis", + "ide-build", + "ide-core", + "ide-coverage", + "ide-dap", + "ide-db", + "ide-http", + "ide-index", + "ide-jsonrpc", + "ide-language", + "ide-lsp", + "ide-remote", + "ide-run", + "ide-sdk", + "ide-search", + "ide-spell", + "ide-ssh", + "ide-syntax", + "ide-terminal", + "ide-text", + "ide-tools", + "ide-vcs", + "ide-vfs", + "ide-workspace", + "parking_lot", + "serde", + "serde_json", + "specta", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "ide-http" version = "0.1.18" @@ -4265,6 +4317,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "ide-remote" +version = "0.1.18" +dependencies = [ + "globset", + "ide-core", + "ide-run", + "ide-ssh", + "ignore", + "parking_lot", + "serde", + "specta", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "ide-run" version = "0.1.18" @@ -4337,6 +4406,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "ide-ssh" +version = "0.1.18" +dependencies = [ + "dirs 6.0.0", + "flate2", + "ide-core", + "parking_lot", + "russh", + "russh-config", + "russh-sftp", + "serde", + "serde_json", + "specta", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "ide-syntax" version = "0.1.18" @@ -5880,6 +5968,7 @@ dependencies = [ name = "openidea" version = "0.1.18" dependencies = [ + "blake3", "dirs 6.0.0", "dunce", "ide-agent", @@ -5892,16 +5981,20 @@ dependencies = [ "ide-db", "ide-docker", "ide-github", + "ide-host", "ide-http", "ide-index", + "ide-jsonrpc", "ide-language", "ide-lsp", "ide-plugin", "ide-profiler", + "ide-remote", "ide-run", "ide-sdk", "ide-search", "ide-spell", + "ide-ssh", "ide-syntax", "ide-terminal", "ide-text", @@ -6993,6 +7086,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "russh-sftp" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "chrono", + "dashmap", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + [[package]] name = "russh-util" version = "0.52.0" @@ -7326,6 +7439,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" diff --git a/Cargo.toml b/Cargo.toml index 55a2c8ad1..37bed70fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ ide-web = { path = "crates/ide-web" } ide-spell = { path = "crates/ide-spell" } ide-agent = { path = "crates/ide-agent" } ide-clipboard = { path = "crates/ide-clipboard" } +ide-ssh = { path = "crates/ide-ssh" } +ide-remote = { path = "crates/ide-remote" } +ide-host = { path = "crates/ide-host" } # serialization & IPC typing serde = { version = "1", features = ["derive"] } diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index bd4bf1399..ca7ed203c 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -703,8 +703,11 @@ impl Task { .chain(prompt.images.iter().map(|i| i.name.clone().unwrap_or_else(|| "image".into()))) .collect(); self.push_entry(AgentEntry::User { text: prompt.text.clone(), attachments }); - self.set_status(AgentStatus::Running, None); + // Launching the agent can take a while (`npx` fetches its adapter on first use). + let starting = self.live.lock().as_ref().is_none_or(|l| l.peer.is_closed()); + self.set_status(if starting { AgentStatus::Starting } else { AgentStatus::Running }, None); let live = self.ensure_session().await?; + self.set_status(AgentStatus::Running, None); if !prompt.images.is_empty() && !live.images { return Err("This agent can't read images.".into()); } @@ -720,6 +723,14 @@ impl Task { .await; match result { Ok(v) => Ok(v.get("stopReason").and_then(Value::as_str).unwrap_or("end_turn").to_owned()), + // Signed out, or the account was refused (Claude Code: "API Error: 403"). + Err(RpcError::Server { message, .. }) if message.to_lowercase().contains("auth") => { + let provider = self.state.lock().task.provider.clone(); + Err(match self.shared.spec(&provider) { + Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint), + None => message, + }) + } Err(e) => Err(self.describe(e)), } } diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index b9859ddca..9db7dfb0a 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -100,6 +100,28 @@ pub enum IdeEvent { ProjectRootsChanged { root: crate::IpcPath, }, + /// A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`. + Askpass { + id: u64, + prompt: String, + kind: AskpassKind, + }, + /// Prompt `id` was answered (in any window) or gave up. + AskpassDone { + id: u64, + }, +} + +/// What an askpass prompt asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AskpassKind { + /// Password or passphrase: hidden input. + Secret, + /// User name: visible input. + Text, + /// Yes / no (an unknown host key); answered with `yes` or `no`. + Confirm, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index ea2e3721d..e4fae4c61 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod inspection_settings; pub mod ipc_path; pub mod process; pub mod project_dir; +pub mod remote_settings; pub mod settings; pub mod text; pub mod workflow_settings; diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs new file mode 100644 index 000000000..771c30735 --- /dev/null +++ b/crates/ide-core/src/remote_settings.rs @@ -0,0 +1,150 @@ +//! Settings of remote work: SSH configurations, SFTP deployment and SSH run +//! targets. The fields hang off `Settings` in `settings.rs`. + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// How an SSH connection authenticates. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SshAuth { + Password, + /// A private key file, optionally protected by a passphrase. + KeyPair, + /// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. + #[default] + OpenSsh, +} + +/// Tools › SSH Configurations. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct SshSettings { + pub configs: Vec, +} + +/// A named SSH host, shared by terminals, deployment, run targets and data sources. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct SshConfiguration { + pub id: String, + pub name: String, + /// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`]. + pub host: String, + pub port: Option, + pub user: String, + pub auth: SshAuth, + /// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory. + pub key_file: String, + /// Keep the password / key passphrase in the saved passwords. + pub save_secret: bool, +} + +impl SshConfiguration { + /// `user@host:port` with what is known (the name when the host is empty). + pub fn label(&self) -> String { + let host = self.host.trim(); + if host.is_empty() { + return self.name.clone(); + } + let mut s = String::new(); + if !self.user.trim().is_empty() { + s.push_str(self.user.trim()); + s.push('@'); + } + s.push_str(host); + if let Some(p) = self.port { + s.push_str(&format!(":{p}")); + } + s + } +} + +/// When local changes are uploaded to the default deployment server. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AutoUpload { + #[default] + Never, + /// Files saved with Save / Save All (⌘S). + OnExplicitSave, + /// Every save, including autosave and external changes. + Always, +} + +/// Build, Execution, Deployment › Deployment (and its Options page). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct DeploymentSettings { + pub servers: Vec, + /// Server used by Upload / Download / Sync and automatic upload; empty = none. + pub default_server: String, + pub auto_upload: AutoUpload, + /// Delete remote files when their local file is deleted (automatic upload). + pub delete_remote: bool, + /// Keep local modification times on uploaded files. + pub preserve_timestamps: bool, + /// File names never uploaded or downloaded (`*` and `?` wildcards; `;`-separated in the UI). + pub exclude: Vec, +} + +impl Default for DeploymentSettings { + fn default() -> Self { + Self { + servers: Vec::new(), + default_server: String::new(), + auto_upload: AutoUpload::Never, + delete_remote: false, + preserve_timestamps: true, + exclude: [".git", ".svn", ".hg", ".idea", ".openidea", ".DS_Store", "*.pyc", "__pycache__"] + .map(String::from) + .to_vec(), + } + } +} + +/// An SFTP server and how project folders map onto it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct DeploymentServer { + pub id: String, + pub name: String, + /// [`SshConfiguration::id`]. + pub ssh_config: String, + /// Absolute remote folder the mappings are relative to; empty = the login directory. + pub root_path: String, + /// Web server URL of the root (Open in Browser on the deployed file). + pub web_url: String, + pub mappings: Vec, + /// Remote paths (relative to the root) never touched. + pub excluded_paths: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct DeploymentMapping { + /// Project-relative folder; empty = the project root. + pub local: String, + /// Remote folder, relative to the server root (a leading `/` is the root too). + pub deployment: String, + /// Web path, relative to the server's URL. + pub web: String, +} + +/// Build, Execution, Deployment › Run Targets: run configurations on another machine. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct RunTarget { + pub id: String, + pub name: String, + /// [`SshConfiguration::id`]. + pub ssh_config: String, + /// Remote folder the project is synchronised to and run from. + pub project_root: String, + /// Upload changed project files before each run. + pub sync_before_run: bool, + /// Interpreters on the target; empty = the one on the remote `PATH`. + pub python: String, + pub node: String, + pub java: String, +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 8732351d6..435763832 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; pub use crate::editor_settings::*; pub use crate::inspection_settings::*; +pub use crate::remote_settings::*; pub use crate::workflow_settings::*; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -45,6 +46,12 @@ pub struct Settings { pub inspections: InspectionSettings, /// Editor › Natural Languages › Spelling. pub spelling: SpellingSettings, + /// Tools › SSH Configurations. + pub ssh: SshSettings, + /// Build, Execution, Deployment › Deployment. + pub deployment: DeploymentSettings, + /// Build, Execution, Deployment › Run Targets. + pub run_targets: Vec, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -84,6 +91,9 @@ impl Default for Settings { tools: BTreeMap::new(), inspections: InspectionSettings::default(), spelling: SpellingSettings::default(), + ssh: SshSettings::default(), + deployment: DeploymentSettings::default(), + run_targets: Vec::new(), } } } diff --git a/crates/ide-db/Cargo.toml b/crates/ide-db/Cargo.toml index 4e062df7d..4d384b809 100644 --- a/crates/ide-db/Cargo.toml +++ b/crates/ide-db/Cargo.toml @@ -17,8 +17,7 @@ parking_lot.workspace = true futures.workspace = true sqlx.workspace = true dirs.workspace = true -russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] } -russh-config = "0.58.0" +ide-ssh.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs index 83f32315d..85eb6806b 100644 --- a/crates/ide-db/src/conn.rs +++ b/crates/ide-db/src/conn.rs @@ -192,7 +192,7 @@ fn looks_like_query(sql: &str) -> bool { fn file_setting(path: &str) -> Option { let path = path.trim(); - (!path.is_empty()).then(|| crate::tunnel::expand(path)) + (!path.is_empty()).then(|| ide_ssh::expand(path)) } fn is_identifier(name: &str) -> bool { diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index 1a7f120aa..806499fd5 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -9,7 +9,6 @@ pub mod secrets; pub mod source; pub mod split; pub mod sql; -pub mod tunnel; pub use conn::{Held, Pool, QueryResult, ResultColumn}; pub use history::HistoryEntry; @@ -21,6 +20,7 @@ pub use sql::{CellValue, DbRowEdit, SortKey}; use history::HistoryStore; use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; +use ide_ssh::{SshManager, SshTarget, Tunnel}; use parking_lot::Mutex; use secrets::SecretStore; use serde::{Deserialize, Serialize}; @@ -29,7 +29,6 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -210,12 +209,12 @@ pub struct DbManager { tunnels: Mutex>>, /// Serialises opening tunnels so concurrent connects share one. tunnel_open: tokio::sync::Mutex<()>, - /// Host keys awaiting the user's trust, by data source id. - unknown_host_keys: Mutex>, - known_hosts: PathBuf, - ssh_config: Option, + /// App-wide SSH connections and host-key trust. + ssh: Arc, saved_passwords: SecretStore, history: Arc, + /// Records executed statements in the history off the async threads, in order. + recorder: std::sync::mpsc::Sender<(String, String, f64)>, /// Passwords entered this session (not saved, or not yet), by credential key. session_passwords: Mutex>, /// Manual-commit data sources, each with the connection of its open @@ -281,12 +280,12 @@ pub fn split_target(target: &str) -> (&str, Option<&str>) { } /// Says which leg failed when the database is unreachable from the SSH host. -fn through_tunnel(e: IdeError, source: &DataSource, tunneled: bool) -> IdeError { - let Some(ssh) = source.ssh.as_ref().filter(|_| tunneled && e.kind == ErrorKind::Io) else { return e }; +fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeError { + let Some(via) = via.filter(|_| e.kind == ErrorKind::Io) else { return e }; let port = source.port.or(source.driver.default_port()).unwrap_or_default(); IdeError::new( ErrorKind::Io, - format!("{} through the SSH tunnel ({}:{port} as seen from {})", e.message, source.host, ssh.host), + format!("{} through the SSH tunnel ({}:{port} as seen from {via})", e.message, source.host), ) } @@ -321,29 +320,31 @@ async fn end_session(session: &Session) { impl DbManager { /// `passwords` / `history` are the app-wide files of saved passwords /// ([`SecretStore`]) and query history ([`HistoryStore`]). - pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf) -> Self { - let ssh_dir = dirs::home_dir().unwrap_or_default().join(".ssh"); - Self::with_ssh_files(root, passwords, history, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config"))) - } - - /// [`DbManager::new`] with other `known_hosts` / `~/.ssh/config` files (tests). - pub fn with_ssh_files( - root: PathBuf, - passwords: PathBuf, - history: PathBuf, - known_hosts: PathBuf, - ssh_config: Option, - ) -> Self { + pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc) -> Self { + let history = Arc::new(HistoryStore::new(history, &root)); + let (recorder, statements) = std::sync::mpsc::channel::<(String, String, f64)>(); + { + let history = history.clone(); + let spawned = std::thread::Builder::new().name("openidea-db-history".into()).spawn(move || { + for (source, text, time) in statements { + if let Err(e) = history.add(&source, &text, time) { + tracing::warn!("{}", e.message); + } + } + }); + if let Err(e) = spawned { + tracing::warn!("query history recorder: {e}"); + } + } Self { saved_passwords: SecretStore::new(passwords, &root), - history: Arc::new(HistoryStore::new(history, &root)), + history, + recorder, root, pools: Mutex::default(), tunnels: Mutex::default(), tunnel_open: tokio::sync::Mutex::new(()), - unknown_host_keys: Mutex::default(), - known_hosts, - ssh_config, + ssh, session_passwords: Mutex::default(), sessions: Mutex::default(), } @@ -397,7 +398,14 @@ impl DbManager { } /// [`DbManager::set_password`] for the SSH password / key passphrase. + /// A data source using an SSH configuration sets that configuration's secret. pub async fn set_ssh_secret(&self, id: &str, secret: Option, save: bool) -> IdeResult<()> { + let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config); + if let Some(c) = config.filter(|c| !c.trim().is_empty()) { + let save = self.ssh.config(&c).map(|c| c.save_secret).unwrap_or(save); + self.ssh.set_secret(&c, secret, save); + return Ok(()); + } self.remember(secrets::ssh_key(id), secret, save).await } @@ -443,10 +451,27 @@ impl DbManager { /// Trusts the host key that failed the last connection of `id` (adds it to `known_hosts`). pub async fn trust_ssh_host(&self, id: &str) -> IdeResult<()> { - let key = - self.unknown_host_keys.lock().remove(id).ok_or_else(|| IdeError::not_found("no host key to trust"))?; - let path = self.known_hosts.clone(); - tokio::task::spawn_blocking(move || key.trust(&path)).await.map_err(|e| IdeError::internal(e.to_string()))? + if self.ssh.trust_host(&self.host_key(id)).await.is_ok() { + return Ok(()); + } + let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config); + match config.filter(|c| !c.trim().is_empty()) { + Some(c) => self.ssh.trust_host(&c).await, + None => Err(IdeError::not_found("no host key to trust")), + } + } + + /// Key of the unknown host keys of data source `id`'s own SSH settings. + fn host_key(&self, id: &str) -> String { + format!("db:{}:{id}", self.root.display()) + } + + /// How the SSH host of `ssh` is shown in errors. + fn ssh_label(&self, ssh: &DbSsh) -> String { + match self.ssh.config(ssh.config.trim()) { + Ok(c) if !ssh.config.trim().is_empty() => c.label(), + _ => ssh.host.clone(), + } } /// Opens the SSH tunnel of `source` when it has one, returning the settings to @@ -470,35 +495,34 @@ impl DbManager { return Ok((local(&t), Some(t))); } } - let secret = match ssh_secret { - Some(s) => Some(s), - None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await, + let config = ssh.config.trim(); + let session = if config.is_empty() { + let secret = match ssh_secret { + Some(s) => Some(s), + None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await, + }; + let target = SshTarget { + host: ssh.host.clone(), + port: ssh.port, + user: ssh.user.clone(), + auth: ssh.auth, + key_file: ssh.key_file.clone(), + }; + Arc::new(self.ssh.connect(&self.host_key(&source.id), &target, secret.as_deref()).await?) + } else { + if let Some(s) = ssh_secret { + let save = self.ssh.config(config)?.save_secret; + self.ssh.set_secret(config, Some(s), save); + } + self.ssh.session(config).await? }; let target_port = source.port.or(source.driver.default_port()).unwrap_or_default(); let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }; - let spec = TunnelSpec { - ssh, - secret: secret.as_deref(), - target_host, - target_port, - known_hosts: &self.known_hosts, - ssh_config: self.ssh_config.as_deref(), - }; - match Tunnel::open(spec).await { - Ok(t) => { - let t = Arc::new(t); - if shared { - self.tunnels.lock().insert(source.id.clone(), t.clone()); - } - Ok((local(&t), Some(t))) - } - Err(OpenError::Other(e)) => Err(e), - Err(OpenError::UnknownHostKey(k)) => { - let message = format!("Unknown SSH host key for {}:{} ({})", k.host, k.port, k.fingerprint()); - self.unknown_host_keys.lock().insert(source.id.clone(), *k); - Err(IdeError::new(ErrorKind::Protocol, message)) - } + let t = Arc::new(Tunnel::open(session, target_host, target_port).await?); + if shared { + self.tunnels.lock().insert(source.id.clone(), t.clone()); } + Ok((local(&t), Some(t))) } /// Connects with `source` as given (not necessarily saved): Test Connection. @@ -514,9 +538,13 @@ impl DbManager { None => self.password(source).await, }; let (target, tunnel) = self.endpoint(source, ssh_secret, false).await?; - let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)) - .await - .map_err(|e| through_tunnel(e, source, tunnel.is_some()))?; + let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)).await.map_err(|e| { + through_tunnel( + e, + source, + tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref(), + ) + })?; let version = match source.driver { DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()", DbDriver::Postgres => "SELECT version()", @@ -553,7 +581,11 @@ impl DbManager { Ok(p) => Arc::new(p), Err(e) => { self.drop_unused_tunnel(source_id); - return Err(through_tunnel(e, &source, tunnel.is_some())); + return Err(through_tunnel( + e, + &source, + tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref(), + )); } }; // Another caller may have connected meanwhile; keep theirs. @@ -901,15 +933,10 @@ impl DbManager { } fn remember_statement(&self, id: &str, statement: &str) { - let (history, source, text) = (self.history.clone(), split_target(id).0.to_owned(), statement.to_owned()); let time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0.0, |d| d.as_secs_f64() * 1000.0); - tokio::task::spawn_blocking(move || { - if let Err(e) = history.add(&source, &text, time) { - tracing::warn!("{}", e.message); - } - }); + let _ = self.recorder.send((split_target(id).0.to_owned(), statement.to_owned(), time)); } /// The query history of data source `id`, newest first. diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index a4e56300c..c580c596c 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -122,23 +122,16 @@ pub struct DbProperty { pub value: String, } -/// How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type"). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub enum DbSshAuth { - Password, - /// A private key file, optionally protected by a passphrase. - KeyPair, - /// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. - #[default] - OpenSsh, -} +/// How the SSH tunnel authenticates (shared with Settings › SSH Configurations). +pub use ide_core::remote_settings::SshAuth as DbSshAuth; /// Connect through an SSH tunnel: the database host and port are reached from `host`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", default)] pub struct DbSsh { pub enabled: bool, + /// A Settings › SSH Configurations id to connect with; empty = the fields below. + pub config: String, /// Host name, or a `Host` alias of `~/.ssh/config` with [`DbSshAuth::OpenSsh`]. pub host: String, pub port: Option, diff --git a/crates/ide-db/src/tunnel.rs b/crates/ide-db/src/tunnel.rs deleted file mode 100644 index 8a6f60a66..000000000 --- a/crates/ide-db/src/tunnel.rs +++ /dev/null @@ -1,474 +0,0 @@ -//! SSH tunnels for data sources: a listener on 127.0.0.1 whose connections are -//! forwarded to the database through `direct-tcpip` channels of an SSH session -//! (russh), optionally reached through `ProxyJump` hosts. -//! -//! Host keys are checked against `known_hosts`; an unknown key fails the -//! connection with [`OpenError::UnknownHostKey`] so the UI can ask the user, -//! and a changed key is always refused. - -use crate::source::{DbSsh, DbSshAuth}; -use ide_core::{IdeError, IdeResult, error::ErrorKind}; -use parking_lot::Mutex; -use russh::client::{self, Handle}; -use russh::keys::{self, HashAlg, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate, known_hosts}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; -use tokio::net::TcpListener; -use tokio::task::JoinHandle; - -/// Generous: a relayed VPN path (e.g. Tailscale via DERP) can take ~1 s per round trip. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(45); -const DEFAULT_IDENTITIES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"]; - -/// A host key the user has not trusted yet. -#[derive(Debug, Clone)] -pub struct UnknownHostKey { - pub host: String, - pub port: u16, - pub key: PublicKey, -} - -impl UnknownHostKey { - /// `ED25519 SHA256:…`, as OpenSSH prints it. - pub fn fingerprint(&self) -> String { - format!("{} {}", self.key.algorithm().as_str(), self.key.fingerprint(HashAlg::Sha256)) - } - - /// Records the key in `known_hosts` (blocking). - pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> { - known_hosts::learn_known_hosts_path(&self.host, self.port, &self.key, known_hosts) - .map_err(|e| IdeError::new(ErrorKind::Io, format!("cannot update {}: {e}", known_hosts.display()))) - } -} - -#[derive(Debug)] -pub enum OpenError { - UnknownHostKey(Box), - Other(IdeError), -} - -impl From for OpenError { - fn from(e: IdeError) -> Self { - OpenError::Other(e) - } -} - -/// Where and how to connect. -pub struct TunnelSpec<'a> { - pub ssh: &'a DbSsh, - /// SSH password or key passphrase. - pub secret: Option<&'a str>, - /// The database, as seen from the SSH host. - pub target_host: &'a str, - pub target_port: u16, - pub known_hosts: &'a Path, - /// `~/.ssh/config` (used with [`DbSshAuth::OpenSsh`]). - pub ssh_config: Option<&'a Path>, -} - -/// An open tunnel; dropping it stops forwarding and closes the SSH sessions. -pub struct Tunnel { - port: u16, - session: Arc>, - /// Sessions of the `ProxyJump` hosts, kept alive under `session`. - _jumps: Vec>, - accept: JoinHandle<()>, -} - -impl Tunnel { - /// Connects and starts listening on a free local port. - pub async fn open(spec: TunnelSpec<'_>) -> Result { - let host = format!("{}:{}", spec.ssh.host, spec.ssh.port.unwrap_or(22)); - match tokio::time::timeout(CONNECT_TIMEOUT, Self::open_inner(spec)).await { - Ok(r) => r, - Err(_) => Err(IdeError::new( - ErrorKind::Io, - format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs()), - ) - .into()), - } - } - - async fn open_inner(spec: TunnelSpec<'_>) -> Result { - let hops = resolve_hops(spec.ssh, spec.ssh_config)?; - let mut sessions: Vec> = Vec::new(); - for (i, hop) in hops.iter().enumerate() { - let target = i == hops.len() - 1; - let mut handle = match sessions.last() { - None => connect::(hop, spec.known_hosts, None).await?, - Some(prev) => { - let channel = prev - .channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0) - .await - .map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?; - connect(hop, spec.known_hosts, Some(channel.into_stream())).await? - } - }; - // Jump hosts always use the agent / identity files; the secret belongs to the target. - let mode = if target { spec.ssh.auth } else { DbSshAuth::OpenSsh }; - authenticate(&mut handle, hop, mode, &spec.ssh.key_file, spec.secret).await?; - sessions.push(handle); - } - let session = Arc::new(sessions.pop().expect("at least one hop")); - // Fail fast with the real reason when the SSH host cannot reach the database - // (a refused forward otherwise surfaces as the driver's connect timeout). - let via = hops.last().expect("at least one hop").label(); - session - .channel_open_direct_tcpip(spec.target_host, u32::from(spec.target_port), "127.0.0.1", 0) - .await - .map_err(|e| unreachable_target(&via, spec.target_host, spec.target_port, &e))?; - let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|e| io(e.to_string()))?; - let port = listener.local_addr().map_err(|e| io(e.to_string()))?.port(); - let accept = tokio::spawn(forward(listener, session.clone(), spec.target_host.to_owned(), spec.target_port)); - Ok(Tunnel { port, session, _jumps: sessions, accept }) - } - - /// The local port to connect to instead of the database. - pub fn port(&self) -> u16 { - self.port - } - - /// The SSH session ended (network loss, server restart). - pub fn is_closed(&self) -> bool { - self.session.is_closed() - } -} - -impl Drop for Tunnel { - fn drop(&mut self) { - self.accept.abort(); - } -} - -async fn forward(listener: TcpListener, session: Arc>, host: String, port: u16) { - loop { - let Ok((mut socket, peer)) = listener.accept().await else { return }; - let session = session.clone(); - let host = host.clone(); - tokio::spawn(async move { - match session - .channel_open_direct_tcpip(host.as_str(), u32::from(port), "127.0.0.1", u32::from(peer.port())) - .await - { - Ok(channel) => { - let mut stream = channel.into_stream(); - let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await; - } - Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"), - } - }); - } -} - -fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError { - let why = match e { - russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => { - "connection refused or host unreachable" - } - russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => { - "port forwarding is not allowed by the SSH server (AllowTcpForwarding)" - } - _ => "", - }; - let why = if why.is_empty() { e.to_string() } else { why.to_owned() }; - io(format!("SSH host {via} cannot reach the database at {host}:{port}: {why}")) -} - -fn io(message: impl Into) -> IdeError { - IdeError::new(ErrorKind::Io, message) -} - -// --- hosts --------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Eq)] -struct Hop { - host: String, - port: u16, - user: String, - identity_files: Vec, -} - -impl Hop { - fn label(&self) -> String { - format!("{}@{}:{}", self.user, self.host, self.port) - } -} - -fn home() -> Option { - dirs::home_dir() -} - -/// `~/x` → `$HOME/x`. -pub(crate) fn expand(path: &str) -> PathBuf { - match (path.strip_prefix("~/"), home()) { - (Some(rest), Some(h)) => h.join(rest), - _ => PathBuf::from(path), - } -} - -fn local_user() -> String { - std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default() -} - -/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults). -fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option) { - let c = config - .and_then(|p| russh_config::parse_path(p, alias).ok()) - .unwrap_or_else(|| russh_config::Config::default(alias)); - let identity_files = c.host_config.identity_file.clone().unwrap_or_default(); - let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files }; - (hop, c.host_config.proxy_jump.clone()) -} - -/// `[user@]host[:port]` of a `ProxyJump` entry. -fn jump_hop(config: Option<&Path>, spec: &str) -> Hop { - let (user, rest) = match spec.rsplit_once('@') { - Some((u, r)) => (Some(u), r), - None => (None, spec), - }; - let (host, port) = match rest.rsplit_once(':') { - Some((h, p)) if p.parse::().is_ok() => (h, p.parse().ok()), - _ => (rest, None), - }; - let (mut hop, _) = config_hop(config, host); - if let Some(u) = user { - hop.user = u.to_owned(); - } - if let Some(p) = port { - hop.port = p; - } - hop -} - -/// The SSH hosts to go through, the tunnel's host last. -fn resolve_hops(ssh: &DbSsh, config: Option<&Path>) -> IdeResult> { - let host = ssh.host.trim(); - if host.is_empty() { - return Err(IdeError::invalid("SSH host is empty")); - } - let (mut hop, proxy_jump) = if ssh.auth == DbSshAuth::OpenSsh { - config_hop(config, host) - } else { - (Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None) - }; - if let Some(p) = ssh.port { - hop.port = p; - } - if !ssh.user.trim().is_empty() { - hop.user = ssh.user.trim().to_owned(); - } - let mut hops: Vec = proxy_jump - .filter(|j| !j.eq_ignore_ascii_case("none")) - .map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect()) - .unwrap_or_default(); - hops.push(hop); - Ok(hops) -} - -// --- connection ---------------------------------------------------------------- - -#[derive(Debug)] -enum HostKeyIssue { - Unknown(PublicKey), - Changed(usize), -} - -pub struct Client { - host: String, - port: u16, - known_hosts: PathBuf, - issue: Arc>>, -} - -impl client::Handler for Client { - type Error = russh::Error; - - async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result { - let key = match key { - PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), - PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()), - }; - let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) { - Ok(true) => return Ok(true), - Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line), - Ok(false) | Err(_) => HostKeyIssue::Unknown(key), - }; - *self.issue.lock() = Some(issue); - Ok(false) - } -} - -async fn connect(hop: &Hop, known_hosts: &Path, stream: Option) -> Result, OpenError> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, -{ - let config = Arc::new(client::Config { - inactivity_timeout: None, - keepalive_interval: Some(Duration::from_secs(30)), - keepalive_max: 3, - nodelay: true, - ..Default::default() - }); - let issue = Arc::new(Mutex::new(None)); - let handler = - Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() }; - let result = match stream { - None => client::connect(config, (hop.host.as_str(), hop.port), handler).await, - Some(s) => client::connect_stream(config, s, handler).await, - }; - result.map_err(|e| match issue.lock().take() { - Some(HostKeyIssue::Unknown(key)) => { - OpenError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key })) - } - Some(HostKeyIssue::Changed(line)) => OpenError::Other(IdeError::new( - ErrorKind::PermissionDenied, - format!( - "SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack", - hop.host, hop.port - ), - )), - None => OpenError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))), - }) -} - -// --- authentication ------------------------------------------------------------ - -fn denied(message: String) -> IdeError { - IdeError::new(ErrorKind::PermissionDenied, message) -} - -async fn rsa_hash(handle: &Handle) -> Option { - handle.best_supported_rsa_hash().await.ok().flatten().flatten() -} - -async fn with_key(handle: &mut Handle, user: &str, key: keys::PrivateKey) -> IdeResult { - let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash(handle).await); - let r = handle.authenticate_publickey(user, key).await.map_err(|e| io(e.to_string()))?; - Ok(r.success()) -} - -/// Every identity of the running SSH agent, until one is accepted. -async fn with_agent(handle: &mut Handle, user: &str) -> bool { - #[cfg(unix)] - let agent = keys::agent::client::AgentClient::connect_env().await; - #[cfg(windows)] - let agent = keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await; - let Ok(mut agent) = agent else { return false }; - let Ok(identities) = agent.request_identities().await else { return false }; - let hash = rsa_hash(handle).await; - for id in identities { - let ok = match id { - keys::agent::AgentIdentity::PublicKey { key, .. } => { - handle.authenticate_publickey_with(user, key, hash, &mut agent).await.map(|r| r.success()) - } - keys::agent::AgentIdentity::Certificate { certificate, .. } => { - handle.authenticate_certificate_with(user, certificate, hash, &mut agent).await.map(|r| r.success()) - } - }; - if ok.unwrap_or(false) { - return true; - } - } - false -} - -async fn authenticate( - handle: &mut Handle, - hop: &Hop, - mode: DbSshAuth, - key_file: &str, - secret: Option<&str>, -) -> IdeResult<()> { - let user = hop.user.as_str(); - let failed = || denied(format!("SSH authentication failed for {}", hop.label())); - match mode { - DbSshAuth::Password => { - let Some(password) = secret else { - return Err(denied(format!("SSH password required for {}", hop.label()))); - }; - let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?; - if r.success() { Ok(()) } else { Err(failed()) } - } - DbSshAuth::KeyPair => { - if key_file.trim().is_empty() { - return Err(IdeError::invalid("SSH private key file is not set")); - } - let path = expand(key_file.trim()); - let key = load_key(&path, secret)?; - if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) } - } - DbSshAuth::OpenSsh => { - if with_agent(handle, user).await { - return Ok(()); - } - let files: Vec = if hop.identity_files.is_empty() { - home().map(|h| DEFAULT_IDENTITIES.iter().map(|n| h.join(".ssh").join(n)).collect()).unwrap_or_default() - } else { - hop.identity_files.iter().map(|p| expand(&p.to_string_lossy())).collect() - }; - let mut locked: Option = None; - for path in files.iter().filter(|p| p.is_file()) { - match load_key(path, secret) { - Ok(key) => { - if with_key(handle, user, key).await? { - return Ok(()); - } - } - Err(e) if e.kind == ErrorKind::PermissionDenied => locked = locked.or(Some(path.clone())), - Err(e) => tracing::warn!("SSH key {}: {}", path.display(), e.message), - } - } - match locked { - // An encrypted key could not be tried: ask for its passphrase. - Some(path) if secret.is_none() => { - Err(denied(format!("SSH key passphrase required for {}", path.display()))) - } - _ => Err(failed()), - } - } - } -} - -/// Loads a private key; an encrypted key without (or with a wrong) passphrase is `PermissionDenied`. -fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult { - match keys::load_secret_key(path, passphrase) { - Ok(k) => Ok(k), - Err(keys::Error::KeyIsEncrypted) => Err(denied(format!("SSH key passphrase required for {}", path.display()))), - Err(e) if passphrase.is_some() => { - Err(denied(format!("cannot decrypt SSH key {} (wrong passphrase?): {e}", path.display()))) - } - Err(e) => Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolves_aliases_and_proxy_jumps_from_ssh_config() { - let tmp = tempfile::tempdir().unwrap(); - let config = tmp.path().join("config"); - std::fs::write( - &config, - "Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump jump@gw.example.com:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n", - ) - .unwrap(); - let ssh = DbSsh { enabled: true, host: "db-bastion".into(), auth: DbSshAuth::OpenSsh, ..Default::default() }; - let hops = resolve_hops(&ssh, Some(&config)).unwrap(); - let labels: Vec = hops.iter().map(Hop::label).collect(); - assert_eq!(labels, ["jump@gw.example.com:2200", "edgeuser@edge.example.com:22", "ops@10.0.0.5:2222"]); - assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]); - - // Explicit fields win over the config. - let ssh = DbSsh { user: "me".into(), port: Some(22), ..ssh }; - assert_eq!(resolve_hops(&ssh, Some(&config)).unwrap()[2].label(), "me@10.0.0.5:22"); - - // Without OpenSSH mode the host is used as is. - let ssh = DbSsh { auth: DbSshAuth::Password, user: "u".into(), port: None, ..ssh }; - let hops = resolve_hops(&ssh, Some(&config)).unwrap(); - assert_eq!(hops.iter().map(Hop::label).collect::>(), ["u@db-bastion:22"]); - assert!(resolve_hops(&DbSsh::default(), None).is_err()); - } -} diff --git a/crates/ide-db/tests/sqlite.rs b/crates/ide-db/tests/sqlite.rs index 315ecff54..f340ee536 100644 --- a/crates/ide-db/tests/sqlite.rs +++ b/crates/ide-db/tests/sqlite.rs @@ -7,7 +7,8 @@ async fn setup() -> (tempfile::TempDir, DbManager) { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); std::fs::File::create(root.join("app.db")).unwrap(); - let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json")); + let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json"))); + let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh); db.save_sources(vec![DataSource { id: "s1".into(), name: "app".into(), diff --git a/crates/ide-db/tests/ssh_tunnel.rs b/crates/ide-db/tests/ssh_tunnel.rs deleted file mode 100644 index ccfa5189b..000000000 --- a/crates/ide-db/tests/ssh_tunnel.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! SSH tunnels against an in-process russh server that forwards `direct-tcpip` -//! channels to a local echo server. - -use ide_core::error::ErrorKind; -use ide_db::tunnel::{OpenError, Tunnel, TunnelSpec}; -use ide_db::{DbSsh, DbSshAuth}; -use russh::Channel; -use russh::keys::{PrivateKey, PublicKey, decode_secret_key}; -use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; - -fn fixture(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name) -} - -fn key(name: &str, passphrase: Option<&str>) -> PrivateKey { - decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap() -} - -#[derive(Clone)] -struct Sshd { - client_key: PublicKey, -} - -impl server::Server for Sshd { - type Handler = Sshd; - fn new_client(&mut self, _: Option) -> Sshd { - self.clone() - } -} - -impl server::Handler for Sshd { - type Error = russh::Error; - - async fn auth_password(&mut self, user: &str, password: &str) -> Result { - Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() }) - } - - async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result { - Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() }) - } - - async fn channel_open_direct_tcpip( - &mut self, - channel: Channel, - host: &str, - port: u32, - _originator_address: &str, - _originator_port: u32, - reply: ChannelOpenHandle, - _session: &mut Session, - ) -> Result<(), Self::Error> { - let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else { - return Ok(()); - }; - reply.accept().await; - tokio::spawn(async move { - let mut stream = channel.into_stream(); - let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await; - }); - Ok(()) - } -} - -/// Echo server standing in for the database. -async fn echo() -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - while let Ok((mut s, _)) = listener.accept().await { - tokio::spawn(async move { - let (mut r, mut w) = s.split(); - let _ = tokio::io::copy(&mut r, &mut w).await; - }); - } - }); - port -} - -async fn sshd() -> u16 { - let config = Arc::new(server::Config { - keys: vec![key("host", None)], - auth_rejection_time: std::time::Duration::from_millis(10), - auth_rejection_time_initial: Some(std::time::Duration::ZERO), - ..Default::default() - }); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let mut server = Sshd { client_key: key("client", None).public_key().clone() }; - tokio::spawn(async move { - let _ = server.run_on_socket(config, &listener).await; - }); - port -} - -struct Env { - _tmp: tempfile::TempDir, - known_hosts: PathBuf, - ssh_port: u16, - db_port: u16, -} - -async fn env() -> Env { - let tmp = tempfile::tempdir().unwrap(); - let known_hosts = tmp.path().join("known_hosts"); - Env { known_hosts, _tmp: tmp, ssh_port: sshd().await, db_port: echo().await } -} - -impl Env { - fn ssh(&self, auth: DbSshAuth) -> DbSsh { - DbSsh { - enabled: true, - host: "127.0.0.1".into(), - port: Some(self.ssh_port), - user: "dev".into(), - auth, - ..Default::default() - } - } - - async fn open(&self, ssh: &DbSsh, secret: Option<&str>) -> Result { - Tunnel::open(TunnelSpec { - ssh, - secret, - target_host: "127.0.0.1", - target_port: self.db_port, - known_hosts: &self.known_hosts, - ssh_config: None, - }) - .await - } - - /// Trusts the server's key on the first connection, as the UI does. - async fn open_trusted(&self, ssh: &DbSsh, secret: Option<&str>) -> Result { - match self.open(ssh, secret).await { - Err(OpenError::UnknownHostKey(k)) => { - k.trust(&self.known_hosts).unwrap(); - self.open(ssh, secret).await - } - other => other, - } - } -} - -async fn round_trip(t: &Tunnel) { - let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap(); - s.write_all(b"SELECT 1").await.unwrap(); - let mut buf = [0u8; 8]; - s.read_exact(&mut buf).await.unwrap(); - assert_eq!(&buf, b"SELECT 1"); -} - -fn denied(r: Result) -> String { - match r { - Err(OpenError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message, - Err(OpenError::Other(e)) => panic!("expected permission denied, got {e:?}"), - Err(OpenError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()), - Ok(_) => panic!("expected permission denied, connected"), - } -} - -#[tokio::test] -async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() { - let env = env().await; - let ssh = env.ssh(DbSshAuth::Password); - let Err(OpenError::UnknownHostKey(k)) = env.open(&ssh, Some("secret")).await else { - panic!("expected an unknown host key"); - }; - assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint()); - k.trust(&env.known_hosts).unwrap(); - round_trip(&env.open(&ssh, Some("secret")).await.ok().unwrap()).await; - - // Another key recorded for the same host: refused, never offered for trust. - let other = key("client", None).public_key().to_openssh().unwrap(); - std::fs::write(&env.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap(); - let msg = denied(env.open(&ssh, Some("secret")).await); - assert!(msg.contains("has changed"), "{msg}"); -} - -#[tokio::test] -async fn password_authentication() { - let env = env().await; - let ssh = env.ssh(DbSshAuth::Password); - let tunnel = env.open_trusted(&ssh, Some("secret")).await.ok().unwrap(); - round_trip(&tunnel).await; - round_trip(&tunnel).await; - assert!(!tunnel.is_closed()); - - assert!(denied(env.open(&ssh, None).await).starts_with("SSH password required")); - assert!(denied(env.open(&ssh, Some("wrong")).await).starts_with("SSH authentication failed")); -} - -#[tokio::test] -async fn key_pair_authentication_with_and_without_passphrase() { - let env = env().await; - let ssh = DbSsh { key_file: fixture("client").to_string_lossy().into(), ..env.ssh(DbSshAuth::KeyPair) }; - round_trip(&env.open_trusted(&ssh, None).await.ok().unwrap()).await; - - // An encrypted key the server does not accept: passphrase first, then the key is refused. - let locked = DbSsh { key_file: fixture("locked").to_string_lossy().into(), ..ssh.clone() }; - assert!(denied(env.open(&locked, None).await).starts_with("SSH key passphrase required")); - assert!(denied(env.open(&locked, Some("nope")).await).contains("wrong passphrase")); - assert!(denied(env.open(&locked, Some("pw")).await).starts_with("SSH authentication failed")); -} - -#[tokio::test] -async fn unreachable_database_fails_fast_with_the_reason() { - let env = env().await; - let ssh = env.ssh(DbSshAuth::Password); - drop(env.open_trusted(&ssh, Some("secret")).await.ok().unwrap()); - let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port(); - let r = Tunnel::open(TunnelSpec { - ssh: &ssh, - secret: Some("secret"), - target_host: "127.0.0.1", - target_port: closed, - known_hosts: &env.known_hosts, - ssh_config: None, - }) - .await; - let Err(OpenError::Other(e)) = r else { panic!("expected an error") }; - assert!(e.message.contains(&format!("cannot reach the database at 127.0.0.1:{closed}")), "{}", e.message); -} diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml new file mode 100644 index 000000000..43df9351e --- /dev/null +++ b/crates/ide-host/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "ide-host" +description = "The IDE backend without a UI toolkit: projects per window, terminals, processes, and the JSON-RPC server for remote development" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "openidea-server" +path = "src/bin/server.rs" + +[dependencies] +ide-core.workspace = true +ide-text.workspace = true +ide-vfs.workspace = true +ide-workspace.workspace = true +ide-language.workspace = true +ide-index.workspace = true +ide-syntax.workspace = true +ide-search.workspace = true +ide-lsp.workspace = true +ide-vcs.workspace = true +ide-dap.workspace = true +ide-build.workspace = true +ide-analysis.workspace = true +ide-coverage.workspace = true +ide-spell.workspace = true +ide-terminal.workspace = true +ide-run.workspace = true +ide-sdk.workspace = true +ide-http.workspace = true +ide-db.workspace = true +ide-ssh.workspace = true +ide-remote.workspace = true +ide-jsonrpc.workspace = true +ide-agent.workspace = true +ide-tools = { path = "../ide-tools" } +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tokio = { workspace = true, features = ["io-std"] } +tracing.workspace = true +tracing-subscriber.workspace = true +parking_lot.workspace = true +dunce.workspace = true +dirs.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] } +tempfile.workspace = true diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs new file mode 100644 index 000000000..a647ba696 --- /dev/null +++ b/crates/ide-host/src/bin/server.rs @@ -0,0 +1,75 @@ +//! `openidea-server --stdio`: the IDE backend for remote development, serving +//! one desktop client over stdin / stdout (started by the client over SSH). + +use ide_host::rpc::{self, Dispatcher}; +use ide_host::{Host, HostConfig}; +use ide_language::LanguageRegistry; +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "--version") { + println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION); + return; + } + if !args.iter().any(|a| a == "--stdio") { + eprintln!("usage: openidea-server --stdio | --version"); + std::process::exit(2); + } + // stdout carries the protocol: logs go to stderr. + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter(tracing_subscriber::EnvFilter::try_from_env("OPENIDEA_LOG").unwrap_or_else(|_| "info".into())) + .init(); + let home = dirs::home_dir().unwrap_or_default(); + let base = std::env::var_os("OPENIDEA_SERVER_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".openidea")); + // An SSH exec channel gets a minimal PATH: take the login shell's, as the + // desktop app does, plus the IDE-managed tools. Before any other thread starts. + let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path("openidea-server"); + let data_dir = base.join("data"); + ide_tools::extend_process_path(&data_dir); + ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data_dir)); + ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data_dir)); + if let Some(refresh) = refresh_shell_path { + refresh(); + } + let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("tokio runtime"); + let code = runtime.block_on(run(base)); + std::process::exit(code); +} + +async fn run(base: PathBuf) -> i32 { + let (config_dir, data_dir) = (base.join("config"), base.join("data")); + for dir in [&config_dir, &data_dir] { + if let Err(e) = std::fs::create_dir_all(dir) { + eprintln!("cannot create {}: {e}", dir.display()); + return 1; + } + } + let languages = match LanguageRegistry::load(Some(&config_dir.join("languages"))) { + Ok(l) => Arc::new(l), + Err(e) => { + eprintln!("language definitions: {e}"); + return 1; + } + }; + let peer = Arc::new(OnceLock::new()); + let sink_peer = peer.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir, + data_dir, + languages, + syntax: Arc::default(), + sink_for: Box::new(move |window| rpc::event_sink(sink_peer.clone(), Some(window.to_owned()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + })); + tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION")); + rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await; + // `process::exit` skips destructors: nothing started for the client may outlive it. + if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() { + tracing::warn!("language servers did not shut down in time"); + } + 0 +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs new file mode 100644 index 000000000..fcf599d15 --- /dev/null +++ b/crates/ide-host/src/lib.rs @@ -0,0 +1,162 @@ +//! The IDE backend without a UI toolkit: open projects per window, terminals, +//! processes, settings and the remote services. The desktop shell (`src-tauri`) +//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server` +//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). + +pub mod ops; +mod project; +pub mod rpc; +pub mod shell_env; + +pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention}; + +use ide_core::{CancellationToken, IdeError, IdeResult, RequestId, SharedSink}; +use ide_language::{LanguageRegistry, Syntax}; +use ide_run::ProcessManager; +use ide_terminal::TerminalManager; +use ide_workspace::SettingsStore; +use parking_lot::{Mutex, RwLock}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; + +/// Event sink for one window (by label). +pub type SinkFactory = Box SharedSink + Send + Sync>; + +pub struct HostConfig { + pub config_dir: PathBuf, + pub data_dir: PathBuf, + pub languages: Arc, + /// Grammars, possibly still being built on another thread. + pub syntax: Arc>>, + pub sink_for: SinkFactory, + /// The window that owns what `window` starts (e.g. a settings window's parent). + pub owner: fn(&str) -> &str, + pub runtime: tokio::runtime::Handle, +} + +pub struct Host { + pub config_dir: PathBuf, + pub data_dir: PathBuf, + pub languages: Arc, + syntax: Arc>>, + pub global_settings: SettingsStore, + pub terminals: TerminalManager, + pub processes: ProcessManager, + /// Debug sessions (adapters run on this machine, next to the project). + pub debugger: Arc, + /// SSH connections of Settings › SSH Configurations. + pub ssh: Arc, + /// SFTP deployment, remote file editing and runs on run targets. + pub remote: Arc, + pub(crate) sink_for: SinkFactory, + owner: fn(&str) -> &str, + pub runtime: tokio::runtime::Handle, + /// One project per window, keyed by window label. + projects: RwLock>>, + requests: Mutex>, +} + +impl Host { + pub fn new(config: HostConfig) -> Self { + let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime } = config; + let global_settings = SettingsStore::load(&config_dir, None); + let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); + ssh.set_configs(global_settings.effective().ssh.configs); + Self { + remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), + ssh, + global_settings, + // Sessions, runs and terminals report to the window that started them (`window_sink`). + terminals: TerminalManager::new(), + processes: ProcessManager::new(), + debugger: ide_dap::DebugManager::new(languages.clone()), + languages, + syntax, + config_dir, + data_dir, + sink_for, + owner, + runtime, + projects: RwLock::default(), + requests: Mutex::default(), + } + } + + /// The tree-sitter grammars (blocks only if start-up warm-up is still running). + pub fn syntax(&self) -> Arc { + self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone() + } + + /// Every open project (all windows). + pub fn projects(&self) -> Vec> { + self.projects.read().values().cloned().collect() + } + + /// The project of `window` (or of the window owning it). + pub fn project(&self, window: &str) -> IdeResult> { + self.projects + .read() + .get((self.owner)(window)) + .cloned() + .ok_or_else(|| IdeError::not_found("no project is open in this window")) + } + + /// The window that owns what `window` starts. + pub fn owner_window<'a>(&self, window: &'a str) -> &'a str { + (self.owner)(window) + } + + /// Event sink for things `window` starts (debug sessions, runs, terminals): its + /// project's window-scoped sink, or a sink for that window when no project is open. + pub fn window_sink(&self, window: &str) -> SharedSink { + match self.project(window) { + Ok(p) => p.sink.clone(), + Err(_) => (self.sink_for)((self.owner)(window)), + } + } + + pub fn close_project(&self, window: &str) { + if let Some(project) = self.projects.write().remove(window) { + project.agents.shutdown(); + let lsp = project.lsp.clone(); + self.runtime.spawn(async move { lsp.shutdown_all().await }); + } + } + + /// Stops what this host started: language servers, agents, runs and terminals. + /// For a process that exits without dropping the host (the remote server). + pub async fn shutdown(&self) { + self.processes.kill_all(); + self.terminals.kill_all(); + let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect(); + for project in projects { + project.agents.shutdown(); + project.lsp.shutdown_all().await; + } + } + + // ---- cancellable requests: the UI allocates ids, `cancel_request` cancels ---- + + pub fn begin_request(&self, id: RequestId) -> CancellationToken { + let token = CancellationToken::new(); + if let Some(old) = self.requests.lock().insert(id, token.clone()) { + old.cancel(); + } + token + } + + pub fn end_request(&self, id: RequestId) { + self.requests.lock().remove(&id); + } + + pub fn cancel_request(&self, id: RequestId) { + if let Some(token) = self.requests.lock().remove(&id) { + token.cancel(); + } + } + + pub(crate) fn insert_project(&self, window: &str, project: Arc) { + self.projects.write().insert(window.to_owned(), project); + } +} diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs new file mode 100644 index 000000000..020a1fcb2 --- /dev/null +++ b/crates/ide-host/src/ops/agent.rs @@ -0,0 +1,114 @@ +//! Agents tool window: coding agent tasks of the project (over ACP). In a remote +//! window they run on the host, next to the project they work on. +use super::Emit; +use crate::Host; +use crate::rpc::Dispatcher; +use ide_agent::{ + AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, +}; +use ide_core::{IdeResult, IpcPath}; + +/// Streams the project's agent events to this window; returns every task. +pub fn subscribe(host: &Host, window: &str, on_event: Emit) -> IdeResult> { + Ok(host.project(window)?.agents.subscribe(on_event)) +} + +pub fn providers(host: &Host, window: &str) -> IdeResult> { + Ok(host.project(window)?.agents.providers()) +} + +/// Slash commands to complete for `provider` in `task`'s directory, or in the project +/// before a task exists (skills and custom commands on disk included). +pub async fn commands( + host: &Host, + window: &str, + provider: String, + task: Option, +) -> IdeResult> { + host.project(window)?.agents.commands(&provider, task.as_deref()).await +} + +pub fn entries(host: &Host, window: &str, task: String) -> IdeResult> { + host.project(window)?.agents.entries(&task) +} + +pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult { + host.project(window)?.agents.create(request).await +} + +/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. +pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> { + host.project(window)?.agents.prompt(&task, prompt, now).await +} + +/// Drops a queued message and returns it (to edit it). +pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeResult { + host.project(window)?.agents.unqueue(&task, &queued) +} + +/// Sends a queued message now, interrupting the current turn. +pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> { + host.project(window)?.agents.send_queued_now(&task, &queued) +} + +/// Changes a session setting (model, mode, thinking effort…). +pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> { + host.project(window)?.agents.set_config(&task, &option, value).await +} + +pub fn cancel(host: &Host, window: &str, task: String) -> IdeResult<()> { + host.project(window)?.agents.cancel(&task) +} + +/// Answers a permission request; `option: None` cancels it. +pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option) -> IdeResult<()> { + host.project(window)?.agents.respond(&task, permission, option) +} + +pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> { + host.project(window)?.agents.archive(&task) +} + +pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> { + host.project(window)?.agents.delete(&task, remove_worktree).await +} + +pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult> { + host.project(window)?.agents.changes(&task).await +} + +/// Content of `path` when the task started (left side of its diff). +pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult> { + host.project(window)?.agents.base_text(&task, path.into_path_buf()).await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) }); + d.add("agent_providers", |c| async move { providers(&c.host, &c.window) }); + d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await }); + d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?) }); + d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await }); + d.add("agent_prompt", |c| async move { + prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await + }); + d.add("agent_unqueue", |c| async move { unqueue(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) }); + d.add( + "agent_send_queued_now", + |c| async move { send_queued_now(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) }, + ); + d.add("agent_set_config", |c| async move { + set_config(&c.host, &c.window, c.arg("task")?, c.arg("option")?, c.arg("value")?).await + }); + d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?) }); + d.add("agent_respond", |c| async move { + respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?) + }); + d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) }); + d.add( + "agent_delete", + |c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await }, + ); + d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await }); + d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await }); +} diff --git a/crates/ide-host/src/ops/analysis.rs b/crates/ide-host/src/ops/analysis.rs new file mode 100644 index 000000000..7d693b52e --- /dev/null +++ b/crates/ide-host/src/ops/analysis.rs @@ -0,0 +1,280 @@ +//! Code analysis over a scope of files: Inspect Code (batch LSP diagnostics), +//! Locate Duplicates and Search / Replace Structurally (`ide-analysis`). +//! +//! Scopes are Find in Files scopes; excluded roots are never analysed. Every +//! command takes a UI-allocated `RequestId` and is cancelled via `cancel_request`. +use crate::Host; +use crate::Project; +use crate::ops::Channel; +use crate::rpc::Dispatcher; + +use super::doc::blocking; +use ide_analysis::{ + DuplicateOptions, DuplicatesReport, SourceFile, StructuralFile, StructuralQuery, StructuralSummary, +}; +use ide_core::{CancellationToken, Diagnostic, DocId, IdeResult, IpcPath, LanguageId, RequestId}; +use ide_language::LanguageRegistry; +use ide_lsp::{FileInspection, InspectHost, InspectOptions}; +use ide_search::{FileMask, SearchScope}; +use ide_workspace::Documents; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +/// Files of `scope`, minus excluded roots and ignored names. +fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult> { + let mask = FileMask::new(&[], &project.search_excludes())?; + Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)? + .into_iter() + .map(|f| SourceFile { path: f.path, rel_path: f.rel_path }) + .collect()) +} + +/// Current text of a file (the unsaved buffer when open) and its language. +fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<(LanguageId, String)> { + if let Some(doc) = project.documents.by_path(path) { + let snap = project.documents.text_snapshot(doc).ok()?; + return (!snap.large).then_some((snap.language, snap.text)); + } + let loaded = ide_vfs::load_text(path).ok()?; + if loaded.binary || loaded.large || loaded.too_large { + return None; + } + let language = languages.detect(path, loaded.text.lines().next()); + Some((language, loaded.text)) +} + +// ---- Inspect Code ---- + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct InspectedFile { + pub path: IpcPath, + pub rel_path: String, + pub diagnostics: Vec, +} + +#[derive(Clone, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct InspectSummary { + /// Files inspected by a language server. + pub files: u32, + pub problems: u32, + /// Files in scope no language server serves. + pub skipped: u32, + /// Files whose server failed or never became ready. + pub failed: u32, + pub cancelled: bool, + pub elapsed_ms: u32, +} + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum InspectEvent { + /// Files about to be inspected. + Started { + total: u32, + }, + /// A file finished (with or without problems). + File { + file: InspectedFile, + done: u32, + }, + Done { + summary: InspectSummary, + }, +} + +struct DocumentsHost(Arc); + +impl InspectHost for DocumentsHost { + fn open(&self, path: &Path) -> IdeResult<(DocId, bool)> { + let was_open = self.0.by_path(path).is_some(); + Ok((self.0.open(path)?.id, was_open)) + } + + fn close(&self, doc: DocId) { + self.0.close(doc); + } +} + +// ---- Locate Duplicates ---- + +// ---- Structural Search ---- + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum StructuralEvent { + File { file: StructuralFile }, + Done { summary: StructuralSummary }, +} + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StructuralLanguage { + pub id: LanguageId, + pub name: String, +} + +/// Inspect Code: diagnostics of every file in `scope` that a language server +/// serves. Files stream through `on_event` as they finish; ends with `Done`. +pub async fn inspect_code( + host: &Host, + window: &str, + request: RequestId, + scope: SearchScope, + on_event: Channel, +) -> IdeResult<()> { + let started = Instant::now(); + let project = host.project(window)?; + let cancel = host.begin_request(request); + let listed = { + let (project, cancel) = (project.clone(), cancel.clone()); + blocking(move || files_in_scope(&project, &scope, &cancel)).await + }; + let files = match listed { + Ok(files) => files, + Err(e) => { + host.end_request(request); + return Err(e); + } + }; + let total_in_scope = files.len() as u32; + let rel: std::collections::HashMap<_, _> = files + .iter() + .filter(|f| project.lsp.serves_path(&f.path)) + .map(|f| (f.path.clone(), f.rel_path.clone())) + .collect(); + let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() }; + let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 }); + + let mut paths: Vec<_> = rel.keys().cloned().collect(); + paths.sort(); + let tally = Mutex::new(summary.clone()); + let done = std::sync::atomic::AtomicU32::new(0); + let docs = Arc::new(DocumentsHost(project.documents.clone())); + project + .lsp + .inspect_files(docs, paths, InspectOptions::default(), cancel.clone(), &|path, outcome| { + let done = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let diagnostics = { + let mut t = tally.lock(); + match outcome { + FileInspection::Inspected(items) => { + t.files += 1; + t.problems += items.len() as u32; + items + } + FileInspection::NoServer => { + t.skipped += 1; + Vec::new() + } + FileInspection::Failed(message) => { + tracing::debug!("inspect {}: {message}", path.display()); + t.failed += 1; + Vec::new() + } + } + }; + let rel_path = rel.get(&path).cloned().unwrap_or_default(); + let file = InspectedFile { path: IpcPath(path), rel_path, diagnostics }; + let _ = on_event.send(InspectEvent::File { file, done }); + }) + .await; + host.end_request(request); + summary = tally.into_inner(); + summary.cancelled = cancel.is_cancelled(); + summary.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX); + let _ = on_event.send(InspectEvent::Done { summary }); + Ok(()) +} + +/// Locate Duplicates over the files of `scope`. +pub async fn analysis_locate_duplicates( + host: &Host, + window: &str, + request: RequestId, + scope: SearchScope, + options: DuplicateOptions, +) -> IdeResult { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let languages = project.languages.clone(); + let syntax = host.syntax(); + let result = blocking(move || { + let files = files_in_scope(&project, &scope, &cancel)?; + let loader = |p: &Path| load(&project, &languages, p); + Ok(ide_analysis::find_duplicates(&syntax, &files, &loader, &options, &cancel)) + }) + .await; + host.end_request(request); + result +} + +/// Languages Search Structurally supports (those with a bundled grammar). +pub async fn structural_languages(host: &Host, _window: &str) -> IdeResult> { + let syntax = host.syntax(); + let mut out: Vec = host + .languages + .all() + .into_iter() + .filter(|c| syntax.supports(&c.id)) + .map(|c| StructuralLanguage { id: c.id, name: c.name }) + .collect(); + out.sort_by_key(|l| l.name.to_lowercase()); + Ok(out) +} + +/// Checks a structural search template (and language) while the user types. +pub async fn structural_validate(host: &Host, _window: &str, query: StructuralQuery) -> IdeResult<()> { + let syntax = host.syntax(); + blocking(move || ide_analysis::validate_structural(&syntax, &query)).await +} + +/// Search Structurally over `scope`; files with matches stream through +/// `on_event`, ending with `Done`. With a replacement, matches carry their +/// replacement text (applied by the UI as a workspace edit). +pub async fn structural_search( + host: &Host, + window: &str, + request: RequestId, + scope: SearchScope, + query: StructuralQuery, + on_event: Channel, +) -> IdeResult<()> { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let languages = project.languages.clone(); + let syntax = host.syntax(); + let events = on_event.clone(); + let result = blocking(move || { + let files = files_in_scope(&project, &scope, &cancel)?; + let loader = |p: &Path| load(&project, &languages, p); + ide_analysis::search_structurally(&syntax, &files, &loader, &query, &cancel, &|file| { + let _ = events.send(StructuralEvent::File { file }); + }) + }) + .await; + host.end_request(request); + let _ = on_event.send(StructuralEvent::Done { summary: result? }); + Ok(()) +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("inspect_code", |c| async move { + inspect_code(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.sender("onEvent")?).await + }); + d.add("analysis_locate_duplicates", |c| async move { + analysis_locate_duplicates(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("options")?).await + }); + d.add("structural_languages", |c| async move { structural_languages(&c.host, &c.window).await }); + d.add("structural_validate", |c| async move { structural_validate(&c.host, &c.window, c.arg("query")?).await }); + d.add("structural_search", |c| async move { + structural_search(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("query")?, c.sender("onEvent")?) + .await + }); +} diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs new file mode 100644 index 000000000..1366b9db0 --- /dev/null +++ b/crates/ide-host/src/ops/build.rs @@ -0,0 +1,263 @@ +//! Maven / Gradle tool windows (the build model and its background sync) and +//! Build › Build Project (compiling with the project's build system). +use crate::ops::Channel; +use crate::rpc::Dispatcher; +use crate::{Host, Project, apply_workspace_edit}; + +use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult}; +use ide_build::pom_edit::{ArtifactKey, Exclusion}; +use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress}; +use ide_core::settings::Settings; +use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit}; +use std::collections::BTreeMap; +use std::path::Path; + +fn join_err(e: tokio::task::JoinError) -> IdeError { + IdeError::internal(e.to_string()) +} + +/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none +/// when the project names no version). +fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option) -> BTreeMap { + let jdks = ide_sdk::java::installed_jdks(); + let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r)))); + jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default() +} + +/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and +/// the Maven home and options from Settings › Maven. +pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { + let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings)); + let jdk = ide_sdk::apply::project_jdk(root, settings); + if let Some(m) = &mut model.maven { + let mvn = ide_sdk::apply::maven_command(root, settings); + let options = ide_sdk::apply::maven_options(root, settings); + for p in &mut m.projects { + p.env = java_env(jdk.as_ref(), p.java_version); + if let Some(mvn) = &mvn { + p.command = mvn.to_string_lossy().into_owned(); + } + p.options = options.clone(); + } + } + if let Some(g) = &mut model.gradle { + g.env = java_env(jdk.as_ref(), g.java_version); + } + // A failed sync of a project no installed JDK can build says so first (with a Download JDK action). + let missing = |release: Option| match jdk { + Some(_) => None, + None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release), + }; + let explain = |error: &mut Option, hint: Option| { + if let (Some(e), Some(hint)) = (error.as_mut(), hint) { + *e = format!("{hint}\n{e}"); + } + }; + if let Some(m) = &mut model.maven + && m.sync_error.is_some() + { + let hint = m.projects.iter().find_map(|p| missing(p.java_version)); + explain(&mut m.sync_error, hint); + } + if let Some(g) = &mut model.gradle + && g.sync_error.is_some() + { + let hint = missing(g.java_version); + explain(&mut g.sync_error, hint); + } + model +} + +/// Maven and Gradle projects of the window's project, from the build files plus the last sync. +pub async fn build_model(host: &Host, window: &str) -> IdeResult { + let project = host.project(window)?; + let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); + tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err) +} + +/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the +/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`. +/// Returns the refreshed model; a failed build is reported in its `syncError`. +pub async fn build_sync( + host: &Host, + window: &str, + tool: BuildTool, + request: RequestId, + offline: bool, + profiles: Vec, + on_progress: Channel, +) -> IdeResult { + let project = host.project(window)?; + let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); + let token = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + let current = load(&root, &cache, &settings); + let env = match tool { + BuildTool::Maven => current + .maven + .as_ref() + .and_then(|m| m.projects.iter().find(|p| !p.env.is_empty())) + .map(|p| p.env.clone()) + .unwrap_or_default(), + BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(), + }; + let options = SyncOptions { + offline, + profiles, + env, + maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()), + maven_options: ide_sdk::apply::maven_options(&root, &settings), + }; + let progress = |p: SyncProgress| { + let _ = on_progress.send(p); + }; + match tool { + BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?, + BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?, + } + Ok(load(&root, &cache, &settings)) + }) + .await + .map_err(join_err); + host.end_request(request); + result? +} + +/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the +/// module's own, else its nearest parent in the project; its path, text and declaration. +fn declaring_pom( + project: &Project, + pom: &str, + dependency: &ArtifactKey, +) -> IdeResult<(std::path::PathBuf, String, Range)> { + let model = ide_build::maven::load(&project.root); + for rel in ide_build::pom_edit::declaring_poms(&model, pom) { + let path = project.root.join(&rel); + let text = project.text_of(&path)?; + if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) { + return Ok((path, text, range)); + } + } + Err(IdeError::not_found(format!( + "{dependency} is not declared in a pom.xml of this project (it may come from an imported BOM or an external parent POM)" + ))) +} + +/// Where the direct dependency `dependency` of the Maven module `pom` is declared. +pub async fn maven_dependency_declaration( + host: &Host, + window: &str, + pom: String, + dependency: ArtifactKey, +) -> IdeResult { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let (path, _, range) = declaring_pom(&project, &pom, &dependency)?; + Ok(Location::file(path, range)) + }) + .await + .map_err(join_err)? +} + +/// Adds an `` of `exclusion` to the declaration of the direct dependency +/// `dependency` of the Maven module `pom`, through the open document when there is one +/// (saved). Returns the declaration; nothing changes when it already excludes it. +pub async fn maven_exclude_dependency( + host: &Host, + window: &str, + pom: String, + dependency: ArtifactKey, + exclusion: ArtifactKey, +) -> IdeResult { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let (path, text, range) = declaring_pom(&project, &pom, &dependency)?; + match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) { + Some(Exclusion::Edit(edit)) => { + let edit = + WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] }; + apply_workspace_edit(&project.documents, &edit)?; + } + Some(Exclusion::Present) => {} + None => return Err(IdeError::not_found(format!("{dependency} is not declared in {}", path.display()))), + } + Ok(Location::file(path, range)) + }) + .await + .map_err(join_err)? +} + +/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the +/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable +/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build) +/// are cleared first; the new messages are published as `build` diagnostics. +pub async fn build_run( + host: &Host, + window: &str, + kind: BuildKind, + file: Option, + request: RequestId, + clear: Vec, + on_event: Channel, +) -> IdeResult { + let project = host.project(window)?; + let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone()); + let settings = project.settings.effective(); + let token = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + for path in clear { + sink.emit(IdeEvent::Diagnostics { + path: path.into(), + source: compile::DIAGNOSTICS_SOURCE.into(), + items: Vec::new(), + }); + } + let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?; + let result = compile::run(&root, &plan, &token, &|e| { + let _ = on_event.send(e); + })?; + for (path, items) in compile::diagnostics(&result.messages) { + sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items }); + } + Ok(result) + }) + .await + .map_err(join_err); + host.end_request(request); + result? +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("build_model", |c| async move { build_model(&c.host, &c.window).await }); + d.add("build_sync", |c| async move { + build_sync( + &c.host, + &c.window, + c.arg("tool")?, + c.arg("request")?, + c.arg("offline")?, + c.arg("profiles")?, + c.sender("onProgress")?, + ) + .await + }); + d.add("maven_dependency_declaration", |c| async move { + maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await + }); + d.add("maven_exclude_dependency", |c| async move { + maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await + }); + d.add("build_run", |c| async move { + build_run( + &c.host, + &c.window, + c.arg("kind")?, + c.arg("file")?, + c.arg("request")?, + c.arg("clear")?, + c.sender("onEvent")?, + ) + .await + }); +} diff --git a/crates/ide-host/src/ops/bytes.rs b/crates/ide-host/src/ops/bytes.rs new file mode 100644 index 000000000..8a2e6896e --- /dev/null +++ b/crates/ide-host/src/ops/bytes.rs @@ -0,0 +1,32 @@ +//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`). +use crate::Host; +use crate::rpc::Dispatcher; + +use crate::ops::doc::blocking; +use ide_core::IdeResult; +use ide_vfs::{ByteRange, FileProbe}; +use std::path::PathBuf; + +/// Size and text/binary classification, from the file's head only. +pub async fn fs_probe(_host: &Host, _window: &str, path: PathBuf) -> IdeResult { + blocking(move || ide_vfs::probe(&path)).await +} + +/// Up to `len` bytes from `offset` (capped per call), base64-encoded. +pub async fn fs_read_range(_host: &Host, _window: &str, path: PathBuf, offset: u64, len: u32) -> IdeResult { + blocking(move || ide_vfs::read_range(&path, offset, len)).await +} + +/// An image file as a `data:` URL. +pub async fn fs_read_image(_host: &Host, _window: &str, path: PathBuf) -> IdeResult { + blocking(move || ide_vfs::read_data_url(&path)).await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("fs_probe", |c| async move { fs_probe(&c.host, &c.window, c.arg("path")?).await }); + d.add("fs_read_range", |c| async move { + fs_read_range(&c.host, &c.window, c.arg("path")?, c.arg("offset")?, c.arg("len")?).await + }); + d.add("fs_read_image", |c| async move { fs_read_image(&c.host, &c.window, c.arg("path")?).await }); +} diff --git a/crates/ide-host/src/ops/coverage.rs b/crates/ide-host/src/ops/coverage.rs new file mode 100644 index 000000000..adaabadf4 --- /dev/null +++ b/crates/ide-host/src/ops/coverage.rs @@ -0,0 +1,45 @@ +//! Run with Coverage: launch plans and coverage reports. +use crate::Host; +use crate::rpc::Dispatcher; + +use ide_core::{IdeError, IdeResult, IpcPath}; +use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite}; +use ide_run::RunConfig; + +/// Output directories kept per kind (older runs' data is deleted). +pub const KEEP_RUNS: usize = 10; + +pub async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +/// How to run `config` with coverage: the command to launch and where its report will be, +/// or which tool is missing. +pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult { + let project = host.project(window)?; + let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage")); + blocking(move || { + let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?; + Ok(ide_coverage::prepare(&root, &config, &out)) + }) + .await +} + +/// Reads the report a coverage run left behind. +pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult { + blocking(move || ide_coverage::collect(&report)).await +} + +/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML, +/// with paths mapped into this project. +pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult { + let root = host.project(window)?.root.clone(); + blocking(move || ide_coverage::import(&path, &root)).await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("coverage_prepare", |c| async move { coverage_prepare(&c.host, &c.window, c.arg("config")?).await }); + d.add("coverage_collect", |c| async move { coverage_collect(&c.host, &c.window, c.arg("report")?).await }); + d.add("coverage_import", |c| async move { coverage_import(&c.host, &c.window, c.arg("path")?).await }); +} diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs new file mode 100644 index 000000000..159723ebe --- /dev/null +++ b/crates/ide-host/src/ops/debug.rs @@ -0,0 +1,357 @@ +use crate::Host; +use crate::rpc::Dispatcher; +use ide_core::{DebugSessionId, IdeResult}; +use ide_dap::*; +use std::path::PathBuf; + +/// Starts a session owned by this window: its events (and those of child sessions) go here only. +pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult { + let label = window; + let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label)); + let settings = match host.project(label) { + Ok(project) => project.settings.effective(), + Err(_) => host.global_settings.effective(), + }; + let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); + let custom = |command: &str| { + let path = ide_sdk::apply::tool_command(&root, &settings, command)?; + Some(path.to_string_lossy().into_owned()) + }; + host.debugger.start_with(config, owner, custom).await +} + +pub async fn debug_set_breakpoints( + host: &Host, + _window: &str, + path: PathBuf, + breakpoints: Vec, +) -> IdeResult> { + host.debugger.set_breakpoints(path, breakpoints).await +} + +pub async fn debug_threads(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult> { + host.debugger.threads(session).await +} + +pub async fn debug_stack_trace( + host: &Host, + _window: &str, + session: DebugSessionId, + thread_id: i64, +) -> IdeResult> { + host.debugger.stack_trace(session, thread_id).await +} + +pub async fn debug_scopes(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult> { + host.debugger.scopes(session, frame_id).await +} + +pub async fn debug_variables( + host: &Host, + _window: &str, + session: DebugSessionId, + reference: i64, +) -> IdeResult> { + host.debugger.variables(session, reference).await +} + +pub async fn debug_evaluate( + host: &Host, + _window: &str, + session: DebugSessionId, + frame_id: Option, + expression: String, +) -> IdeResult { + host.debugger.evaluate(session, frame_id, expression).await +} + +pub async fn debug_resume(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> { + host.debugger.resume(session, thread_id).await +} + +pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> { + host.debugger.pause(session, thread_id).await +} + +pub async fn debug_step( + host: &Host, + _window: &str, + session: DebugSessionId, + thread_id: i64, + kind: StepKind, +) -> IdeResult<()> { + host.debugger.step(session, thread_id, kind).await +} + +pub async fn debug_run_to( + host: &Host, + _window: &str, + session: DebugSessionId, + thread_id: i64, + path: PathBuf, + line: u32, + force: bool, +) -> IdeResult<()> { + host.debugger.run_to(session, thread_id, path, line, force).await +} + +pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> { + host.debugger.stop(session).await +} + +/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`. +pub async fn debug_evaluate_in( + host: &Host, + _window: &str, + session: DebugSessionId, + frame_id: Option, + expression: String, + context: Option, +) -> IdeResult { + host.debugger.evaluate_in(session, frame_id, expression, context).await +} + +/// Last known verification states of a file's breakpoints (refetch on `breakpointsChanged`). +pub fn debug_breakpoint_states(host: &Host, _window: &str, path: PathBuf) -> IdeResult> { + Ok(host.debugger.breakpoint_states(&path)) +} + +/// This window's live sessions, including adapter-started child sessions. +pub fn debug_sessions(host: &Host, window: &str) -> IdeResult> { + Ok(host.debugger.sessions(Some(host.owner_window(window)))) +} + +pub fn debug_exception_filters(host: &Host, window: &str) -> IdeResult> { + Ok(host.debugger.exception_filters(Some(host.owner_window(window)))) +} + +pub async fn debug_set_exception_breakpoints( + host: &Host, + _window: &str, + choices: std::collections::BTreeMap, +) -> IdeResult<()> { + host.debugger.set_exception_breakpoints(choices).await +} + +#[allow(clippy::too_many_arguments)] +pub async fn debug_set_variable( + host: &Host, + _window: &str, + session: DebugSessionId, + container: i64, + name: String, + evaluate_name: Option, + frame_id: Option, + value: String, +) -> IdeResult { + host.debugger.set_variable(session, container, name, evaluate_name, frame_id, value).await +} + +/// Force Step Over: step over with every breakpoint suspended until the step ends. +pub async fn debug_force_step_over( + host: &Host, + _window: &str, + session: DebugSessionId, + thread_id: i64, +) -> IdeResult<()> { + host.debugger.force_step_over(session, thread_id).await +} + +/// Smart Step Into: the calls on the current line of `frame_id`. +pub async fn debug_step_in_targets( + host: &Host, + _window: &str, + session: DebugSessionId, + frame_id: i64, +) -> IdeResult> { + host.debugger.step_in_targets(session, frame_id).await +} + +pub async fn debug_step_in_target( + host: &Host, + _window: &str, + session: DebugSessionId, + thread_id: i64, + target_id: i64, +) -> IdeResult<()> { + host.debugger.step_in_target(session, thread_id, target_id).await +} + +/// Reset Frame (Drop Frame). +pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<()> { + host.debugger.restart_frame(session, frame_id).await +} + +pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult { + host.debugger.capabilities(session) +} + +/// Method breakpoints (all files); states aligned with `breakpoints`. +pub async fn debug_set_function_breakpoints( + host: &Host, + _window: &str, + breakpoints: Vec, +) -> IdeResult> { + host.debugger.set_function_breakpoints(breakpoints).await +} + +/// Field watchpoints; states aligned with `breakpoints`. +pub async fn debug_set_data_breakpoints( + host: &Host, + _window: &str, + breakpoints: Vec, +) -> IdeResult> { + host.debugger.set_data_breakpoints(breakpoints).await +} + +pub async fn debug_data_breakpoint_info( + host: &Host, + _window: &str, + session: DebugSessionId, + container: i64, + name: String, + frame_id: Option, +) -> IdeResult { + host.debugger.data_breakpoint_info(session, container, name, frame_id).await +} + +/// Attach to Process: the user's processes a debugger can attach to. +pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResult> { + tokio::task::spawn_blocking(attach::attachable_processes) + .await + .map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))? +} + +/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote +/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to. +pub async fn debug_listen_for_jvm( + host: &Host, + _window: &str, + request: ide_core::RequestId, + jvm_host: String, + port: u16, +) -> IdeResult { + let token = host.begin_request(request); + let result = jdwp::listen_for_jvm(&jvm_host, port, token).await; + host.end_request(request); + result +} + +/// HotSwap (Reload Changed Classes) in a java-debug session. +pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult { + host.debugger.redefine_classes(session).await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await }); + d.add("debug_set_breakpoints", |c| async move { + debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await + }); + d.add("debug_threads", |c| async move { debug_threads(&c.host, &c.window, c.arg("session")?).await }); + d.add("debug_stack_trace", |c| async move { + debug_stack_trace(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await + }); + d.add( + "debug_scopes", + |c| async move { debug_scopes(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await }, + ); + d.add("debug_variables", |c| async move { + debug_variables(&c.host, &c.window, c.arg("session")?, c.arg("reference")?).await + }); + d.add("debug_evaluate", |c| async move { + debug_evaluate(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?, c.arg("expression")?).await + }); + d.add( + "debug_resume", + |c| async move { debug_resume(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await }, + ); + d.add( + "debug_pause", + |c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await }, + ); + d.add("debug_step", |c| async move { + debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await + }); + d.add("debug_run_to", |c| async move { + debug_run_to( + &c.host, + &c.window, + c.arg("session")?, + c.arg("threadId")?, + c.arg("path")?, + c.arg("line")?, + c.arg("force")?, + ) + .await + }); + d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await }); + d.add("debug_evaluate_in", |c| async move { + debug_evaluate_in( + &c.host, + &c.window, + c.arg("session")?, + c.arg("frameId")?, + c.arg("expression")?, + c.arg("context")?, + ) + .await + }); + d.add("debug_breakpoint_states", |c| async move { debug_breakpoint_states(&c.host, &c.window, c.arg("path")?) }); + d.add("debug_sessions", |c| async move { debug_sessions(&c.host, &c.window) }); + d.add("debug_exception_filters", |c| async move { debug_exception_filters(&c.host, &c.window) }); + d.add("debug_set_exception_breakpoints", |c| async move { + debug_set_exception_breakpoints(&c.host, &c.window, c.arg("choices")?).await + }); + d.add("debug_set_variable", |c| async move { + debug_set_variable( + &c.host, + &c.window, + c.arg("session")?, + c.arg("container")?, + c.arg("name")?, + c.arg("evaluateName")?, + c.arg("frameId")?, + c.arg("value")?, + ) + .await + }); + d.add("debug_force_step_over", |c| async move { + debug_force_step_over(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await + }); + d.add("debug_step_in_targets", |c| async move { + debug_step_in_targets(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await + }); + d.add("debug_step_in_target", |c| async move { + debug_step_in_target(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("targetId")?).await + }); + d.add("debug_restart_frame", |c| async move { + debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await + }); + d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) }); + d.add("debug_set_function_breakpoints", |c| async move { + debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await + }); + d.add("debug_set_data_breakpoints", |c| async move { + debug_set_data_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await + }); + d.add("debug_data_breakpoint_info", |c| async move { + debug_data_breakpoint_info( + &c.host, + &c.window, + c.arg("session")?, + c.arg("container")?, + c.arg("name")?, + c.arg("frameId")?, + ) + .await + }); + d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await }); + d.add("debug_listen_for_jvm", |c| async move { + debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await + }); + d.add( + "debug_redefine_classes", + |c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await }, + ); +} diff --git a/crates/ide-host/src/ops/doc.rs b/crates/ide-host/src/ops/doc.rs new file mode 100644 index 000000000..d47bfd607 --- /dev/null +++ b/crates/ide-host/src/ops/doc.rs @@ -0,0 +1,42 @@ +use crate::Host; +use ide_core::{ChangeSet, DocId, IdeResult}; +use ide_workspace::DocumentInfo; +use std::path::PathBuf; + +pub async fn open(host: &Host, window: &str, path: PathBuf) -> IdeResult { + let project = host.project(window)?; + host.blocking(move || { + let info = project.documents.open(&path)?; + project.index.mark_recent(&path); + Ok(info) + }) + .await +} + +pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> { + host.project(window)?.documents.close(doc); + Ok(()) +} + +/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale). +pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult { + host.project(window)?.documents.edit(doc, base_version, changes) +} + +pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> { + let project = host.project(window)?; + host.blocking(move || project.documents.save(doc)).await +} + +pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> { + let project = host.project(window)?; + host.blocking(move || project.documents.save_all()).await +} + +pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult { + let project = host.project(window)?; + host.blocking(move || project.documents.snapshot(doc)).await +} + +mod commands; +pub use commands::*; diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs new file mode 100644 index 000000000..b8156c56f --- /dev/null +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -0,0 +1,125 @@ +#[allow(unused_imports)] +use super::*; +use crate::Host; +use crate::rpc::Dispatcher; +use ide_core::{DocId, IdeError, IdeResult, OutlineItem, Range}; +use ide_language::{FoldRange, SyntaxHighlights}; +use ide_text::LineEnding; +use ide_workspace::{DocumentInfo, HistoryEntry}; +use std::path::PathBuf; + +/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool, +/// keeping it off the main thread and the async runtime's workers. +pub async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +/// Opens a read-only virtual document (see `lsp_virtual_document`) as an +/// editor buffer, so syntax features and language-server requests work in it. +pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult { + let project = host.project(window)?; + let vd = project.lsp.virtual_document(&uri).await?; + blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await +} + +/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label. +pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult { + host.project(window)?.documents.set_encoding(doc, &encoding, reload) +} + +pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> { + host.project(window)?.documents.set_line_ending(doc, line_ending) +} + +/// Structure view: LSP document symbols when available (with tree-sitter +/// visibility), tree-sitter otherwise. +/// Tree-sitter answers come from the per-document tree cache (incremental +/// re-parse); large documents get no syntax structure. +pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult> { + let project = host.project(window)?; + let lsp = project.lsp.document_symbols(doc).await.ok().flatten(); + blocking(move || { + let snap = match project.documents.text_snapshot(doc) { + Ok(snap) => snap, + Err(e) => return lsp.ok_or(e), + }; + let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text); + Ok(match lsp { + // Server symbols carry no visibility; borrow it from the syntax tree. + Some(mut items) => { + if !snap.large { + ide_language::merge_visibility(&mut items, &syntax()); + } + items + } + None if snap.large => Vec::new(), + None => syntax(), + }) + }) + .await +} + +pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult> { + let project = host.project(window)?; + blocking(move || { + let snap = project.documents.text_snapshot(doc)?; + if snap.large { + return Ok(Vec::new()); + } + Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text)) + }) + .await +} + +/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for; +/// empty when the grammar has no highlights query or the document is large. +pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + let snap = project.documents.text_snapshot(doc)?; + if snap.large { + return Ok(SyntaxHighlights::default()); + } + Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text)) + }) + .await +} + +/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first. +/// Large documents fall back to word / line / document ranges. +pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult> { + let project = host.project(window)?; + blocking(move || { + let snap = project.documents.text_snapshot(doc)?; + let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language }; + Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset)) + }) + .await +} + +pub fn history_list(host: &Host, window: &str, path: PathBuf) -> IdeResult> { + host.project(window)?.history.list(&path) +} + +pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult { + host.project(window)?.history.content(&id) +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await }); + d.add("doc_set_encoding", |c| async move { + doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?) + }); + d.add("doc_set_line_ending", |c| async move { + doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?) + }); + d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await }); + d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await }); + d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await }); + d.add("doc_selection_ranges", |c| async move { + doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await + }); + d.add("history_list", |c| async move { history_list(&c.host, &c.window, c.arg("path")?) }); + d.add("history_content", |c| async move { history_content(&c.host, &c.window, c.arg("id")?) }); +} diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs new file mode 100644 index 000000000..b9ef8af82 --- /dev/null +++ b/crates/ide-host/src/ops/fs.rs @@ -0,0 +1,85 @@ +use crate::Host; +use ide_core::IdeResult; +use ide_vfs::FileEntry; +use std::path::PathBuf; + +/// Files of a deleted folder snapshotted in Local History. +const DELETE_SNAPSHOT_LIMIT: usize = 2000; + +pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult> { + let excludes = host + .project(window) + .map(|p| { + let globs = p.exclude_globs(); + // `read_dir` matches relative to the enclosing repository: re-anchor + // project-root patterns (`/build`) when the project is a repo subfolder. + let repo = p.root.ancestors().find(|d| d.join(".git").exists()); + match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) { + Some(rel) => { + let rel = rel.to_string_lossy().replace('\\', "/"); + globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect() + } + None => globs, + } + }) + .unwrap_or_default(); + ide_vfs::read_dir(&path, &excludes) +} + +/// Creates an empty file; inside a project, honours the EditorConfig +/// `charset` (writes the BOM for `utf-8-bom` / UTF-16). +pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> { + match host.project(window) { + Ok(p) => { + let defaults = p.documents.file_defaults(); + if defaults.editorconfig { + ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?; + } else { + ide_vfs::create_file(&path)?; + } + // No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM". + let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false); + let bom = defaults.new_file_bytes(); + if empty && !bom.is_empty() { + std::fs::write(&path, bom)?; + } + Ok(()) + } + Err(_) => ide_vfs::create_file(&path), + } +} + +/// Toggle Read-only Attribute: sets the writable bit (and the open document's +/// state). Returns whether the file is read-only now. +pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult { + match host.project(window) { + Ok(p) => p.documents.set_writable(&path, writable), + Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable), + } +} + +/// Moves to the trash; Local History keeps the contents so deleted files can be restored. +pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> { + let history = host.project(window).ok().map(|p| p.history.clone()); + host.blocking(move || { + if let Some(h) = history { + h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT); + } + ide_vfs::delete(&path) + }) + .await +} + +pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> { + host.blocking(move || ide_vfs::copy(&from, &to)).await +} + +/// Reads a text file without opening it as a document; prefers the unsaved buffer. +pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult { + let project = host.project(window).ok(); + host.blocking(move || match project { + Some(p) => p.text_of(&path), + None => Ok(ide_vfs::load_text(&path)?.text), + }) + .await +} diff --git a/crates/ide-host/src/ops/history.rs b/crates/ide-host/src/ops/history.rs new file mode 100644 index 000000000..2037a6b05 --- /dev/null +++ b/crates/ide-host/src/ops/history.rs @@ -0,0 +1,55 @@ +//! Local History beyond single files: folders, labels, restoring deleted files. +use crate::Host; +use crate::rpc::Dispatcher; + +use ide_core::IdeResult; +use ide_workspace::DirectoryHistory; +use std::path::PathBuf; + +use super::doc::blocking; + +/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels. +pub async fn history_list_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.history.list_dir(&path)).await +} + +/// Local History › Put Label…: a named point in the history of every file. +pub fn history_put_label(host: &Host, window: &str, label: String) -> IdeResult<()> { + let project = host.project(window)?; + project.history.put_label(&project.root, &label) +} + +/// Revert to a snapshot: writes its content to `path` (re-creating a deleted +/// file and its folders); an open editor updates in place. +pub async fn history_restore(host: &Host, window: &str, path: PathBuf, id: String) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || { + let text = project.history.content(&id)?; + if let Some(doc) = project.documents.by_path(&path) { + project.documents.replace_all(doc, &text)?; + return project.documents.save(doc); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + if !path.exists() { + ide_vfs::create_file(&path)?; + } + let doc = project.documents.open(&path)?.id; + let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc)); + project.documents.close(doc); + result + }) + .await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("history_list_dir", |c| async move { history_list_dir(&c.host, &c.window, c.arg("path")?).await }); + d.add("history_put_label", |c| async move { history_put_label(&c.host, &c.window, c.arg("label")?) }); + d.add( + "history_restore", + |c| async move { history_restore(&c.host, &c.window, c.arg("path")?, c.arg("id")?).await }, + ); +} diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs new file mode 100644 index 000000000..89a4e99c4 --- /dev/null +++ b/crates/ide-host/src/ops/java.rs @@ -0,0 +1,95 @@ +//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls). +use crate::Host; +use crate::rpc::Dispatcher; + +use super::build::load; +use ide_build::compile; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_lsp::jdtls::JavaLaunchInfo; +use std::path::PathBuf; + +/// Builds the workspace (when `build`), then resolves the runtime (or test) +/// classpath and the JDK of the module at `module` (absolute, or relative to the project root). +/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first. +pub async fn java_resolve_launch( + host: &Host, + window: &str, + module: PathBuf, + test: bool, + build: bool, +) -> IdeResult { + let project = host.project(window)?; + let module = project.root.join(module); + let settings = project.settings.effective(); + let built = if build && compile::has_non_java_sources(&module) { + let (root, cache, module, settings) = + (project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone()); + tokio::task::spawn_blocking(move || { + compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new()) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))?? + } else { + Vec::new() + }; + let mut info = project.lsp.java_launch(&module, test, build).await?; + let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); + for p in std::mem::take(&mut info.class_paths) { + if !class_paths.contains(&p) { + class_paths.push(p); + } + } + info.class_paths = class_paths; + // The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version. + let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings); + let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file()); + let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| { + let release = ide_sdk::java::project_java_version(&module); + let jdks = ide_sdk::java::installed_jdks(); + info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release)); + ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) + }); + info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); + let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java")); + let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows)); + info.shorten_command_line = Some(shorten.as_str().to_owned()); + if info.error_count > 0 { + let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace()); + let managed = ide_tools::managed_bundle(&host.data_dir, "lombok"); + let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings); + let root = project.root.clone(); + // Detection may read the project's sources. + let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo)) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + info.hints.extend(lombok.problem()); + } + Ok(info) +} + +/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). +pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult { + let project = host.project(window)?; + let module = project.root.join(module); + project.lsp.java_debug_server(&module).await +} + +/// Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap). +pub async fn java_build_workspace( + host: &Host, + window: &str, + module: PathBuf, +) -> IdeResult { + let project = host.project(window)?; + let module = project.root.join(module); + project.lsp.java_build(&module).await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("java_resolve_launch", |c| async move { + java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await + }); + d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await }); + d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await }); +} diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs new file mode 100644 index 000000000..6be7e2028 --- /dev/null +++ b/crates/ide-host/src/ops/lsp.rs @@ -0,0 +1,357 @@ +use crate::Host; +use crate::apply_workspace_edit as apply_edit; +use crate::rpc::Dispatcher; +use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit}; +use ide_lsp::types::*; + +pub async fn lsp_completion( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + pos: Pos, + trigger: Option, +) -> IdeResult { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.completion(doc, pos, trigger, cancel).await; + host.end_request(request); + result +} + +/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing. +pub async fn lsp_inline_completion( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + pos: Pos, + automatic: bool, +) -> IdeResult> { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.inline_completion(doc, pos, automatic, cancel).await; + host.end_request(request); + result +} + +pub async fn lsp_resolve_completion( + host: &Host, + window: &str, + doc: DocId, + item: CompletionItem, +) -> IdeResult { + host.project(window)?.lsp.resolve_completion(doc, item).await +} + +pub async fn lsp_hover( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + pos: Pos, +) -> IdeResult> { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.hover(doc, pos, cancel).await; + host.end_request(request); + result +} + +pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { + host.project(window)?.lsp.signature_help(doc, pos).await +} + +pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { + host.project(window)?.lsp.goto(doc, pos, kind).await +} + +/// Implementations of the symbols at `positions`, one list per position (the +/// editor's "is implemented / overridden" gutter markers). Cancel with +/// `cancel_request(request)`. +pub async fn lsp_implementations( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + positions: Vec, +) -> IdeResult>> { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.implementations(doc, &positions, cancel).await; + host.end_request(request); + result +} + +/// Contents of a virtual document (library class, decompiled code) that a +/// language server returned a non-`file:` URI for. Cached per project. +pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult { + host.project(window)?.lsp.virtual_document(&uri).await +} + +/// References of the symbol at `pos`. `project_wide` also searches project files +/// outside the language server's analysis scope; bulk scans over many symbols +/// leave it off. Cancel with `cancel_request(request)`. +pub async fn lsp_references( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + pos: Pos, + include_declaration: bool, + project_wide: bool, +) -> IdeResult { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await; + host.end_request(request); + result +} + +pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { + host.project(window)?.lsp.document_highlights(doc, pos).await +} + +pub async fn lsp_workspace_symbols( + host: &Host, + window: &str, + request: RequestId, + query: String, +) -> IdeResult> { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.workspace_symbols(&query, cancel).await; + host.end_request(request); + result +} + +pub async fn lsp_code_actions( + host: &Host, + window: &str, + doc: DocId, + range: Range, + only: Vec, +) -> IdeResult> { + host.project(window)?.lsp.code_actions(doc, range, only).await +} + +/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). +pub async fn lsp_code_action_query( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + range: Range, + only: Vec, +) -> IdeResult { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.code_action_query(doc, range, only, cancel).await; + host.end_request(request); + result +} + +/// Resolves a code action's edit without applying it (intention preview). +pub async fn lsp_resolve_code_action( + host: &Host, + window: &str, + doc: DocId, + action: CodeAction, +) -> IdeResult { + if action.edit.is_some() { + return Ok(action); + } + host.project(window)?.lsp.resolve_code_action(doc, action).await +} + +/// Resolves (if needed), applies the edit, then runs the command of a code action. +pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> { + let project = host.project(window)?; + let action = if action.edit.is_none() && action.command.is_none() { + project.lsp.resolve_code_action(doc, action).await? + } else { + action + }; + if let Some(edit) = &action.edit { + apply_edit(&project.documents, edit)?; + } + if let Some(command) = action.command { + project.lsp.execute_command(doc, command).await?; + } + Ok(()) +} + +pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { + host.project(window)?.lsp.prepare_rename(doc, pos).await +} + +/// Computes the rename edit without applying it, so the UI can preview conflicts. +pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult { + host.project(window)?.lsp.rename(doc, pos, new_name).await +} + +pub async fn lsp_format( + host: &Host, + window: &str, + doc: DocId, + range: Option, + options: FormatOptions, +) -> IdeResult> { + host.project(window)?.lsp.format(doc, range, options).await +} + +pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult> { + host.project(window)?.lsp.inlay_hints(doc, range).await +} + +pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult> { + host.project(window)?.lsp.semantic_tokens(doc).await +} + +pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult> { + host.project(window)?.lsp.code_lens(doc).await +} + +/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers. +pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult> { + host.project(window)?.lsp.document_links(doc).await +} + +pub async fn lsp_call_hierarchy( + host: &Host, + window: &str, + doc: DocId, + pos: Pos, + direction: HierarchyDirection, +) -> IdeResult> { + host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await +} + +pub async fn lsp_type_hierarchy( + host: &Host, + window: &str, + doc: DocId, + pos: Pos, + direction: HierarchyDirection, +) -> IdeResult> { + host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await +} + +pub fn lsp_servers(host: &Host, window: &str) -> IdeResult> { + Ok(host.project(window)?.lsp.servers()) +} + +pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> { + host.project(window)?.lsp.restart(server).await +} + +/// Applies a (previewed) workspace edit, e.g. after rename confirmation. +pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> { + let project = host.project(window)?; + // Multi-file refactorings leave a Local History point to go back to. + let paths: Vec<&std::path::Path> = edit + .files + .iter() + .filter_map(|f| match f { + ide_core::FileEdit::Edit { path, .. } => Some(&**path), + _ => None, + }) + .collect(); + if edit.files.len() > 1 { + project.record_before_refactoring(&paths, "Apply changes"); + } + apply_edit(&project.documents, &edit) +} + +/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's +/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`. +pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> { + host.project(window)?.lsp.execute_command(doc, command).await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("lsp_completion", |c| async move { + lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await + }); + d.add("lsp_inline_completion", |c| async move { + lsp_inline_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("automatic")?) + .await + }); + d.add("lsp_resolve_completion", |c| async move { + lsp_resolve_completion(&c.host, &c.window, c.arg("doc")?, c.arg("item")?).await + }); + d.add("lsp_hover", |c| async move { + lsp_hover(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?).await + }); + d.add("lsp_signature_help", |c| async move { + lsp_signature_help(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await + }); + d.add( + "lsp_goto", + |c| async move { lsp_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await }, + ); + d.add("lsp_implementations", |c| async move { + lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await + }); + d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await }); + d.add("lsp_references", |c| async move { + lsp_references( + &c.host, + &c.window, + c.arg("request")?, + c.arg("doc")?, + c.arg("pos")?, + c.arg("includeDeclaration")?, + c.arg("projectWide")?, + ) + .await + }); + d.add("lsp_document_highlights", |c| async move { + lsp_document_highlights(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await + }); + d.add("lsp_workspace_symbols", |c| async move { + lsp_workspace_symbols(&c.host, &c.window, c.arg("request")?, c.arg("query")?).await + }); + d.add("lsp_code_actions", |c| async move { + lsp_code_actions(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("only")?).await + }); + d.add("lsp_code_action_query", |c| async move { + lsp_code_action_query(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("range")?, c.arg("only")?) + .await + }); + d.add("lsp_resolve_code_action", |c| async move { + lsp_resolve_code_action(&c.host, &c.window, c.arg("doc")?, c.arg("action")?).await + }); + d.add("lsp_apply_code_action", |c| async move { + lsp_apply_code_action(&c.host, &c.window, c.arg("doc")?, c.arg("action")?).await + }); + d.add("lsp_prepare_rename", |c| async move { + lsp_prepare_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await + }); + d.add("lsp_rename", |c| async move { + lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await + }); + d.add("lsp_format", |c| async move { + lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await + }); + d.add( + "lsp_inlay_hints", + |c| async move { lsp_inlay_hints(&c.host, &c.window, c.arg("doc")?, c.arg("range")?).await }, + ); + d.add("lsp_semantic_tokens", |c| async move { lsp_semantic_tokens(&c.host, &c.window, c.arg("doc")?).await }); + d.add("lsp_code_lens", |c| async move { lsp_code_lens(&c.host, &c.window, c.arg("doc")?).await }); + d.add("lsp_document_links", |c| async move { lsp_document_links(&c.host, &c.window, c.arg("doc")?).await }); + d.add("lsp_call_hierarchy", |c| async move { + lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await + }); + d.add("lsp_type_hierarchy", |c| async move { + lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await + }); + d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) }); + d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await }); + d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) }); + d.add("lsp_execute_command", |c| async move { + lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await + }); +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs new file mode 100644 index 000000000..4f5587540 --- /dev/null +++ b/crates/ide-host/src/ops/mod.rs @@ -0,0 +1,85 @@ +//! Command implementations shared by the desktop shell's IPC commands and the +//! remote server ([`crate::rpc`]): each takes the [`Host`], the calling window +//! and the command's arguments, and streams through [`Emit`] callbacks. + +pub mod agent; +pub mod analysis; +pub mod build; +pub mod bytes; +pub mod coverage; +pub mod debug; +pub mod doc; +pub mod fs; +pub mod history; +pub mod java; +pub mod lsp; +pub mod nav; +pub mod project; +pub mod refactor; +pub mod run; +pub mod sdk; +pub mod search; +pub mod spell; +pub mod syntax; +pub mod term; +pub mod tools; +pub mod vcs; + +use crate::Host; +use ide_core::{IdeError, IdeResult}; +use std::sync::Arc; + +/// A stream of values to the caller (a Tauri channel, or channel notifications). +pub type Emit = Arc; + +/// A streaming argument: what the desktop shell passes as a Tauri channel and +/// the server as `channel` notifications. +pub struct Channel(Emit); + +impl Channel { + pub fn new(send: impl Fn(T) + Send + Sync + 'static) -> Self { + Self(Arc::new(send)) + } + + /// Sends one value; never fails (a closed receiver drops it). + pub fn send(&self, value: T) -> IdeResult<()> { + (self.0)(value); + Ok(()) + } +} + +impl Clone for Channel { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +/// Registers the commands of every module with the remote server. +pub fn register_all(d: &mut crate::rpc::Dispatcher) { + agent::register(d); + lsp::register(d); + nav::register(d); + refactor::register(d); + analysis::register(d); + syntax::register(d); + spell::register(d); + history::register(d); + coverage::register(d); + build::register(d); + tools::register(d); + sdk::register(d); + java::register(d); + debug::register(d); + bytes::register(d); + vcs::register(d); + doc::register(d); + search::register(d); + run::register(d); +} + +impl Host { + /// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool. + pub async fn blocking(&self, f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? + } +} diff --git a/crates/ide-host/src/ops/nav.rs b/crates/ide-host/src/ops/nav.rs new file mode 100644 index 000000000..5a4c3b32b --- /dev/null +++ b/crates/ide-host/src/ops/nav.rs @@ -0,0 +1,176 @@ +use super::doc::blocking; +use crate::Host; +use crate::Project; +use crate::rpc::Dispatcher; +use ide_core::{DocId, IdeResult, IpcPath, Location, Pos}; +use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch}; +use ide_language::{FileReference, WebReference}; +use ide_workspace::file_refs::{self, PathCompletion}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::PathBuf; + +/// A web reference no language server resolves and where it points: `class="card"` → +/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` → +/// `--x:` declarations. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct WebGoto { + pub reference: WebReference, + pub targets: Vec, +} + +const MAX_WEB_TARGETS: usize = 200; + +/// The project root and its resource roots (served at `/`), for file references. +fn reference_roots(project: &Project) -> (PathBuf, Vec) { + let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect(); + (project.root.clone(), resources) +} + +pub fn index_status(host: &Host, window: &str) -> IdeResult { + Ok(host.project(window)?.index.status()) +} + +pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult> { + Ok(host.project(window)?.index.find_files(&query, limit as usize)) +} + +pub fn find_symbols( + host: &Host, + window: &str, + query: String, + filter: SymbolFilter, + limit: u32, +) -> IdeResult> { + Ok(host.project(window)?.index.find_symbols(&query, filter, limit as usize)) +} + +pub fn mark_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> { + host.project(window)?.index.mark_recent(&path); + Ok(()) +} + +pub fn remove_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> { + host.project(window)?.index.remove_recent(&path); + Ok(()) +} + +/// Recently opened files, most recent first (⌘E). +pub fn recent_files(host: &Host, window: &str) -> IdeResult> { + Ok(host.project(window)?.index.recent_files().into_iter().map(Into::into).collect()) +} + +/// The web reference at `pos` of `doc` and its targets; `None` when there is no reference. +pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { + let project = host.project(window)?; + let syntax = host.syntax(); + blocking(move || { + let snap = project.documents.snapshot(doc)?; + if snap.large { + return Ok(None); + } + let Some(reference) = syntax.web_reference_at(&snap.language, &snap.text, pos) else { return Ok(None) }; + let here = |range| Location { path: snap.path.clone(), range, uri: None }; + let targets = match reference.symbol() { + None => syntax.web_anchors(&snap.language, &snap.text, &reference.name).into_iter().map(here).collect(), + Some((name, kind)) => { + // The open document from its (possibly unsaved) text, other files from the index. + let mut targets: Vec = syntax + .web_definitions(&snap.language, &snap.text) + .into_iter() + .filter(|t| t.name == name && t.kind == kind) + .map(|t| here(t.range)) + .collect(); + targets.extend( + project + .index + .symbols_named(&name, Some(kind), MAX_WEB_TARGETS) + .into_iter() + .filter(|m| *m.path != *snap.path) + .map(|m| Location { path: m.path, range: m.range, uri: None }), + ); + targets + } + }; + Ok(Some(WebGoto { reference, targets })) + }) + .await +} + +/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal). +pub async fn fs_resolve_reference( + host: &Host, + window: &str, + from: IpcPath, + value: String, +) -> IdeResult> { + let project = host.project(window).ok(); + blocking(move || { + let (root, resources) = project.as_deref().map(reference_roots).unzip(); + let resources = resources.unwrap_or_default(); + Ok(file_refs::resolve(&value, &from, root.as_deref(), &resources).map(Into::into)) + }) + .await +} + +/// Files and folders that continue `prefix`, a path being typed in `from`. +pub async fn path_completions( + host: &Host, + window: &str, + from: IpcPath, + prefix: String, +) -> IdeResult> { + let project = host.project(window).ok(); + blocking(move || { + let (root, resources) = project.as_deref().map(reference_roots).unzip(); + let resources = resources.unwrap_or_default(); + Ok(file_refs::complete(&prefix, &from, root.as_deref(), &resources)) + }) + .await +} + +/// Paths in an HTML document or stylesheet that name no existing file. +pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) -> IdeResult> { + let project = host.project(window)?; + let syntax = host.syntax(); + blocking(move || { + let snap = project.documents.snapshot(doc)?; + if snap.large { + return Ok(Vec::new()); + } + let (root, resources) = reference_roots(&project); + Ok(syntax + .web_file_references(&snap.language, &snap.text) + .into_iter() + .filter(|r| { + file_refs::is_file_reference(&r.value) + && !file_refs::path_part(&r.value).is_empty() + && file_refs::resolve(&r.value, &snap.path, Some(&root), &resources).is_none() + }) + .collect()) + }) + .await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("index_status", |c| async move { index_status(&c.host, &c.window) }); + d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) }); + d.add("find_symbols", |c| async move { + find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?) + }); + d.add("mark_recent", |c| async move { mark_recent(&c.host, &c.window, c.arg("path")?) }); + d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) }); + d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) }); + d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await }); + d.add("fs_resolve_reference", |c| async move { + fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await + }); + d.add("path_completions", |c| async move { + path_completions(&c.host, &c.window, c.arg("from")?, c.arg("prefix")?).await + }); + d.add("web_unresolved_references", |c| async move { + web_unresolved_references(&c.host, &c.window, c.arg("doc")?).await + }); +} diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs new file mode 100644 index 000000000..2e624f25c --- /dev/null +++ b/crates/ide-host/src/ops/project.rs @@ -0,0 +1,135 @@ +use crate::{Host, Project}; +use ide_core::IdeResult; +use ide_core::settings::Settings; +use ide_workspace::settings::SettingsScope; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; +use std::sync::Arc; + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectInfo { + pub root: ide_core::IpcPath, + pub name: String, + pub is_git: bool, + pub settings: Settings, +} + +pub fn info(project: &Project) -> ProjectInfo { + let settings = project.settings.effective(); + let custom = settings.general.project_name.trim(); + ProjectInfo { + name: if custom.is_empty() { + project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() + } else { + custom.to_owned() + }, + is_git: !project.repos.read().is_empty(), + settings, + root: project.root.clone().into(), + } +} + +/// Opens `path` in `window` (off the async workers: it touches the disk). +pub async fn open(host: &Arc, window: &str, path: &Path) -> IdeResult> { + let (h, window, path) = (host.clone(), window.to_owned(), path.to_path_buf()); + host.blocking(move || h.open_project(&window, &path)).await +} + +pub fn current(host: &Host, window: &str) -> Option { + host.project(window).ok().map(|p| info(&p)) +} + +/// Effective settings for the window's project, or global settings without one. +pub fn settings(host: &Host, window: &str) -> Settings { + match host.project(window) { + Ok(p) => p.settings.effective(), + Err(_) => host.global_settings.effective(), + } +} + +pub fn roots(host: &Host, window: &str) -> IdeResult { + Ok(host.project(window)?.roots()) +} + +/// What [`set_settings`] changed; the caller tells the windows. +pub struct SettingsApplied { + pub settings: Settings, + /// A project value (else a global one). + pub project_scope: bool, + /// The project's root, when its content roots changed. + pub roots_changed: Option, +} + +/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's +/// project or globally, and applies it to the open projects: file defaults, +/// file types, language servers, index scope and SSH configurations. +pub fn set_settings( + host: &Host, + window: &str, + scope: SettingsScope, + key: &str, + value: &str, +) -> IdeResult { + let value: serde_json::Value = + serde_json::from_str(value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?; + let current = host.project(window).ok(); + let project_scope = matches!(scope, SettingsScope::Project) && current.is_some(); + let settings = match ¤t { + Some(p) if project_scope => p.settings.set(scope, key, value)?, + Some(p) => { + host.global_settings.set(SettingsScope::Global, key, value)?; + p.settings.reload() + } + None => host.global_settings.set(SettingsScope::Global, key, value)?, + }; + if let Some(p) = ¤t { + p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root))); + } + // A global value reaches every other open project too. + if !project_scope { + for project in host.projects() { + if current.as_ref().is_some_and(|c| c.root == project.root) { + continue; + } + let effective = project.settings.reload(); + project + .documents + .set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root))); + } + } + let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() }; + // File type associations re-detect open documents (off the command thread). + if key == "files" || key.starts_with("files.fileType") { + let affected = affected(); + host.runtime.spawn_blocking(move || { + for project in affected { + project.apply_file_types(&project.settings.effective()); + } + }); + } + if key == "ssh" || key.starts_with("ssh.") { + host.ssh.set_configs(host.global_settings.effective().ssh.configs); + } + // A new interpreter / toolchain / server executable restarts the language servers it affects. + if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) { + for project in affected() { + let lsp = project.lsp.clone(); + host.runtime.spawn(async move { lsp.reconfigure().await }); + } + } + let roots_changed = key == "projectStructure" || key.starts_with("projectStructure."); + // Content roots and excludes (files.exclude, ignored names, excluded folders) reach the + // index and the watchers without reopening the project (the rescan runs in the background). + if roots_changed || key == "files" || key.starts_with("files.") { + let affected = affected(); + host.runtime.spawn_blocking(move || { + for project in affected { + project.refresh_index_scope(); + } + }); + } + let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into()); + Ok(SettingsApplied { settings, project_scope, roots_changed }) +} diff --git a/crates/ide-host/src/ops/refactor.rs b/crates/ide-host/src/ops/refactor.rs new file mode 100644 index 000000000..806f0f3dc --- /dev/null +++ b/crates/ide-host/src/ops/refactor.rs @@ -0,0 +1,112 @@ +//! File move / rename refactoring: reference updates from the language +//! servers, the move itself (one undoable step) and its undo. +use crate::Host; +use crate::apply_workspace_edit; +use crate::rpc::Dispatcher; + +use ide_core::{IdeResult, RequestId, WorkspaceEdit}; +use ide_lsp::types::FileRenameEdits; +use ide_workspace::{FileMove, UndoOutcome}; +use std::path::PathBuf; + +use super::doc::blocking; + +fn pairs(moves: &[FileMove]) -> Vec<(PathBuf, PathBuf)> { + moves.iter().map(|m| (m.from.to_path_buf(), m.to.to_path_buf())).collect() +} + +/// `workspace/willRenameFiles` on every interested server (before the move). Cancellable. +pub async fn refactor_file_move_edits( + host: &Host, + window: &str, + request: RequestId, + moves: Vec, +) -> IdeResult { + let project = host.project(window)?; + let token = host.begin_request(request); + let result = project.lsp.will_rename_files(&pairs(&moves), token).await; + host.end_request(request); + result +} + +/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the +/// files and notifies the servers. Returns the id for [`refactor_undo_file_move`]. +pub async fn refactor_move_files( + host: &Host, + window: &str, + moves: Vec, + edit: WorkspaceEdit, + label: String, +) -> IdeResult { + let project = host.project(window)?; + let p = project.clone(); + let moved = moves.clone(); + let id = blocking(move || { + let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e); + p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply) + }) + .await?; + project.lsp.did_rename_files(&pairs(&moves)); + Ok(id) +} + +/// Undoes a file move: moves back and reverts the reference updates. +pub async fn refactor_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult { + let project = host.project(window)?; + let p = project.clone(); + let outcome = blocking(move || p.moves.undo(&p.documents, id)).await?; + project.lsp.did_rename_files(&pairs(&outcome.moves)); + Ok(outcome) +} + +/// Whether the file move `id` can still be undone. +pub fn refactor_can_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult { + Ok(host.project(window)?.moves.can_undo(id)) +} + +/// "Search in comments and strings": textual occurrences of the moved files' +/// names, qualified names and paths (before the move). Cancellable. +pub async fn refactor_file_move_text_edits( + host: &Host, + window: &str, + request: RequestId, + moves: Vec, +) -> IdeResult { + let project = host.project(window)?; + let token = host.begin_request(request); + let cancel = token.clone(); + let result = blocking(move || { + let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves); + let edit = ide_workspace::text_refs::find_text_occurrences( + &project.root, + &project.exclude_globs(), + &replacements, + &|| cancel.is_cancelled(), + ); + if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) } + }) + .await; + host.end_request(request); + result +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("refactor_file_move_edits", |c| async move { + refactor_file_move_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await + }); + d.add("refactor_move_files", |c| async move { + refactor_move_files(&c.host, &c.window, c.arg("moves")?, c.arg("edit")?, c.arg("label")?).await + }); + d.add( + "refactor_undo_file_move", + |c| async move { refactor_undo_file_move(&c.host, &c.window, c.arg("id")?).await }, + ); + d.add( + "refactor_can_undo_file_move", + |c| async move { refactor_can_undo_file_move(&c.host, &c.window, c.arg("id")?) }, + ); + d.add("refactor_file_move_text_edits", |c| async move { + refactor_file_move_text_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await + }); +} diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs new file mode 100644 index 000000000..36e60775d --- /dev/null +++ b/crates/ide-host/src/ops/run.rs @@ -0,0 +1,91 @@ +use super::Emit; +use crate::Host; +use ide_core::{IdeError, IdeResult, RunId}; +use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions}; +use std::time::Duration; + +/// Detected configs followed by user-defined ones (user ids win on conflict). +pub fn configs(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + let root = project.root.clone(); + let user = ide_run::load_user_configs(&root)?; + let mut all: Vec = + ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect(); + all.extend(user); + // Project interpreter / package manager / toolchains from Languages & Frameworks. + ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective()); + Ok(all) +} + +pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> { + ide_run::save_user_configs(&host.project(window)?.root, configs) +} + +pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit) -> IdeResult { + let project = host.project(window)?; + if config.command.is_empty() { + return Err(IdeError::invalid("run configuration has no command")); + } + // Settings › Run Targets: over SSH. + if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) { + let settings = project.settings.effective(); + let out = on_output.clone(); + return host + .remote + .run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c))) + .await; + } + let settings = project.settings.effective(); + // Configurations made in the UI name bare tools (`godot`) that may live outside PATH. + let mut config = config; + ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings); + if config.command == ide_sdk::godot::COMMAND { + return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); + } + let encoding = settings.console.encoding; + let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) }; + host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c))) +} + +/// Stops a run (interrupt, then kill after Settings › Run's termination timeout). +pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> { + let settings = match host.project(window) { + Ok(p) => p.settings.effective(), + Err(_) => host.global_settings.effective(), + }; + let grace = Duration::from_millis(u64::from(settings.run.termination_timeout_ms)); + if host.remote.runs.contains(id) { + return host.remote.runs.stop(id, grace); + } + host.processes.stop_with_grace(id, grace) +} + +pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> { + if host.remote.runs.contains(id) { + return host.remote.runs.write_stdin(id, data); + } + host.processes.write_stdin(id, data) +} + +pub fn resize(host: &Host, id: RunId, cols: u16, rows: u16) -> IdeResult<()> { + if host.remote.runs.contains(id) { + return host.remote.runs.resize(id, cols, rows); + } + host.processes.resize(id, cols, rows) +} + +pub fn close_stdin(host: &Host, id: RunId) -> IdeResult<()> { + if host.remote.runs.contains(id) { + return host.remote.runs.close_stdin(id); + } + host.processes.close_stdin(id) +} + +pub fn list(host: &Host) -> Vec { + let mut runs = host.processes.list(); + runs.extend(host.remote.runs.list()); + runs +} + +mod commands; +pub use commands::*; diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs new file mode 100644 index 000000000..ec0075e28 --- /dev/null +++ b/crates/ide-host/src/ops/run/commands.rs @@ -0,0 +1,48 @@ +#[allow(unused_imports)] +use super::*; +use crate::Host; +use crate::rpc::Dispatcher; +use ide_core::{IdeError, IdeResult}; +use ide_run::RunConfig; + +/// Configuration templates: per-type defaults applied to new configurations. +pub fn run_templates(host: &Host, window: &str) -> IdeResult> { + let root = host.project(window)?.root.clone(); + ide_run::load_templates(&root) +} + +pub fn run_save_templates(host: &Host, window: &str, templates: Vec) -> IdeResult<()> { + let root = host.project(window)?.root.clone(); + ide_run::save_templates(&root, &templates) +} + +/// The environment runs inherit when "Include system environment variables" is on. +pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap { + ide_run::system_env() +} + +/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at +/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms). +pub async fn run_test_reports( + host: &Host, + window: &str, + module: Option, + since_ms: f64, +) -> IdeResult> { + let root = host.project(window)?.root.clone(); + let dir = module.map(|m| root.join(m)).unwrap_or(root); + let since = std::time::UNIX_EPOCH + std::time::Duration::from_millis(since_ms.max(0.0) as u64); + tokio::task::spawn_blocking(move || ide_run::junit::read_reports(&ide_run::junit::report_dirs(&dir), since)) + .await + .map_err(|e| IdeError::internal(e.to_string())) +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) }); + d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) }); + d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) }); + d.add("run_test_reports", |c| async move { + run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await + }); +} diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs new file mode 100644 index 000000000..0efe04b02 --- /dev/null +++ b/crates/ide-host/src/ops/sdk.rs @@ -0,0 +1,170 @@ +//! Interpreters / toolchains (`ide-sdk`): discovery, Python virtualenvs and packages. +use crate::Host; +use crate::ops::Channel; +use crate::rpc::Dispatcher; + +use ide_core::{IdeError, IdeResult, RequestId}; +use ide_sdk::python::PythonPackage; +use ide_sdk::{Sdk, SdkKind}; +use std::path::PathBuf; + +fn join_err(e: impl std::fmt::Display) -> IdeError { + IdeError::internal(e.to_string()) +} + +/// Executables found for "run this file", and where single files are compiled to. +#[derive(Clone, Debug, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct LocatedTools { + pub tools: Vec, + pub compile_dir: ide_core::IpcPath, +} + +/// Installed interpreters / toolchains of `kind`; project-local ones first. +pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult> { + let root = host.project(window).ok().map(|p| p.root.clone()); + tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err) +} + +/// The interpreter the project uses: the configured one, else its virtualenv. +pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into)) +} + +/// SDK / library source folders shown under "External Libraries" in the Project view, +/// with the Maven / Gradle dependency jars of the build model. +pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); + tokio::task::spawn_blocking(move || { + let model = crate::ops::build::load(&root, &cache, &settings); + let deps: Vec = ide_build::dependency_coordinates(&root, &model) + .into_iter() + .map(|c| ide_sdk::libraries::BuildDependency { + gradle: c.gradle, + group: c.group_id, + artifact: c.artifact_id, + version: c.version, + }) + .collect(); + ide_sdk::libraries::project_libraries(&root, &settings, &deps) + }) + .await + .map_err(join_err) +} + +/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path. +pub async fn sdk_locate_tools( + _host: &Host, + _window: &str, + names: Vec, + near: Option, +) -> IdeResult { + tokio::task::spawn_blocking(move || LocatedTools { + tools: ide_sdk::locate::locate_tools(&names, near.as_deref()), + compile_dir: ide_sdk::locate::compile_dir().into(), + }) + .await + .map_err(join_err) +} + +/// `base -m venv location`; output lines stream through `on_output`. +pub async fn sdk_python_create_venv( + host: &Host, + _window: &str, + base: PathBuf, + location: PathBuf, + request: RequestId, + on_output: Channel, +) -> IdeResult { + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + ide_sdk::python::create_venv(&base, &location, &cancel, &|line| { + let _ = on_output.send(line); + }) + }) + .await + .map_err(join_err); + host.end_request(request); + result? +} + +pub async fn sdk_python_packages( + host: &Host, + _window: &str, + python: PathBuf, + request: RequestId, +) -> IdeResult> { + let cancel = host.begin_request(request); + let result = + tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err); + host.end_request(request); + result? +} + +/// Installs (`uninstall = false`) or removes a package; output streams through `on_output`. +#[allow(clippy::too_many_arguments)] +pub async fn sdk_python_pip( + host: &Host, + _window: &str, + python: PathBuf, + requirement: String, + uninstall: bool, + upgrade: bool, + request: RequestId, + on_output: Channel, +) -> IdeResult<()> { + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + let send = |line: String| { + let _ = on_output.send(line); + }; + if uninstall { + ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send) + } else { + ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send) + } + }) + .await + .map_err(join_err); + host.end_request(request); + result? +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await }); + d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) }); + d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await }); + d.add("sdk_locate_tools", |c| async move { + sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await + }); + d.add("sdk_python_create_venv", |c| async move { + sdk_python_create_venv( + &c.host, + &c.window, + c.arg("base")?, + c.arg("location")?, + c.arg("request")?, + c.sender("onOutput")?, + ) + .await + }); + d.add("sdk_python_packages", |c| async move { + sdk_python_packages(&c.host, &c.window, c.arg("python")?, c.arg("request")?).await + }); + d.add("sdk_python_pip", |c| async move { + sdk_python_pip( + &c.host, + &c.window, + c.arg("python")?, + c.arg("requirement")?, + c.arg("uninstall")?, + c.arg("upgrade")?, + c.arg("request")?, + c.sender("onOutput")?, + ) + .await + }); +} diff --git a/crates/ide-host/src/ops/search.rs b/crates/ide-host/src/ops/search.rs new file mode 100644 index 000000000..094f8279a --- /dev/null +++ b/crates/ide-host/src/ops/search.rs @@ -0,0 +1,97 @@ +use super::Emit; +use crate::Host; +use ide_core::{IdeResult, RequestId}; +use ide_search::{FileMatches, ProjectFileSet, SearchQuery, SearchSummary}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum SearchEvent { + File { file: FileMatches }, + Done { summary: SearchSummary }, +} + +pub fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange { + let kind = match l.kind { + ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment, + ide_language::LiteralKind::String => ide_search::LiteralKind::String, + }; + ide_search::LiteralRange { start: l.start, end: l.end, kind } +} + +/// Find in Files. Results stream through `on_event`; ends with `Done`. +/// Cancel with `cancel_request(request)`. +pub async fn start( + host: &Host, + window: &str, + request: RequestId, + mut query: SearchQuery, + on_event: Emit, +) -> IdeResult<()> { + let project = host.project(window)?; + // Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and + // ignored names are never searched. + query.exclude.extend(project.search_excludes()); + let roots = project.roots(); + let set = match query.scope { + ide_search::SearchScope::FileSet { set } => Some(set), + _ => None, + }; + let cancel = host.begin_request(request); + let documents = project.documents.clone(); + let root = project.root.clone(); + // The whole project also covers the Project Structure content roots. + let content_roots = match query.scope { + ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => { + project.content_roots() + } + _ => Vec::new(), + }; + let languages = project.languages.clone(); + let syntax = host.syntax(); + let events = on_event.clone(); + let summary = host + .blocking(move || { + let filter = |rel: &str| match set { + Some(ProjectFileSet::Production) => !roots.is_test(rel), + Some(ProjectFileSet::Test) => roots.is_test(rel), + Some(ProjectFileSet::All) | None => true, + }; + // Comments and string literals for the search context filter. + let literals = |path: &Path, text: &str| { + let language = languages.detect(path, text.lines().next()); + let found = syntax.literals(&language, text)?; + Some(found.into_iter().map(literal_range).collect()) + }; + let mut total = SearchSummary::default(); + for dir in std::iter::once(&root).chain(&content_roots) { + let s = ide_search::search_in_context( + dir, + &query, + Some(&filter), + Some(&literals), + &|p| documents.text_of(p), + &cancel, + &|file| events(SearchEvent::File { file }), + )?; + total.files += s.files; + total.matches += s.matches; + total.elapsed_ms += s.elapsed_ms; + total.limit_hit |= s.limit_hit; + total.cancelled |= s.cancelled; + if total.limit_hit || total.cancelled { + break; + } + } + Ok(total) + }) + .await; + host.end_request(request); + on_event(SearchEvent::Done { summary: summary? }); + Ok(()) +} + +mod commands; +pub use commands::*; diff --git a/crates/ide-host/src/ops/search/commands.rs b/crates/ide-host/src/ops/search/commands.rs new file mode 100644 index 000000000..a5e22049c --- /dev/null +++ b/crates/ide-host/src/ops/search/commands.rs @@ -0,0 +1,133 @@ +#[allow(unused_imports)] +use super::*; +use crate::Host; +use crate::ops::Channel; +use crate::rpc::Dispatcher; +use ide_core::{IdeResult, Pos, RequestId, TextEdit}; +use ide_search::{ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceTarget, SearchQuery}; +use ide_workspace::Documents; +use std::path::{Path, PathBuf}; + +struct DocumentsHost<'a>(&'a Documents); + +impl ReplaceHost for DocumentsHost<'_> { + fn edit_file(&self, path: &Path, compute: &mut dyn FnMut(&str) -> IdeResult>) -> IdeResult { + let docs = self.0; + let (doc, transient) = match docs.by_path(path) { + Some(doc) => (doc, false), + None => (docs.open(path)?.id, true), + }; + let result = (|| { + // Recompute if the user typed between reading the text and applying. + for _ in 0..3 { + let snap = docs.text_snapshot(doc)?; + let edits = compute(&snap.text)?; + if edits.is_empty() { + return Ok(0); + } + if docs.text_snapshot(doc)?.version != snap.version { + continue; + } + docs.apply_edits(doc, &edits)?; + if transient { + docs.save(doc)?; + } + return Ok(edits.len() as u32); + } + Err(ide_core::IdeError::unavailable("the file kept changing during Replace")) + })(); + if transient { + docs.close(doc); + } + result + } +} + +/// Replace in Files. Runs off the command thread; progress streams through +/// `on_event` and ends with `Done`. Cancel with `cancel_request(request)`. +/// +/// Edits go through the document layer as one patch per file, so open editors +/// update live and undo the whole file's replacements in one step. Unsaved +/// editor buffers are replaced in place and left unsaved; files that were not +/// open are opened transiently, saved and closed. +#[allow(clippy::too_many_arguments)] +pub async fn replace_in_files( + host: &Host, + window: &str, + request: RequestId, + query: SearchQuery, + replacement: String, + preserve_case: bool, + targets: Vec, + on_event: Channel, +) -> IdeResult<()> { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let events = on_event.clone(); + let summary = tokio::task::spawn_blocking(move || { + let host = DocumentsHost(&project.documents); + ide_search::replace_files(&host, &query, &replacement, preserve_case, &targets, &cancel, &|e| { + let _ = events.send(e); + }) + }) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string())); + host.end_request(request); + let summary = summary??; + let _ = on_event.send(ReplaceEvent::Done { summary }); + Ok(()) +} + +/// Before / after text of `path` for Replace › Preview (unsaved buffer included). +pub async fn replace_preview( + host: &Host, + window: &str, + query: SearchQuery, + replacement: String, + preserve_case: bool, + path: PathBuf, + only: Option>, +) -> IdeResult { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + let text = project.text_of(&path)?; + ide_search::preview(&text, &query, &replacement, preserve_case, only.as_deref()) + }) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))? +} + +/// Validates a query (e.g. regex syntax) while the user types. +pub fn search_validate(_host: &Host, _window: &str, query: SearchQuery) -> IdeResult<()> { + ide_search::validate_query(&query) +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("replace_in_files", |c| async move { + replace_in_files( + &c.host, + &c.window, + c.arg("request")?, + c.arg("query")?, + c.arg("replacement")?, + c.arg("preserveCase")?, + c.arg("targets")?, + c.sender("onEvent")?, + ) + .await + }); + d.add("replace_preview", |c| async move { + replace_preview( + &c.host, + &c.window, + c.arg("query")?, + c.arg("replacement")?, + c.arg("preserveCase")?, + c.arg("path")?, + c.arg("only")?, + ) + .await + }); + d.add("search_validate", |c| async move { search_validate(&c.host, &c.window, c.arg("query")?) }); +} diff --git a/crates/ide-host/src/ops/spell.rs b/crates/ide-host/src/ops/spell.rs new file mode 100644 index 000000000..dd741da92 --- /dev/null +++ b/crates/ide-host/src/ops/spell.rs @@ -0,0 +1,98 @@ +//! Proofreading › Typo: spell checking of open documents. +use crate::Host; +use crate::Project; +use crate::rpc::Dispatcher; + +use super::doc::blocking; +use ide_core::settings::SpellingSettings; +use ide_core::{DocId, IdeError, IdeResult, RequestId}; +use ide_language::SpellingKind; +use ide_spell::{Region, RegionKind, SpellScope, Speller, Typo}; +use std::path::{Path, PathBuf}; + +/// Languages checked as prose (all text but code spans). +const PROSE: &[&str] = &["plaintext", "markdown", "restructuredtext", "asciidoc", "latex"]; + +/// Custom dictionary paths: `~/…` from home, relative ones from the project root. +fn dictionary_paths(settings: &SpellingSettings, root: &Path) -> Vec { + settings + .custom_dictionaries + .iter() + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .map(|p| match p.strip_prefix("~/") { + Some(rest) => dirs::home_dir().unwrap_or_default().join(rest), + None => root.join(p), + }) + .collect() +} + +fn speller(project: &Project) -> Speller { + let s = project.settings.effective().spelling; + let words = s.ide_words.iter().chain(&s.project_words); + Speller::new(words, &dictionary_paths(&s, &project.root)) +} + +/// Misspelt words in the comments, string literals and declared names of +/// `doc` that `scope` includes (all of its text for prose). Cancel with +/// `cancel_request(request)`. +pub async fn spell_check( + host: &Host, + window: &str, + doc: DocId, + scope: SpellScope, + request: RequestId, +) -> IdeResult> { + let project = host.project(window)?; + let syntax = host.syntax(); + let cancel = host.begin_request(request); + let result = blocking(move || { + let snap = project.documents.text_snapshot(doc)?; + if snap.large { + return Ok(Vec::new()); + } + let regions = if PROSE.contains(&snap.language.as_str()) { + ide_spell::prose_regions(&snap.text) + } else { + let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) }; + found + .into_iter() + .map(|r| Region { + start: r.start, + end: r.end, + kind: match r.kind { + SpellingKind::Comment => RegionKind::Comment, + SpellingKind::String => RegionKind::String, + SpellingKind::Name => RegionKind::Name, + }, + }) + .filter(|r| scope.includes(r.kind)) + .collect() + }; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled) + }) + .await; + host.end_request(request); + result +} + +/// Replacements for a misspelt `word`, best first (Typo › Change to…). +pub async fn spell_suggest(host: &Host, window: &str, word: String) -> IdeResult> { + let project = host.project(window).ok(); + blocking(move || { + let speller = project.as_deref().map(speller).unwrap_or_default(); + Ok(speller.suggest(&word, 8)) + }) + .await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("spell_check", |c| async move { + spell_check(&c.host, &c.window, c.arg("doc")?, c.arg("scope")?, c.arg("request")?).await + }); + d.add("spell_suggest", |c| async move { spell_suggest(&c.host, &c.window, c.arg("word")?).await }); +} diff --git a/crates/ide-host/src/ops/syntax.rs b/crates/ide-host/src/ops/syntax.rs new file mode 100644 index 000000000..027444b97 --- /dev/null +++ b/crates/ide-host/src/ops/syntax.rs @@ -0,0 +1,175 @@ +//! Tree-sitter structure queries (`ide-syntax`): the fallback editor features +//! use when the language server is unavailable, not ready or failing. +//! +//! All positions are 0-based lines / UTF-16 columns. Document queries run on +//! the blocking pool against the document's cached, incrementally re-parsed +//! tree; an optional `request` id makes them cancellable via `cancel_request`. +//! Languages without a grammar (and documents too large to parse) reject with +//! `unavailable`. +use crate::Host; +use crate::Project; +use crate::rpc::Dispatcher; + +use super::doc::blocking; +use ide_core::{CancellationToken, DocId, IdeError, IdeResult, LanguageId, Pos, Range, RequestId}; +use ide_syntax::{ + ExpressionAt, FileIdentifiers, IdentifierOccurrence, Locals, NodeAt, Snapshot, StructuralRanges, StructureResult, + StructureService, StructureSupport, SyntaxErrors, +}; +use std::path::PathBuf; +use std::sync::Arc; + +/// Runs `f` on the current snapshot of `doc` off the command thread. +async fn on_doc( + host: &Host, + window: &str, + doc: DocId, + request: Option, + f: impl FnOnce(&StructureService, &Snapshot, &CancellationToken) -> StructureResult + Send + 'static, +) -> IdeResult { + let project: Arc = host.project(window)?; + let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id)); + let result = blocking(move || { + let snap = project.documents.text_snapshot(doc)?; + if snap.large { + return Err(IdeError::unavailable("file is too large for syntax analysis")); + } + let s = Snapshot::doc(doc, snap.version, &snap.language, &snap.text); + Ok(f(&project.structure, &s, &cancel)?) + }) + .await; + if let Some(id) = request { + host.end_request(id); + } + result +} + +/// What the structure service supports for a language (`tree`: every query, +/// `locals`: scope analysis). +pub fn syntax_support(host: &Host, window: &str, language: LanguageId) -> IdeResult { + Ok(host.project(window)?.structure.support(&language)) +} + +/// The smallest named node covering `range`, with its ancestors (parent first). +pub async fn syntax_node_at( + host: &Host, + window: &str, + doc: DocId, + range: Range, + request: Option, +) -> IdeResult> { + on_doc(host, window, doc, request, move |s, snap, c| s.node_at(snap, range, c)).await +} + +/// The expression a selection denotes (exact or minimally enclosing), with its +/// statement, block and caret candidates. +pub async fn syntax_expression_at( + host: &Host, + window: &str, + doc: DocId, + range: Range, + request: Option, +) -> IdeResult { + on_doc(host, window, doc, request, move |s, snap, c| s.expression_at(snap, range, c)).await +} + +/// `ERROR` / `MISSING` nodes (e.g. a missing `;`) in document order. +pub async fn syntax_errors( + host: &Host, + window: &str, + doc: DocId, + request: Option, +) -> IdeResult { + on_doc(host, window, doc, request, |s, snap, c| s.syntax_errors(snap, c)).await +} + +/// Scope analysis of the identifier at `pos`: its local definition and +/// references, or why it is not a local. +pub async fn syntax_locals( + host: &Host, + window: &str, + doc: DocId, + pos: Pos, + request: Option, +) -> IdeResult { + on_doc(host, window, doc, request, move |s, snap, c| s.locals(snap, pos, c)).await +} + +/// Identifier tokens spelled `name` in a document, excluding comments and strings. +pub async fn syntax_identifiers( + host: &Host, + window: &str, + doc: DocId, + name: String, + request: Option, +) -> IdeResult> { + on_doc(host, window, doc, request, move |s, snap, c| s.identifiers_named(snap, &name, c)).await +} + +/// [`syntax_identifiers`] over files (open buffers win over disk); files +/// without occurrences or without a grammar are left out. Cancellable. +pub async fn syntax_identifiers_in_files( + host: &Host, + window: &str, + paths: Vec, + name: String, + request: RequestId, +) -> IdeResult> { + let project = host.project(window)?; + let languages = project.languages.clone(); + let cancel = host.begin_request(request); + let result = blocking(move || { + let load = |path: &std::path::Path| { + if let Some(doc) = project.documents.by_path(path) { + let snap = project.documents.text_snapshot(doc).ok()?; + return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text)); + } + let text = ide_vfs::load_text(path).ok()?.text; + let first_line = text.lines().next(); + Some((languages.detect(path, first_line), None, text)) + }; + Ok(project.structure.identifiers_in_files(&paths, &name, load, &cancel)?) + }) + .await; + host.end_request(request); + result +} + +/// Extend Selection chain plus statement / declaration ranges at `pos` +/// (Move Statement, Complete Statement for languages without a Lezer grammar). +pub async fn syntax_structural_ranges( + host: &Host, + window: &str, + doc: DocId, + pos: Pos, + request: Option, +) -> IdeResult { + on_doc(host, window, doc, request, move |s, snap, c| s.structural_ranges(snap, pos, c)).await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("syntax_support", |c| async move { syntax_support(&c.host, &c.window, c.arg("language")?) }); + d.add("syntax_node_at", |c| async move { + syntax_node_at(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("request")?).await + }); + d.add("syntax_expression_at", |c| async move { + syntax_expression_at(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("request")?).await + }); + d.add( + "syntax_errors", + |c| async move { syntax_errors(&c.host, &c.window, c.arg("doc")?, c.arg("request")?).await }, + ); + d.add("syntax_locals", |c| async move { + syntax_locals(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("request")?).await + }); + d.add("syntax_identifiers", |c| async move { + syntax_identifiers(&c.host, &c.window, c.arg("doc")?, c.arg("name")?, c.arg("request")?).await + }); + d.add("syntax_identifiers_in_files", |c| async move { + syntax_identifiers_in_files(&c.host, &c.window, c.arg("paths")?, c.arg("name")?, c.arg("request")?).await + }); + d.add("syntax_structural_ranges", |c| async move { + syntax_structural_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("request")?).await + }); +} diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs new file mode 100644 index 000000000..0fc9956a1 --- /dev/null +++ b/crates/ide-host/src/ops/term.rs @@ -0,0 +1,33 @@ +use super::Emit; +use crate::Host; +use ide_core::{IdeResult, TerminalId}; +use ide_terminal::{TerminalInfo, TerminalOptions}; + +pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit) -> IdeResult { + // Python › "Activate virtualenv": typed into the new shell, like PyCharm. + let activation = host + .project(window) + .ok() + .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())); + let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; + if let Some(line) = activation { + let _ = host.terminals.write(info.id, &format!(" {line}\r")); + } + Ok(info) +} + +pub fn write(host: &Host, id: TerminalId, data: &str) -> IdeResult<()> { + host.terminals.write(id, data) +} + +pub fn resize(host: &Host, id: TerminalId, cols: u16, rows: u16) -> IdeResult<()> { + host.terminals.resize(id, cols, rows) +} + +pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> { + host.terminals.kill(id) +} + +pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult { + host.terminals.is_busy(id) +} diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs new file mode 100644 index 000000000..db0ab6651 --- /dev/null +++ b/crates/ide-host/src/ops/tools.rs @@ -0,0 +1,170 @@ +//! Language server / debug adapter installer and JDK downloads (`ide-tools`). +use crate::Host; +use crate::ops::Channel; +use crate::rpc::Dispatcher; + +use ide_core::{IdeError, IdeResult, RequestId}; +use ide_sdk::Sdk; +use ide_tools::jdk::{JdkReleases, JdkVendor}; +use ide_tools::{InstallEvent, ToolInfo, Tools}; +use std::path::PathBuf; + +/// The installer, reporting the executables chosen in the window's settings. +fn tools(host: &Host, window: &str) -> IdeResult { + let data = host.data_dir.clone(); + let (settings, root) = match host.project(window) { + Ok(project) => (project.settings.effective(), project.root.clone()), + Err(_) => (host.global_settings.effective(), Default::default()), + }; + let custom = settings + .tools + .iter() + .filter_map(|(command, t)| Some((command.clone(), ide_sdk::apply::setting_path(&root, &t.path)?))) + .collect(); + // Godot is usually an app bundle / Flatpak outside the search path. + let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p)); + Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect())) +} + +fn join_err(e: impl std::fmt::Display) -> IdeError { + IdeError::internal(e.to_string()) +} + +fn reconfigure_servers(host: &Host) { + for project in host.projects() { + let lsp = project.lsp.clone(); + host.runtime.spawn(async move { lsp.reconfigure().await }); + } +} + +/// Status of every known language server / debug adapter and the languages using it. +pub async fn tools_list(host: &Host, window: &str) -> IdeResult> { + let tools = tools(host, window)?; + let langs = host.languages.all(); + tokio::task::spawn_blocking(move || tools.list(&langs)).await.map_err(join_err) +} + +/// Installs (or reinstalls) a tool into `/tools`, streaming output through +/// `on_event`. Cancel with `cancel_request(request)`. +pub async fn tools_install( + host: &Host, + window: &str, + name: String, + request: RequestId, + on_event: Channel, +) -> IdeResult { + let tools = tools(host, window)?; + let langs = host.languages.all(); + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + tools.install(&name, &langs, &cancel, &|e| { + let _ = on_event.send(e); + }) + }) + .await + .map_err(join_err); + host.end_request(request); + let info = result??; + // Language servers whose launch depends on installed tools (jdtls loads the + // java-debug bundle) pick the change up. + reconfigure_servers(host); + Ok(info) +} + +/// Removes a tool the IDE installed. +pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult { + let tools = tools(host, window)?; + let langs = host.languages.all(); + let info = tokio::task::spawn_blocking(move || tools.uninstall(&name, &langs)).await.map_err(join_err)??; + reconfigure_servers(host); + Ok(info) +} + +/// JDK distributions Download JDK offers. +pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec { + ide_tools::jdk::vendors() +} + +/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`. +pub async fn jdk_download_releases( + host: &Host, + window: &str, + vendor: String, + request: RequestId, +) -> IdeResult { + let tools = tools(host, window)?; + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); + host.end_request(request); + result? +} + +/// Where Download JDK puts JDKs unless told otherwise. +pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult { + Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into()) +} + +/// Downloads the latest `vendor` JDK of Java `version` into `location` (default: +/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable +/// with `cancel_request(request)`. The JDK is then detected like an installed one, +/// and language servers waiting for a JDK (jdtls) start on it. +#[allow(clippy::too_many_arguments)] +pub async fn jdk_download( + host: &Host, + window: &str, + vendor: String, + version: u32, + location: Option, + request: RequestId, + on_event: Channel, +) -> IdeResult { + let tools = tools(host, window)?; + let location = location.filter(|p| !p.as_os_str().is_empty()); + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + let jdk = tools.download_jdk(&vendor, version, location.as_deref(), &cancel, &|e| { + let _ = on_event.send(e); + })?; + let home = jdk.home.into_path_buf(); + // A chosen location is scanned from now on too (the default one is from startup). + if let Some(location) = &location { + ide_sdk::java::add_jdk_root(location); + } + ide_sdk::java::rescan_jdks(); + let jdk = ide_sdk::java::jdk_at(&home) + .ok_or_else(|| IdeError::internal(format!("{} is not a JDK home", home.display())))?; + IdeResult::Ok(ide_sdk::jdk_sdk(&jdk)) + }) + .await + .map_err(join_err); + host.end_request(request); + let sdk = result??; + reconfigure_servers(host); + Ok(sdk) +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("tools_list", |c| async move { tools_list(&c.host, &c.window).await }); + d.add("tools_install", |c| async move { + tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await + }); + d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await }); + d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) }); + d.add("jdk_download_releases", |c| async move { + jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await + }); + d.add("jdk_download_location", |c| async move { jdk_download_location(&c.host, &c.window) }); + d.add("jdk_download", |c| async move { + jdk_download( + &c.host, + &c.window, + c.arg("vendor")?, + c.arg("version")?, + c.arg("location")?, + c.arg("request")?, + c.sender("onEvent")?, + ) + .await + }); +} diff --git a/crates/ide-host/src/ops/vcs.rs b/crates/ide-host/src/ops/vcs.rs new file mode 100644 index 000000000..0fe319e30 --- /dev/null +++ b/crates/ide-host/src/ops/vcs.rs @@ -0,0 +1,24 @@ +use crate::{Host, apply_git_executable}; +use ide_core::IdeResult; +use ide_vcs::FileStatus; + +/// Changes of every repository of the project. +pub async fn status(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + host.blocking(move || { + let settings = project.settings.effective(); + apply_git_executable(&settings); + let repos = project.repos(); + if repos.is_empty() { + return Err(ide_core::IdeError::unavailable("project is not a git repository")); + } + for r in repos.all() { + r.set_ignored_names(settings.ignored_names()); + } + repos.status() + }) + .await +} + +mod commands; +pub use commands::*; diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs new file mode 100644 index 000000000..1b40ba30c --- /dev/null +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -0,0 +1,919 @@ +#[allow(unused_imports)] +use super::*; +use crate::Host; +use crate::Project; +use crate::ops::Channel; +use crate::rpc::Dispatcher; +use ide_core::{IdeResult, RequestId}; +use ide_vcs::*; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; + +use crate::apply_git_executable; + +/// A repository of the project (IDEA's VCS root). +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct VcsRoot { + /// Worktree root. + pub root: ide_core::IpcPath, + /// Merge / rebase / cherry-pick / revert in progress. + pub operation: Option, + /// Current branch; `None` when HEAD is detached (or unreadable). + pub head: Option, + /// HEAD commit; `None` before the first commit. + pub head_commit: Option, +} + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(tag = "op", rename_all = "camelCase")] +pub enum RemoteOp { + Fetch, + Pull { + rebase: bool, + }, + Push { + force_with_lease: bool, + set_upstream: bool, + /// Push dialog › Push tags. + #[serde(default)] + tags: Option, + }, + /// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`). + DeleteBranch { + branch: String, + }, + /// Pushes local `branch` (only up to `revision` when set) to its upstream, or to + /// remote branch `target` of the same remote. + PushBranch { + branch: String, + revision: Option, + force_with_lease: bool, + #[serde(default)] + target: Option, + }, + /// Fast-forwards local `branch` (not checked out) to its upstream. + UpdateBranch { + branch: String, + }, + /// Pull dialog: `branches` of `remote` (none = the configured merge branch). + PullFrom { + remote: String, + branches: Vec, + rebase: bool, + options: MergeOptions, + }, +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GitExecutableInfo { + /// `git version …` output. + pub version: String, + /// The executable that was run. + pub path: String, +} + +// ---- in-progress operations, history rewriting (Git Log actions) ---- + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(tag = "op", rename_all = "camelCase")] +pub enum GitOperation { + /// Continue the merge / rebase / cherry-pick / revert in progress. + Continue, + Abort, + Skip, + /// Commits oldest first. + CherryPick { + commits: Vec, + }, + /// Commits newest first. + Revert { + commits: Vec, + }, + Reset { + commit: String, + mode: ResetMode, + }, + /// Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first. + InteractiveRebase { + base: Option, + entries: Vec, + }, +} + +/// Runs `f` once per repository with the `paths` that belong to it. +fn per_repo( + project: &Project, + paths: &[PathBuf], + recorded: Option<&str>, + f: impl Fn(&Repo, &[PathBuf]) -> IdeResult<()>, +) -> IdeResult<()> { + let groups = project.repos().group(paths); + if groups.is_empty() { + return project.with_repo_recorded(None, recorded, |r| f(r, paths)); + } + for (repo, paths) in groups { + project.with_repo_recorded(Some(repo.root()), recorded, |r| f(r, &paths))?; + } + Ok(()) +} + +/// Shelf and patches work in the primary repository only: changes of a nested one +/// fail instead of being left out silently. +fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> { + let repos = project.repos(); + let primary = repos.primary().map(|r| r.root()); + match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) { + Some(p) => Err(ide_core::IdeError::invalid(format!( + "{} is in another git repository; shelving and patches support the project's main repository only", + p.display() + ))), + None => Ok(()), + } +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| ide_core::IdeError::internal(e.to_string()))? +} + +// ---- patches ---- + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(tag = "from", rename_all = "camelCase")] +pub enum PatchSource { + /// Local changes of these files (absolute paths). + Changes { paths: Vec, reverse: bool }, + /// Changes made by these commits (oldest first). + Commits { commits: Vec, reverse: bool }, +} + +// ---- shelf (`.openidea/shelf`) and change lists (`.openidea/changelists.json`) ---- + +const PRIVATE_ENTRIES: [&str; 2] = ["/shelf/", "/changelists.json"]; + +fn shelf_dir(root: &Path) -> PathBuf { + ide_core::project_dir::path(root, "shelf") +} + +fn keep_private(root: &Path) { + if let Err(e) = ide_vcs::changelists::ensure_ignored(&ide_core::project_dir::path(root, ""), &PRIVATE_ENTRIES) { + tracing::warn!("could not update .openidea/.gitignore: {e}"); + } +} + +/// The project's repositories, the primary one first. +pub fn vcs_roots(host: &Host, window: &str) -> IdeResult> { + let repos = host.project(window)?.repos(); + let primary = repos.primary().map(|r| r.root().to_path_buf()); + let mut all: Vec<&std::sync::Arc> = repos.all().iter().collect(); + all.sort_by_key(|r| Some(r.root()) != primary.as_deref()); + Ok(all + .into_iter() + .map(|r| { + let (head, head_commit) = r.head().unwrap_or_default(); + VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten(), head, head_commit } + }) + .collect()) +} + +pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await +} + +/// Gutter change markers for a file, computed against its current (unsaved) text. +pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult> { + let project = host.project(window)?; + let text = project.text_of(&path)?; + project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false)) +} + +pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await +} + +pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult> { + let project = host.project(window)?; + let text = project.documents.text_of(&path); + blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await +} + +pub async fn vcs_log(host: &Host, window: &str, repo: Option, query: LogQuery) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await +} + +pub async fn vcs_commit_files( + host: &Host, + window: &str, + repo: Option, + commit: String, +) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await +} + +/// Combined changes of several commits, `commits` oldest first. +pub async fn vcs_commits_changes( + host: &Host, + window: &str, + repo: Option, + commits: Vec, +) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await +} + +pub async fn vcs_branches(host: &Host, window: &str, repo: Option) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await +} + +pub async fn vcs_stashes(host: &Host, window: &str, repo: Option) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stashes())).await +} + +pub async fn vcs_stage(host: &Host, window: &str, paths: Vec) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await +} + +pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await +} + +pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await +} + +pub async fn vcs_discard(host: &Host, window: &str, paths: Vec) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await +} + +pub async fn vcs_commit( + host: &Host, + window: &str, + repo: Option, + message: String, + options: CommitOptions, +) -> IdeResult { + // Commit hooks may run for a long time. + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await +} + +pub async fn vcs_checkout( + host: &Host, + window: &str, + repo: Option, + branch: String, + create: bool, + options: Option, +) -> IdeResult { + let project = host.project(window)?; + let options = options.unwrap_or_default(); + blocking(move || { + project + .with_repo_recorded(repo.as_deref(), Some("Git checkout"), |r| r.checkout_with(&branch, create, &options)) + }) + .await +} + +/// New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. +pub async fn vcs_create_branch( + host: &Host, + window: &str, + repo: Option, + name: String, + start: Option, + checkout: bool, +) -> IdeResult<()> { + let project = host.project(window)?; + let recorded = checkout.then_some("Git checkout"); + blocking(move || { + project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout)) + }) + .await +} + +/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged. +/// Returns the undone commit's message. +pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option, commit: String) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await +} + +pub async fn vcs_delete_branch( + host: &Host, + window: &str, + repo: Option, + branch: String, +) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await +} + +/// "Restore" of the branch-deleted notification. +pub async fn vcs_restore_branch( + host: &Host, + window: &str, + repo: Option, + deleted: ide_vcs::DeletedBranch, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await +} + +pub async fn vcs_rename_branch( + host: &Host, + window: &str, + repo: Option, + from: String, + to: String, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await +} + +pub async fn vcs_merge(host: &Host, window: &str, repo: Option, branch: String) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await +} + +pub async fn vcs_rebase(host: &Host, window: &str, repo: Option, onto: String) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await +} + +pub async fn vcs_stash_push( + host: &Host, + window: &str, + repo: Option, + message: Option, + include_untracked: bool, + keep_index: bool, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || { + project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| { + r.stash_push(message.as_deref(), include_untracked, keep_index) + }) + }) + .await +} + +pub async fn vcs_stash_apply( + host: &Host, + window: &str, + repo: Option, + index: u32, + pop: bool, + reinstate_index: bool, + branch: Option, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || { + project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() { + Some(name) => r.stash_branch(index, name), + None => r.stash_apply(index, pop, reinstate_index), + }) + }) + .await +} + +pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option, index: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await +} + +/// Network operation; progress lines stream through `on_output`. Cancellable. +pub async fn vcs_remote( + host: &Host, + window: &str, + repo: Option, + request: RequestId, + op: RemoteOp, + on_output: Channel, +) -> IdeResult<()> { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + let out = |line: String| { + let _ = on_output.send(line); + }; + let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull"); + project.with_repo_recorded(repo.as_deref(), recorded, |r| match op { + RemoteOp::Fetch => r.fetch(&cancel, &out), + RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out), + RemoteOp::Push { force_with_lease, set_upstream, tags } => { + r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out) + } + RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out), + RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target { + Some(target) => { + r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out) + } + None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out), + }, + RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out), + RemoteOp::PullFrom { remote, branches, rebase, options } => { + r.pull_from(&remote, &branches, rebase, &options, &cancel, &out) + } + }) + }) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; + host.end_request(request); + result +} + +/// The user's side is `ours` (sides swapped during a rebase, as in IDEA). +pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await +} + +pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await +} + +/// Line hunks between two arbitrary texts (diff viewer, commit dialog). +pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: String) -> Vec { + ide_vcs::diff_hunks(&old_text, &new_text) +} + +/// Stages only the selected hunks of a file's working-tree changes. +/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`. +pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec) -> IdeResult<()> { + let project = host.project(window)?; + let current = ide_vfs::load_text(&path)?.text; + project.with_repo_of(&path, |r| { + let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default(); + let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + let selected: Vec = hunks.iter().map(|&h| h as usize).collect(); + let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected); + if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) } + }) +} + +/// Clones a repository (Get from VCS). Works without an open project. +pub async fn vcs_clone( + host: &Host, + _window: &str, + request: RequestId, + url: String, + dest: PathBuf, + on_output: Channel, +) -> IdeResult<()> { + apply_git_executable(&host.global_settings.effective()); + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + ide_vcs::clone(&url, &dest, &cancel, &|line| { + let _ = on_output.send(line); + }) + }) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; + host.end_request(request); + result +} + +/// `git init` in `dir` (File › New Project… "Create Git repository"). +pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()> { + apply_git_executable(&host.global_settings.effective()); + blocking(move || ide_vcs::init(&dir)).await +} + +/// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected +/// executable when `path` is empty. +pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult { + tokio::task::spawn_blocking(move || { + let explicit = path.trim(); + let exe = if explicit.is_empty() { + ide_vcs::detected_git_executable() + .ok_or_else(|| ide_core::IdeError::unavailable("git executable not found on PATH"))? + } else { + PathBuf::from(explicit) + }; + let version = ide_vcs::git_version(Some(&exe))?; + Ok(GitExecutableInfo { version, path: exe.to_string_lossy().into_owned() }) + }) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))? +} + +/// The auto-detected git executable (shown as the field's placeholder). +pub fn vcs_detected_git(_host: &Host, _window: &str) -> Option { + ide_vcs::detected_git_executable().map(|p| p.to_string_lossy().into_owned()) +} + +/// `git config --get ` in the project repository; `None` when unset. +pub async fn vcs_config_get( + host: &Host, + window: &str, + repo: Option, + key: String, +) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await +} + +/// Local history operation that may take a while (hooks, large checkouts); git's +/// output streams through `on_output`. Cancellable. +pub async fn vcs_operation( + host: &Host, + window: &str, + repo: Option, + request: RequestId, + op: GitOperation, + on_output: Channel, +) -> IdeResult<()> { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || { + let out = |line: String| { + let _ = on_output.send(line); + }; + let recorded = match &op { + GitOperation::CherryPick { .. } => Some("Git cherry-pick"), + GitOperation::Revert { .. } => Some("Git revert"), + GitOperation::Reset { .. } => Some("Git reset"), + GitOperation::InteractiveRebase { .. } => Some("Git rebase"), + _ => None, + }; + project.with_repo_recorded(repo.as_deref(), recorded, |r| match op { + GitOperation::Continue => r.continue_operation(&cancel, &out), + GitOperation::Abort => r.abort_operation(&cancel, &out), + GitOperation::Skip => r.skip_operation(&cancel, &out), + GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out), + GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out), + GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out), + GitOperation::InteractiveRebase { base, entries } => { + r.interactive_rebase(base.as_deref(), &entries, &cancel, &out) + } + }) + }) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; + host.end_request(request); + result +} + +pub async fn vcs_create_tag( + host: &Host, + window: &str, + repo: Option, + name: String, + commit: String, + message: Option, + force: bool, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force))) + .await +} + +pub async fn vcs_tags(host: &Host, window: &str, repo: Option) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await +} + +/// Merge dialog: merges `branches` into the current branch. +pub async fn vcs_merge_with( + host: &Host, + window: &str, + repo: Option, + branches: Vec, + options: MergeOptions, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || { + project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options)) + }) + .await +} + +/// Rebase dialog: rebases the current branch onto `upstream` (or `--onto`). +pub async fn vcs_rebase_with( + host: &Host, + window: &str, + repo: Option, + upstream: String, + options: RebaseOptions, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || { + project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options)) + }) + .await +} + +pub async fn vcs_remotes(host: &Host, window: &str, repo: Option) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await +} + +pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option, edit: RemoteEdit) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await +} + +pub async fn vcs_worktrees(host: &Host, window: &str, repo: Option) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.worktrees())).await +} + +pub async fn vcs_add_worktree( + host: &Host, + window: &str, + repo: Option, + path: PathBuf, + commitish: String, + new_branch: Option, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || { + project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) + }) + .await +} + +pub async fn vcs_remove_worktree( + host: &Host, + window: &str, + repo: Option, + path: PathBuf, + force: bool, +) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await +} + +/// Compare with Local: tracked files that differ between `rev` and the working tree. +pub async fn vcs_diff_with_worktree( + host: &Host, + window: &str, + repo: Option, + rev: String, +) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await +} + +/// Files that differ between two revisions (the "N files updated" of Update Project). +pub async fn vcs_diff_revisions( + host: &Host, + window: &str, + repo: Option, + from: String, + to: String, +) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await +} + +pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + project.with_repo(|r| match source { + PatchSource::Changes { paths, reverse } => { + let paths: Vec = paths.into_iter().map(Into::into).collect(); + in_primary_repo(&project, &paths)?; + r.create_patch(&paths, reverse) + } + PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse), + }) + }) + .await +} + +pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await +} + +pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await +} + +/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. +pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + in_primary_repo(&project, &paths)?; + keep_private(&project.root); + project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths)) + }) + .await +} + +pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + ide_vcs::shelf::list(&shelf_dir(&project.root)) +} + +/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the +/// merge UI); with `remove`, deletes it from the shelf. +pub async fn vcs_unshelve(host: &Host, window: &str, id: String, remove: bool) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove)) + }) + .await +} + +pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> { + let project = host.project(window)?; + ide_vcs::shelf::delete(&shelf_dir(&project.root), &id) +} + +pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> { + let project = host.project(window)?; + ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name) +} + +pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult { + let project = host.project(window)?; + ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id) +} + +pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult { + let project = host.project(window)?; + Ok(ide_vcs::changelists::load(&ide_core::project_dir::path(&project.root, "changelists.json"))) +} + +pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistState) -> IdeResult<()> { + let project = host.project(window)?; + keep_private(&project.root); + ide_vcs::changelists::save(&ide_core::project_dir::path(&project.root, "changelists.json"), &changelists) +} + +/// Create Patch › Save to file. +pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> { + blocking(move || { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, patch)?; + Ok(()) + }) + .await +} + +/// Commands of this module the remote server answers. +pub(crate) fn register(d: &mut Dispatcher) { + d.add("vcs_roots", |c| async move { vcs_roots(&c.host, &c.window) }); + d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await }); + d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) }); + d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await }); + d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?).await }); + d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await }); + d.add("vcs_commit_files", |c| async move { + vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await + }); + d.add("vcs_commits_changes", |c| async move { + vcs_commits_changes(&c.host, &c.window, c.arg("repo")?, c.arg("commits")?).await + }); + d.add("vcs_branches", |c| async move { vcs_branches(&c.host, &c.window, c.arg("repo")?).await }); + d.add("vcs_stashes", |c| async move { vcs_stashes(&c.host, &c.window, c.arg("repo")?).await }); + d.add("vcs_stage", |c| async move { vcs_stage(&c.host, &c.window, c.arg("paths")?).await }); + d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await }); + d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await }); + d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await }); + d.add("vcs_commit", |c| async move { + vcs_commit(&c.host, &c.window, c.arg("repo")?, c.arg("message")?, c.arg("options")?).await + }); + d.add("vcs_checkout", |c| async move { + vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await + }); + d.add("vcs_create_branch", |c| async move { + vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await + }); + d.add( + "vcs_undo_commit", + |c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await }, + ); + d.add("vcs_delete_branch", |c| async move { + vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await + }); + d.add("vcs_restore_branch", |c| async move { + vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await + }); + d.add("vcs_rename_branch", |c| async move { + vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await + }); + d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await }); + d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await }); + d.add("vcs_stash_push", |c| async move { + vcs_stash_push( + &c.host, + &c.window, + c.arg("repo")?, + c.arg("message")?, + c.arg("includeUntracked")?, + c.arg("keepIndex")?, + ) + .await + }); + d.add("vcs_stash_apply", |c| async move { + vcs_stash_apply( + &c.host, + &c.window, + c.arg("repo")?, + c.arg("index")?, + c.arg("pop")?, + c.arg("reinstateIndex")?, + c.arg("branch")?, + ) + .await + }); + d.add( + "vcs_stash_drop", + |c| async move { vcs_stash_drop(&c.host, &c.window, c.arg("repo")?, c.arg("index")?).await }, + ); + d.add("vcs_remote", |c| async move { + vcs_remote(&c.host, &c.window, c.arg("repo")?, c.arg("request")?, c.arg("op")?, c.sender("onOutput")?).await + }); + d.add("vcs_conflict_versions", |c| async move { vcs_conflict_versions(&c.host, &c.window, c.arg("path")?).await }); + d.add( + "vcs_diff_text", + |c| async move { vcs_diff_text(&c.host, &c.window, c.arg("path")?, c.arg("staged")?).await }, + ); + d.add( + "vcs_diff_hunks", + |c| async move { Ok(vcs_diff_hunks(&c.host, &c.window, c.arg("oldText")?, c.arg("newText")?)) }, + ); + d.add("vcs_stage_hunks", |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?) }); + d.add("vcs_clone", |c| async move { + vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await + }); + d.add("vcs_init", |c| async move { vcs_init(&c.host, &c.window, c.arg("dir")?).await }); + d.add("vcs_git_version", |c| async move { vcs_git_version(&c.host, &c.window, c.arg("path")?).await }); + d.add("vcs_detected_git", |c| async move { Ok(vcs_detected_git(&c.host, &c.window)) }); + d.add("vcs_config_get", |c| async move { vcs_config_get(&c.host, &c.window, c.arg("repo")?, c.arg("key")?).await }); + d.add("vcs_operation", |c| async move { + vcs_operation(&c.host, &c.window, c.arg("repo")?, c.arg("request")?, c.arg("op")?, c.sender("onOutput")?).await + }); + d.add("vcs_create_tag", |c| async move { + vcs_create_tag( + &c.host, + &c.window, + c.arg("repo")?, + c.arg("name")?, + c.arg("commit")?, + c.arg("message")?, + c.arg("force")?, + ) + .await + }); + d.add("vcs_tags", |c| async move { vcs_tags(&c.host, &c.window, c.arg("repo")?).await }); + d.add("vcs_merge_with", |c| async move { + vcs_merge_with(&c.host, &c.window, c.arg("repo")?, c.arg("branches")?, c.arg("options")?).await + }); + d.add("vcs_rebase_with", |c| async move { + vcs_rebase_with(&c.host, &c.window, c.arg("repo")?, c.arg("upstream")?, c.arg("options")?).await + }); + d.add("vcs_remotes", |c| async move { vcs_remotes(&c.host, &c.window, c.arg("repo")?).await }); + d.add( + "vcs_edit_remote", + |c| async move { vcs_edit_remote(&c.host, &c.window, c.arg("repo")?, c.arg("edit")?).await }, + ); + d.add("vcs_worktrees", |c| async move { vcs_worktrees(&c.host, &c.window, c.arg("repo")?).await }); + d.add("vcs_add_worktree", |c| async move { + vcs_add_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("commitish")?, c.arg("newBranch")?) + .await + }); + d.add("vcs_remove_worktree", |c| async move { + vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await + }); + d.add("vcs_diff_with_worktree", |c| async move { + vcs_diff_with_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("rev")?).await + }); + d.add("vcs_diff_revisions", |c| async move { + vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await + }); + d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await }); + d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await }); + d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await }); + d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await }); + d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) }); + d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await }); + d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) }); + d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) }); + d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) }); + d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) }); + d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) }); + d.add( + "vcs_write_patch", + |c| async move { vcs_write_patch(&c.host, &c.window, c.arg("path")?, c.arg("patch")?).await }, + ); +} diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs new file mode 100644 index 000000000..869541659 --- /dev/null +++ b/crates/ide-host/src/project.rs @@ -0,0 +1,767 @@ +//! One open project folder: documents, index, language servers, repositories, +//! watchers and the per-project services, opened by [`crate::Host::open_project`]. + +use crate::Host; +use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink}; +use ide_index::ProjectIndex; +use ide_language::{FileTypes, LanguageRegistry, SyntaxCache}; +use ide_lsp::LspManager; +use ide_syntax::StructureService; +use ide_text::{AppliedChange, ByteEdit}; +use ide_vcs::{Repo, Repos}; +use ide_vfs::Watcher; +use ide_workspace::documents::DocumentObserver; +use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder}; +use parking_lot::{Mutex, RwLock}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Everything bound to one open project folder. +pub struct Project { + pub root: PathBuf, + /// Window-scoped event sink, for commands that emit project events. + pub sink: SharedSink, + /// The app's language definitions with this project's File Types applied. + pub languages: Arc, + pub settings: Arc, + pub documents: Arc, + pub history: Arc, + /// Undo stack of file move / rename refactorings. + pub moves: FileMoves, + pub index: Arc, + pub lsp: Arc, + /// Git repositories: the one containing the project root and those nested below it. + pub repos: Arc>, + /// Incrementally maintained syntax trees of the open documents. + pub syntax: Arc, + /// Tree-sitter structure queries (LSP fallback) over `syntax`. + pub structure: Arc, + /// Roots auto-detected from build files; see [`Project::roots`]. + pub detected_roots: Arc>>, + /// Per-project cache directory (build tool sync results…). + pub cache_dir: PathBuf, + /// Database tool window: data sources and their connections. + pub db: Arc, + /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. + pub http_history: Arc, + /// Coding agent tasks (Agents tool window). + pub agents: Arc, + pub(crate) watcher: Watcher, + /// Watchers of the Project Structure content roots outside the project folder. + pub(crate) content_watchers: Mutex>, + pub(crate) _scratch_watcher: Option, +} + +impl Host { + pub fn open_project(&self, window: &str, root: &Path) -> IdeResult> { + let started = std::time::Instant::now(); + let mut lap = started; + let mut laps: Vec<(&str, u128)> = Vec::new(); + let mut mark = |name: &'static str| { + laps.push((name, lap.elapsed().as_millis())); + lap = std::time::Instant::now(); + }; + let root = dunce::canonicalize(root)?; + // The window reloaded (or asked again) for the project it already has: keep + // it, with its warm index, language servers and unsaved documents. + if let Ok(current) = self.project(window) + && current.root == root + { + return Ok(current); + } + self.close_project(window); + let sink: SharedSink = (self.sink_for)(window); + let settings = SettingsStore::load(&self.config_dir, Some(&root)); + let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root))); + let excludes = exclude_globs(&settings.effective(), &detected_roots.read()); + let extra_roots = content_roots(&settings.effective(), &root); + mark("roots"); + + // The project's own registry: File Types associations may be project-scoped. + let languages = Arc::new(self.languages.fork()); + languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root))); + let documents = { + let languages = languages.clone(); + Arc::new(Documents::new( + sink.clone(), + Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)), + )) + }; + documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root))); + let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?); + { + let history = history.clone(); + let retention = history_retention(&settings.effective()); + std::thread::Builder::new().name("openidea-history-prune".into()).spawn(move || { + if let Err(e) = history.apply_retention(retention) { + tracing::warn!("local history: {e}"); + } + })?; + } + let project_key = project_key(&root); + let index = ProjectIndex::open( + &root, + &self.data_dir.join("index").join(&project_key), + extra_roots.clone(), + excludes.clone(), + languages.clone(), + self.syntax(), + sink.clone(), + )?; + mark("index"); + let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone()); + let settings = Arc::new(settings); + { + let settings = settings.clone(); + let root = root.clone(); + let data_dir = self.data_dir.clone(); + lsp.set_config_hook(Box::new(move |cfg, server_root| { + let effective = settings.effective(); + ide_sdk::apply::configure_server(cfg, &root, server_root, &effective); + if cfg.name == "jdtls" { + let bundles: Vec = + ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); + let min_java = ide_sdk::java::jdtls_min_java(cfg); + let jdks = ide_sdk::java::jdtls_jdks(min_java); + let lombok = ide_sdk::lombok::lombok_agent( + server_root, + ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)), + ide_tools::managed_bundle(&data_dir, "lombok"), + ide_sdk::apply::maven_local_repository(&root, &effective), + ); + if let Some(problem) = lombok.problem() { + tracing::warn!("{problem}"); + } + let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| { + cfg.launch_error = Some(e); + None + }); + ide_sdk::java::configure_jdtls( + cfg, + server_root, + &data_dir, + &jdks, + ide_sdk::apply::project_jdk(&root, &effective).as_ref(), + server_jdk.as_ref(), + min_java, + &bundles, + lombok.jar(), + ); + ide_sdk::jdtls::launch_directly(cfg); + } + })); + } + { + let documents = documents.clone(); + lsp.set_apply_edit_hook(Box::new(move |edit| apply_workspace_edit(&documents, &edit).is_ok())); + } + let syntax = Arc::new(SyntaxCache::new(self.syntax())); + documents.add_observer(Arc::new(SyntaxObserver(syntax.clone()))); + documents.add_observer(Arc::new(LspObserver(lsp.clone()))); + documents.add_observer(Arc::new(HistoryObserver(history.clone()))); + documents.add_observer(Arc::new(DeployObserver { + remote: self.remote.clone(), + settings: settings.clone(), + root: root.clone(), + sink: sink.clone(), + runtime: self.runtime.clone(), + })); + + let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); + let watcher = { + let repos = repos.clone(); + let settings = settings.clone(); + let index = index.clone(); + let documents = documents.clone(); + let sink = sink.clone(); + let root = root.clone(); + let detected_roots = detected_roots.clone(); + let history = history.clone(); + let remote = self.remote.clone(); + let runtime = self.runtime.clone(); + Watcher::start( + &root.clone(), + excludes.clone(), + sink.clone(), + Box::new(move |batch| { + let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); + index.update(&changed, &batch.removed); + // Atomic saves by external tools surface as `created`. + documents.on_disk_changed(&changed); + record_external_changes(&history, &changed, &batch.removed); + if !batch.removed.is_empty() { + delete_deployed( + &runtime, + remote.clone(), + settings.clone(), + root.clone(), + batch.removed.clone(), + sink.clone(), + ); + } + // Build files / conventional folders changed: re-detect roots. + let touched = changed.iter().chain(&batch.removed).filter_map(|p| p.strip_prefix(&root).ok()); + if touched.into_iter().any(ide_workspace::roots::affects_roots) { + let fresh = ide_workspace::roots::detect(&root); + if *detected_roots.read() != fresh { + // Detected excluded folders (`target`, `build`) leave the index too. + let effective = settings.effective(); + index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh)); + *detected_roots.write() = fresh; + sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); + } + } + // A repository was cloned / initialised / removed below the project: its + // `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went. + let known = repos.read().roots(); + let git_dirs = changed.iter().chain(&batch.removed).any(|p| { + let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten() + else { + return false; + }; + known.iter().any(|r| r == dir) != p.is_file() + }); + if git_dirs { + scan_repositories(&root, &repos, &settings, &sink); + } + sink.emit(IdeEvent::VcsChanged { root: root.clone().into() }); + }), + )? + }; + + // Scratch files live outside the project; watch them too so the + // Project view and open editors follow changes made there. + let scratch_watcher = { + let dir = self.data_dir.join("scratches"); + let documents = documents.clone(); + std::fs::create_dir_all(&dir).ok().and_then(|()| { + Watcher::start( + &dir, + Vec::new(), + sink.clone(), + Box::new(move |batch| { + let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); + documents.on_disk_changed(&changed); + }), + ) + .map_err(|e| tracing::warn!("scratches watcher: {e}")) + .ok() + }) + }; + + let content_watchers = extra_roots + .iter() + .filter_map(|r| { + let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?; + Some((r.clone(), w)) + }) + .collect(); + mark("watcher"); + { + let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone()); + std::thread::Builder::new() + .name("openidea-vcs-roots".into()) + .spawn(move || scan_repositories(&root, &repos, &settings, &sink))?; + } + mark("git"); + tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis()); + + let db = Arc::new(ide_db::DbManager::new( + root.clone(), + self.data_dir.join("db-passwords.json"), + self.data_dir.join("db-history.json"), + self.ssh.clone(), + )); + let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key))); + let agents = Arc::new(ide_agent::AgentManager::new( + ide_agent::AgentPaths { + project_root: root.clone(), + store_dir: self.data_dir.join("agents").join(&project_key), + // Outside the app data dir: its path has spaces on macOS, which trips up build scripts. + worktrees_dir: dirs::home_dir() + .unwrap_or_else(|| self.data_dir.clone()) + .join(".openidea") + .join("worktrees") + .join(&project_key), + models_file: self.data_dir.join("agents").join("models.json"), + custom_agents_dir: Some(self.config_dir.join("agents")), + home_dir: dirs::home_dir(), + }, + self.runtime.clone(), + )); + let project = Arc::new(Project { + root, + sink, + languages, + settings, + documents, + history, + moves: FileMoves::new(), + index, + lsp, + repos, + structure: Arc::new(StructureService::new(syntax.clone())), + syntax, + detected_roots, + cache_dir: self.data_dir.join("cache").join(&project_key), + db, + http_history, + agents, + watcher, + content_watchers: Mutex::new(content_watchers), + _scratch_watcher: scratch_watcher, + }); + { + let weak = Arc::downgrade(&project); + project.lsp.set_candidates_hook(Box::new(move |q, cancel| { + weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default() + })); + } + self.insert_project(window, project.clone()); + Ok(project) + } +} + +/// Looks for repositories nested below the project (and the one containing it); +/// emits `VcsChanged` when the set of roots changed. +fn scan_repositories(root: &Path, repos: &RwLock, settings: &SettingsStore, sink: &SharedSink) { + let ignored = settings.effective().ignored_names(); + let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy())); + match Repos::discover(root, &skip) { + Ok(found) => { + if found.roots() != repos.read().roots() { + *repos.write() = found; + sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() }); + } + } + Err(e) => tracing::warn!("looking for git repositories: {e}"), + } +} + +impl Project { + /// The project's git repositories. + pub fn repos(&self) -> Repos { + self.repos.read().clone() + } + + /// Runs `f` with the repository whose root is `at`, the project's primary repository + /// when `None`; fails when there is none (a repository-scoped command never falls back + /// to another repository). + pub fn with_repo_at(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + let repos = self.repos(); + match at { + Some(root) => match repos.at_root(root) { + Some(repo) => self.run_in(repo, f), + None => { + Err(IdeError::unavailable(format!("{} is not a git repository of the project", root.display()))) + } + }, + None => match repos.primary() { + Some(repo) => self.run_in(repo, f), + None => Err(IdeError::unavailable("project is not a git repository")), + }, + } + } + + /// Runs `f` with the repository `path` (a file or folder) belongs to. + pub fn with_repo_of(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + match self.repos().for_path(path) { + Some(repo) => self.run_in(repo, f), + None => Err(IdeError::unavailable(format!("{} is not in a git repository", path.display()))), + } + } + + fn run_in(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + let settings = self.settings.effective(); + apply_git_executable(&settings); + repo.set_ignored_names(settings.ignored_names()); + f(repo) + } + + /// Runs `f` with the project's primary git repository, or fails when the project is not under git. + pub fn with_repo(&self, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + self.with_repo_at(None, f) + } + + /// Runs a VCS operation that rewrites the working tree (checkout, merge, + /// rebase, pull, reset, stash, rollback): Local History gets a label and a + /// snapshot of every locally changed file before, and a label after. + /// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree). + pub fn with_repo_recorded( + &self, + at: Option<&Path>, + operation: Option<&str>, + f: impl FnOnce(&Repo) -> IdeResult, + ) -> IdeResult { + let Some(name) = operation else { return self.with_repo_at(at, f) }; + self.history_label(&format!("Before {name}")); + if let Ok(changed) = self.with_repo_at(at, |r| r.status()) { + for file in changed.iter().take(HISTORY_BATCH_LIMIT) { + if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) { + tracing::debug!("local history: {e}"); + } + } + } + let result = self.with_repo_at(at, f); + self.history_label(&format!("After {name}")); + result + } + + fn history_label(&self, label: &str) { + if let Err(e) = self.history.put_label(&self.root, label) { + tracing::warn!("local history: {e}"); + } + } + + /// Local History snapshots of `paths` before a refactoring changes them. + pub fn record_before_refactoring(&self, paths: &[&Path], name: &str) { + let label = format!("Before refactoring: {name}"); + for path in paths { + let result = match self.documents.text_of(path) { + Some(text) => self.history.record_event(path, &text, &label), + None => self.history.record_file(path, &label, true).map(|_| ()), + }; + if let Err(e) = result { + tracing::debug!("local history: {e}"); + } + } + } + + /// Effective source / test / resource / excluded roots (detected + Project Structure marks). + pub fn roots(&self) -> ProjectRoots { + ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure) + } + + /// Where the Maven / Gradle tool windows cache what the build tools reported. + pub fn build_cache_dir(&self) -> PathBuf { + self.cache_dir.join("build") + } + + /// File masks (`ide_search::FileMask`) for Find in Files and inspections: the + /// excluded roots, the ignored names and the "Exclude files" patterns (files, + /// and everything under such folders). + pub fn search_excludes(&self) -> Vec { + let mut out: Vec = + self.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect(); + let settings = self.settings.effective(); + let names = + settings.files.ignored_names.iter().cloned().chain(settings.project_structure.exclude_file_patterns()); + for name in names { + let name = name.trim(); + if !name.is_empty() && !name.contains(['/', ',']) { + out.push(name.to_owned()); + out.push(format!("**/{name}/**")); + } + } + out + } + + /// Project files of `q.languages` under `q.root` containing `q.word` as a whole + /// word (unsaved buffers included; excluded roots, ignored names and + /// `.gitignore`d files skipped): the files a references search opens on its server. + pub fn reference_candidates( + &self, + q: &ide_lsp::CandidateQuery, + cancel: &CancellationToken, + ) -> ide_lsp::CandidateFiles { + if !q.root.starts_with(&self.root) { + return ide_lsp::CandidateFiles::default(); + } + let query = ide_search::WordQuery { + word: q.word.clone(), + dir: q.root.clone(), + exclude: self.search_excludes(), + limit: q.limit, + }; + let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None)); + match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) { + Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit }, + Err(e) => { + tracing::debug!("reference candidates: {}", e.message); + ide_lsp::CandidateFiles::default() + } + } + } + + /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). + pub fn exclude_globs(&self) -> Vec { + exclude_globs(&self.settings.effective(), &self.detected_roots.read()) + } + + /// Project Structure content roots besides the project folder (existing directories). + pub fn content_roots(&self) -> Vec { + content_roots(&self.settings.effective(), &self.root) + } + + /// Applies Settings › File Types; open documents whose language changed are + /// re-detected (language servers and syntax trees follow) and the index + /// re-parses the files whose language changed. + pub fn apply_file_types(&self, settings: &ide_core::settings::Settings) { + let before = Arc::new(self.languages.snapshot()); + if self.languages.set_file_types(FileTypes::from_settings(&settings.files, Some(&self.root))) { + self.documents.redetect_languages(); + self.index.languages_changed(before); + } + } + + /// Applies changed content roots and exclude settings to the index (which + /// rescans in the background) and the file watchers, without reopening the project. + pub fn refresh_index_scope(&self) { + let (roots, excludes) = (self.content_roots(), self.exclude_globs()); + self.index.set_scope(roots.clone(), excludes.clone()); + self.watcher.set_excludes(&excludes); + let mut watchers = self.content_watchers.lock(); + watchers.retain(|(p, _)| roots.contains(p)); + for (_, w) in watchers.iter() { + w.set_excludes(&excludes); + } + for r in roots { + if watchers.iter().any(|(p, _)| *p == r) { + continue; + } + if let Some(w) = + content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history) + { + watchers.push((r, w)); + } + } + } + + /// Current text of `path`: the open buffer when available, else the file on disk. + pub fn text_of(&self, path: &Path) -> IdeResult { + match self.documents.text_of(path) { + Some(text) => Ok(text), + None => Ok(ide_vfs::load_text(path)?.text), + } + } +} + +/// Applies a multi-file edit through the document layer so open editors update live. +pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> { + use ide_core::FileEdit; + for file in &edit.files { + match file { + FileEdit::Edit { path, edits } => { + // Files not open in an editor are opened transiently and closed again. + let (doc, transient) = match documents.by_path(path) { + Some(doc) => (doc, false), + None => (documents.open(path)?.id, true), + }; + let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc)); + if transient { + documents.close(doc); + } + result?; + } + FileEdit::Create { path, overwrite } => { + if path.exists() { + if *overwrite { + ide_vfs::save_text(path, "", "utf-8", false)?; + } + } else { + ide_vfs::create_file(path)?; + } + } + FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?, + FileEdit::Delete { path, .. } => ide_vfs::delete(path)?, + } + } + Ok(()) +} + +/// Files the watcher reports per batch that Local History snapshots. +const HISTORY_BATCH_LIMIT: usize = 500; + +/// Local History for changes made outside the IDE (other tools, VCS, deletions). +/// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped. +fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) { + for path in changed.iter().take(HISTORY_BATCH_LIMIT) { + if let Err(e) = history.record_file(path, "External change", false) { + tracing::debug!("local history: {e}"); + } + } + if !removed.is_empty() + && let Err(e) = history.note_removed(removed) + { + tracing::debug!("local history: {e}"); + } +} + +pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspace::Retention { + ide_workspace::Retention { + working_days: settings.files.local_history_days, + max_bytes: u64::from(settings.files.local_history_max_mb.max(1)) * 1024 * 1024, + } +} + +/// Project Structure content roots outside `root` (existing absolute directories). +fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec { + let mut out: Vec = Vec::new(); + for r in &settings.project_structure.content_roots { + let p = PathBuf::from(r.trim()); + if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) { + out.push(p); + } + } + out +} + +/// Watches a content root outside the project: the index, open documents and +/// Local History follow its changes like the project folder's. +fn content_root_watcher( + dir: &Path, + excludes: &[String], + sink: &SharedSink, + index: &Arc, + documents: &Arc, + history: &Arc, +) -> Option { + let (index, documents, history) = (index.clone(), documents.clone(), history.clone()); + Watcher::start( + dir, + excludes.to_vec(), + sink.clone(), + Box::new(move |batch| { + let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); + index.update(&changed, &batch.removed); + documents.on_disk_changed(&changed); + record_external_changes(&history, &changed, &batch.removed); + }), + ) + .map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display())) + .ok() +} + +fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec { + let mut globs = settings.file_excludes(); + globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs()); + globs +} + +fn project_key(root: &Path) -> String { + // Stable FNV-1a directory name per project for caches. + let s = root.to_string_lossy(); + let mut h: u64 = 0xcbf29ce484222325; + for b in s.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + format!("{h:016x}") +} + +struct LspObserver(Arc); + +impl DocumentObserver for LspObserver { + fn opened(&self, info: &DocumentInfo) { + // Language servers choke on huge files (IDEA disables code insight too); + // changes to documents never opened on a server are ignored. + if info.large { + return; + } + match &info.uri { + Some(uri) => self.0.did_open_virtual(info.id, uri, &info.language, info.version, &info.text), + None => self.0.did_open(info.id, &info.path, &info.language, info.version, &info.text), + } + } + fn changed( + &self, + doc: ide_core::DocId, + _path: &Path, + version: u32, + changes: &[AppliedChange], + full_text: &dyn Fn() -> String, + ) { + self.0.did_change(doc, version, changes, full_text); + } + fn saved(&self, doc: ide_core::DocId, _path: &Path, text: &str) { + self.0.did_save(doc, text); + } + fn closed(&self, doc: ide_core::DocId, _path: &Path) { + self.0.did_close(doc); + } +} + +/// Feeds document edits into the per-project syntax tree cache. +struct SyntaxObserver(Arc); + +impl DocumentObserver for SyntaxObserver { + fn opened(&self, info: &DocumentInfo) { + if !info.large { + self.0.open(info.id, &info.language, info.version); + } + } + fn edited(&self, doc: ide_core::DocId, version: u32, edits: &[ByteEdit]) { + self.0.edit(doc, version, edits); + } + fn closed(&self, doc: ide_core::DocId, _path: &Path) { + self.0.close(doc); + } +} + +struct HistoryObserver(Arc); + +impl DocumentObserver for HistoryObserver { + fn saved(&self, _doc: ide_core::DocId, path: &Path, text: &str) { + if let Err(e) = self.0.record(path, text, "Saved") { + tracing::warn!("local history: {e}"); + } + } +} + +/// Uploads saved files: remote files' local copies, and automatic upload to the default server. +struct DeployObserver { + remote: Arc, + settings: Arc, + root: PathBuf, + sink: SharedSink, + runtime: tokio::runtime::Handle, +} + +impl DocumentObserver for DeployObserver { + fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { + let (remote, settings, root, sink, path) = + (self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf()); + self.runtime.spawn(async move { + if let Some(result) = remote.on_saved(&settings.effective(), &root, &path).await { + notify_transfer(&sink, "Upload", &path, result); + } + }); + } +} + +fn delete_deployed( + runtime: &tokio::runtime::Handle, + remote: Arc, + settings: Arc, + root: PathBuf, + removed: Vec, + sink: SharedSink, +) { + runtime.spawn(async move { + if let Some(result) = remote.on_deleted(&settings.effective(), &root, &removed).await { + let first = removed.first().cloned().unwrap_or_default(); + notify_transfer(&sink, "Delete on server", &first, result); + } + }); +} + +/// Reports a failed automatic transfer (successes stay quiet). +fn notify_transfer(sink: &SharedSink, what: &str, path: &Path, result: IdeResult) { + let body = match result { + Ok(r) if r.errors.is_empty() => return, + Ok(r) => r.errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect::>().join("\n"), + Err(e) => e.message, + }; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + sink.emit(IdeEvent::Notify { + level: ide_core::event::NotifyLevel::Error, + title: format!("{what} failed: {name}"), + body: Some(body), + }); +} + +/// Settings › Version Control › Git › Path to Git executable (empty = auto-detect). +pub fn apply_git_executable(settings: &ide_core::settings::Settings) { + let exe = settings.vcs.git.executable.trim(); + ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe))); +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs new file mode 100644 index 000000000..859d222ca --- /dev/null +++ b/crates/ide-host/src/rpc.rs @@ -0,0 +1,302 @@ +//! Remote development protocol: JSON-RPC 2.0 (`Content-Length` framing) between +//! the desktop app and `openidea-server`, over the server's stdio (an SSH exec +//! channel). +//! +//! * client → server request `initialize` `{ version }` → [`ServerInfo`]; +//! * client → server request `invoke` `{ window, cmd, args }` → the command's +//! result, `args` as the desktop command takes them (camelCase names). A +//! streaming argument is `{ "$channel": }`; +//! * server → client notification `event` `{ window, event }` (an `IdeEvent` +//! for that window, or every window when `window` is null); +//! * server → client notification `channel` `{ id, data }`: one value of a +//! streaming argument. + +use crate::Host; +use crate::ops::{self, Emit}; +use ide_core::{IdeError, IdeEvent, IdeResult, RequestId}; +use ide_jsonrpc::{Handler, Peer, codes}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::{Arc, OnceLock}; + +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerInfo { + pub version: String, + pub protocol: u32, + pub os: String, + pub arch: String, + pub home: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Invoke { + pub window: String, + pub cmd: String, + #[serde(default)] + pub args: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventMessage { + pub window: Option, + pub event: IdeEvent, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelMessage { + pub id: u64, + pub data: Value, +} + +/// One `invoke`: its arguments and a way to stream values back. +pub struct Call { + pub host: Arc, + pub window: String, + pub args: Value, + peer: Arc>, +} + +impl Call { + /// Argument `name` (missing = `null`, so `Option`s may be omitted). + pub fn arg(&self, name: &str) -> IdeResult { + let v = self.args.get(name).cloned().unwrap_or(Value::Null); + serde_json::from_value(v).map_err(|e| IdeError::invalid(format!("argument {name}: {e}"))) + } + + /// Streaming argument `name` as an [`ops::Channel`]. + pub fn sender(&self, name: &str) -> IdeResult> { + let emit = self.channel(name)?; + Ok(ops::Channel::new(move |v| emit(v))) + } + + /// Streaming argument `name` (`{ "$channel": id }`). + pub fn channel(&self, name: &str) -> IdeResult> { + let id = self + .args + .get(name) + .and_then(|v| v.get("$channel")) + .and_then(Value::as_u64) + .ok_or_else(|| IdeError::invalid(format!("argument {name}: expected a channel")))?; + let peer = self.peer.clone(); + Ok(Arc::new(move |data: T| { + let Some(peer) = peer.get() else { return }; + match serde_json::to_value(&data) { + Ok(data) => { + let _ = peer.notify("channel", ChannelMessage { id, data }); + } + Err(e) => tracing::warn!("channel {id}: {e}"), + } + })) + } +} + +type BoxFuture = Pin + Send>>; +type HandlerFn = Arc BoxFuture> + Send + Sync>; + +/// Commands the server answers, by name. +#[derive(Default, Clone)] +pub struct Dispatcher { + handlers: HashMap<&'static str, HandlerFn>, +} + +impl Dispatcher { + pub fn add(&mut self, name: &'static str, f: F) -> &mut Self + where + F: Fn(Call) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + T: Serialize, + { + let f = Arc::new(f); + self.handlers.insert( + name, + Arc::new(move |call| { + let fut = f(call); + Box::pin(async move { + let value = fut.await?; + serde_json::to_value(value).map_err(|e| IdeError::internal(e.to_string())) + }) + }), + ); + self + } + + pub fn commands(&self) -> Vec<&'static str> { + let mut out: Vec<_> = self.handlers.keys().copied().collect(); + out.sort_unstable(); + out + } + + /// The commands of the core remote slice: project, files, documents, search, + /// terminals, runs and VCS status. + pub fn standard() -> Self { + let mut d = Self::default(); + d.add("open_project", |c| async move { + let path: PathBuf = c.arg("path")?; + let project = ops::project::open(&c.host, &c.window, &path).await?; + Ok(ops::project::info(&project)) + }) + .add("project_current", |c| async move { Ok(ops::project::current(&c.host, &c.window)) }) + .add("close_project", |c| async move { + c.host.close_project(&c.window); + Ok(()) + }) + .add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) }) + .add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) }) + .add("settings_set", |c| async move { + let key: String = c.arg("key")?; + let scope = c.arg("scope")?; + let applied = ops::project::set_settings(&c.host, &c.window, scope, &key, &c.arg::("value")?)?; + let sink = c.host.window_sink(&c.window); + sink.emit(IdeEvent::SettingsChanged { key }); + if let Some(root) = applied.roots_changed { + sink.emit(IdeEvent::ProjectRootsChanged { root }); + } + Ok(applied.settings) + }) + .add("cancel_request", |c| async move { + c.host.cancel_request(c.arg::("id")?); + Ok(()) + }) + .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) }) + .add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) }) + .add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::("path")?) }) + .add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::("from")?, &c.arg::("to")?) }) + .add("fs_set_writable", |c| async move { + ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?) + }) + .add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await }) + .add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await }) + .add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await }) + .add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await }) + .add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) }) + .add("doc_edit", |c| async move { + ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + }) + .add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await }) + .add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await }) + .add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await }) + .add("search_start", |c| async move { + let on_event = c.channel("onEvent")?; + ops::search::start(&c.host, &c.window, c.arg("request")?, c.arg("query")?, on_event).await + }) + .add("term_spawn", |c| async move { + let on_output = c.channel("onOutput")?; + ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output) + }) + .add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::("data")?) }) + .add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) }) + .add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) }) + .add("term_is_busy", |c| async move { ops::term::is_busy(&c.host, c.arg("id")?) }) + .add("term_default_shell", |_| async move { Ok(ide_terminal::default_shell_path()) }) + .add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) }) + .add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) }) + .add("run_save_configs", |c| async move { + ops::run::save_configs(&c.host, &c.window, &c.arg::>("configs")?) + }) + .add("run_system_env", |_| async move { Ok(ide_run::system_env()) }) + .add("run_start", |c| async move { + let on_output = c.channel("onOutput")?; + ops::run::start(&c.host, &c.window, c.arg("config")?, on_output).await + }) + .add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) }) + .add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::("data")?) }) + .add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) }) + .add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) }) + .add("run_list", |c| async move { Ok(ops::run::list(&c.host)) }) + .add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await }); + ops::register_all(&mut d); + d + } +} + +/// Serves `host` over `reader` / `writer` until the client disconnects. +/// `peer` must be the one whose events `host` sends ([`event_sink`]). +pub async fn serve(host: Arc, dispatcher: Dispatcher, peer: Arc>, reader: R, writer: W) +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let handler = Arc::new(ServerHandler { host: host.clone(), dispatcher, peer: peer.clone() }); + let p = Peer::connect(reader, writer, handler); + let _ = peer.set(p.clone()); + p.closed().await; +} + +/// Sends a window's events to the client as `event` notifications. +pub fn event_sink(peer: Arc>, window: Option) -> ide_core::SharedSink { + Arc::new(PeerSink { peer, window }) +} + +struct PeerSink { + peer: Arc>, + window: Option, +} + +impl ide_core::EventSink for PeerSink { + fn emit(&self, event: IdeEvent) { + if let Some(peer) = self.peer.get() { + let _ = peer.notify("event", EventMessage { window: self.window.clone(), event }); + } + } +} + +struct ServerHandler { + host: Arc, + dispatcher: Dispatcher, + peer: Arc>, +} + +pub fn server_info() -> ServerInfo { + ServerInfo { + version: env!("CARGO_PKG_VERSION").into(), + protocol: PROTOCOL_VERSION, + os: std::env::consts::OS.into(), + arch: std::env::consts::ARCH.into(), + home: dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(), + } +} + +impl Handler for ServerHandler { + fn on_notification(&self, _method: &str, _params: Value) {} + + fn on_request(&self, method: &str, params: Value) -> Result { + let internal = |e: serde_json::Error| (codes::INTERNAL_ERROR, e.to_string()); + match method { + "initialize" => serde_json::to_value(server_info()).map_err(internal), + "commands" => serde_json::to_value(self.dispatcher.commands()).map_err(internal), + "invoke" => { + let call: Invoke = + serde_json::from_value(params).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))?; + let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else { + return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd))); + }; + let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() }; + // Requests run on blocking-capable threads: wait for the command here. + self.host.runtime.block_on(handler(c)).map_err(error_reply) + } + _ => Err((codes::METHOD_NOT_FOUND, format!("unknown method {method}"))), + } + } +} + +/// An `IdeError` as a JSON-RPC error: the JSON of the error is the message. +fn error_reply(e: IdeError) -> (i64, String) { + (codes::INTERNAL_ERROR, serde_json::to_string(&e).unwrap_or(e.message)) +} + +/// The `IdeError` of an `invoke` reply (see [`error_reply`]). +pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError { + match e { + ide_jsonrpc::RpcError::Server { message, .. } => serde_json::from_str(&message) + .unwrap_or_else(|_| IdeError::new(ide_core::error::ErrorKind::Protocol, message)), + other => other.into(), + } +} diff --git a/src-tauri/src/shell_env.rs b/crates/ide-host/src/shell_env.rs similarity index 100% rename from src-tauri/src/shell_env.rs rename to crates/ide-host/src/shell_env.rs diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs new file mode 100644 index 000000000..bfb6abf4c --- /dev/null +++ b/crates/ide-host/tests/rpc.rs @@ -0,0 +1,151 @@ +//! The remote protocol end to end: a server on one side of an in-memory pipe, +//! a JSON-RPC client on the other. + +use ide_host::rpc::{self, ChannelMessage, Dispatcher, EventMessage, Invoke, ServerInfo}; +use ide_host::{Host, HostConfig}; +use ide_jsonrpc::{Handler, Peer}; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +#[derive(Default)] +struct Client { + channels: Mutex>, + events: Mutex>, +} + +impl Handler for Client { + fn on_notification(&self, method: &str, params: Value) { + match method { + "channel" => self.channels.lock().push(serde_json::from_value(params).unwrap()), + "event" => self.events.lock().push(serde_json::from_value(params).unwrap()), + _ => {} + } + } + fn on_request(&self, method: &str, _: Value) -> Result { + Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned())) + } +} + +async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result { + let call = Invoke { window: "main".into(), cmd: cmd.into(), args }; + peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply) +} + +async fn until(what: &str, mut done: impl FnMut() -> bool) { + for _ in 0..500 { + if done() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("timed out waiting for {what}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn serves_projects_files_documents_search_and_runs() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("demo"); + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::write(project.join("src/main.py"), "print('needle')\n").unwrap(); + let base = tmp.path().join("server"); + + let peer_slot = Arc::new(OnceLock::new()); + let sink_peer = peer_slot.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + })); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let client = Arc::new(Client::default()); + let peer = Peer::connect(cr, cw, client.clone()); + + let info: ServerInfo = peer.request("initialize", json!({ "version": 1 }), None).await.unwrap(); + assert_eq!(info.protocol, rpc::PROTOCOL_VERSION); + + let opened = invoke(&peer, "open_project", json!({ "path": project })).await.unwrap(); + assert_eq!(opened["name"], "demo"); + let current = invoke(&peer, "project_current", json!({})).await.unwrap(); + assert_eq!(current["name"], "demo"); + + let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap(); + assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}"); + + // Edit and save a document. + let file = project.join("src/main.py"); + let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap(); + let id = doc["id"].clone(); + let version = doc["version"].as_u64().unwrap(); + let changes = json!({ "changes": [{ "from": 16, "to": 16, "insert": "print('more')\n" }] }); + invoke(&peer, "doc_edit", json!({ "doc": id, "baseVersion": version, "changes": changes })).await.unwrap(); + invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\nprint('more')\n"); + + // Commands shared with the desktop shell (camelCase argument names, as Tauri takes them). + invoke(&peer, "doc_set_line_ending", json!({ "doc": id, "lineEnding": "crLf" })).await.unwrap(); + invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\r\nprint('more')\r\n"); + let settings = invoke( + &peer, + "settings_set", + json!({ "scope": "project", "key": "deployment.autoUpload", + "value": "\"always\"" }), + ) + .await + .unwrap(); + assert_eq!(settings["deployment"]["autoUpload"], "always"); + assert!(std::fs::read_to_string(project.join(".openidea/settings.toml")).unwrap().contains("autoUpload")); + invoke(&peer, "vcs_init", json!({ "dir": project })).await.unwrap(); + until("git repository", || project.join(".git").is_dir()).await; + + // Unknown commands and bad arguments come back as IdeErrors. + let e = invoke(&peer, "window_minimize", json!({})).await.unwrap_err(); + assert!(e.message.contains("not available remotely"), "{e:?}"); + let e = invoke(&peer, "doc_save", json!({ "doc": "nope" })).await.unwrap_err(); + assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput, "{e:?}"); + + // Find in Files streams through a channel. + let query = json!({ "pattern": "needle", "isRegex": false, "caseSensitive": false, "wholeWord": false, + "include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 0 }); + invoke(&peer, "search_start", json!({ "request": 7, "query": query, "onEvent": { "$channel": 1 } })).await.unwrap(); + let found = client.channels.lock().iter().filter(|c| c.id == 1).map(|c| c.data.clone()).collect::>(); + assert!(found.iter().any(|d| d["type"] == "file"), "{found:?}"); + assert_eq!(found.last().unwrap()["type"], "done"); + + // A run's output streams through a channel and its exit is an event. + let config = json!({ "id": "echo", "name": "echo", "kind": "shell", "command": "echo", "args": ["remote-hi"], + "cwd": null, "env": {}, "detected": false, "isTest": false, "debugAdapter": null }); + invoke(&peer, "run_start", json!({ "config": config, "onOutput": { "$channel": 2 } })).await.unwrap(); + until("run exit", || client.events.lock().iter().any(|e| e.event_type() == "runExited")).await; + let out: String = client + .channels + .lock() + .iter() + .filter(|c| c.id == 2) + .filter_map(|c| c.data["text"].as_str().map(str::to_owned)) + .collect(); + assert!(out.contains("remote-hi"), "{out}"); + assert_eq!( + client.events.lock().iter().find(|e| e.event_type() == "runExited").unwrap().window.as_deref(), + Some("main") + ); +} + +trait EventType { + fn event_type(&self) -> String; +} + +impl EventType for EventMessage { + fn event_type(&self) -> String { + serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned() + } +} diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 93b7483b4..ee339784b 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -502,7 +502,9 @@ impl Peer { } // Notifications are delivered in order on a dedicated thread. Requests start from the same - // queue, so a request handler sees every notification that arrived before it. + // queue, so a request handler sees every notification that arrived before it, and responses + // complete from it, so a caller sees the notifications sent before its response (a streamed + // result's last values). let (notif_tx, notif_rx) = std::sync::mpsc::channel::(); { let handler = handler.clone(); @@ -525,6 +527,9 @@ impl Peer { Incoming::Request(id, method, params) => { runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params)); } + Incoming::Response(tx, result) => { + let _ = tx.send(result); + } } } }) @@ -696,6 +701,7 @@ fn parse_id(id: &Value) -> Option { enum Incoming { Notification(String, Value), Request(Value, String, Value), + Response(oneshot::Sender>, Result), } async fn handle_request(inner: Arc, handler: Arc, id: Value, method: String, params: Value) { @@ -767,7 +773,7 @@ fn dispatch(inner: &Arc, notif_tx: &std::sync::mpsc::Sender, bo } _ => Ok(obj.remove("result").unwrap_or(Value::Null)), }; - let _ = tx.send(result); + let _ = notif_tx.send(Incoming::Response(tx, result)); } } } diff --git a/crates/ide-language/src/syntax/literals.rs b/crates/ide-language/src/syntax/literals.rs index 4a784d768..5914a9c11 100644 --- a/crates/ide-language/src/syntax/literals.rs +++ b/crates/ide-language/src/syntax/literals.rs @@ -84,8 +84,13 @@ fn is_name_leaf(kind: &str) -> bool { /// `name = …` assignments) rather than a use of one. fn declares(cursor: &TreeCursor<'_>, node: &Node<'_>) -> bool { let parent = node.parent().map(|p| p.kind()).unwrap_or_default(); + // Paths and calls name what is declared elsewhere (`use a::b::name`, `a.name()`). + let reference = ["scoped", "qualified", "invocation", "member_access", "use_", "import", "call"] + .iter() + .any(|k| parent.contains(k)); match cursor.field_name() { - Some("name" | "pattern" | "declarator") => true, + Some("name") => !reference, + Some("pattern" | "declarator") => true, Some("left") => parent.contains("assignment") || parent.contains("declarator"), _ => parent.contains("parameter"), } diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index 4f4be7047..fb344abf4 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -1310,4 +1310,14 @@ fn spelling_regions_cover_declared_names_only() { .map(|r| &py[r.start..r.end]) .collect(); assert_eq!(names, ["load_config", "path", "retries", "total"]); + // Names in paths and calls are uses. + let rs = "use crate::ui::keymap;\nfn run() { let n = std::ffi::OsStr::new(\"x\"); a::b::millis(); }\n"; + let names: Vec<&str> = syntax() + .spelling_regions(&lang("rust"), rs) + .unwrap() + .iter() + .filter(|r| r.kind == SpellingKind::Name) + .map(|r| &rs[r.start..r.end]) + .collect(); + assert_eq!(names, ["run", "n"]); } diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index b42273a8c..232eec692 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -74,7 +74,7 @@ impl Launcher for ProcessLauncher { let search = server_search_path(config); let binary = which::which_in(&config.command, Some(&search), root) .ok() - .map(|b| resolve_rustup_proxy(&config.command, b)); + .and_then(|b| resolve_rustup_proxy(&config.command, b)); let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); if let Some(addr) = &config.connect { @@ -109,11 +109,12 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString { /// minimal environment. /// `~/.cargo/bin/` is usually a rustup proxy that fails when the active /// toolchain lacks the component (common for rust-analyzer). In that case use -/// the newest installed toolchain that ships the binary. -fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf { +/// the newest installed toolchain that ships the binary; with none, the tool is +/// missing (an install hint, not a server that crashes on every restart). +fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option { let is_proxy = binary.parent().is_some_and(|d| d.ends_with(".cargo/bin")); if !is_proxy || command.contains('/') { - return binary; + return Some(binary); } let works = ide_core::process::no_window(&mut std::process::Command::new(&binary)) .arg("--version") @@ -123,7 +124,7 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf { .status() .is_ok_and(|s| s.success()); if works { - return binary; + return Some(binary); } let toolchains = std::env::var_os("RUSTUP_HOME") .map(PathBuf::from) @@ -143,9 +144,12 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf { match candidates.pop() { Some((_, path)) => { tracing::info!("using {} instead of the rustup proxy", path.display()); - path + Some(path) + } + None => { + tracing::info!("{} is a rustup proxy without the component", binary.display()); + None } - None => binary, } } @@ -286,7 +290,7 @@ mod proxy_tests { #[test] fn non_proxy_binaries_are_kept() { let p = PathBuf::from("/usr/bin/true"); - assert_eq!(resolve_rustup_proxy("true", p.clone()), p); + assert_eq!(resolve_rustup_proxy("true", p.clone()), Some(p)); } /// Exercises the real machine setup when a broken rustup proxy is present. @@ -295,7 +299,7 @@ mod proxy_tests { fn broken_rustup_proxy_falls_back_to_a_toolchain() { let home = PathBuf::from(std::env::var_os("HOME").unwrap()); let proxy = home.join(".cargo/bin/rust-analyzer"); - let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone()); + let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone()).unwrap(); eprintln!("resolved: {}", resolved.display()); assert_ne!(resolved, proxy); } diff --git a/crates/ide-remote/Cargo.toml b/crates/ide-remote/Cargo.toml new file mode 100644 index 000000000..8442d6fe7 --- /dev/null +++ b/crates/ide-remote/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ide-remote" +description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +ide-ssh.workspace = true +ide-run.workspace = true +serde.workspace = true +specta.workspace = true +tokio.workspace = true +tracing.workspace = true +parking_lot.workspace = true +globset.workspace = true +ignore.workspace = true + +[dev-dependencies] +ide-ssh = { workspace = true, features = ["testing"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tempfile.workspace = true diff --git a/crates/ide-remote/src/deploy.rs b/crates/ide-remote/src/deploy.rs new file mode 100644 index 000000000..81887a01d --- /dev/null +++ b/crates/ide-remote/src/deploy.rs @@ -0,0 +1,377 @@ +//! Upload, download, delete and synchronise project files with a deployment +//! server over SFTP. + +use crate::mapping::{Excludes, Mappings, posix}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_ssh::RemoteFs; +use ide_ssh::sftp::mtime_of; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Modification times closer than this count as equal (FAT, rounding by servers). +const MTIME_SLACK: u32 = 2; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TransferReport { + pub files: u32, + /// Files left alone: excluded, or outside every mapping. + pub skipped: u32, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TransferError { + pub path: String, + pub message: String, +} + +impl TransferReport { + fn fail(&mut self, path: impl Into, e: IdeError) { + self.errors.push(TransferError { path: path.into(), message: e.message }); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileStamp { + pub size: u64, + pub mtime: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SyncStatus { + LocalOnly, + RemoteOnly, + LocalNewer, + RemoteNewer, + /// Same time, different size. + Different, + Same, +} + +/// One file of Sync with Deployed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncEntry { + /// Project-relative, `/`-separated. + pub path: String, + pub remote_path: String, + pub local: Option, + pub remote: Option, + pub status: SyncStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SyncAction { + Upload, + Download, + DeleteRemote, + DeleteLocal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncStep { + /// Project-relative, `/`-separated. + pub path: String, + pub action: SyncAction, +} + +pub fn status(local: Option, remote: Option) -> SyncStatus { + match (local, remote) { + (Some(_), None) => SyncStatus::LocalOnly, + (None, Some(_)) | (None, None) => SyncStatus::RemoteOnly, + (Some(l), Some(r)) => match (l.mtime, r.mtime) { + (Some(lt), Some(rt)) if lt > rt + MTIME_SLACK => SyncStatus::LocalNewer, + (Some(lt), Some(rt)) if rt > lt + MTIME_SLACK => SyncStatus::RemoteNewer, + _ if l.size != r.size => SyncStatus::Different, + _ => SyncStatus::Same, + }, + } +} + +/// Operations on one server through one SFTP session. +pub struct Deployment<'a> { + pub fs: &'a RemoteFs, + pub map: &'a Mappings, + pub exclude: &'a Excludes, + pub preserve_time: bool, + pub cancel: CancellationToken, +} + +impl Deployment<'_> { + fn rel(&self, local: &Path) -> String { + posix(local.strip_prefix(self.map.project_root()).unwrap_or(local)) + } + + fn excluded_local(&self, local: &Path) -> bool { + self.exclude.local(local.strip_prefix(self.map.project_root()).unwrap_or(local)) + } + + /// Local files under `paths` (folders recursively), excluded names left out. + fn local_files(&self, paths: &[PathBuf], report: &mut TransferReport) -> Vec { + let mut out = Vec::new(); + for p in paths { + if self.excluded_local(p) { + report.skipped += 1; + continue; + } + if p.is_dir() { + let walk = ignore::WalkBuilder::new(p).standard_filters(false).follow_links(false).build(); + for entry in walk.flatten() { + let path = entry.path(); + if entry.file_type().is_some_and(|t| t.is_file()) { + if self.excluded_local(path) { + report.skipped += 1; + } else { + out.push(path.to_path_buf()); + } + } + } + } else if p.is_file() { + out.push(p.clone()); + } + } + out + } + + /// Remote files under `dir` (recursively), excluded ones left out. + async fn remote_files(&self, dir: &str, out: &mut Vec) -> IdeResult<()> { + for e in self.fs.list(dir).await? { + if self.cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if self.exclude.remote(&e.path) { + continue; + } + if e.is_dir && !e.is_symlink { + Box::pin(self.remote_files(&e.path, out)).await?; + } else if !e.is_dir { + out.push(e); + } + } + Ok(()) + } + + pub async fn upload( + &self, + paths: &[PathBuf], + progress: &mut (dyn FnMut(&str, usize, usize) + Send), + ) -> TransferReport { + let mut report = TransferReport::default(); + let files = self.local_files(paths, &mut report); + for (i, local) in files.iter().enumerate() { + if self.cancel.is_cancelled() { + break; + } + let rel = self.rel(local); + let Some(remote) = self.map.remote(local).filter(|r| !self.exclude.remote(r)) else { + report.skipped += 1; + continue; + }; + progress(&rel, i, files.len()); + match self.fs.upload(local, &remote, self.preserve_time).await { + Ok(()) => report.files += 1, + Err(e) => report.fail(rel, e), + } + } + report + } + + /// Downloads the remote counterparts of `paths` (folders recursively). + pub async fn download( + &self, + paths: &[PathBuf], + progress: &mut (dyn FnMut(&str, usize, usize) + Send), + ) -> TransferReport { + let mut report = TransferReport::default(); + let mut jobs: Vec<(String, PathBuf)> = Vec::new(); + for p in paths { + let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else { + report.skipped += 1; + continue; + }; + match self.fs.stat(&remote).await { + Ok(Some(e)) if e.is_dir => { + let mut files = Vec::new(); + if let Err(e) = self.remote_files(&remote, &mut files).await { + report.fail(self.rel(p), e); + } + for f in files { + if let Some(local) = self.map.local(&f.path) { + jobs.push((f.path, local)); + } + } + } + Ok(Some(_)) => jobs.push((remote, p.clone())), + Ok(None) => { + report.fail(self.rel(p), IdeError::not_found(format!("{remote} does not exist on the server"))) + } + Err(e) => report.fail(self.rel(p), e), + } + } + for (i, (remote, local)) in jobs.iter().enumerate() { + if self.cancel.is_cancelled() { + break; + } + if self.excluded_local(local) { + report.skipped += 1; + continue; + } + let rel = self.rel(local); + progress(&rel, i, jobs.len()); + match self.fs.download(remote, local).await { + Ok(()) => report.files += 1, + Err(e) => report.fail(rel, e), + } + } + report + } + + /// Deletes the remote counterparts of `paths`. + pub async fn delete_remote(&self, paths: &[PathBuf]) -> TransferReport { + let mut report = TransferReport::default(); + for p in paths { + // Never a whole mapping (the project folder went away, or a mapped folder did). + let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r) && !self.map.is_root(r)) else { + report.skipped += 1; + continue; + }; + match self.fs.stat(&remote).await { + Ok(None) => {} + Ok(Some(_)) => match self.fs.remove(&remote).await { + Ok(()) => report.files += 1, + Err(e) => report.fail(self.rel(p), e), + }, + Err(e) => report.fail(self.rel(p), e), + } + } + report + } + + /// Sync with Deployed: local and remote files under `local` side by side. + pub async fn compare(&self, local: &Path) -> IdeResult> { + let remote_root = + self.map.remote(local).ok_or_else(|| IdeError::invalid(format!("{} is not mapped", local.display())))?; + let mut by_rel: BTreeMap = BTreeMap::new(); + let mut ignored = TransferReport::default(); + for f in self.local_files(&[local.to_path_buf()], &mut ignored) { + let Some(remote_path) = self.map.remote(&f) else { continue }; + let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0); + let stamp = FileStamp { size, mtime: mtime_of(&f) }; + by_rel.insert( + self.rel(&f), + SyncEntry { + path: self.rel(&f), + remote_path, + local: Some(stamp), + remote: None, + status: SyncStatus::LocalOnly, + }, + ); + } + let mut remote = Vec::new(); + match self.fs.stat(&remote_root).await? { + Some(e) if e.is_dir => self.remote_files(&remote_root, &mut remote).await?, + Some(e) => remote.push(e), + None => {} + } + for e in remote { + let Some(l) = self.map.local(&e.path) else { continue }; + if self.excluded_local(&l) { + continue; + } + let rel = self.rel(&l); + let stamp = FileStamp { size: e.size, mtime: e.mtime }; + by_rel.entry(rel.clone()).and_modify(|x| x.remote = Some(stamp)).or_insert(SyncEntry { + path: rel, + remote_path: e.path, + local: None, + remote: Some(stamp), + status: SyncStatus::RemoteOnly, + }); + } + Ok(by_rel + .into_values() + .map(|mut e| { + e.status = status(e.local, e.remote); + e + }) + .collect()) + } + + /// Applies the chosen Sync with Deployed actions. + pub async fn apply( + &self, + steps: &[SyncStep], + progress: &mut (dyn FnMut(&str, usize, usize) + Send), + ) -> TransferReport { + let mut report = TransferReport::default(); + let root = self.map.project_root().to_path_buf(); + for (i, step) in steps.iter().enumerate() { + if self.cancel.is_cancelled() { + break; + } + let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s)); + let Some(remote) = + self.map.remote(&local).filter(|r| !self.exclude.remote(r) && !self.excluded_local(&local)) + else { + report.skipped += 1; + continue; + }; + progress(&step.path, i, steps.len()); + let r = match step.action { + SyncAction::Upload => self.fs.upload(&local, &remote, self.preserve_time).await, + SyncAction::Download => self.fs.download(&remote, &local).await, + SyncAction::DeleteRemote => self.fs.remove(&remote).await, + SyncAction::DeleteLocal => std::fs::remove_file(&local).map_err(IdeError::from), + }; + match r { + Ok(()) => report.files += 1, + Err(e) => report.fail(step.path.clone(), e), + } + } + report + } + + /// Uploads what changed locally under `local` (new, newer or resized files): sync before a run. + pub async fn push_changes( + &self, + local: &Path, + progress: &mut (dyn FnMut(&str, usize, usize) + Send), + ) -> IdeResult { + let steps: Vec = self + .compare(local) + .await? + .into_iter() + .filter(|e| matches!(e.status, SyncStatus::LocalOnly | SyncStatus::LocalNewer | SyncStatus::Different)) + .map(|e| SyncStep { path: e.path, action: SyncAction::Upload }) + .collect(); + Ok(self.apply(&steps, progress).await) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stamp(size: u64, mtime: u32) -> Option { + Some(FileStamp { size, mtime: Some(mtime) }) + } + + #[test] + fn compares_by_time_then_size() { + assert_eq!(status(stamp(1, 100), None), SyncStatus::LocalOnly); + assert_eq!(status(None, stamp(1, 100)), SyncStatus::RemoteOnly); + assert_eq!(status(stamp(1, 200), stamp(1, 100)), SyncStatus::LocalNewer); + assert_eq!(status(stamp(1, 100), stamp(1, 200)), SyncStatus::RemoteNewer); + assert_eq!(status(stamp(1, 101), stamp(1, 100)), SyncStatus::Same); + assert_eq!(status(stamp(2, 100), stamp(1, 100)), SyncStatus::Different); + } +} diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs new file mode 100644 index 000000000..09af9f62e --- /dev/null +++ b/crates/ide-remote/src/lib.rs @@ -0,0 +1,12 @@ +//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run +//! configurations on run targets ([`target`]). + +pub mod deploy; +pub mod mapping; +pub mod service; +pub mod target; + +pub use deploy::{Deployment, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport}; +pub use mapping::{Excludes, Mappings}; +pub use service::{DeployedPath, RemoteService}; +pub use target::{RemoteRuns, RemoteStart}; diff --git a/crates/ide-remote/src/mapping.rs b/crates/ide-remote/src/mapping.rs new file mode 100644 index 000000000..0fbcb0caa --- /dev/null +++ b/crates/ide-remote/src/mapping.rs @@ -0,0 +1,219 @@ +//! Local ↔ remote paths of a deployment server's mappings, and excluded names. + +use globset::{Glob, GlobSet, GlobSetBuilder}; +use ide_core::remote_settings::{DeploymentServer, DeploymentSettings}; +use ide_ssh::sftp::join; +use std::path::{Component, Path, PathBuf}; + +/// A server's mappings resolved against the project and the server root. +#[derive(Debug, Clone)] +pub struct Mappings { + root: PathBuf, + /// (absolute local folder, absolute remote folder, web path), longest local first. + entries: Vec<(PathBuf, String, String)>, + web_url: String, +} + +/// `a/b` (POSIX) of a relative path. +pub fn posix(rel: &Path) -> String { + rel.components() + .filter_map(|c| match c { + Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/") +} + +impl Mappings { + /// `remote_root` is the server root resolved on the host (its login + /// directory when the setting is empty). A server without mappings maps the + /// project root onto the server root. + pub fn new(server: &DeploymentServer, project_root: &Path, remote_root: &str) -> Self { + let mut entries: Vec<(PathBuf, String, String)> = server + .mappings + .iter() + .map(|m| { + let local = m.local.trim().trim_matches('/'); + let local = if local.is_empty() { project_root.to_path_buf() } else { project_root.join(local) }; + // Relative to the server root, with or without a leading `/`. + let deployment = m.deployment.trim().trim_start_matches("./").trim_matches('/'); + let remote = if deployment.is_empty() { remote_root.to_owned() } else { join(remote_root, deployment) }; + (local, remote.trim_end_matches('/').to_owned(), m.web.trim().trim_matches('/').to_owned()) + }) + .collect(); + if entries.is_empty() { + entries.push((project_root.to_path_buf(), remote_root.trim_end_matches('/').to_owned(), String::new())); + } + entries.sort_by_key(|(l, _, _)| std::cmp::Reverse(l.components().count())); + Self { + root: project_root.to_path_buf(), + entries, + web_url: server.web_url.trim().trim_end_matches('/').to_owned(), + } + } + + pub fn project_root(&self) -> &Path { + &self.root + } + + /// The remote path of a local file or folder (`None`: not under any mapping). + pub fn remote(&self, local: &Path) -> Option { + self.entries.iter().find_map(|(l, r, _)| { + let rel = local.strip_prefix(l).ok()?; + // `..` would leave the mapping (`posix` drops it). + if rel.components().any(|c| !matches!(c, Component::Normal(_))) { + return None; + } + let rel = posix(rel); + Some(if rel.is_empty() { r.clone() } else { join(r, &rel) }) + }) + } + + /// `remote` is the remote folder of a mapping (deleting it would wipe the deployment). + pub fn is_root(&self, remote: &str) -> bool { + let remote = remote.trim_end_matches('/'); + self.entries.iter().any(|(_, r, _)| r.trim_end_matches('/') == remote) + } + + /// The local path of a remote file or folder (`None`: not under any mapping). + pub fn local(&self, remote: &str) -> Option { + let remote = remote.trim_end_matches('/'); + let mut best: Option<(&PathBuf, &str, usize)> = None; + for (l, r, _) in &self.entries { + let rest = if remote == r { + "" + } else if let Some(rest) = remote.strip_prefix(r.as_str()).and_then(|x| x.strip_prefix('/')) { + rest + } else if r.is_empty() || r == "/" { + remote.trim_start_matches('/') + } else { + continue; + }; + if best.is_none_or(|(_, _, len)| r.len() > len) { + best = Some((l, rest, r.len())); + } + } + let (l, rest, _) = best?; + // A `..` from the server must not leave the local folder. + if rest.split('/').any(|s| s == ".." || s == ".") { + return None; + } + Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s))) + } + + /// The web URL of a local file (`None` without a server URL or mapping). + pub fn web_url(&self, local: &Path) -> Option { + if self.web_url.is_empty() { + return None; + } + self.entries.iter().find_map(|(l, _, web)| { + let rel = posix(local.strip_prefix(l).ok()?); + let path = + [web.as_str(), rel.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join("/"); + Some(if path.is_empty() { format!("{}/", self.web_url) } else { format!("{}/{path}", self.web_url) }) + }) + } +} + +/// Settings › Deployment › Options "Exclude items by name". +#[derive(Debug, Clone)] +pub struct Excludes { + names: GlobSet, + /// Remote paths (absolute) never touched. + remote: Vec, +} + +impl Excludes { + pub fn new(settings: &DeploymentSettings, server: &DeploymentServer, remote_root: &str) -> Self { + let mut b = GlobSetBuilder::new(); + for pattern in settings.exclude.iter().flat_map(|p| p.split(';')).map(str::trim).filter(|p| !p.is_empty()) { + match Glob::new(pattern) { + Ok(g) => { + b.add(g); + } + Err(e) => tracing::warn!("deployment exclude {pattern:?}: {e}"), + } + } + let remote = server + .excluded_paths + .iter() + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .map(|p| join(remote_root, p.trim_start_matches('/')).trim_end_matches('/').to_owned()) + .collect(); + Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), remote } + } + + pub fn name(&self, name: &str) -> bool { + self.names.is_match(name) + } + + /// Any component of `rel` (relative to the project) is excluded by name. + pub fn local(&self, rel: &Path) -> bool { + rel.components().any(|c| matches!(c, Component::Normal(s) if self.name(&s.to_string_lossy()))) + } + + pub fn remote(&self, path: &str) -> bool { + let path = path.trim_end_matches('/'); + path.rsplit('/').next().is_some_and(|n| self.name(n)) + || self + .remote + .iter() + .any(|r| path == r || path.strip_prefix(r.as_str()).is_some_and(|x| x.starts_with('/'))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::remote_settings::DeploymentMapping; + + fn server(mappings: Vec<(&str, &str, &str)>) -> DeploymentServer { + DeploymentServer { + web_url: "https://example.com/app/".into(), + mappings: mappings + .into_iter() + .map(|(l, d, w)| DeploymentMapping { local: l.into(), deployment: d.into(), web: w.into() }) + .collect(), + excluded_paths: vec!["logs".into()], + ..Default::default() + } + } + + #[test] + fn maps_paths_both_ways_by_the_longest_mapping() { + let root = Path::new("/p"); + let m = Mappings::new(&server(vec![("", "www", ""), ("static", "/cdn/assets/", "s")]), root, "/home/me"); + assert_eq!(m.remote(Path::new("/p/src/a.py")).as_deref(), Some("/home/me/www/src/a.py")); + assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/home/me/cdn/assets/x.css")); + assert_eq!(m.remote(Path::new("/p")).as_deref(), Some("/home/me/www")); + assert_eq!(m.remote(Path::new("/elsewhere/a")), None); + assert_eq!(m.local("/home/me/www/src/a.py"), Some(PathBuf::from("/p/src/a.py"))); + assert_eq!(m.local("/home/me/cdn/assets/x.css"), Some(PathBuf::from("/p/static/x.css"))); + assert_eq!(m.local("/var/other"), None); + assert_eq!(m.web_url(Path::new("/p/static/x.css")).as_deref(), Some("https://example.com/app/s/x.css")); + assert_eq!(m.web_url(Path::new("/p/index.html")).as_deref(), Some("https://example.com/app/index.html")); + } + + #[test] + fn without_mappings_the_project_root_maps_onto_the_server_root() { + let m = Mappings::new(&server(vec![]), Path::new("/p"), "/srv/app"); + assert_eq!(m.remote(Path::new("/p/a/b.txt")).as_deref(), Some("/srv/app/a/b.txt")); + assert_eq!(m.local("/srv/app"), Some(PathBuf::from("/p"))); + } + + #[test] + fn excludes_names_and_remote_paths() { + let settings = + DeploymentSettings { exclude: vec![".git;*.pyc".into(), "node_modules".into()], ..Default::default() }; + let x = Excludes::new(&settings, &server(vec![]), "/srv"); + assert!(x.local(Path::new("src/.git/config"))); + assert!(x.local(Path::new("a/b.pyc"))); + assert!(x.local(Path::new("node_modules/x"))); + assert!(!x.local(Path::new("src/main.py"))); + assert!(x.remote("/srv/logs/today.log")); + assert!(x.remote("/srv/logs")); + assert!(!x.remote("/srv/logsx")); + } +} diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs new file mode 100644 index 000000000..9be7444b5 --- /dev/null +++ b/crates/ide-remote/src/service.rs @@ -0,0 +1,368 @@ +//! The deployment and run-target operations of one app, over the shared SSH +//! connections: what the IPC commands and the save hook call. + +use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport}; +use crate::mapping::{Excludes, Mappings}; +use crate::target::{RemoteRuns, RemoteStart, SyncFn}; +use ide_core::remote_settings::{AutoUpload, DeploymentServer, RunTarget}; +use ide_core::settings::Settings; +use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink}; +use ide_run::{OutputChunk, RunConfig, RunInfo}; +use ide_ssh::{RemoteEntry, RemoteFs, SshManager}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Progress of a transfer: (project-relative path, index, total). +pub type Progress<'a> = &'a mut (dyn FnMut(&str, usize, usize) + Send); + +/// A remote file opened for editing (a local copy uploaded back on save). +#[derive(Debug, Clone)] +struct RemoteEdit { + ssh_config: String, + remote: String, +} + +/// Where a local file is deployed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DeployedPath { + pub server: String, + pub remote_path: String, + pub web_url: Option, +} + +pub struct RemoteService { + ssh: Arc, + /// Local copies of remote files: `//`. + cache: PathBuf, + edits: Mutex>, + pub runs: RemoteRuns, +} + +/// The deployment server `id`, or the default one. +pub fn server(settings: &Settings, id: Option<&str>) -> IdeResult { + let d = &settings.deployment; + let id = id.filter(|s| !s.is_empty()).unwrap_or(&d.default_server); + if id.is_empty() { + return Err(IdeError::invalid("no default deployment server (Settings › Deployment)")); + } + d.servers + .iter() + .find(|s| s.id == id) + .cloned() + .ok_or_else(|| IdeError::not_found(format!("no deployment server {id}"))) +} + +pub fn run_target(settings: &Settings, id: &str) -> IdeResult { + settings + .run_targets + .iter() + .find(|t| t.id == id) + .cloned() + .ok_or_else(|| IdeError::not_found(format!("no run target {id} (Settings › Run Targets)"))) +} + +impl RemoteService { + pub fn new(ssh: Arc, cache: PathBuf) -> Self { + Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() } + } + + async fn fs(&self, ssh_config: &str) -> IdeResult { + if ssh_config.is_empty() { + return Err(IdeError::invalid("the deployment server has no SSH configuration")); + } + let session = self.ssh.session(ssh_config).await?; + Ok(RemoteFs::new(session.sftp().await?)) + } + + /// Runs `f` with a [`Deployment`] on `server`. + async fn with_deployment( + &self, + settings: &Settings, + root: &Path, + server: &DeploymentServer, + cancel: CancellationToken, + f: impl AsyncFnOnce(&Deployment<'_>) -> T, + ) -> IdeResult { + let fs = self.fs(&server.ssh_config).await?; + let remote_root = match fs.resolve(&server.root_path).await { + Ok(r) => r, + Err(e) => { + fs.close().await; + return Err(e); + } + }; + let map = Mappings::new(server, root, &remote_root); + let exclude = Excludes::new(&settings.deployment, server, &remote_root); + let d = Deployment { + fs: &fs, + map: &map, + exclude: &exclude, + preserve_time: settings.deployment.preserve_timestamps, + cancel, + }; + let out = f(&d).await; + fs.close().await; + Ok(out) + } + + pub async fn upload( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + paths: &[PathBuf], + cancel: CancellationToken, + progress: Progress<'_>, + ) -> IdeResult { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, cancel, async |d| d.upload(paths, progress).await).await + } + + pub async fn download( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + paths: &[PathBuf], + cancel: CancellationToken, + progress: Progress<'_>, + ) -> IdeResult { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, cancel, async |d| d.download(paths, progress).await).await + } + + pub async fn delete_remote( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + paths: &[PathBuf], + ) -> IdeResult { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| d.delete_remote(paths).await) + .await + } + + pub async fn compare( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + path: &Path, + cancel: CancellationToken, + ) -> IdeResult> { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, cancel, async |d| d.compare(path).await).await? + } + + pub async fn sync( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + steps: &[SyncStep], + cancel: CancellationToken, + progress: Progress<'_>, + ) -> IdeResult { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, cancel, async |d| d.apply(steps, progress).await).await + } + + /// Where `path` is deployed on `server_id` (or the default server). + pub async fn deployed( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + path: &Path, + ) -> IdeResult> { + let server = server(settings, server_id)?; + let name = server.name.clone(); + self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| { + d.map.remote(path).map(|remote_path| DeployedPath { + server: name, + remote_path, + web_url: d.map.web_url(path), + }) + }) + .await + } + + // --- Remote Host browser ------------------------------------------------------- + + /// The server root of `server_id`, resolved on the host. + pub async fn server_root(&self, settings: &Settings, server_id: &str) -> IdeResult { + let server = server(settings, Some(server_id))?; + let fs = self.fs(&server.ssh_config).await?; + let r = fs.resolve(&server.root_path).await; + fs.close().await; + r + } + + pub async fn list(&self, ssh_config: &str, path: &str) -> IdeResult> { + let fs = self.fs(ssh_config).await?; + let r = async { fs.list(&fs.resolve(path).await?).await }.await; + fs.close().await; + r + } + + /// Downloads a remote file for editing; saving the copy uploads it back. + pub async fn open_remote(&self, ssh_config: &str, remote: &str) -> IdeResult { + let rel = remote.split('/').filter(|s| !s.is_empty() && *s != "..").fold(PathBuf::new(), |p, s| p.join(s)); + let local = self.cache.join(sanitize(ssh_config)).join(rel); + let fs = self.fs(ssh_config).await?; + let r = fs.download(remote, &local).await; + fs.close().await; + r?; + self.edits + .lock() + .insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() }); + Ok(local) + } + + /// The remote file a local copy stands for. + pub fn remote_of(&self, local: &Path) -> Option<(String, String)> { + self.edits.lock().get(local).map(|e| (e.ssh_config.clone(), e.remote.clone())) + } + + pub async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { + let fs = self.fs(ssh_config).await?; + let r = fs.remove(remote).await; + fs.close().await; + r + } + + pub async fn remote_mkdir(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { + let fs = self.fs(ssh_config).await?; + let r = fs.mkdir_all(remote).await; + fs.close().await; + r + } + + pub async fn remote_rename(&self, ssh_config: &str, from: &str, to: &str) -> IdeResult<()> { + let fs = self.fs(ssh_config).await?; + let r = fs.rename(from, to).await; + fs.close().await; + r + } + + // --- save hook ----------------------------------------------------------------- + + /// After a document save: uploads a remote file's copy, or (automatic upload) + /// the file to the default server. `None` when nothing applies. + pub async fn on_saved(&self, settings: &Settings, root: &Path, path: &Path) -> Option> { + if let Some((config, remote)) = self.remote_of(path) { + let r = async { + let fs = self.fs(&config).await?; + let r = fs.upload(path, &remote, false).await; + fs.close().await; + r.map(|()| TransferReport { files: 1, ..Default::default() }) + } + .await; + return Some(r); + } + if settings.deployment.auto_upload == AutoUpload::Never + || settings.deployment.default_server.is_empty() + || !path.starts_with(root) + { + return None; + } + Some( + self.upload(settings, root, None, &[path.to_path_buf()], CancellationToken::new(), &mut |_, _, _| {}).await, + ) + } + + /// After files were deleted locally: their remote copies too (automatic upload + Delete remote files). + pub async fn on_deleted( + &self, + settings: &Settings, + root: &Path, + paths: &[PathBuf], + ) -> Option> { + let d = &settings.deployment; + if d.auto_upload == AutoUpload::Never || !d.delete_remote || d.default_server.is_empty() { + return None; + } + Some(self.delete_remote(settings, root, None, paths).await) + } + + // --- run targets --------------------------------------------------------------- + + /// Starts `config` on its run target. + pub async fn run( + &self, + settings: &Settings, + root: &Path, + config: &RunConfig, + sink: SharedSink, + on_output: Box, + ) -> IdeResult { + let target_id = config.extras.target.as_deref().unwrap_or_default(); + let target = run_target(settings, target_id)?; + let session = self.ssh.session(&target.ssh_config).await?; + let fs = RemoteFs::new(session.sftp().await?); + let folder = if target.project_root.trim().is_empty() { + format!( + "~/{}", + root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "project".into()) + ) + } else { + target.project_root.clone() + }; + let remote_root = fs.resolve(&folder).await; + let remote_root = match remote_root { + Ok(r) => r, + Err(e) => { + fs.close().await; + return Err(e); + } + }; + let sync: Option = if target.sync_before_run { + let deployment = settings.deployment.clone(); + let root = root.to_path_buf(); + let remote_root = remote_root.clone(); + Some(Box::new(move |out| { + Box::pin(async move { + let server = DeploymentServer { root_path: remote_root.clone(), ..Default::default() }; + let map = Mappings::new(&server, &root, &remote_root); + let exclude = Excludes::new(&deployment, &server, &remote_root); + let d = Deployment { + fs: &fs, + map: &map, + exclude: &exclude, + preserve_time: true, + cancel: CancellationToken::new(), + }; + let mut progress = |_: &str, _: usize, _: usize| {}; + let report = d.push_changes(&root, &mut progress).await; + fs.close().await; + let report = report?; + out(OutputChunk::System { + text: format!("Uploaded {} changed file(s) to {remote_root}\n", report.files), + }); + match report.errors.first() { + Some(e) => Err(IdeError::new( + ide_core::error::ErrorKind::Io, + format!("upload of {} failed: {}", e.path, e.message), + )), + None => Ok(()), + } + }) + })) + } else { + fs.close().await; + None + }; + let start = RemoteStart { session, target, root: root.to_path_buf(), remote_root, sync }; + Ok(self.runs.start(start, config, sink, on_output)) + } +} + +fn sanitize(s: &str) -> String { + s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect() +} diff --git a/crates/ide-remote/src/target.rs b/crates/ide-remote/src/target.rs new file mode 100644 index 000000000..97577e0d9 --- /dev/null +++ b/crates/ide-remote/src/target.rs @@ -0,0 +1,368 @@ +//! Run configurations on a run target: the project is synchronised to the +//! target's folder, then the command runs there over SSH with local paths and +//! interpreters swapped for their remote counterparts. + +use ide_core::remote_settings::RunTarget; +use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, RunId, SharedSink}; +use ide_run::{OutputChunk, RunConfig, RunInfo}; +use ide_ssh::sftp::join; +use ide_ssh::{Output, RemoteProcess, Session, Sig}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::future::Future; +use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +type OutputFn = Arc; +/// Uploads local changes before the run; reports progress lines. +pub type SyncFn = Box Pin> + Send>> + Send>; + +/// The remote program, arguments, folder and environment of a configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteCommand { + pub program: String, + pub args: Vec, + pub cwd: String, + pub env: BTreeMap, + /// A whole shell command line (configurations with a command and no arguments). + pub shell_line: Option, +} + +impl RemoteCommand { + pub fn line(&self) -> String { + match &self.shell_line { + Some(line) => { + let exports: String = self + .env + .iter() + .map(|(k, v)| format!("export {}; ", ide_ssh::shell::quote(&format!("{k}={v}")))) + .collect(); + format!("cd {} && {exports}{line}", ide_ssh::shell::quote(&self.cwd)) + } + None => ide_ssh::shell::command_line(Some(&self.cwd), &self.env, &self.program, &self.args), + } + } + + /// The echoed command line. + pub fn display(&self) -> String { + match &self.shell_line { + Some(line) => line.clone(), + None => std::iter::once(self.program.as_str()) + .chain(self.args.iter().map(String::as_str)) + .map(ide_ssh::shell::quote) + .collect::>() + .join(" "), + } + } +} + +/// `s` with the path `root` replaced by `remote` where it appears as a whole path +/// (followed by a separator or the end of a path), not as the prefix of a longer name. +/// The rest of a Windows path after the root takes `/` separators too. +fn replace_root(s: &str, root: &str, remote: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find(root) { + let mut after = &rest[i + root.len()..]; + let whole = after.chars().next().is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))); + out.push_str(&rest[..i]); + if whole { + out.push_str(remote); + if root.contains('\\') { + let end = after + .find(|c: char| c.is_whitespace() || matches!(c, ';' | ',' | '"' | '\'')) + .unwrap_or(after.len()); + out.push_str(&after[..end].replace('\\', "/")); + after = &after[end..]; + } + } else { + out.push_str(root); + } + rest = after; + } + out.push_str(rest); + out +} + +fn file_name(program: &str) -> &str { + program.rsplit(['/', '\\']).next().unwrap_or(program) +} + +/// The target's interpreter for a local interpreter executable, if it is one. +fn interpreter<'a>(program: &str, target: &'a RunTarget) -> Option<&'a str> { + let name = file_name(program).trim_end_matches(".exe").to_ascii_lowercase(); + let pick = |configured: &'a str, default: &'a str| { + Some(if configured.trim().is_empty() { default } else { configured.trim() }) + }; + if name == "python" || name.starts_with("python3") || name == "python2" { + pick(&target.python, "python3") + } else if name == "node" { + pick(&target.node, "node") + } else if name == "java" { + pick(&target.java, "java") + } else { + None + } +} + +/// Builds the remote command of `config`; `root` (the project) maps onto `remote_root`. +pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_root: &str) -> RemoteCommand { + let root_str = root.to_string_lossy().into_owned(); + let map = |s: &str| -> String { + if root_str.is_empty() { + return s.to_owned(); + } + replace_root(s, &root_str, remote_root.trim_end_matches('/')).replace('\\', "/") + }; + let map_text = |s: &str| -> String { + if root_str.is_empty() { s.to_owned() } else { replace_root(s, &root_str, remote_root.trim_end_matches('/')) } + }; + let cwd = match config.cwd.as_ref().map(|p| p.to_path_buf()) { + Some(dir) if dir.is_absolute() => match dir.strip_prefix(root) { + Ok(rel) => join(remote_root, &crate::mapping::posix(rel)), + Err(_) => remote_root.to_owned(), + }, + Some(dir) if !dir.as_os_str().is_empty() => join(remote_root, &crate::mapping::posix(&dir)), + _ => remote_root.to_owned(), + }; + let env = config.env.iter().map(|(k, v)| (k.clone(), map_text(v))).collect(); + let command = config.command.trim(); + if config.args.is_empty() && command.contains(char::is_whitespace) { + return RemoteCommand { + program: String::new(), + args: Vec::new(), + cwd, + env, + shell_line: Some(map_text(command)), + }; + } + let program = match interpreter(command, target) { + Some(remote) => remote.to_owned(), + None if Path::new(command).is_absolute() && Path::new(command).starts_with(root) => map(command), + None if Path::new(command).is_absolute() => file_name(command).to_owned(), + None => command.to_owned(), + }; + let args = config.args.iter().map(|a| map_text(a)).collect(); + RemoteCommand { program, args, cwd, env, shell_line: None } +} + +struct RemoteRun { + info: Mutex, + process: Mutex>>, + cancel: CancellationToken, + stopping: AtomicBool, + /// The runtime it started on: stdin / stop requests may come from threads without one. + runtime: tokio::runtime::Handle, +} + +/// Runs on run targets, alongside the local `ide_run::ProcessManager`. +#[derive(Default)] +pub struct RemoteRuns { + runs: Arc>>>, +} + +pub struct RemoteStart { + pub session: Arc, + pub target: RunTarget, + pub root: std::path::PathBuf, + /// The target's project folder, resolved on the host. + pub remote_root: String, + /// Uploads local changes first (`sync_before_run`). + pub sync: Option, +} + +impl RemoteRuns { + pub fn new() -> Self { + Self::default() + } + + pub fn contains(&self, id: RunId) -> bool { + self.runs.lock().contains_key(&id) + } + + pub fn list(&self) -> Vec { + self.runs.lock().values().map(|r| r.info.lock().clone()).collect() + } + + /// Starts `config` on the target; returns at once (synchronisation and the + /// remote process follow in the background, reported as output). + pub fn start( + &self, + start: RemoteStart, + config: &RunConfig, + sink: SharedSink, + on_output: Box, + ) -> RunInfo { + let id = RunId::next(); + let pty = config.extras.emulate_terminal; + let info = + RunInfo { id, config_id: config.id.clone(), name: config.name.clone(), pid: None, running: true, pty }; + let run = Arc::new(RemoteRun { + info: Mutex::new(info.clone()), + process: Mutex::new(None), + cancel: CancellationToken::new(), + stopping: AtomicBool::new(false), + runtime: tokio::runtime::Handle::current(), + }); + self.runs.lock().insert(id, run.clone()); + let on_output: OutputFn = Arc::from(on_output); + let command = remote_command(config, &start.target, &start.root, &start.remote_root); + tokio::spawn(async move { + let code = run_remote(&run, start, command, pty, on_output.clone()).await; + let message = match &code { + Ok(Some(c)) => format!("Process finished with exit code {c}"), + Ok(None) if run.stopping.load(Ordering::Acquire) => "Process finished (stopped)".to_owned(), + Ok(None) => "Process finished".to_owned(), + Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => "Process finished (stopped)".to_owned(), + Err(e) => format!("Cannot run on the target: {}", e.message), + }; + on_output(OutputChunk::System { text: format!("\n{message}\n") }); + run.info.lock().running = false; + run.process.lock().take(); + let code = code.ok().flatten().and_then(|c| i32::try_from(c).ok()); + sink.emit(IdeEvent::RunExited { run: id, code }); + }); + info + } + + pub fn write_stdin(&self, id: RunId, data: &str) -> IdeResult<()> { + let (process, rt) = self.process(id)?; + let data = data.as_bytes().to_vec(); + rt.spawn(async move { process.write(&data).await }); + Ok(()) + } + + pub fn close_stdin(&self, id: RunId) -> IdeResult<()> { + let (process, rt) = self.process(id)?; + rt.spawn(async move { process.eof().await }); + Ok(()) + } + + pub fn resize(&self, id: RunId, cols: u16, rows: u16) -> IdeResult<()> { + let (process, rt) = self.process(id)?; + rt.spawn(async move { process.resize(cols, rows).await }); + Ok(()) + } + + /// `INT`, then `KILL` (closing the channel) after `grace`. + pub fn stop(&self, id: RunId, grace: std::time::Duration) -> IdeResult<()> { + let run = + self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?; + run.stopping.store(true, Ordering::Release); + run.cancel.cancel(); + let Some(process) = run.process.lock().clone() else { return Ok(()) }; + let rt = run.runtime.clone(); + rt.spawn(async move { + let _ = process.signal(Sig::INT).await; + tokio::time::sleep(grace).await; + if run.info.lock().running { + let _ = process.signal(Sig::KILL).await; + } + }); + Ok(()) + } + + fn process(&self, id: RunId) -> IdeResult<(Arc, tokio::runtime::Handle)> { + let run = + self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?; + let process = run.process.lock().clone(); + let process = process.ok_or_else(|| IdeError::unavailable("the remote process has not started yet"))?; + Ok((process, run.runtime.clone())) + } +} + +async fn run_remote( + run: &RemoteRun, + start: RemoteStart, + command: RemoteCommand, + pty: bool, + on_output: OutputFn, +) -> IdeResult> { + on_output(OutputChunk::System { text: format!("[{}] {}\n", start.target.name, start.session.label()) }); + if let Some(sync) = start.sync { + tokio::select! { + r = sync(on_output.clone()) => r?, + _ = run.cancel.cancelled() => return Err(IdeError::cancelled()), + } + } + if run.cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + on_output(OutputChunk::System { text: format!("{}\n", command.display()) }); + let out = on_output.clone(); + let process = start + .session + .spawn( + &command.line(), + pty.then_some((120, 30)), + Box::new(move |chunk| match chunk { + Output::Stdout(b) => out(OutputChunk::Stdout { text: String::from_utf8_lossy(b).into_owned() }), + Output::Stderr(b) => out(OutputChunk::Stderr { text: String::from_utf8_lossy(b).into_owned() }), + }), + ) + .await?; + let process = Arc::new(process); + *run.process.lock() = Some(process.clone()); + Ok(process.wait().await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + fn config(command: &str, args: &[&str], cwd: Option<&str>) -> RunConfig { + RunConfig { + id: "c".into(), + name: "c".into(), + kind: "python".into(), + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + cwd: cwd.map(|c| ide_core::IpcPath::from(std::path::PathBuf::from(c))), + env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + } + } + + // Unix paths are not absolute on Windows (`tests/remote.rs` runs there). + #[cfg(unix)] + #[test] + fn maps_interpreters_paths_and_folders() { + let target = RunTarget { python: "/opt/py/bin/python".into(), ..Default::default() }; + let root = Path::new("/work/proj"); + let c = remote_command( + &config("/work/proj/.venv/bin/python", &["-u", "/work/proj/src/main.py"], Some("/work/proj/src")), + &target, + root, + "/home/me/proj", + ); + assert_eq!(c.program, "/opt/py/bin/python"); + assert_eq!(c.args, ["-u", "/home/me/proj/src/main.py"]); + assert_eq!(c.cwd, "/home/me/proj/src"); + assert_eq!(c.env["DATA"], "/home/me/proj/data"); + assert_eq!( + c.line(), + "cd /home/me/proj/src && exec env DATA=/home/me/proj/data /opt/py/bin/python -u /home/me/proj/src/main.py" + ); + + let c = remote_command(&config("npm run dev", &[], None), &target, root, "/home/me/proj"); + assert_eq!(c.line(), "cd /home/me/proj && export DATA=/home/me/proj/data; npm run dev"); + let c = remote_command(&config("/usr/local/bin/node", &["app.js"], None), &RunTarget::default(), root, "/srv"); + assert_eq!((c.program.as_str(), c.cwd.as_str()), ("node", "/srv")); + let c = remote_command(&config("/usr/bin/make", &["test"], None), &RunTarget::default(), root, "/srv"); + assert_eq!(c.program, "make"); + } + + #[test] + fn maps_the_root_only_as_a_whole_path() { + assert_eq!(replace_root("/work/proj2/x:/work/proj/y", "/work/proj", "/srv"), "/work/proj2/x:/srv/y"); + assert_eq!(replace_root("/work/proj", "/work/proj", "/srv"), "/srv"); + assert_eq!(replace_root(r"C:\p\src/a.py C:\p2", r"C:\p", "/srv"), r"/srv/src/a.py C:\p2"); + } +} diff --git a/crates/ide-remote/tests/remote.rs b/crates/ide-remote/tests/remote.rs new file mode 100644 index 000000000..8d6f76013 --- /dev/null +++ b/crates/ide-remote/tests/remote.rs @@ -0,0 +1,179 @@ +//! Deployment and run targets against the in-process SSH / SFTP server. + +use ide_core::remote_settings::{DeploymentMapping, DeploymentServer, DeploymentSettings, RunTarget, SshAuth}; +use ide_core::{CancellationToken, IdeEvent, SharedSink}; +use ide_remote::{Deployment, Excludes, Mappings, RemoteRuns, RemoteStart, SyncAction, SyncStatus, SyncStep}; +use ide_run::{OutputChunk, RunConfig}; +use ide_ssh::{RemoteFs, Session, SshFiles, SshTarget}; +use parking_lot::Mutex; +use std::sync::Arc; + +struct Env { + _tmp: tempfile::TempDir, + project: std::path::PathBuf, + server_root: std::path::PathBuf, + session: Arc, +} + +async fn env() -> Env { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let server_root = tmp.path().join("server"); + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::create_dir_all(project.join(".git")).unwrap(); + std::fs::create_dir_all(&server_root).unwrap(); + std::fs::write(project.join("src/main.py"), "print('hi')\n").unwrap(); + std::fs::write(project.join("README.md"), "# demo\n").unwrap(); + std::fs::write(project.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + let port = ide_ssh::testing::sshd(server_root.clone(), Arc::new(|cmd| (format!("ran: {cmd}"), 0))).await; + let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }; + let target = SshTarget { + host: "127.0.0.1".into(), + port: Some(port), + user: "dev".into(), + auth: SshAuth::Password, + key_file: String::new(), + }; + let session = match Session::connect(&target, Some("secret"), &files).await { + Err(ide_ssh::ConnectError::UnknownHostKey(k)) => { + k.trust(&files.known_hosts).unwrap(); + Session::connect(&target, Some("secret"), &files).await.ok().unwrap() + } + other => other.ok().unwrap(), + }; + Env { _tmp: tmp, project, server_root, session: Arc::new(session) } +} + +fn server() -> DeploymentServer { + DeploymentServer { + id: "s".into(), + name: "staging".into(), + root_path: "/srv".into(), + mappings: vec![DeploymentMapping { local: String::new(), deployment: "app".into(), web: String::new() }], + ..Default::default() + } +} + +fn no_progress() -> impl FnMut(&str, usize, usize) + Send { + |_, _, _| {} +} + +#[tokio::test] +async fn uploads_compares_syncs_and_downloads() { + let env = env().await; + let fs = RemoteFs::new(env.session.sftp().await.unwrap()); + let server = server(); + let settings = DeploymentSettings::default(); + let map = Mappings::new(&server, &env.project, "/srv"); + let exclude = Excludes::new(&settings, &server, "/srv"); + let d = Deployment { fs: &fs, map: &map, exclude: &exclude, preserve_time: true, cancel: CancellationToken::new() }; + + let report = d.upload(std::slice::from_ref(&env.project), &mut no_progress()).await; + assert_eq!((report.files, report.errors.len()), (2, 0), "{report:?}"); + assert_eq!(std::fs::read_to_string(env.server_root.join("srv/app/src/main.py")).unwrap(), "print('hi')\n"); + assert!(!env.server_root.join("srv/app/.git").exists(), ".git is excluded by name"); + + let same = d.compare(&env.project).await.unwrap(); + assert!(same.iter().all(|e| e.status == SyncStatus::Same), "{same:?}"); + + // A new local file, a file only on the server, a remote change. + std::fs::write(env.project.join("src/new.py"), "x = 1\n").unwrap(); + std::fs::write(env.server_root.join("srv/app/remote.txt"), "server side\n").unwrap(); + std::fs::write(env.server_root.join("srv/app/README.md"), "# changed on the server, longer\n").unwrap(); + let file = std::fs::File::options().write(true).open(env.server_root.join("srv/app/README.md")).unwrap(); + file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60)).unwrap(); + let entries = d.compare(&env.project).await.unwrap(); + let status = |p: &str| entries.iter().find(|e| e.path == p).map(|e| e.status); + assert_eq!(status("src/new.py"), Some(SyncStatus::LocalOnly)); + assert_eq!(status("remote.txt"), Some(SyncStatus::RemoteOnly)); + assert_eq!(status("README.md"), Some(SyncStatus::RemoteNewer)); + assert_eq!(status("src/main.py"), Some(SyncStatus::Same)); + + let steps = vec![ + SyncStep { path: "src/new.py".into(), action: SyncAction::Upload }, + SyncStep { path: "README.md".into(), action: SyncAction::Download }, + SyncStep { path: "remote.txt".into(), action: SyncAction::DeleteRemote }, + ]; + let report = d.apply(&steps, &mut no_progress()).await; + assert_eq!((report.files, report.errors.len()), (3, 0), "{report:?}"); + assert!(env.server_root.join("srv/app/src/new.py").is_file()); + assert!(!env.server_root.join("srv/app/remote.txt").exists()); + assert_eq!(std::fs::read_to_string(env.project.join("README.md")).unwrap(), "# changed on the server, longer\n"); + + // Download a folder, then delete it remotely. + std::fs::remove_dir_all(env.project.join("src")).unwrap(); + let report = d.download(&[env.project.join("src")], &mut no_progress()).await; + assert_eq!((report.files, report.errors.len()), (2, 0), "{report:?}"); + assert!(env.project.join("src/main.py").is_file()); + let report = d.delete_remote(&[env.project.join("src")]).await; + assert_eq!(report.files, 1); + assert!(!env.server_root.join("srv/app/src").exists()); + + // Sync before a run uploads only what changed. + let report = d.push_changes(&env.project, &mut no_progress()).await.unwrap(); + assert_eq!(report.files, 2, "{report:?}"); + assert_eq!(d.push_changes(&env.project, &mut no_progress()).await.unwrap().files, 0); +} + +struct Sink(Mutex>); + +impl ide_core::EventSink for Sink { + fn emit(&self, event: IdeEvent) { + self.0.lock().push(event); + } +} + +#[tokio::test] +async fn runs_a_configuration_on_a_target() { + let env = env().await; + let runs = RemoteRuns::new(); + let target = + RunTarget { id: "t".into(), name: "box".into(), python: "/usr/bin/python3".into(), ..Default::default() }; + let config = RunConfig { + id: "main".into(), + name: "main".into(), + kind: "python".into(), + command: "python3".into(), + args: vec![env.project.join("src/main.py").to_string_lossy().into_owned()], + cwd: None, + env: Default::default(), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + let output: Arc> = Arc::default(); + let out = output.clone(); + let sink = Arc::new(Sink(Mutex::default())); + let start = RemoteStart { + session: env.session.clone(), + target, + root: env.project.clone(), + remote_root: "/home/dev/demo".into(), + sync: None, + }; + let info = runs.start( + start, + &config, + sink.clone() as SharedSink, + Box::new(move |chunk| match chunk { + OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => { + out.lock().push_str(&text) + } + }), + ); + assert!(runs.contains(info.id)); + for _ in 0..200 { + if !sink.0.lock().is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let events = sink.0.lock().clone(); + assert!(matches!(events.as_slice(), [IdeEvent::RunExited { code: Some(0), .. }]), "{events:?}"); + let text = output.lock().clone(); + assert!(text.contains("ran: cd /home/dev/demo && exec /usr/bin/python3 /home/dev/demo/src/main.py"), "{text}"); + assert!(text.contains("Process finished with exit code 0"), "{text}"); + assert!(!runs.list()[0].running); +} diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index e2f067088..9125a4c35 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -111,6 +111,7 @@ struct FileConfig { emulate_terminal: bool, #[serde(alias = "stopCommand")] stop_command: Option, + target: Option, } fn slug(s: &str) -> String { @@ -173,6 +174,7 @@ fn convert(configs: Vec, local: bool) -> Vec { focus_tool_window: c.focus_tool_window, emulate_terminal: c.emulate_terminal, stop_command: non_empty(c.stop_command), + target: non_empty(c.target), }, }); } @@ -275,6 +277,9 @@ fn to_table(c: &RunConfig) -> Table { if let Some(stop) = &x.stop_command { t.insert("stop-command", value(stop)); } + if let Some(target) = &x.target { + t.insert("target", value(target)); + } t } diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 58e6b12cc..f4642380d 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -125,6 +125,10 @@ pub struct RunExtras { #[serde(default, skip_serializing_if = "Option::is_none")] #[specta(optional)] pub stop_command: Option, + /// Settings › Run Targets id the process runs on (over SSH); `None` = this machine. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub target: Option, } impl Default for RunExtras { @@ -145,6 +149,7 @@ impl Default for RunExtras { focus_tool_window: false, emulate_terminal: false, stop_command: None, + target: None, } } } diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 246d3dbf2..729f757f1 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -575,10 +575,9 @@ impl ProcessManager { pub fn list(&self) -> Vec { self.runs.lock().values().map(|e| e.info()).collect() } -} -impl Drop for ProcessManager { - fn drop(&mut self) { + /// Kills every running process tree at once (shutting down). + pub fn kill_all(&self) { for entry in self.runs.lock().values() { if !entry.exited.load(Ordering::Acquire) && let Some(pid) = entry.pid @@ -589,6 +588,12 @@ impl Drop for ProcessManager { } } +impl Drop for ProcessManager { + fn drop(&mut self) { + self.kill_all(); + } +} + /// The environment a configuration's process gets on top of the inherited one: `.env` /// files, then the configured variables (later wins). fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { diff --git a/crates/ide-spell/src/dict/software.txt b/crates/ide-spell/src/dict/software.txt index b67a860cd..478f3ddb6 100644 --- a/crates/ide-spell/src/dict/software.txt +++ b/crates/ide-spell/src/dict/software.txt @@ -503,3 +503,51 @@ webhook webhooks websocket websockets +# Systems, graphics and terminal code +ascii +bools +clippy +cmd +cmds +dep +dst +ffi +gif +gpui +hsl +isize +jpeg +jpg +keybinding +keybindings +keymap +keymaps +lang +langs +lifecycle +micros +millis +nanos +opengl +otf +pty +ptys +realloc +refcount +refcounted +rgb +rgba +rustup +rwlock +semaphore +serde +subdir +subdirs +tokio +ttf +tty +udp +vulkan +wgpu +woff +woff2 diff --git a/crates/ide-ssh/Cargo.toml b/crates/ide-ssh/Cargo.toml new file mode 100644 index 000000000..feb93e126 --- /dev/null +++ b/crates/ide-ssh/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ide-ssh" +description = "SSH connections: configurations, host keys, authentication, remote commands, SFTP, port forwarding and askpass" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tokio.workspace = true +tracing.workspace = true +parking_lot.workspace = true +dirs.workspace = true +russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] } +russh-config = "0.58.0" +russh-sftp = "2.4" +flate2 = "1" + +[features] +# `testing`: an in-process SSH / SFTP server for tests of crates built on this one. +testing = [] + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tempfile.workspace = true diff --git a/crates/ide-ssh/src/askpass.rs b/crates/ide-ssh/src/askpass.rs new file mode 100644 index 000000000..8e84d7d3e --- /dev/null +++ b/crates/ide-ssh/src/askpass.rs @@ -0,0 +1,216 @@ +//! Credential prompts of child processes (`ssh`, `git`) answered by the UI. +//! +//! The app's own executable doubles as the `SSH_ASKPASS` / `GIT_ASKPASS` +//! program: started with [`PORT_ENV`] set, it sends the prompt to the +//! [`AskpassServer`] on 127.0.0.1 and prints the answer ([`run_helper`]). + +use ide_core::{IdeError, IdeResult}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::time::Duration; + +pub const PORT_ENV: &str = "OPENIDEA_ASKPASS_PORT"; +pub const TOKEN_ENV: &str = "OPENIDEA_ASKPASS_TOKEN"; +/// An unanswered prompt fails after this long. +const ANSWER_TIMEOUT: Duration = Duration::from_secs(300); + +pub use ide_core::event::AskpassKind; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AskpassPrompt { + pub id: u64, + pub prompt: String, + pub kind: AskpassKind, +} + +pub fn classify(prompt: &str) -> AskpassKind { + let p = prompt.to_ascii_lowercase(); + if p.contains("(yes/no") || p.contains("continue connecting") { + AskpassKind::Confirm + } else if p.starts_with("username") { + AskpassKind::Text + } else { + AskpassKind::Secret + } +} + +#[derive(Serialize, Deserialize)] +struct Request { + token: String, + prompt: String, +} + +#[derive(Serialize, Deserialize)] +struct Response { + answer: Option, +} + +type Pending = Arc>>>>; + +/// Serves prompts to `on_prompt`; answers come back through [`AskpassServer::answer`]. +pub struct AskpassServer { + port: u16, + token: String, + exe: std::path::PathBuf, + pending: Pending, +} + +impl AskpassServer { + /// Listens on a free local port. `exe` is the helper program (the app itself). + pub fn start(exe: std::path::PathBuf, on_prompt: Arc) -> IdeResult { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + let token = random_token(); + let pending: Pending = Arc::default(); + let (t, p) = (token.clone(), pending.clone()); + std::thread::Builder::new() + .name("askpass".into()) + .spawn(move || { + let next = AtomicU64::new(1); + for stream in listener.incoming().flatten() { + let id = next.fetch_add(1, Ordering::Relaxed); + let (t, p, on_prompt) = (t.clone(), p.clone(), on_prompt.clone()); + let _ = std::thread::Builder::new() + .name("askpass-prompt".into()) + .spawn(move || serve(stream, id, &t, &p, on_prompt.as_ref())); + } + }) + .map_err(|e| IdeError::internal(format!("askpass: {e}")))?; + Ok(Self { port, token, exe, pending }) + } + + /// Answers prompt `id` (`None`: cancelled). + pub fn answer(&self, id: u64, answer: Option) { + if let Some(tx) = self.pending.lock().remove(&id) { + let _ = tx.send(answer); + } + } + + /// Environment that routes `ssh` / `git` prompts here. + pub fn env(&self) -> Vec<(String, String)> { + let exe = self.exe.to_string_lossy().into_owned(); + vec![ + (PORT_ENV.into(), self.port.to_string()), + (TOKEN_ENV.into(), self.token.clone()), + ("SSH_ASKPASS".into(), exe.clone()), + ("SSH_ASKPASS_REQUIRE".into(), "force".into()), + ("GIT_ASKPASS".into(), exe), + ] + } +} + +fn serve( + stream: TcpStream, + id: u64, + token: &str, + pending: &Pending, + on_prompt: &(dyn Fn(AskpassPrompt) + Send + Sync), +) { + let mut line = String::new(); + // The helper writes its request at once: an idle connection must not hold a thread. + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10))); + let Ok(mut reader) = stream.try_clone().map(BufReader::new) else { return }; + if reader.read_line(&mut line).is_err() { + return; + } + let Ok(req) = serde_json::from_str::(&line) else { return }; + let answer = if req.token != token { + None + } else { + let (tx, rx) = mpsc::channel(); + pending.lock().insert(id, tx); + let prompt = req.prompt.trim().to_owned(); + on_prompt(AskpassPrompt { id, kind: classify(&prompt), prompt }); + let answer = rx.recv_timeout(ANSWER_TIMEOUT).ok().flatten(); + pending.lock().remove(&id); + answer + }; + let mut out = stream; + let _ = writeln!(out, "{}", serde_json::to_string(&Response { answer }).unwrap_or_default()); +} + +fn random_token() -> String { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + // Two independently seeded SipHash keys: unpredictable to other local users. + (0..2) + .map(|_| { + let mut h = RandomState::new().build_hasher(); + h.write_u128( + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(), + ); + format!("{:016x}", h.finish()) + }) + .collect() +} + +/// When started as the askpass helper (by `ssh` / `git`), forwards the prompt +/// (the first argument) and returns the exit code; `None` otherwise. +pub fn run_helper() -> Option { + let port: u16 = std::env::var(PORT_ENV).ok()?.parse().ok()?; + let token = std::env::var(TOKEN_ENV).unwrap_or_default(); + let prompt = std::env::args().nth(1).unwrap_or_else(|| "Password:".into()); + let ask = || -> std::io::Result> { + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + writeln!(stream, "{}", serde_json::to_string(&Request { token, prompt }).unwrap_or_default())?; + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line)?; + Ok(serde_json::from_str::(&line).ok().and_then(|r| r.answer)) + }; + match ask() { + Ok(Some(answer)) => { + println!("{answer}"); + Some(0) + } + _ => Some(1), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_prompts() { + assert_eq!(classify("Enter passphrase for key '/u/.ssh/id_ed25519': "), AskpassKind::Secret); + assert_eq!(classify("git@github.com's password: "), AskpassKind::Secret); + assert_eq!(classify("Username for 'https://github.com': "), AskpassKind::Text); + assert_eq!( + classify("Are you sure you want to continue connecting (yes/no/[fingerprint])? "), + AskpassKind::Confirm + ); + } + + #[test] + fn round_trips_a_prompt() { + let seen: Arc>> = Arc::default(); + let s = seen.clone(); + let server = Arc::new(AskpassServer::start("helper".into(), Arc::new(move |p| *s.lock() = Some(p))).unwrap()); + let port = server.port; + let token = server.token.clone(); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + writeln!(stream, "{}", serde_json::to_string(&Request { token, prompt: "Password: ".into() }).unwrap()) + .unwrap(); + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).unwrap(); + serde_json::from_str::(&line).unwrap().answer + }); + let id = loop { + if let Some(p) = seen.lock().clone() { + assert_eq!((p.prompt.as_str(), p.kind), ("Password:", AskpassKind::Secret)); + break p.id; + } + std::thread::sleep(Duration::from_millis(5)); + }; + server.answer(id, Some("hunter2".into())); + assert_eq!(client.join().unwrap().as_deref(), Some("hunter2")); + assert!(server.env().iter().any(|(k, v)| k == "SSH_ASKPASS" && v == "helper")); + } +} diff --git a/crates/ide-ssh/src/lib.rs b/crates/ide-ssh/src/lib.rs new file mode 100644 index 000000000..ec8627011 --- /dev/null +++ b/crates/ide-ssh/src/lib.rs @@ -0,0 +1,21 @@ +//! SSH for every remote feature: configurations and `~/.ssh/config` hosts, +//! host-key trust, authentication (password, key, agent), remote commands and +//! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`. + +pub mod askpass; +pub mod manager; +pub mod secrets; +pub mod session; +pub mod sftp; +pub mod shell; +pub mod target; +#[cfg(feature = "testing")] +pub mod testing; +pub mod tunnel; + +pub use manager::SshManager; +pub use russh::Sig; +pub use session::{ConnectError, ExecOutput, Output, RemoteProcess, Session, UnknownHostKey}; +pub use sftp::{RemoteEntry, RemoteFs}; +pub use target::{SshFiles, SshTarget, expand, openssh_args}; +pub use tunnel::Tunnel; diff --git a/crates/ide-ssh/src/manager.rs b/crates/ide-ssh/src/manager.rs new file mode 100644 index 000000000..07abb555a --- /dev/null +++ b/crates/ide-ssh/src/manager.rs @@ -0,0 +1,192 @@ +//! App-wide SSH connections, one per SSH configuration, shared by every +//! feature that works on that host (deployment, run targets, data sources). + +use crate::secrets::SecretFile; +use crate::session::{ConnectError, Session, UnknownHostKey}; +use crate::target::{SshFiles, SshTarget}; +use ide_core::remote_settings::{SshAuth, SshConfiguration}; +use ide_core::{IdeError, IdeResult}; +use parking_lot::{Mutex, RwLock}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +pub struct SshManager { + files: SshFiles, + saved: SecretFile, + /// Secrets entered this session (not saved, or not yet), by configuration id. + entered: Mutex>, + configs: RwLock>, + sessions: Mutex>>, + /// Serialises connecting per configuration so concurrent users share one + /// session (an unreachable host doesn't hold up the others). + opening: Mutex>>>, + /// Host keys awaiting the user's trust, by key (configuration id or a caller's own). + unknown: Mutex>, +} + +impl SshManager { + /// `secrets` is the file of saved SSH passwords. + pub fn new(secrets: PathBuf) -> Self { + Self::with_files(secrets, SshFiles::default()) + } + + pub fn with_files(secrets: PathBuf, files: SshFiles) -> Self { + Self { + files, + saved: SecretFile::new(secrets), + entered: Mutex::default(), + configs: RwLock::default(), + sessions: Mutex::default(), + opening: Mutex::default(), + unknown: Mutex::default(), + } + } + + pub fn files(&self) -> &SshFiles { + &self.files + } + + /// Replaces the configurations (Settings › SSH Configurations); sessions of + /// changed or removed ones are closed, secrets of removed ones forgotten. + pub fn set_configs(&self, configs: Vec) { + let old = std::mem::replace(&mut *self.configs.write(), configs.clone()); + for o in old { + match configs.iter().find(|c| c.id == o.id) { + Some(n) + if n.host == o.host + && n.port == o.port + && n.user == o.user + && n.auth == o.auth + && n.key_file == o.key_file => {} + Some(_) => { + self.sessions.lock().remove(&o.id); + } + None => { + self.sessions.lock().remove(&o.id); + self.entered.lock().remove(&o.id); + self.saved.delete(&o.id); + } + } + } + } + + pub fn configs(&self) -> Vec { + self.configs.read().clone() + } + + pub fn config(&self, id: &str) -> IdeResult { + self.configs + .read() + .iter() + .find(|c| c.id == id) + .cloned() + .ok_or_else(|| IdeError::not_found(format!("no SSH configuration {id}"))) + } + + /// Remembers the password / passphrase of configuration `id` for the session, + /// and in the saved passwords with `save` (`None` forgets it). + pub fn set_secret(&self, id: &str, secret: Option, save: bool) { + match &secret { + Some(s) => { + self.entered.lock().insert(id.to_owned(), s.clone()); + } + None => { + self.entered.lock().remove(id); + } + } + match (secret, save) { + (Some(s), true) => { + if let Err(e) = self.saved.set(id, &s) { + tracing::warn!("could not save the SSH password: {}", e.message); + } + } + _ => self.saved.delete(id), + } + } + + /// The known password / passphrase of configuration `id`. + pub fn secret(&self, config: &SshConfiguration) -> Option { + if let Some(s) = self.entered.lock().get(&config.id) { + return Some(s.clone()); + } + config.save_secret.then(|| self.saved.get(&config.id)).flatten() + } + + /// The shared session of configuration `id`, connecting first if needed. + pub async fn session(&self, id: &str) -> IdeResult> { + if let Some(s) = self.live(id) { + return Ok(s); + } + let lock = self.opening.lock().entry(id.to_owned()).or_default().clone(); + let _opening = lock.lock().await; + if let Some(s) = self.live(id) { + return Ok(s); + } + let config = self.config(id)?; + let secret = self.secret(&config); + let session = Arc::new(self.connect(id, &SshTarget::from(&config), secret.as_deref()).await?); + self.sessions.lock().insert(id.to_owned(), session.clone()); + Ok(session) + } + + fn live(&self, id: &str) -> Option> { + let mut sessions = self.sessions.lock(); + match sessions.get(id) { + Some(s) if !s.is_closed() => Some(s.clone()), + Some(_) => { + sessions.remove(id); + None + } + None => None, + } + } + + /// A private session to `target`; an unknown host key is kept under `key` + /// for [`SshManager::trust_host`]. + pub async fn connect(&self, key: &str, target: &SshTarget, secret: Option<&str>) -> IdeResult { + match Session::connect(target, secret, &self.files).await { + Ok(s) => Ok(s), + Err(ConnectError::Other(e)) => Err(e), + Err(ConnectError::UnknownHostKey(k)) => { + let e = k.error(); + self.unknown.lock().insert(key.to_owned(), *k); + Err(e) + } + } + } + + /// Connects with `config` as given (not necessarily saved) and describes the host. + pub async fn test(&self, config: &SshConfiguration, secret: Option) -> IdeResult { + let secret = secret.or_else(|| self.secret(config)); + let session = self.connect(&config.id, &SshTarget::from(config), secret.as_deref()).await?; + let out = session.exec("uname -srm 2>/dev/null || ver", None).await?; + let os = out.stdout_str().trim().to_owned(); + Ok(if os.is_empty() { + format!("Connected to {}", session.label()) + } else { + format!("Connected to {}: {os}", session.label()) + }) + } + + /// Trusts the host key that failed the last connection under `key` (adds it to `known_hosts`). + pub async fn trust_host(&self, key: &str) -> IdeResult<()> { + let k = self.unknown.lock().remove(key).ok_or_else(|| IdeError::not_found("no host key to trust"))?; + let path = self.files.known_hosts.clone(); + tokio::task::spawn_blocking(move || k.trust(&path)).await.map_err(|e| IdeError::internal(e.to_string()))? + } + + pub fn disconnect(&self, id: &str) { + self.sessions.lock().remove(id); + } + + /// Ids of the configurations with an open session. + pub fn connected(&self) -> Vec { + self.sessions.lock().iter().filter(|(_, s)| !s.is_closed()).map(|(k, _)| k.clone()).collect() + } + + /// Whether connecting to `config` will need a secret the user has not given. + pub fn needs_secret(&self, config: &SshConfiguration) -> bool { + config.auth == SshAuth::Password && self.secret(config).is_none() + } +} diff --git a/crates/ide-ssh/src/secrets.rs b/crates/ide-ssh/src/secrets.rs new file mode 100644 index 000000000..5612ec9e8 --- /dev/null +++ b/crates/ide-ssh/src/secrets.rs @@ -0,0 +1,87 @@ +//! Saved SSH passwords / key passphrases of SSH configurations, in the app's +//! data directory rather than the OS credential store (no system prompts). +//! One JSON object, configuration id → secret; owner-only on Unix. + +use ide_core::{IdeError, IdeResult}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::PathBuf; + +pub struct SecretFile { + path: PathBuf, + lock: Mutex<()>, +} + +impl SecretFile { + pub fn new(path: PathBuf) -> Self { + Self { path, lock: Mutex::new(()) } + } + + pub fn get(&self, key: &str) -> Option { + let _g = self.lock.lock(); + self.read().remove(key) + } + + pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> { + let _g = self.lock.lock(); + let mut all = self.read(); + all.insert(key.to_owned(), secret.to_owned()); + self.write(&all) + } + + pub fn delete(&self, key: &str) { + let _g = self.lock.lock(); + let mut all = self.read(); + if all.remove(key).is_some() + && let Err(e) = self.write(&all) + { + tracing::warn!("saved SSH passwords: {}", e.message); + } + } + + fn read(&self) -> BTreeMap { + match std::fs::read(&self.path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { + tracing::warn!("saved SSH passwords: {}: {e}", self.path.display()); + BTreeMap::new() + }), + Err(_) => BTreeMap::new(), + } + } + + fn write(&self, all: &BTreeMap) -> IdeResult<()> { + let err = |e: std::io::Error| IdeError::unavailable(format!("saved SSH passwords: {e}")); + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir).map_err(err)?; + } + let tmp = self.path.with_extension("json.tmp"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?; + std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?; + std::fs::rename(&tmp, &self.path).map_err(err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stores_and_forgets_secrets() { + let tmp = tempfile::tempdir().unwrap(); + let file = SecretFile::new(tmp.path().join("ssh-passwords.json")); + file.set("a", "pw").unwrap(); + assert_eq!(file.get("a").as_deref(), Some("pw")); + file.delete("a"); + assert_eq!(file.get("a"), None); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(tmp.path().join("ssh-passwords.json")).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + } +} diff --git a/crates/ide-ssh/src/session.rs b/crates/ide-ssh/src/session.rs new file mode 100644 index 000000000..47f9cd21f --- /dev/null +++ b/crates/ide-ssh/src/session.rs @@ -0,0 +1,521 @@ +//! An authenticated SSH connection (russh), optionally through `ProxyJump` +//! hosts, and what runs over it: commands, processes, SFTP and TCP channels. +//! +//! Host keys are checked against `known_hosts`; an unknown key fails the +//! connection with [`ConnectError::UnknownHostKey`] so the UI can ask the user, +//! and a changed key is always refused. + +use crate::target::{Hop, SshFiles, SshTarget, expand, home, resolve_hops}; +use ide_core::remote_settings::SshAuth; +use ide_core::{IdeError, IdeResult, error::ErrorKind}; +use parking_lot::Mutex; +use russh::client::{self, Handle, Msg}; +use russh::keys::{self, HashAlg, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate, known_hosts}; +use russh::{ChannelMsg, ChannelStream, ChannelWriteHalf, Sig}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; + +/// Generous: a relayed VPN path (e.g. Tailscale via DERP) can take ~1 s per round trip. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(45); +const DEFAULT_IDENTITIES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"]; + +/// A host key the user has not trusted yet. +#[derive(Debug, Clone)] +pub struct UnknownHostKey { + pub host: String, + pub port: u16, + pub key: PublicKey, +} + +impl UnknownHostKey { + /// `ED25519 SHA256:…`, as OpenSSH prints it. + pub fn fingerprint(&self) -> String { + format!("{} {}", self.key.algorithm().as_str(), self.key.fingerprint(HashAlg::Sha256)) + } + + /// Records the key in `known_hosts` (blocking). + pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> { + if let Some(dir) = known_hosts.parent() { + std::fs::create_dir_all(dir)?; + } + known_hosts::learn_known_hosts_path(&self.host, self.port, &self.key, known_hosts) + .map_err(|e| IdeError::new(ErrorKind::Io, format!("cannot update {}: {e}", known_hosts.display()))) + } + + /// The error shown for it; the UI recognises the `Unknown SSH host key` prefix. + pub fn error(&self) -> IdeError { + IdeError::new( + ErrorKind::Protocol, + format!("Unknown SSH host key for {}:{} ({})", self.host, self.port, self.fingerprint()), + ) + } +} + +#[derive(Debug)] +pub enum ConnectError { + UnknownHostKey(Box), + Other(IdeError), +} + +impl From for ConnectError { + fn from(e: IdeError) -> Self { + ConnectError::Other(e) + } +} + +impl From for IdeError { + fn from(e: ConnectError) -> Self { + match e { + ConnectError::UnknownHostKey(k) => k.error(), + ConnectError::Other(e) => e, + } + } +} + +/// Output of a finished remote command. +#[derive(Debug, Clone, Default)] +pub struct ExecOutput { + /// `None` when the server reported no status (killed by a signal). + pub code: Option, + pub stdout: Vec, + pub stderr: Vec, +} + +impl ExecOutput { + pub fn success(&self) -> bool { + self.code == Some(0) + } + + pub fn stdout_str(&self) -> String { + String::from_utf8_lossy(&self.stdout).into_owned() + } + + /// stdout on success, else an error with stderr. + pub fn into_stdout(self, what: &str) -> IdeResult { + if self.success() { + return Ok(self.stdout_str()); + } + let err = String::from_utf8_lossy(&self.stderr).trim().to_owned(); + let code = self.code.map_or_else(|| "killed".to_owned(), |c| format!("exit code {c}")); + Err(io(if err.is_empty() { format!("{what} failed ({code})") } else { format!("{what} failed: {err}") })) + } +} + +/// An open SSH connection; dropping it closes the sessions. +pub struct Session { + handle: Handle, + /// Sessions of the `ProxyJump` hosts, kept alive under `handle`. + _jumps: Vec>, + label: String, +} + +impl Session { + /// Connects and authenticates; `secret` is the password or key passphrase. + pub async fn connect(target: &SshTarget, secret: Option<&str>, files: &SshFiles) -> Result { + let host = format!("{}:{}", target.host, target.port.unwrap_or(22)); + match tokio::time::timeout(CONNECT_TIMEOUT, Self::connect_inner(target, secret, files)).await { + Ok(r) => r, + Err(_) => { + Err(io(format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs())).into()) + } + } + } + + async fn connect_inner( + target: &SshTarget, + secret: Option<&str>, + files: &SshFiles, + ) -> Result { + let hops = resolve_hops(target, files.config.as_deref())?; + let mut sessions: Vec> = Vec::new(); + for (i, hop) in hops.iter().enumerate() { + let last = i == hops.len() - 1; + let mut handle = match sessions.last() { + None => connect::(hop, &files.known_hosts, None).await?, + Some(prev) => { + let channel = prev + .channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0) + .await + .map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?; + connect(hop, &files.known_hosts, Some(channel.into_stream())).await? + } + }; + // Jump hosts always use the agent / identity files; the secret belongs to the target. + let mode = if last { target.auth } else { SshAuth::OpenSsh }; + authenticate(&mut handle, hop, mode, &target.key_file, secret).await?; + sessions.push(handle); + } + let handle = sessions.pop().expect("at least one hop"); + let label = hops.last().expect("at least one hop").label(); + Ok(Session { handle, _jumps: sessions, label }) + } + + /// `user@host:port` of the target. + pub fn label(&self) -> &str { + &self.label + } + + /// The connection ended (network loss, server restart). + pub fn is_closed(&self) -> bool { + self.handle.is_closed() + } + + /// A TCP connection to `host:port` as seen from the SSH host. + pub async fn direct_tcpip(&self, host: &str, port: u16) -> Result, russh::Error> { + let channel = self.handle.channel_open_direct_tcpip(host, u32::from(port), "127.0.0.1", 0).await?; + Ok(channel.into_stream()) + } + + /// Starts `command` and returns its stdin / stdout as one byte stream (stderr is dropped). + pub async fn exec_stream(&self, command: &str) -> Result, russh::Error> { + let channel = self.handle.channel_open_session().await?; + channel.exec(true, command).await?; + Ok(channel.into_stream()) + } + + /// Runs `command` through the remote shell and collects its output. + pub async fn exec(&self, command: &str, stdin: Option<&[u8]>) -> IdeResult { + let mut channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?; + channel.exec(true, command).await.map_err(|e| self.failed(e))?; + if let Some(input) = stdin { + channel.data(input).await.map_err(|e| self.failed(e))?; + } + channel.eof().await.map_err(|e| self.failed(e))?; + let mut out = ExecOutput::default(); + while let Some(msg) = channel.wait().await { + match msg { + ChannelMsg::Data { data } => out.stdout.extend_from_slice(&data), + ChannelMsg::ExtendedData { data, ext: 1 } => out.stderr.extend_from_slice(&data), + ChannelMsg::ExitStatus { exit_status } => out.code = Some(exit_status), + _ => {} + } + } + Ok(out) + } + + /// Starts `command` (through the remote shell), streaming its output. + /// With `pty` (columns, rows) stdout and stderr arrive merged, as in a terminal. + pub async fn spawn( + &self, + command: &str, + pty: Option<(u16, u16)>, + on_output: Box) + Send>, + ) -> IdeResult { + let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?; + if let Some((cols, rows)) = pty { + channel + .request_pty(true, "xterm-256color", u32::from(cols), u32::from(rows), 0, 0, &[]) + .await + .map_err(|e| self.failed(e))?; + } + if command.is_empty() { + channel.request_shell(true).await.map_err(|e| self.failed(e))?; + } else { + channel.exec(true, command).await.map_err(|e| self.failed(e))?; + } + let (mut read, write) = channel.split(); + let (tx, rx) = oneshot::channel(); + let mut on_output = on_output; + tokio::spawn(async move { + let mut code = None; + while let Some(msg) = read.wait().await { + match msg { + ChannelMsg::Data { data } => on_output(Output::Stdout(&data)), + ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)), + ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status), + _ => {} + } + } + let _ = tx.send(code); + }); + Ok(RemoteProcess { write: Arc::new(write), exit: Mutex::new(Some(rx)) }) + } + + /// Writes `data` to `remote` (absolute; its folder is created) through an + /// exec channel, gzip-compressed. Much faster than SFTP for one big file: + /// SFTP writes wait for each packet's reply, the channel only for its window. + pub async fn put(&self, remote: &str, data: &[u8]) -> IdeResult<()> { + self.put_with_progress(remote, data, &|_| {}).await + } + + /// [`Self::put`], reporting the fraction sent (0..1) as it goes. + pub async fn put_with_progress( + &self, + remote: &str, + data: &[u8], + progress: &(dyn Fn(f64) + Send + Sync), + ) -> IdeResult<()> { + use std::io::Write as _; + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + gz.write_all(data)?; + let packed = gz.finish()?; + let q = crate::shell::quote; + let dir = crate::sftp::parent(remote).unwrap_or("/"); + let command = format!("mkdir -p {} && gzip -dc > {}", q(dir), q(remote)); + let stderr = Arc::new(Mutex::new(Vec::new())); + let sink = stderr.clone(); + let process = self + .spawn( + &command, + None, + Box::new(move |out| { + if let Output::Stderr(b) = out { + sink.lock().extend_from_slice(b); + } + }), + ) + .await?; + let mut sent = 0; + for chunk in packed.chunks(256 * 1024) { + process.write(chunk).await?; + sent += chunk.len(); + progress(sent as f64 / packed.len().max(1) as f64); + } + process.eof().await?; + match process.wait().await { + Some(0) => Ok(()), + code => { + let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned(); + Err(io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label))) + } + } + } + + /// An SFTP session on its own channel. + pub async fn sftp(&self) -> IdeResult { + let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?; + channel.request_subsystem(true, "sftp").await.map_err(|e| self.failed(e))?; + russh_sftp::client::SftpSession::new(channel.into_stream()) + .await + .map_err(|e| io(format!("SFTP is not available on {}: {e}", self.label))) + } + + fn failed(&self, e: russh::Error) -> IdeError { + io(format!("SSH {}: {e}", self.label)) + } +} + +/// A chunk of a remote process's output. +pub enum Output<'a> { + Stdout(&'a [u8]), + Stderr(&'a [u8]), +} + +/// A running remote command. +pub struct RemoteProcess { + write: Arc>, + exit: Mutex>>>, +} + +impl RemoteProcess { + pub async fn write(&self, data: &[u8]) -> IdeResult<()> { + self.write.data(data).await.map_err(|e| io(e.to_string())) + } + + /// Closes stdin. + pub async fn eof(&self) -> IdeResult<()> { + self.write.eof().await.map_err(|e| io(e.to_string())) + } + + pub async fn resize(&self, cols: u16, rows: u16) -> IdeResult<()> { + self.write.window_change(u32::from(cols), u32::from(rows), 0, 0).await.map_err(|e| io(e.to_string())) + } + + /// Sends `INT` / `TERM` / `KILL`; servers that ignore signals get the channel closed. + pub async fn signal(&self, signal: Sig) -> IdeResult<()> { + let kill = matches!(signal, Sig::KILL); + let r = self.write.signal(signal).await; + if kill || r.is_err() { + let _ = self.write.close().await; + } + Ok(()) + } + + /// Waits for the exit status (`None`: killed, or no status reported). Once. + pub async fn wait(&self) -> Option { + let rx = self.exit.lock().take()?; + rx.await.ok().flatten() + } +} + +fn io(message: impl Into) -> IdeError { + IdeError::new(ErrorKind::Io, message) +} + +// --- connection ---------------------------------------------------------------- + +#[derive(Debug)] +enum HostKeyIssue { + Unknown(PublicKey), + Changed(usize), +} + +pub struct Client { + host: String, + port: u16, + known_hosts: PathBuf, + issue: Arc>>, +} + +impl client::Handler for Client { + type Error = russh::Error; + + async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result { + let key = match key { + PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), + PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()), + }; + let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) { + Ok(true) => return Ok(true), + Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line), + Ok(false) | Err(_) => HostKeyIssue::Unknown(key), + }; + *self.issue.lock() = Some(issue); + Ok(false) + } +} + +async fn connect(hop: &Hop, known_hosts: &Path, stream: Option) -> Result, ConnectError> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let config = Arc::new(client::Config { + inactivity_timeout: None, + keepalive_interval: Some(Duration::from_secs(30)), + keepalive_max: 3, + nodelay: true, + ..Default::default() + }); + let issue = Arc::new(Mutex::new(None)); + let handler = + Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() }; + let result = match stream { + None => client::connect(config, (hop.host.as_str(), hop.port), handler).await, + Some(s) => client::connect_stream(config, s, handler).await, + }; + result.map_err(|e| match issue.lock().take() { + Some(HostKeyIssue::Unknown(key)) => { + ConnectError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key })) + } + Some(HostKeyIssue::Changed(line)) => ConnectError::Other(IdeError::new( + ErrorKind::PermissionDenied, + format!( + "SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack", + hop.host, hop.port + ), + )), + None => ConnectError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))), + }) +} + +// --- authentication ------------------------------------------------------------ + +fn denied(message: String) -> IdeError { + IdeError::new(ErrorKind::PermissionDenied, message) +} + +async fn rsa_hash(handle: &Handle) -> Option { + handle.best_supported_rsa_hash().await.ok().flatten().flatten() +} + +async fn with_key(handle: &mut Handle, user: &str, key: keys::PrivateKey) -> IdeResult { + let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash(handle).await); + let r = handle.authenticate_publickey(user, key).await.map_err(|e| io(e.to_string()))?; + Ok(r.success()) +} + +/// Every identity of the running SSH agent, until one is accepted. +async fn with_agent(handle: &mut Handle, user: &str) -> bool { + #[cfg(unix)] + let agent = keys::agent::client::AgentClient::connect_env().await; + #[cfg(windows)] + let agent = keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await; + let Ok(mut agent) = agent else { return false }; + let Ok(identities) = agent.request_identities().await else { return false }; + let hash = rsa_hash(handle).await; + for id in identities { + let ok = match id { + keys::agent::AgentIdentity::PublicKey { key, .. } => { + handle.authenticate_publickey_with(user, key, hash, &mut agent).await.map(|r| r.success()) + } + keys::agent::AgentIdentity::Certificate { certificate, .. } => { + handle.authenticate_certificate_with(user, certificate, hash, &mut agent).await.map(|r| r.success()) + } + }; + if ok.unwrap_or(false) { + return true; + } + } + false +} + +async fn authenticate( + handle: &mut Handle, + hop: &Hop, + mode: SshAuth, + key_file: &str, + secret: Option<&str>, +) -> IdeResult<()> { + let user = hop.user.as_str(); + let failed = || denied(format!("SSH authentication failed for {}", hop.label())); + match mode { + SshAuth::Password => { + let Some(password) = secret else { + return Err(denied(format!("SSH password required for {}", hop.label()))); + }; + let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?; + if r.success() { Ok(()) } else { Err(failed()) } + } + SshAuth::KeyPair => { + if key_file.trim().is_empty() { + return Err(IdeError::invalid("SSH private key file is not set")); + } + let path = expand(key_file.trim()); + let key = load_key(&path, secret)?; + if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) } + } + SshAuth::OpenSsh => { + if with_agent(handle, user).await { + return Ok(()); + } + let files: Vec = if hop.identity_files.is_empty() { + home().map(|h| DEFAULT_IDENTITIES.iter().map(|n| h.join(".ssh").join(n)).collect()).unwrap_or_default() + } else { + hop.identity_files.iter().map(|p| expand(&p.to_string_lossy())).collect() + }; + let mut locked: Option = None; + for path in files.iter().filter(|p| p.is_file()) { + match load_key(path, secret) { + Ok(key) => { + if with_key(handle, user, key).await? { + return Ok(()); + } + } + Err(e) if e.kind == ErrorKind::PermissionDenied => locked = locked.or(Some(path.clone())), + Err(e) => tracing::warn!("SSH key {}: {}", path.display(), e.message), + } + } + match locked { + // An encrypted key could not be tried: ask for its passphrase. + Some(path) if secret.is_none() => { + Err(denied(format!("SSH key passphrase required for {}", path.display()))) + } + _ => Err(failed()), + } + } + } +} + +/// Loads a private key; an encrypted key without (or with a wrong) passphrase is `PermissionDenied`. +fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult { + match keys::load_secret_key(path, passphrase) { + Ok(k) => Ok(k), + Err(keys::Error::KeyIsEncrypted) => Err(denied(format!("SSH key passphrase required for {}", path.display()))), + Err(e) if passphrase.is_some() => { + Err(denied(format!("cannot decrypt SSH key {} (wrong passphrase?): {e}", path.display()))) + } + Err(e) => Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))), + } +} diff --git a/crates/ide-ssh/src/sftp.rs b/crates/ide-ssh/src/sftp.rs new file mode 100644 index 000000000..c14c14463 --- /dev/null +++ b/crates/ide-ssh/src/sftp.rs @@ -0,0 +1,247 @@ +//! Remote files over SFTP. Remote paths are POSIX strings. + +use ide_core::{IdeError, IdeResult, error::ErrorKind}; +use russh_sftp::client::SftpSession; +use russh_sftp::client::error::Error as SftpError; +use russh_sftp::protocol::{FileAttributes, StatusCode}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// A remote directory entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteEntry { + pub name: String, + /// Absolute remote path. + pub path: String, + pub is_dir: bool, + pub is_symlink: bool, + pub size: u64, + /// Seconds since the Unix epoch. + pub mtime: Option, + /// `rwxr-xr-x`. + pub permissions: String, +} + +/// `dir/name` without doubling the separator. +pub fn join(dir: &str, name: &str) -> String { + if name.starts_with('/') { + return name.to_owned(); + } + let dir = dir.trim_end_matches('/'); + if dir.is_empty() && !name.is_empty() { format!("/{name}") } else { format!("{dir}/{name}") } +} + +/// The parent of a remote path (`/` for top-level entries). +pub fn parent(path: &str) -> Option<&str> { + let trimmed = path.trim_end_matches('/'); + let i = trimmed.rfind('/')?; + Some(if i == 0 { "/" } else { &trimmed[..i] }) +} + +fn mode_string(mode: u32) -> String { + let bits = [ + (0o400, 'r'), + (0o200, 'w'), + (0o100, 'x'), + (0o40, 'r'), + (0o20, 'w'), + (0o10, 'x'), + (0o4, 'r'), + (0o2, 'w'), + (0o1, 'x'), + ]; + bits.iter().map(|&(b, c)| if mode & b != 0 { c } else { '-' }).collect() +} + +fn entry(path: String, name: String, attrs: &FileAttributes, target_is_dir: bool) -> RemoteEntry { + RemoteEntry { + name, + path, + is_dir: attrs.is_dir() || target_is_dir, + is_symlink: attrs.is_symlink(), + size: attrs.size.unwrap_or(0), + mtime: attrs.mtime, + permissions: attrs.permissions.map(mode_string).unwrap_or_default(), + } +} + +fn err(path: &str, e: SftpError) -> IdeError { + let kind = match &e { + SftpError::Status(s) if s.status_code == StatusCode::NoSuchFile => ErrorKind::NotFound, + SftpError::Status(s) if s.status_code == StatusCode::PermissionDenied => ErrorKind::PermissionDenied, + _ => ErrorKind::Io, + }; + IdeError::new(kind, format!("{path}: {e}")) +} + +/// Higher-level operations on an [`SftpSession`]. +pub struct RemoteFs { + sftp: SftpSession, +} + +impl RemoteFs { + pub fn new(sftp: SftpSession) -> Self { + Self { sftp } + } + + /// The login directory. + pub async fn home(&self) -> IdeResult { + self.sftp.canonicalize(".").await.map_err(|e| err(".", e)) + } + + /// Absolute form of `path`; `~` and `~/…` are relative to the login directory. + pub async fn resolve(&self, path: &str) -> IdeResult { + let path = path.trim(); + if path.is_empty() || path == "~" { + return self.home().await; + } + if let Some(rest) = path.strip_prefix("~/") { + return Ok(join(&self.home().await?, rest)); + } + if path.starts_with('/') { + return Ok(path.to_owned()); + } + Ok(join(&self.home().await?, path)) + } + + pub async fn stat(&self, path: &str) -> IdeResult> { + match self.sftp.metadata(path).await { + Ok(a) => { + let name = path.rsplit('/').next().unwrap_or(path).to_owned(); + Ok(Some(entry(path.to_owned(), name, &a, false))) + } + Err(e) if err(path, e.clone()).kind == ErrorKind::NotFound => Ok(None), + Err(e) => Err(err(path, e)), + } + } + + /// Entries of `dir`, folders first, then by name; symlinks to folders count as folders. + pub async fn list(&self, dir: &str) -> IdeResult> { + let read = self.sftp.read_dir(dir).await.map_err(|e| err(dir, e))?; + let mut out = Vec::new(); + for e in read { + let name = e.file_name(); + // A name with `/` (from a hostile server) would point elsewhere once joined. + if name == "." || name == ".." || name.contains('/') { + continue; + } + let path = join(dir, &name); + let attrs = e.metadata(); + let target_is_dir = + attrs.is_symlink() && self.sftp.metadata(path.as_str()).await.map(|m| m.is_dir()).unwrap_or(false); + out.push(entry(path, name, &attrs, target_is_dir)); + } + out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(out) + } + + pub async fn read(&self, path: &str) -> IdeResult> { + let mut file = self.sftp.open(path).await.map_err(|e| err(path, e))?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf).await?; + Ok(buf) + } + + /// Writes `data`, creating missing parent folders. + pub async fn write(&self, path: &str, data: &[u8], mtime: Option) -> IdeResult<()> { + if let Some(dir) = parent(path) { + self.mkdir_all(dir).await?; + } + let mut file = self.sftp.create(path).await.map_err(|e| err(path, e))?; + file.write_all(data).await?; + file.shutdown().await?; + if let Some(t) = mtime { + let attrs = FileAttributes { atime: Some(t), mtime: Some(t), ..FileAttributes::empty() }; + if let Err(e) = self.sftp.set_metadata(path, attrs).await { + tracing::debug!("SFTP: cannot set the time of {path}: {e}"); + } + } + Ok(()) + } + + pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> { + let data = tokio::fs::read(local).await?; + let mtime = if preserve_time { mtime_of(local) } else { None }; + self.write(remote, &data, mtime).await + } + + pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> { + let data = self.read(remote).await?; + if let Some(dir) = local.parent() { + tokio::fs::create_dir_all(dir).await?; + } + tokio::fs::write(local, data).await?; + Ok(()) + } + + /// `mkdir -p`. + pub async fn mkdir_all(&self, dir: &str) -> IdeResult<()> { + if dir.is_empty() || dir == "/" { + return Ok(()); + } + match self.sftp.metadata(dir).await { + Ok(a) if a.is_dir() => return Ok(()), + Ok(_) => return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{dir} is not a folder"))), + Err(_) => {} + } + if let Some(p) = parent(dir) { + Box::pin(self.mkdir_all(p)).await?; + } + match self.sftp.create_dir(dir).await { + Ok(()) => Ok(()), + // Created meanwhile by a concurrent upload. + Err(_) if self.sftp.metadata(dir).await.is_ok_and(|a| a.is_dir()) => Ok(()), + Err(e) => Err(err(dir, e)), + } + } + + /// Deletes a file, or a folder with its contents. + pub async fn remove(&self, path: &str) -> IdeResult<()> { + let attrs = self.sftp.symlink_metadata(path).await.map_err(|e| err(path, e))?; + if attrs.is_dir() { + for e in self.list(path).await? { + if e.is_dir && !e.is_symlink { + Box::pin(self.remove(&e.path)).await?; + } else { + self.sftp.remove_file(e.path.as_str()).await.map_err(|x| err(&e.path, x))?; + } + } + self.sftp.remove_dir(path).await.map_err(|e| err(path, e)) + } else { + self.sftp.remove_file(path).await.map_err(|e| err(path, e)) + } + } + + pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> { + self.sftp.rename(from, to).await.map_err(|e| err(from, e)) + } + + pub async fn close(&self) { + let _ = self.sftp.close().await; + } +} + +/// Seconds since the epoch of a local file's modification. +pub fn mtime_of(path: &Path) -> Option { + let t = std::fs::metadata(path).ok()?.modified().ok()?; + u32::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn joins_and_splits_paths() { + assert_eq!(join("/srv/app/", "a.txt"), "/srv/app/a.txt"); + assert_eq!(join("/", "etc"), "/etc"); + assert_eq!(join("/x", "/abs"), "/abs"); + assert_eq!(parent("/srv/app/a.txt"), Some("/srv/app")); + assert_eq!(parent("/etc"), Some("/")); + assert_eq!(parent("rel"), None); + assert_eq!(mode_string(0o755), "rwxr-xr-x"); + } +} diff --git a/crates/ide-ssh/src/shell.rs b/crates/ide-ssh/src/shell.rs new file mode 100644 index 000000000..827640204 --- /dev/null +++ b/crates/ide-ssh/src/shell.rs @@ -0,0 +1,69 @@ +//! POSIX shell command lines for the remote side. + +use std::collections::BTreeMap; + +/// Quotes `s` for a POSIX shell (`'…'`, with `'\''` for quotes inside). +pub fn quote(s: &str) -> String { + if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_./=:,+@%".contains(&b)) { + return s.to_owned(); + } + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// `cd && exec env K=V … `. A program starting with `~/` +/// is left to the shell's tilde expansion. +pub fn command_line(cwd: Option<&str>, env: &BTreeMap, program: &str, args: &[String]) -> String { + let mut s = String::new(); + if let Some(dir) = cwd.filter(|d| !d.is_empty()) { + s.push_str("cd "); + s.push_str(&tilde_quote(dir)); + s.push_str(" && "); + } + s.push_str("exec "); + if !env.is_empty() { + s.push_str("env"); + for (k, v) in env { + s.push(' '); + s.push_str("e(&format!("{k}={v}"))); + } + s.push(' '); + } + s.push_str(&tilde_quote(program)); + for a in args { + s.push(' '); + s.push_str("e(a)); + } + s +} + +/// [`quote`], except that a leading `~/` stays open to tilde expansion. +pub fn tilde_quote(path: &str) -> String { + match path.strip_prefix("~/") { + Some(rest) => format!("~/{}", quote(rest)), + None if path == "~" => "~".into(), + None => quote(path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_only_when_needed() { + assert_eq!(quote("plain/path-1.txt"), "plain/path-1.txt"); + assert_eq!(quote("a b"), "'a b'"); + assert_eq!(quote("it's"), r"'it'\''s'"); + assert_eq!(quote(""), "''"); + } + + #[test] + fn builds_a_command_line() { + let env = BTreeMap::from([("A".to_owned(), "1 2".to_owned())]); + assert_eq!( + command_line(Some("~/proj dir"), &env, "python3", &["-u".into(), "main.py".into()]), + "cd ~/'proj dir' && exec env 'A=1 2' python3 -u main.py" + ); + assert_eq!(command_line(None, &BTreeMap::new(), "ls", &[]), "exec ls"); + } +} diff --git a/crates/ide-ssh/src/target.rs b/crates/ide-ssh/src/target.rs new file mode 100644 index 000000000..18ef67214 --- /dev/null +++ b/crates/ide-ssh/src/target.rs @@ -0,0 +1,184 @@ +//! Where to connect: an [`SshTarget`] and the hosts to go through to reach it +//! (`~/.ssh/config` aliases and `ProxyJump` chains). + +use ide_core::remote_settings::{SshAuth, SshConfiguration}; +use ide_core::{IdeError, IdeResult}; +use std::path::{Path, PathBuf}; + +/// An SSH host and how to authenticate to it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SshTarget { + /// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`]. + pub host: String, + pub port: Option, + pub user: String, + pub auth: SshAuth, + /// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory. + pub key_file: String, +} + +impl From<&SshConfiguration> for SshTarget { + fn from(c: &SshConfiguration) -> Self { + Self { host: c.host.clone(), port: c.port, user: c.user.clone(), auth: c.auth, key_file: c.key_file.clone() } + } +} + +/// The user's OpenSSH files. +#[derive(Debug, Clone)] +pub struct SshFiles { + pub known_hosts: PathBuf, + /// `~/.ssh/config` (used with [`SshAuth::OpenSsh`]). + pub config: Option, +} + +impl Default for SshFiles { + fn default() -> Self { + let dir = home().unwrap_or_default().join(".ssh"); + Self { known_hosts: dir.join("known_hosts"), config: Some(dir.join("config")) } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Hop { + pub host: String, + pub port: u16, + pub user: String, + pub identity_files: Vec, +} + +impl Hop { + pub fn label(&self) -> String { + format!("{}@{}:{}", self.user, self.host, self.port) + } +} + +pub(crate) fn home() -> Option { + dirs::home_dir() +} + +/// `~/x` → `$HOME/x`. +pub fn expand(path: &str) -> PathBuf { + match (path.strip_prefix("~/"), home()) { + (Some(rest), Some(h)) => h.join(rest), + _ => PathBuf::from(path), + } +} + +fn local_user() -> String { + std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default() +} + +/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults). +fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option) { + let c = config + .and_then(|p| russh_config::parse_path(p, alias).ok()) + .unwrap_or_else(|| russh_config::Config::default(alias)); + let identity_files = c.host_config.identity_file.clone().unwrap_or_default(); + let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files }; + (hop, c.host_config.proxy_jump.clone()) +} + +/// `[user@]host[:port]` of a `ProxyJump` entry. +fn jump_hop(config: Option<&Path>, spec: &str) -> Hop { + let (user, rest) = match spec.rsplit_once('@') { + Some((u, r)) => (Some(u), r), + None => (None, spec), + }; + let (host, port) = match rest.rsplit_once(':') { + Some((h, p)) if p.parse::().is_ok() => (h, p.parse().ok()), + _ => (rest, None), + }; + let (mut hop, _) = config_hop(config, host); + if let Some(u) = user { + hop.user = u.to_owned(); + } + if let Some(p) = port { + hop.port = p; + } + hop +} + +/// The SSH hosts to go through, the target last. +pub(crate) fn resolve_hops(target: &SshTarget, config: Option<&Path>) -> IdeResult> { + let host = target.host.trim(); + if host.is_empty() { + return Err(IdeError::invalid("SSH host is empty")); + } + let (mut hop, proxy_jump) = if target.auth == SshAuth::OpenSsh { + config_hop(config, host) + } else { + (Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None) + }; + if let Some(p) = target.port { + hop.port = p; + } + if !target.user.trim().is_empty() { + hop.user = target.user.trim().to_owned(); + } + let mut hops: Vec = proxy_jump + .filter(|j| !j.eq_ignore_ascii_case("none")) + .map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect()) + .unwrap_or_default(); + hops.push(hop); + Ok(hops) +} + +/// `ssh` arguments that reach `target` (the system client: terminals, git). +pub fn openssh_args(target: &SshTarget) -> Vec { + let mut args = Vec::new(); + if let Some(p) = target.port { + args.extend(["-p".into(), p.to_string()]); + } + if target.auth == SshAuth::KeyPair && !target.key_file.trim().is_empty() { + args.extend(["-i".into(), expand(target.key_file.trim()).to_string_lossy().into_owned()]); + args.extend(["-o".into(), "IdentitiesOnly=yes".into()]); + } + if target.auth == SshAuth::Password { + args.extend(["-o".into(), "PreferredAuthentications=password,keyboard-interactive".into()]); + } + let host = target.host.trim(); + // A host starting with `-` must not be read as an option. + args.push("--".into()); + args.push(if target.user.trim().is_empty() { host.to_owned() } else { format!("{}@{host}", target.user.trim()) }); + args +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_aliases_and_proxy_jumps_from_ssh_config() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + std::fs::write( + &config, + "Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump jump@gw.example.com:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n", + ) + .unwrap(); + let target = SshTarget { host: "db-bastion".into(), auth: SshAuth::OpenSsh, ..Default::default() }; + let hops = resolve_hops(&target, Some(&config)).unwrap(); + let labels: Vec = hops.iter().map(Hop::label).collect(); + assert_eq!(labels, ["jump@gw.example.com:2200", "edgeuser@edge.example.com:22", "ops@10.0.0.5:2222"]); + assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]); + + // Explicit fields win over the config. + let target = SshTarget { user: "me".into(), port: Some(22), ..target }; + assert_eq!(resolve_hops(&target, Some(&config)).unwrap()[2].label(), "me@10.0.0.5:22"); + + // Without OpenSSH mode the host is used as is. + let target = SshTarget { auth: SshAuth::Password, user: "u".into(), port: None, ..target }; + let hops = resolve_hops(&target, Some(&config)).unwrap(); + assert_eq!(hops.iter().map(Hop::label).collect::>(), ["u@db-bastion:22"]); + assert!(resolve_hops(&SshTarget::default(), None).is_err()); + } + + #[test] + fn builds_openssh_arguments() { + let t = SshTarget { host: "box".into(), user: "me".into(), port: Some(2200), ..Default::default() }; + assert_eq!(openssh_args(&t), ["-p", "2200", "--", "me@box"]); + let t = + SshTarget { host: "box".into(), auth: SshAuth::KeyPair, key_file: "/k/id".into(), ..Default::default() }; + assert_eq!(openssh_args(&t), ["-i", "/k/id", "-o", "IdentitiesOnly=yes", "--", "box"]); + } +} diff --git a/crates/ide-ssh/src/testing.rs b/crates/ide-ssh/src/testing.rs new file mode 100644 index 000000000..2ad98509f --- /dev/null +++ b/crates/ide-ssh/src/testing.rs @@ -0,0 +1,262 @@ +//! An in-process SSH server for tests (feature `testing`): password `dev` / +//! `secret`, commands answered by a closure, and SFTP served from a local +//! folder (remote `/x` is `/x`). + +use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session}; +use russh::{Channel, ChannelId}; +use russh_sftp::protocol::{Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version}; +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +/// Reply of a command: (stdout, exit status). +pub type ExecFn = Arc (String, u32) + Send + Sync>; + +/// Starts the server; returns its port. `root` backs SFTP. +pub async fn sshd(root: PathBuf, exec: ExecFn) -> u16 { + let config = Arc::new(server::Config { + keys: vec![russh::keys::decode_secret_key(include_str!("../tests/fixtures/ssh/host"), None).expect("host key")], + auth_rejection_time: std::time::Duration::from_millis(10), + auth_rejection_time_initial: Some(std::time::Duration::ZERO), + ..Default::default() + }); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let mut server = Sshd { root, exec }; + tokio::spawn(async move { + let _ = server.run_on_socket(config, &listener).await; + }); + port +} + +#[derive(Clone)] +struct Sshd { + root: PathBuf, + exec: ExecFn, +} + +impl server::Server for Sshd { + type Handler = Conn; + fn new_client(&mut self, _: Option) -> Conn { + Conn { root: self.root.clone(), exec: self.exec.clone(), channels: Arc::default() } + } +} + +struct Conn { + root: PathBuf, + exec: ExecFn, + channels: Arc>>>, +} + +impl server::Handler for Conn { + type Error = russh::Error; + + async fn auth_password(&mut self, user: &str, password: &str) -> Result { + Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() }) + } + + async fn channel_open_session( + &mut self, + channel: Channel, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + self.channels.lock().await.insert(channel.id(), channel); + reply.accept().await; + Ok(()) + } + + async fn exec_request( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + let (out, code) = (self.exec)(&String::from_utf8_lossy(data)); + session.channel_success(channel)?; + session.data(channel, out.into_bytes())?; + session.exit_status_request(channel, code)?; + session.eof(channel)?; + session.close(channel)?; + Ok(()) + } + + async fn subsystem_request( + &mut self, + channel: ChannelId, + name: &str, + session: &mut Session, + ) -> Result<(), Self::Error> { + let Some(ch) = self.channels.lock().await.remove(&channel).filter(|_| name == "sftp") else { + session.channel_failure(channel)?; + return Ok(()); + }; + session.channel_success(channel)?; + let sftp = Sftp { root: self.root.clone(), files: HashMap::new(), dirs: HashMap::new(), next: 0 }; + tokio::spawn(russh_sftp::server::run(ch.into_stream(), sftp)); + Ok(()) + } +} + +struct Sftp { + root: PathBuf, + files: HashMap, + /// Open directories: entries not yet sent. + dirs: HashMap>>, + next: u64, +} + +fn ok(id: u32) -> Status { + Status { id, status_code: StatusCode::Ok, error_message: "Ok".into(), language_tag: "en-US".into() } +} + +fn code(e: std::io::Error) -> StatusCode { + match e.kind() { + std::io::ErrorKind::NotFound => StatusCode::NoSuchFile, + std::io::ErrorKind::PermissionDenied => StatusCode::PermissionDenied, + _ => StatusCode::Failure, + } +} + +impl Sftp { + fn local(&self, path: &str) -> PathBuf { + path.split('/').filter(|s| !s.is_empty() && *s != "." && *s != "..").fold(self.root.clone(), |p, s| p.join(s)) + } + + fn handle(&mut self) -> String { + self.next += 1; + format!("h{}", self.next) + } + + fn attrs(path: &Path) -> Result { + std::fs::symlink_metadata(path).map(|m| FileAttributes::from(&m)).map_err(code) + } +} + +impl russh_sftp::server::Handler for Sftp { + type Error = StatusCode; + + fn unimplemented(&self) -> Self::Error { + StatusCode::OpUnsupported + } + + async fn init(&mut self, _version: u32, _ext: HashMap) -> Result { + Ok(Version::new()) + } + + async fn realpath(&mut self, id: u32, path: String) -> Result { + let p = if path == "." || path.is_empty() { "/home/dev".to_owned() } else { path }; + Ok(Name { id, files: vec![File::dummy(p)] }) + } + + async fn open( + &mut self, + id: u32, + filename: String, + pflags: OpenFlags, + _attrs: FileAttributes, + ) -> Result { + let path = self.local(&filename); + let file = std::fs::OpenOptions::new() + .read(pflags.contains(OpenFlags::READ)) + .write(pflags.contains(OpenFlags::WRITE)) + .create(pflags.contains(OpenFlags::CREATE)) + .truncate(pflags.contains(OpenFlags::TRUNCATE)) + .open(path) + .map_err(code)?; + let handle = self.handle(); + self.files.insert(handle.clone(), file); + Ok(Handle { id, handle }) + } + + async fn close(&mut self, id: u32, handle: String) -> Result { + self.files.remove(&handle); + self.dirs.remove(&handle); + Ok(ok(id)) + } + + async fn read(&mut self, id: u32, handle: String, offset: u64, len: u32) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + file.seek(SeekFrom::Start(offset)).map_err(code)?; + let mut buf = vec![0; len as usize]; + let n = file.read(&mut buf).map_err(code)?; + if n == 0 { + return Err(StatusCode::Eof); + } + buf.truncate(n); + Ok(Data { id, data: buf }) + } + + async fn write(&mut self, id: u32, handle: String, offset: u64, data: Vec) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + file.seek(SeekFrom::Start(offset)).map_err(code)?; + file.write_all(&data).map_err(code)?; + Ok(ok(id)) + } + + async fn stat(&mut self, id: u32, path: String) -> Result { + let p = self.local(&path); + let attrs = std::fs::metadata(&p).map(|m| FileAttributes::from(&m)).map_err(code)?; + Ok(Attrs { id, attrs }) + } + + async fn lstat(&mut self, id: u32, path: String) -> Result { + Ok(Attrs { id, attrs: Self::attrs(&self.local(&path))? }) + } + + async fn fstat(&mut self, id: u32, handle: String) -> Result { + let file = self.files.get(&handle).ok_or(StatusCode::Failure)?; + Ok(Attrs { id, attrs: FileAttributes::from(&file.metadata().map_err(code)?) }) + } + + async fn setstat(&mut self, id: u32, path: String, attrs: FileAttributes) -> Result { + if let Some(t) = attrs.mtime { + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.into()); + let f = std::fs::File::options().write(true).open(self.local(&path)).map_err(code)?; + f.set_modified(time).map_err(code)?; + } + Ok(ok(id)) + } + + async fn opendir(&mut self, id: u32, path: String) -> Result { + let dir = self.local(&path); + let mut files = Vec::new(); + for e in std::fs::read_dir(&dir).map_err(code)?.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + files.push(File::new(name, Self::attrs(&e.path())?)); + } + let handle = self.handle(); + self.dirs.insert(handle.clone(), Some(files)); + Ok(Handle { id, handle }) + } + + async fn readdir(&mut self, id: u32, handle: String) -> Result { + match self.dirs.get_mut(&handle).and_then(Option::take) { + Some(files) if !files.is_empty() => Ok(Name { id, files }), + _ => Err(StatusCode::Eof), + } + } + + async fn remove(&mut self, id: u32, filename: String) -> Result { + std::fs::remove_file(self.local(&filename)).map_err(code)?; + Ok(ok(id)) + } + + async fn mkdir(&mut self, id: u32, path: String, _attrs: FileAttributes) -> Result { + std::fs::create_dir(self.local(&path)).map_err(code)?; + Ok(ok(id)) + } + + async fn rmdir(&mut self, id: u32, path: String) -> Result { + std::fs::remove_dir(self.local(&path)).map_err(code)?; + Ok(ok(id)) + } + + async fn rename(&mut self, id: u32, oldpath: String, newpath: String) -> Result { + std::fs::rename(self.local(&oldpath), self.local(&newpath)).map_err(code)?; + Ok(ok(id)) + } +} diff --git a/crates/ide-ssh/src/tunnel.rs b/crates/ide-ssh/src/tunnel.rs new file mode 100644 index 000000000..c076ee2a3 --- /dev/null +++ b/crates/ide-ssh/src/tunnel.rs @@ -0,0 +1,80 @@ +//! Local port forwarding: a listener on 127.0.0.1 whose connections are +//! forwarded through `direct-tcpip` channels of a [`Session`]. + +use crate::session::Session; +use ide_core::{IdeError, IdeResult, error::ErrorKind}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +/// An open tunnel; dropping it stops forwarding (the session stays open while +/// other users hold it). +pub struct Tunnel { + port: u16, + session: Arc, + accept: JoinHandle<()>, +} + +impl Tunnel { + /// Checks that the SSH host reaches `host:port`, then listens on a free local port. + pub async fn open(session: Arc, host: &str, port: u16) -> IdeResult { + // Fail fast with the real reason when the SSH host cannot reach the target + // (a refused forward otherwise surfaces as the client's connect timeout). + session.direct_tcpip(host, port).await.map_err(|e| unreachable_target(session.label(), host, port, &e))?; + Self::listen(session, host, port).await + } + + /// [`Tunnel::open`] without the reachability check (the target may start later). + pub async fn listen(session: Arc, host: &str, port: u16) -> IdeResult { + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let local = listener.local_addr()?.port(); + let accept = tokio::spawn(forward(listener, session.clone(), host.to_owned(), port)); + Ok(Tunnel { port: local, session, accept }) + } + + /// The local port to connect to instead of the target. + pub fn port(&self) -> u16 { + self.port + } + + pub fn is_closed(&self) -> bool { + self.session.is_closed() + } +} + +impl Drop for Tunnel { + fn drop(&mut self) { + self.accept.abort(); + } +} + +async fn forward(listener: TcpListener, session: Arc, host: String, port: u16) { + loop { + let Ok((mut socket, _)) = listener.accept().await else { return }; + let session = session.clone(); + let host = host.clone(); + tokio::spawn(async move { + match session.direct_tcpip(&host, port).await { + Ok(mut stream) => { + let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await; + } + Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"), + } + }); + } +} + +/// Why the SSH host `via` cannot open a channel to `host:port`. +pub fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError { + let why = match e { + russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => { + "connection refused or host unreachable" + } + russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => { + "port forwarding is not allowed by the SSH server (AllowTcpForwarding)" + } + _ => "", + }; + let why = if why.is_empty() { e.to_string() } else { why.to_owned() }; + IdeError::new(ErrorKind::Io, format!("SSH host {via} cannot reach {host}:{port}: {why}")) +} diff --git a/crates/ide-db/tests/fixtures/ssh/client b/crates/ide-ssh/tests/fixtures/ssh/client similarity index 100% rename from crates/ide-db/tests/fixtures/ssh/client rename to crates/ide-ssh/tests/fixtures/ssh/client diff --git a/crates/ide-db/tests/fixtures/ssh/host b/crates/ide-ssh/tests/fixtures/ssh/host similarity index 100% rename from crates/ide-db/tests/fixtures/ssh/host rename to crates/ide-ssh/tests/fixtures/ssh/host diff --git a/crates/ide-db/tests/fixtures/ssh/locked b/crates/ide-ssh/tests/fixtures/ssh/locked similarity index 100% rename from crates/ide-db/tests/fixtures/ssh/locked rename to crates/ide-ssh/tests/fixtures/ssh/locked diff --git a/crates/ide-ssh/tests/session.rs b/crates/ide-ssh/tests/session.rs new file mode 100644 index 000000000..819037d1a --- /dev/null +++ b/crates/ide-ssh/tests/session.rs @@ -0,0 +1,256 @@ +//! Sessions against an in-process russh server that runs commands (a canned +//! reply) and forwards `direct-tcpip` channels to a local echo server. + +use ide_core::error::ErrorKind; +use ide_core::remote_settings::SshAuth; +use ide_ssh::{ConnectError, Output, Session, SshFiles, SshTarget, Tunnel}; +use parking_lot::Mutex; +use russh::keys::{PrivateKey, PublicKey, decode_secret_key}; +use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session as ServerSession}; +use russh::{Channel, ChannelId}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name) +} + +fn key(name: &str, passphrase: Option<&str>) -> PrivateKey { + decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap() +} + +#[derive(Clone)] +struct Sshd { + client_key: PublicKey, +} + +impl server::Server for Sshd { + type Handler = Sshd; + fn new_client(&mut self, _: Option) -> Sshd { + self.clone() + } +} + +impl server::Handler for Sshd { + type Error = russh::Error; + + async fn auth_password(&mut self, user: &str, password: &str) -> Result { + Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() }) + } + + async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result { + Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() }) + } + + async fn channel_open_session( + &mut self, + _channel: Channel, + reply: ChannelOpenHandle, + _session: &mut ServerSession, + ) -> Result<(), Self::Error> { + reply.accept().await; + Ok(()) + } + + /// `ran: ` on stdout, `oops` on stderr, exit status 3. + async fn exec_request( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut ServerSession, + ) -> Result<(), Self::Error> { + session.channel_success(channel)?; + session.data(channel, format!("ran: {}", String::from_utf8_lossy(data)).into_bytes())?; + session.extended_data(channel, 1, b"oops".to_vec())?; + session.exit_status_request(channel, 3)?; + session.eof(channel)?; + session.close(channel)?; + Ok(()) + } + + async fn channel_open_direct_tcpip( + &mut self, + channel: Channel, + host: &str, + port: u32, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut ServerSession, + ) -> Result<(), Self::Error> { + let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else { + return Ok(()); + }; + reply.accept().await; + tokio::spawn(async move { + let mut stream = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await; + }); + Ok(()) + } +} + +/// Echo server standing in for a remote service. +async fn echo() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((mut s, _)) = listener.accept().await { + tokio::spawn(async move { + let (mut r, mut w) = s.split(); + let _ = tokio::io::copy(&mut r, &mut w).await; + }); + } + }); + port +} + +async fn sshd() -> u16 { + let config = Arc::new(server::Config { + keys: vec![key("host", None)], + auth_rejection_time: std::time::Duration::from_millis(10), + auth_rejection_time_initial: Some(std::time::Duration::ZERO), + ..Default::default() + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut server = Sshd { client_key: key("client", None).public_key().clone() }; + tokio::spawn(async move { + let _ = server.run_on_socket(config, &listener).await; + }); + port +} + +struct Env { + _tmp: tempfile::TempDir, + files: SshFiles, + ssh_port: u16, + echo_port: u16, +} + +async fn env() -> Env { + let tmp = tempfile::tempdir().unwrap(); + let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }; + Env { files, _tmp: tmp, ssh_port: sshd().await, echo_port: echo().await } +} + +impl Env { + fn target(&self, auth: SshAuth) -> SshTarget { + SshTarget { + host: "127.0.0.1".into(), + port: Some(self.ssh_port), + user: "dev".into(), + auth, + ..Default::default() + } + } + + async fn connect(&self, target: &SshTarget, secret: Option<&str>) -> Result { + Session::connect(target, secret, &self.files).await + } + + /// Trusts the server's key on the first connection, as the UI does. + async fn connect_trusted(&self, target: &SshTarget, secret: Option<&str>) -> Result { + match self.connect(target, secret).await { + Err(ConnectError::UnknownHostKey(k)) => { + k.trust(&self.files.known_hosts).unwrap(); + self.connect(target, secret).await + } + other => other, + } + } +} + +async fn round_trip(t: &Tunnel) { + let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap(); + s.write_all(b"SELECT 1").await.unwrap(); + let mut buf = [0u8; 8]; + s.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"SELECT 1"); +} + +fn denied(r: Result) -> String { + match r { + Err(ConnectError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message, + Err(ConnectError::Other(e)) => panic!("expected permission denied, got {e:?}"), + Err(ConnectError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()), + Ok(_) => panic!("expected permission denied, connected"), + } +} + +#[tokio::test] +async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() { + let env = env().await; + let target = env.target(SshAuth::Password); + let Err(ConnectError::UnknownHostKey(k)) = env.connect(&target, Some("secret")).await else { + panic!("expected an unknown host key"); + }; + assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint()); + assert!(k.error().message.starts_with("Unknown SSH host key for 127.0.0.1:")); + k.trust(&env.files.known_hosts).unwrap(); + assert!(env.connect(&target, Some("secret")).await.is_ok()); + + // Another key recorded for the same host: refused, never offered for trust. + let other = key("client", None).public_key().to_openssh().unwrap(); + std::fs::write(&env.files.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap(); + let msg = denied(env.connect(&target, Some("secret")).await); + assert!(msg.contains("has changed"), "{msg}"); +} + +#[tokio::test] +async fn password_authentication_and_forwarding() { + let env = env().await; + let target = env.target(SshAuth::Password); + let session = Arc::new(env.connect_trusted(&target, Some("secret")).await.ok().unwrap()); + let tunnel = Tunnel::open(session.clone(), "127.0.0.1", env.echo_port).await.unwrap(); + round_trip(&tunnel).await; + round_trip(&tunnel).await; + assert!(!tunnel.is_closed()); + + assert!(denied(env.connect(&target, None).await).starts_with("SSH password required")); + assert!(denied(env.connect(&target, Some("wrong")).await).starts_with("SSH authentication failed")); + + let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port(); + let Err(e) = Tunnel::open(session, "127.0.0.1", closed).await else { panic!("expected an error") }; + assert!(e.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", e.message); +} + +#[tokio::test] +async fn key_pair_authentication_with_and_without_passphrase() { + let env = env().await; + let target = SshTarget { key_file: fixture("client").to_string_lossy().into(), ..env.target(SshAuth::KeyPair) }; + assert!(env.connect_trusted(&target, None).await.is_ok()); + + // An encrypted key the server does not accept: passphrase first, then the key is refused. + let locked = SshTarget { key_file: fixture("locked").to_string_lossy().into(), ..target.clone() }; + assert!(denied(env.connect(&locked, None).await).starts_with("SSH key passphrase required")); + assert!(denied(env.connect(&locked, Some("nope")).await).contains("wrong passphrase")); + assert!(denied(env.connect(&locked, Some("pw")).await).starts_with("SSH authentication failed")); +} + +#[tokio::test] +async fn runs_commands_and_streams_processes() { + let env = env().await; + let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap(); + let out = session.exec("uname -a", None).await.unwrap(); + assert_eq!((out.code, out.stdout_str().as_str(), out.stderr.as_slice()), (Some(3), "ran: uname -a", &b"oops"[..])); + assert!(out.into_stdout("uname").unwrap_err().message.contains("oops")); + + let seen: Arc> = Arc::default(); + let s = seen.clone(); + let process = session + .spawn( + "python3 main.py", + None, + Box::new(move |o| match o { + Output::Stdout(b) => s.lock().0.push_str(&String::from_utf8_lossy(b)), + Output::Stderr(b) => s.lock().1.push_str(&String::from_utf8_lossy(b)), + }), + ) + .await + .unwrap(); + assert_eq!(process.wait().await, Some(3)); + assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned())); +} diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 1ab4b72e9..18814cb4a 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -189,10 +189,9 @@ impl TerminalManager { fn session(&self, id: TerminalId) -> IdeResult> { self.sessions.lock().get(&id).cloned().ok_or_else(|| not_found(id)) } -} -impl Drop for TerminalManager { - fn drop(&mut self) { + /// Terminates every session (shutting down). + pub fn kill_all(&self) { let sessions: Vec<_> = self.sessions.lock().drain().map(|(_, s)| s).collect(); for s in sessions { terminate(&s); @@ -200,6 +199,12 @@ impl Drop for TerminalManager { } } +impl Drop for TerminalManager { + fn drop(&mut self) { + self.kill_all(); + } +} + fn not_found(id: TerminalId) -> IdeError { IdeError::not_found(format!("no such terminal: {id}")) } diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 209466ed5..bd0e52bad 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -14,6 +14,7 @@ pub mod jdk; pub mod platform; mod process; pub mod registry; +pub mod server; pub mod sources; use ide_core::error::ErrorKind; diff --git a/crates/ide-tools/src/server.rs b/crates/ide-tools/src/server.rs new file mode 100644 index 000000000..3e3481c41 --- /dev/null +++ b/crates/ide-tools/src/server.rs @@ -0,0 +1,105 @@ +//! `openidea-server` builds for remote development: the release of this +//! version publishes one per supported host platform (see `release.yml`). + +use crate::process::Http; +use crate::{InstallEvent, default_search_path, fetch_verified, sources}; +use ide_core::CancellationToken; +use ide_core::{IdeError, IdeResult}; +use std::path::{Path, PathBuf}; + +const RELEASES: &str = "https://github.com/l0ng-ai/openidea/releases/download"; + +/// Host platforms with a published server (Rust's `OS`-`ARCH` names). +pub const PLATFORMS: &[(&str, &str)] = + &[("linux", "x86_64"), ("linux", "aarch64"), ("macos", "aarch64"), ("macos", "x86_64")]; + +/// The release asset of the server for `os`-`arch` (gzip-compressed binary). +pub fn asset_name(os: &str, arch: &str) -> String { + format!("openidea-server-{os}-{arch}.gz") +} + +/// Where a downloaded server of `version` is kept. +pub fn cached_path(app_data: &Path, version: &str, os: &str, arch: &str) -> PathBuf { + app_data.join("server").join(version).join(format!("{os}-{arch}")).join("openidea-server") +} + +/// The server of `version` for `os`-`arch`: the cached download, or the release +/// asset downloaded now (checksum verified). +pub fn fetch( + app_data: &Path, + version: &str, + os: &str, + arch: &str, + cancel: &CancellationToken, + emit: &dyn Fn(InstallEvent), +) -> IdeResult { + let dest = cached_path(app_data, version, os, arch); + if dest.is_file() { + return Ok(dest); + } + if !PLATFORMS.contains(&(os, arch)) { + return Err(IdeError::unavailable(format!("remote development does not support {os}-{arch} hosts"))); + } + let name = asset_name(os, arch); + let url = format!("{RELEASES}/v{version}/{name}"); + let dir = dest.parent().expect("cached path has a parent"); + std::fs::create_dir_all(dir)?; + let staging = tempfile_dir(dir)?; + let result = (|| { + let http = Http::new(&default_search_path())?; + // The binary runs on the remote host: never install it unverified. + let checksum_url = format!("{url}.sha256"); + let sha256 = sources::parse_checksum(&http.get_text(&checksum_url, cancel)?, &name) + .ok_or_else(|| IdeError::unavailable(format!("no SHA-256 for {name} at {checksum_url}")))?; + let dl = sources::Download { + version: version.to_owned(), + checksum_url: None, + name, + url, + size: None, + sha256: Some(sha256), + }; + let archive = fetch_verified(&http, &dl, &staging, cancel, emit)?; + let unpacked = staging.join("openidea-server"); + let mut gz = flate2::read::GzDecoder::new(std::fs::File::open(&archive)?); + std::io::copy(&mut gz, &mut std::fs::File::create(&unpacked)?)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&unpacked, std::fs::Permissions::from_mode(0o755))?; + } + std::fs::rename(&unpacked, &dest)?; + Ok(dest.clone()) + })(); + let _ = std::fs::remove_dir_all(&staging); + result +} + +fn tempfile_dir(parent: &Path) -> IdeResult { + // Unique per call: two windows of this process may download at once. + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = parent.join(format!(".download-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_and_cache_layout() { + assert_eq!(asset_name("linux", "x86_64"), "openidea-server-linux-x86_64.gz"); + let p = cached_path(Path::new("/data"), "1.2.3", "linux", "aarch64"); + assert_eq!(p, Path::new("/data/server/1.2.3/linux-aarch64/openidea-server")); + } + + #[test] + fn unsupported_hosts_are_refused_without_downloading() { + let tmp = tempfile::tempdir().unwrap(); + let e = fetch(tmp.path(), "1.2.3", "freebsd", "x86_64", &CancellationToken::new(), &|_| {}).unwrap_err(); + assert!(e.message.contains("freebsd-x86_64"), "{e:?}"); + } +} diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 97f687c63..b80ef4c0a 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -19,6 +19,14 @@ pub fn set_git_executable(path: Option<&Path>) { } } +/// Environment that routes credential prompts (`ssh` passphrases and host keys, +/// HTTPS user names and passwords) to the UI; empty = prompts fail. +static ASKPASS: std::sync::RwLock> = std::sync::RwLock::new(Vec::new()); + +pub fn set_askpass_env(env: Vec<(String, String)>) { + *ASKPASS.write().unwrap_or_else(|e| e.into_inner()) = env; +} + /// Locates the auto-detected git executable once per process. /// /// GUI apps on macOS start with a minimal `PATH`, so a few well-known install @@ -95,6 +103,9 @@ pub(crate) fn command(root: &Path) -> IdeResult { .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_INDEX_FILE"); + for (k, v) in ASKPASS.read().unwrap_or_else(|e| e.into_inner()).iter() { + cmd.env(k, v); + } ide_core::process::no_window(&mut cmd); Ok(cmd) } diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 51d9e98f3..246f8d855 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -452,7 +452,7 @@ pub mod shelf; mod status; mod write; -pub use cli::{detected_git_executable, git_version, set_git_executable}; +pub use cli::{detected_git_executable, git_version, set_askpass_env, set_git_executable}; pub use diff::build_partial_patch; pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree}; pub use remotes::Remote; diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 33e03ca21..d9defba9e 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -531,7 +531,7 @@ a token. | `--bg-toolbar-pressed` | `#ffffff29` | `#00000020` | | `--bg-toolbar-selected` | `#2e4d89` | `#d0dffe` | | `--bg-toolbar-selected-hover` | `#2f5eb9` | `#bdd3ff` | -| `--bg-button-primary-hover` | `#2f5eb9` | `#2f5eb9` | +| `--bg-button-primary-hover` | `var(--accent-hover)` | `var(--accent-hover)` | | `--fg-button-primary` | `#ffffff` | `#ffffff` | | `--bg-button` | `transparent` | `#ffffff` | | `--border-button` | `#40434a` | `#d1d3d9` | diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 410a81bd2..be5f963e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ serde.workspace = true serde_json.workspace = true tokio.workspace = true parking_lot.workspace = true +blake3.workspace = true tracing.workspace = true tracing-subscriber.workspace = true dirs.workspace = true @@ -57,6 +58,10 @@ ide-agent.workspace = true ide-db.workspace = true ide-web.workspace = true ide-spell.workspace = true +ide-ssh.workspace = true +ide-remote.workspace = true +ide-host.workspace = true +ide-jsonrpc.workspace = true ide-tools = { path = "../crates/ide-tools" } ide-plugin = { path = "../crates/ide-plugin" } ide-docker = { path = "../crates/ide-docker" } diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index f04372c5e..1c69a9b19 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -1,8 +1,11 @@ +//! IPC commands of `ide_host::ops::agent` (the shared implementation). + use crate::state::AppState; use ide_agent::{ AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, }; use ide_core::{IdeResult, IpcPath}; +use ide_host::ops::agent; use std::sync::Arc; use tauri::ipc::Channel; use tauri::{State, Window}; @@ -15,16 +18,19 @@ pub fn agent_subscribe( state: State<'_, AppState>, on_event: Channel, ) -> IdeResult> { - let project = state.project(window.label())?; - Ok(project.agents.subscribe(Arc::new(move |e| { - let _ = on_event.send(e); - }))) + agent::subscribe( + &state.host, + window.label(), + Arc::new(move |e| { + let _ = on_event.send(e); + }), + ) } #[tauri::command] #[specta::specta] pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult> { - Ok(state.project(window.label())?.agents.providers()) + agent::providers(&state.host, window.label()) } /// Slash commands to complete for `provider` in `task`'s directory, or in the project @@ -37,21 +43,19 @@ pub async fn agent_commands( provider: String, task: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - project.agents.commands(&provider, task.as_deref()).await + agent::commands(&state.host, window.label(), provider, task).await } #[tauri::command] #[specta::specta] pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult> { - state.project(window.label())?.agents.entries(&task) + agent::entries(&state.host, window.label(), task) } #[tauri::command] #[specta::specta] pub async fn agent_create(window: Window, state: State<'_, AppState>, request: NewAgentTask) -> IdeResult { - let project = state.project(window.label())?; - project.agents.create(request).await + agent::create(&state.host, window.label(), request).await } /// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. @@ -64,8 +68,7 @@ pub async fn agent_prompt( prompt: AgentPrompt, now: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - project.agents.prompt(&task, prompt, now).await + agent::prompt(&state.host, window.label(), task, prompt, now).await } /// Drops a queued message and returns it (to edit it). @@ -77,7 +80,7 @@ pub fn agent_unqueue( task: String, queued: String, ) -> IdeResult { - state.project(window.label())?.agents.unqueue(&task, &queued) + agent::unqueue(&state.host, window.label(), task, queued) } /// Sends a queued message now, interrupting the current turn. @@ -89,7 +92,7 @@ pub fn agent_send_queued_now( task: String, queued: String, ) -> IdeResult<()> { - state.project(window.label())?.agents.send_queued_now(&task, &queued) + agent::send_queued_now(&state.host, window.label(), task, queued) } /// Changes a session setting (model, mode, thinking effort…). @@ -102,14 +105,13 @@ pub async fn agent_set_config( option: String, value: String, ) -> IdeResult<()> { - let project = state.project(window.label())?; - project.agents.set_config(&task, &option, value).await + agent::set_config(&state.host, window.label(), task, option, value).await } #[tauri::command] #[specta::specta] pub fn agent_cancel(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> { - state.project(window.label())?.agents.cancel(&task) + agent::cancel(&state.host, window.label(), task) } /// Answers a permission request; `option: null` cancels it. @@ -122,13 +124,13 @@ pub fn agent_respond( permission: u32, option: Option, ) -> IdeResult<()> { - state.project(window.label())?.agents.respond(&task, permission, option) + agent::respond(&state.host, window.label(), task, permission, option) } #[tauri::command] #[specta::specta] pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> { - state.project(window.label())?.agents.archive(&task) + agent::archive(&state.host, window.label(), task) } #[tauri::command] @@ -139,15 +141,13 @@ pub async fn agent_delete( task: String, remove_worktree: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - project.agents.delete(&task, remove_worktree).await + agent::delete(&state.host, window.label(), task, remove_worktree).await } #[tauri::command] #[specta::specta] pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: String) -> IdeResult> { - let project = state.project(window.label())?; - project.agents.changes(&task).await + agent::changes(&state.host, window.label(), task).await } /// Content of `path` when the task started (left side of its diff). @@ -159,6 +159,5 @@ pub async fn agent_base_text( task: String, path: IpcPath, ) -> IdeResult> { - let project = state.project(window.label())?; - project.agents.base_text(&task, path.into_path_buf()).await + agent::base_text(&state.host, window.label(), task, path).await } diff --git a/src-tauri/src/commands/analysis.rs b/src-tauri/src/commands/analysis.rs index 3646f8310..937b2f1c1 100644 --- a/src-tauri/src/commands/analysis.rs +++ b/src-tauri/src/commands/analysis.rs @@ -1,105 +1,14 @@ -//! Code analysis over a scope of files: Inspect Code (batch LSP diagnostics), -//! Locate Duplicates and Search / Replace Structurally (`ide-analysis`). -//! -//! Scopes are Find in Files scopes; excluded roots are never analysed. Every -//! command takes a UI-allocated `RequestId` and is cancelled via `cancel_request`. +//! IPC commands of `ide_host::ops::analysis` (the shared implementation). -use super::doc::blocking; -use crate::state::{AppState, Project}; -use ide_analysis::{ - DuplicateOptions, DuplicatesReport, SourceFile, StructuralFile, StructuralQuery, StructuralSummary, -}; -use ide_core::{CancellationToken, Diagnostic, DocId, IdeResult, IpcPath, LanguageId, RequestId}; -use ide_language::LanguageRegistry; -use ide_lsp::{FileInspection, InspectHost, InspectOptions}; -use ide_search::{FileMask, SearchScope}; -use ide_workspace::Documents; -use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::path::Path; -use std::sync::Arc; -use std::time::Instant; +use crate::state::AppState; +use ide_analysis::{DuplicateOptions, DuplicatesReport, StructuralQuery}; +use ide_core::{IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::analysis::*; +use ide_search::SearchScope; use tauri::ipc::Channel; use tauri::{State, Window}; -/// Files of `scope`, minus excluded roots and ignored names. -fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult> { - let mask = FileMask::new(&[], &project.search_excludes())?; - Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)? - .into_iter() - .map(|f| SourceFile { path: f.path, rel_path: f.rel_path }) - .collect()) -} - -/// Current text of a file (the unsaved buffer when open) and its language. -fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<(LanguageId, String)> { - if let Some(doc) = project.documents.by_path(path) { - let snap = project.documents.text_snapshot(doc).ok()?; - return (!snap.large).then_some((snap.language, snap.text)); - } - let loaded = ide_vfs::load_text(path).ok()?; - if loaded.binary || loaded.large || loaded.too_large { - return None; - } - let language = languages.detect(path, loaded.text.lines().next()); - Some((language, loaded.text)) -} - -// ---- Inspect Code ---- - -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct InspectedFile { - pub path: IpcPath, - pub rel_path: String, - pub diagnostics: Vec, -} - -#[derive(Clone, Default, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct InspectSummary { - /// Files inspected by a language server. - pub files: u32, - pub problems: u32, - /// Files in scope no language server serves. - pub skipped: u32, - /// Files whose server failed or never became ready. - pub failed: u32, - pub cancelled: bool, - pub elapsed_ms: u32, -} - -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum InspectEvent { - /// Files about to be inspected. - Started { - total: u32, - }, - /// A file finished (with or without problems). - File { - file: InspectedFile, - done: u32, - }, - Done { - summary: InspectSummary, - }, -} - -struct DocumentsHost(Arc); - -impl InspectHost for DocumentsHost { - fn open(&self, path: &Path) -> IdeResult<(DocId, bool)> { - let was_open = self.0.by_path(path).is_some(); - Ok((self.0.open(path)?.id, was_open)) - } - - fn close(&self, doc: DocId) { - self.0.close(doc); - } -} - /// Inspect Code: diagnostics of every file in `scope` that a language server /// serves. Files stream through `on_event` as they finish; ends with `Done`. #[tauri::command] @@ -111,72 +20,16 @@ pub async fn inspect_code( scope: SearchScope, on_event: Channel, ) -> IdeResult<()> { - let started = Instant::now(); - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let listed = { - let (project, cancel) = (project.clone(), cancel.clone()); - blocking(move || files_in_scope(&project, &scope, &cancel)).await - }; - let files = match listed { - Ok(files) => files, - Err(e) => { - state.end_request(request); - return Err(e); - } - }; - let total_in_scope = files.len() as u32; - let rel: std::collections::HashMap<_, _> = files - .iter() - .filter(|f| project.lsp.serves_path(&f.path)) - .map(|f| (f.path.clone(), f.rel_path.clone())) - .collect(); - let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() }; - let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 }); - - let mut paths: Vec<_> = rel.keys().cloned().collect(); - paths.sort(); - let tally = Mutex::new(summary.clone()); - let done = std::sync::atomic::AtomicU32::new(0); - let host = Arc::new(DocumentsHost(project.documents.clone())); - project - .lsp - .inspect_files(host, paths, InspectOptions::default(), cancel.clone(), &|path, outcome| { - let done = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - let diagnostics = { - let mut t = tally.lock(); - match outcome { - FileInspection::Inspected(items) => { - t.files += 1; - t.problems += items.len() as u32; - items - } - FileInspection::NoServer => { - t.skipped += 1; - Vec::new() - } - FileInspection::Failed(message) => { - tracing::debug!("inspect {}: {message}", path.display()); - t.failed += 1; - Vec::new() - } - } - }; - let rel_path = rel.get(&path).cloned().unwrap_or_default(); - let file = InspectedFile { path: IpcPath(path), rel_path, diagnostics }; - let _ = on_event.send(InspectEvent::File { file, done }); - }) - .await; - state.end_request(request); - summary = tally.into_inner(); - summary.cancelled = cancel.is_cancelled(); - summary.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX); - let _ = on_event.send(InspectEvent::Done { summary }); - Ok(()) + ide_host::ops::analysis::inspect_code( + &state.host, + window.label(), + request, + scope, + crate::commands::sender(on_event), + ) + .await } -// ---- Locate Duplicates ---- - /// Locate Duplicates over the files of `scope`. #[tauri::command] #[specta::specta] @@ -187,58 +40,21 @@ pub async fn analysis_locate_duplicates( scope: SearchScope, options: DuplicateOptions, ) -> IdeResult { - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let languages = project.languages.clone(); - let syntax = state.syntax(); - let result = blocking(move || { - let files = files_in_scope(&project, &scope, &cancel)?; - let loader = |p: &Path| load(&project, &languages, p); - Ok(ide_analysis::find_duplicates(&syntax, &files, &loader, &options, &cancel)) - }) - .await; - state.end_request(request); - result -} - -// ---- Structural Search ---- - -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum StructuralEvent { - File { file: StructuralFile }, - Done { summary: StructuralSummary }, -} - -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct StructuralLanguage { - pub id: LanguageId, - pub name: String, + ide_host::ops::analysis::analysis_locate_duplicates(&state.host, window.label(), request, scope, options).await } /// Languages Search Structurally supports (those with a bundled grammar). #[tauri::command] #[specta::specta] pub async fn structural_languages(state: State<'_, AppState>) -> IdeResult> { - let syntax = state.syntax(); - let mut out: Vec = state - .languages - .all() - .into_iter() - .filter(|c| syntax.supports(&c.id)) - .map(|c| StructuralLanguage { id: c.id, name: c.name }) - .collect(); - out.sort_by_key(|l| l.name.to_lowercase()); - Ok(out) + ide_host::ops::analysis::structural_languages(&state.host, "").await } /// Checks a structural search template (and language) while the user types. #[tauri::command] #[specta::specta] pub async fn structural_validate(state: State<'_, AppState>, query: StructuralQuery) -> IdeResult<()> { - let syntax = state.syntax(); - blocking(move || ide_analysis::validate_structural(&syntax, &query)).await + ide_host::ops::analysis::structural_validate(&state.host, "", query).await } /// Search Structurally over `scope`; files with matches stream through @@ -254,20 +70,13 @@ pub async fn structural_search( query: StructuralQuery, on_event: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let languages = project.languages.clone(); - let syntax = state.syntax(); - let events = on_event.clone(); - let result = blocking(move || { - let files = files_in_scope(&project, &scope, &cancel)?; - let loader = |p: &Path| load(&project, &languages, p); - ide_analysis::search_structurally(&syntax, &files, &loader, &query, &cancel, &|file| { - let _ = events.send(StructuralEvent::File { file }); - }) - }) - .await; - state.end_request(request); - let _ = on_event.send(StructuralEvent::Done { summary: result? }); - Ok(()) + ide_host::ops::analysis::structural_search( + &state.host, + window.label(), + request, + scope, + query, + crate::commands::sender(on_event), + ) + .await } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 4a48827e0..6456fbf75 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -17,14 +17,7 @@ pub struct AppInfo { pub config_dir: ide_core::IpcPath, } -#[derive(Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct ProjectInfo { - pub root: ide_core::IpcPath, - pub name: String, - pub is_git: bool, - pub settings: Settings, -} +pub use ide_host::ops::project::ProjectInfo; #[tauri::command] #[specta::specta] @@ -69,18 +62,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult ProjectInfo { - let settings = project.settings.effective(); - let custom = settings.general.project_name.trim(); - ProjectInfo { - name: if custom.is_empty() { - project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() - } else { - custom.to_owned() - }, - is_git: !project.repos.read().is_empty(), - settings, - root: project.root.clone().into(), - } + ide_host::ops::project::info(project) } /// File › Rename Project…: the project's display name (title bar, recent @@ -122,10 +104,7 @@ pub fn languages(state: State<'_, AppState>) -> Vec { #[specta::specta] pub fn settings_get(window: Window, state: State<'_, AppState>) -> Settings { state.note_ipc(); - match state.project(window.label()) { - Ok(p) => p.settings.effective(), - Err(_) => state.global_settings.effective(), - } + ide_host::ops::project::settings(&state.host, window.label()) } /// Defaults plus the global layer, without any project's values: what new @@ -148,76 +127,22 @@ pub fn settings_set( key: String, value: String, ) -> IdeResult { - let value: serde_json::Value = - serde_json::from_str(&value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?; - let current = state.project(window.label()).ok(); - let project_scope = matches!(scope, SettingsScope::Project) && current.is_some(); - let settings = match ¤t { - Some(p) if project_scope => p.settings.set(scope, &key, value)?, - Some(p) => { - state.global_settings.set(SettingsScope::Global, &key, value)?; - p.settings.reload() - } - None => state.global_settings.set(SettingsScope::Global, &key, value)?, - }; - if let Some(p) = ¤t { - p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root))); - } - // A global value reaches every other open project too. - if !project_scope { - for project in state.projects() { - if current.as_ref().is_some_and(|c| c.root == project.root) { - continue; - } - let effective = project.settings.reload(); - project - .documents - .set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root))); - } - } - // File type associations re-detect open documents (off the command thread). - if key == "files" || key.starts_with("files.fileType") { - let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() }; - tauri::async_runtime::spawn_blocking(move || { - for project in affected { - project.apply_file_types(&project.settings.effective()); - } - }); - } - // A new interpreter / toolchain / server executable restarts the language servers it affects. - if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) { - let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() }; - for project in affected { - let lsp = project.lsp.clone(); - tauri::async_runtime::spawn(async move { lsp.reconfigure().await }); - } - } - let roots_changed = key == "projectStructure" || key.starts_with("projectStructure."); - // Content roots and excludes (files.exclude, ignored names, excluded folders) reach the - // index and the watchers without reopening the project (the rescan runs in the background). - if roots_changed || key == "files" || key.starts_with("files.") { - let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() }; - tauri::async_runtime::spawn_blocking(move || { - for project in affected { - project.refresh_index_scope(); - } - }); - } + let applied = ide_host::ops::project::set_settings(&state.host, window.label(), scope, &key, &value)?; // A project value concerns only that project's window (and its settings window). let event = ide_core::IdeEvent::SettingsChanged { key }; - if project_scope { + if applied.project_scope { let owner = AppState::owner_window(window.label()); for label in [owner.to_owned(), crate::windows::settings_label(owner)] { let sink = crate::events::TauriSink { app: state.app.clone(), window: Some(label) }; sink.emit(event.clone()); - if let Some(p) = current.as_ref().filter(|_| roots_changed) { - sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: p.root.clone().into() }); + if let Some(root) = &applied.roots_changed { + sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: root.clone() }); } } } else { state.sink.emit(event); } - Ok(settings) + Ok(applied.settings) } /// File › Manage IDE Settings › Export Settings…: the global settings plus the @@ -254,6 +179,7 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P let lsp = project.lsp.clone(); tauri::async_runtime::spawn(async move { lsp.reconfigure().await }); } + crate::commands::ssh::apply_ssh_settings(&state); state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() }); let settings = match state.project(window.label()) { Ok(p) => p.settings.effective(), @@ -267,7 +193,7 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P #[tauri::command] #[specta::specta] pub fn project_roots(window: Window, state: State<'_, AppState>) -> IdeResult { - Ok(state.project(window.label())?.roots()) + ide_host::ops::project::roots(&state.host, window.label()) } /// The project open in this window (a settings window: its parent's), if any. diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 30d0e2bb0..9f661d5bd 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -1,80 +1,20 @@ -//! Maven / Gradle tool windows (the build model and its background sync) and -//! Build › Build Project (compiling with the project's build system). +//! IPC commands of `ide_host::ops::build` (the shared implementation). -use crate::state::{AppState, apply_workspace_edit}; -use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult}; -use ide_build::pom_edit::{ArtifactKey, Exclusion}; -use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress}; -use ide_core::settings::Settings; -use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit}; -use std::collections::BTreeMap; -use std::path::Path; +use crate::state::AppState; +use ide_build::compile::{BuildEvent, BuildKind, BuildResult}; +use ide_build::pom_edit::ArtifactKey; +use ide_build::{BuildModel, BuildTool, SyncProgress}; +use ide_core::{IdeResult, Location, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::build::*; use tauri::ipc::Channel; use tauri::{State, Window}; -fn join_err(e: tauri::Error) -> IdeError { - IdeError::internal(e.to_string()) -} - -/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none -/// when the project names no version). -fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option) -> BTreeMap { - let jdks = ide_sdk::java::installed_jdks(); - let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r)))); - jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default() -} - -/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and -/// the Maven home and options from Settings › Maven. -pub(crate) fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { - let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings)); - let jdk = ide_sdk::apply::project_jdk(root, settings); - if let Some(m) = &mut model.maven { - let mvn = ide_sdk::apply::maven_command(root, settings); - let options = ide_sdk::apply::maven_options(root, settings); - for p in &mut m.projects { - p.env = java_env(jdk.as_ref(), p.java_version); - if let Some(mvn) = &mvn { - p.command = mvn.to_string_lossy().into_owned(); - } - p.options = options.clone(); - } - } - if let Some(g) = &mut model.gradle { - g.env = java_env(jdk.as_ref(), g.java_version); - } - // A failed sync of a project no installed JDK can build says so first (with a Download JDK action). - let missing = |release: Option| match jdk { - Some(_) => None, - None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release), - }; - let explain = |error: &mut Option, hint: Option| { - if let (Some(e), Some(hint)) = (error.as_mut(), hint) { - *e = format!("{hint}\n{e}"); - } - }; - if let Some(m) = &mut model.maven - && m.sync_error.is_some() - { - let hint = m.projects.iter().find_map(|p| missing(p.java_version)); - explain(&mut m.sync_error, hint); - } - if let Some(g) = &mut model.gradle - && g.sync_error.is_some() - { - let hint = missing(g.java_version); - explain(&mut g.sync_error, hint); - } - model -} - /// Maven and Gradle projects of the window's project, from the build files plus the last sync. #[tauri::command] #[specta::specta] pub async fn build_model(window: Window, state: State<'_, AppState>) -> IdeResult { - let project = state.project(window.label())?; - let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); - tauri::async_runtime::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err) + ide_host::ops::build::build_model(&state.host, window.label()).await } /// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the @@ -91,60 +31,16 @@ pub async fn build_sync( profiles: Vec, on_progress: Channel, ) -> IdeResult { - let project = state.project(window.label())?; - let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); - let token = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - let current = load(&root, &cache, &settings); - let env = match tool { - BuildTool::Maven => current - .maven - .as_ref() - .and_then(|m| m.projects.iter().find(|p| !p.env.is_empty())) - .map(|p| p.env.clone()) - .unwrap_or_default(), - BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(), - }; - let options = SyncOptions { - offline, - profiles, - env, - maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()), - maven_options: ide_sdk::apply::maven_options(&root, &settings), - }; - let progress = |p: SyncProgress| { - let _ = on_progress.send(p); - }; - match tool { - BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?, - BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?, - } - Ok(load(&root, &cache, &settings)) - }) + ide_host::ops::build::build_sync( + &state.host, + window.label(), + tool, + request, + offline, + profiles, + crate::commands::sender(on_progress), + ) .await - .map_err(join_err); - state.end_request(request); - result? -} - -/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the -/// module's own, else its nearest parent in the project; its path, text and declaration. -fn declaring_pom( - project: &crate::state::Project, - pom: &str, - dependency: &ArtifactKey, -) -> IdeResult<(std::path::PathBuf, String, Range)> { - let model = ide_build::maven::load(&project.root); - for rel in ide_build::pom_edit::declaring_poms(&model, pom) { - let path = project.root.join(&rel); - let text = project.text_of(&path)?; - if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) { - return Ok((path, text, range)); - } - } - Err(IdeError::not_found(format!( - "{dependency} is not declared in a pom.xml of this project (it may come from an imported BOM or an external parent POM)" - ))) } /// Where the direct dependency `dependency` of the Maven module `pom` is declared. @@ -156,13 +52,7 @@ pub async fn maven_dependency_declaration( pom: String, dependency: ArtifactKey, ) -> IdeResult { - let project = state.project(window.label())?; - tauri::async_runtime::spawn_blocking(move || { - let (path, _, range) = declaring_pom(&project, &pom, &dependency)?; - Ok(Location::file(path, range)) - }) - .await - .map_err(join_err)? + ide_host::ops::build::maven_dependency_declaration(&state.host, window.label(), pom, dependency).await } /// Adds an `` of `exclusion` to the declaration of the direct dependency @@ -177,22 +67,7 @@ pub async fn maven_exclude_dependency( dependency: ArtifactKey, exclusion: ArtifactKey, ) -> IdeResult { - let project = state.project(window.label())?; - tauri::async_runtime::spawn_blocking(move || { - let (path, text, range) = declaring_pom(&project, &pom, &dependency)?; - match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) { - Some(Exclusion::Edit(edit)) => { - let edit = - WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] }; - apply_workspace_edit(&project.documents, &edit)?; - } - Some(Exclusion::Present) => {} - None => return Err(IdeError::not_found(format!("{dependency} is not declared in {}", path.display()))), - } - Ok(Location::file(path, range)) - }) - .await - .map_err(join_err)? + ide_host::ops::build::maven_exclude_dependency(&state.host, window.label(), pom, dependency, exclusion).await } /// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the @@ -210,29 +85,14 @@ pub async fn build_run( clear: Vec, on_event: Channel, ) -> IdeResult { - let project = state.project(window.label())?; - let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone()); - let settings = project.settings.effective(); - let token = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - for path in clear { - sink.emit(IdeEvent::Diagnostics { - path: path.into(), - source: compile::DIAGNOSTICS_SOURCE.into(), - items: Vec::new(), - }); - } - let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?; - let result = compile::run(&root, &plan, &token, &|e| { - let _ = on_event.send(e); - })?; - for (path, items) in compile::diagnostics(&result.messages) { - sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items }); - } - Ok(result) - }) + ide_host::ops::build::build_run( + &state.host, + window.label(), + kind, + file, + request, + clear, + crate::commands::sender(on_event), + ) .await - .map_err(join_err); - state.end_request(request); - result? } diff --git a/src-tauri/src/commands/bytes.rs b/src-tauri/src/commands/bytes.rs index 62bc3b344..8f798ad88 100644 --- a/src-tauri/src/commands/bytes.rs +++ b/src-tauri/src/commands/bytes.rs @@ -1,27 +1,30 @@ -//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`). +//! IPC commands of `ide_host::ops::bytes` (the shared implementation). -use crate::commands::doc::blocking; +use crate::state::AppState; use ide_core::IdeResult; +#[allow(unused_imports)] +pub use ide_host::ops::bytes::*; use ide_vfs::{ByteRange, FileProbe}; use std::path::PathBuf; +use tauri::State; /// Size and text/binary classification, from the file's head only. #[tauri::command] #[specta::specta] -pub async fn fs_probe(path: PathBuf) -> IdeResult { - blocking(move || ide_vfs::probe(&path)).await +pub async fn fs_probe(state: State<'_, AppState>, path: PathBuf) -> IdeResult { + ide_host::ops::bytes::fs_probe(&state.host, "", path).await } /// Up to `len` bytes from `offset` (capped per call), base64-encoded. #[tauri::command] #[specta::specta] -pub async fn fs_read_range(path: PathBuf, offset: u64, len: u32) -> IdeResult { - blocking(move || ide_vfs::read_range(&path, offset, len)).await +pub async fn fs_read_range(state: State<'_, AppState>, path: PathBuf, offset: u64, len: u32) -> IdeResult { + ide_host::ops::bytes::fs_read_range(&state.host, "", path, offset, len).await } /// An image file as a `data:` URL. #[tauri::command] #[specta::specta] -pub async fn fs_read_image(path: PathBuf) -> IdeResult { - blocking(move || ide_vfs::read_data_url(&path)).await +pub async fn fs_read_image(state: State<'_, AppState>, path: PathBuf) -> IdeResult { + ide_host::ops::bytes::fs_read_image(&state.host, "", path).await } diff --git a/src-tauri/src/commands/coverage.rs b/src-tauri/src/commands/coverage.rs index 8fe66f2d7..ea3312872 100644 --- a/src-tauri/src/commands/coverage.rs +++ b/src-tauri/src/commands/coverage.rs @@ -1,18 +1,13 @@ -//! Run with Coverage: launch plans and coverage reports. +//! IPC commands of `ide_host::ops::coverage` (the shared implementation). use crate::state::AppState; -use ide_core::{IdeError, IdeResult, IpcPath}; +use ide_core::{IdeResult, IpcPath}; use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite}; +#[allow(unused_imports)] +pub use ide_host::ops::coverage::*; use ide_run::RunConfig; use tauri::{State, Window}; -/// Output directories kept per kind (older runs' data is deleted). -pub(crate) const KEEP_RUNS: usize = 10; - -pub(crate) async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { - tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? -} - /// How to run `config` with coverage: the command to launch and where its report will be, /// or which tool is missing. #[tauri::command] @@ -22,20 +17,14 @@ pub async fn coverage_prepare( state: State<'_, AppState>, config: RunConfig, ) -> IdeResult { - let project = state.project(window.label())?; - let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage")); - blocking(move || { - let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?; - Ok(ide_coverage::prepare(&root, &config, &out)) - }) - .await + ide_host::ops::coverage::coverage_prepare(&state.host, window.label(), config).await } /// Reads the report a coverage run left behind. #[tauri::command] #[specta::specta] -pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult { - blocking(move || ide_coverage::collect(&report)).await +pub async fn coverage_collect(state: State<'_, AppState>, report: CoverageReportSpec) -> IdeResult { + ide_host::ops::coverage::coverage_collect(&state.host, "", report).await } /// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML, @@ -43,6 +32,5 @@ pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult, path: IpcPath) -> IdeResult { - let root = state.project(window.label())?.root.clone(); - blocking(move || ide_coverage::import(&path, &root)).await + ide_host::ops::coverage::coverage_import(&state.host, window.label(), path).await } diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index 0721eb42a..d9cffa65e 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -1,6 +1,10 @@ +//! IPC commands of `ide_host::ops::debug` (the shared implementation). + use crate::state::AppState; use ide_core::{DebugSessionId, IdeResult}; use ide_dap::*; +#[allow(unused_imports)] +pub use ide_host::ops::debug::*; use std::path::PathBuf; use tauri::{State, Window}; @@ -12,18 +16,7 @@ pub async fn debug_start( state: State<'_, AppState>, config: LaunchConfig, ) -> IdeResult { - let label = window.label(); - let owner = SessionOwner::new(AppState::owner_window(label), state.window_sink(label)); - let settings = match state.project(label) { - Ok(project) => project.settings.effective(), - Err(_) => state.global_settings.effective(), - }; - let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); - let custom = |command: &str| { - let path = ide_sdk::apply::tool_command(&root, &settings, command)?; - Some(path.to_string_lossy().into_owned()) - }; - state.debugger.start_with(config, owner, custom).await + ide_host::ops::debug::debug_start(&state.host, window.label(), config).await } #[tauri::command] @@ -33,13 +26,13 @@ pub async fn debug_set_breakpoints( path: PathBuf, breakpoints: Vec, ) -> IdeResult> { - state.debugger.set_breakpoints(path, breakpoints).await + ide_host::ops::debug::debug_set_breakpoints(&state.host, "", path, breakpoints).await } #[tauri::command] #[specta::specta] pub async fn debug_threads(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult> { - state.debugger.threads(session).await + ide_host::ops::debug::debug_threads(&state.host, "", session).await } #[tauri::command] @@ -49,13 +42,13 @@ pub async fn debug_stack_trace( session: DebugSessionId, thread_id: i64, ) -> IdeResult> { - state.debugger.stack_trace(session, thread_id).await + ide_host::ops::debug::debug_stack_trace(&state.host, "", session, thread_id).await } #[tauri::command] #[specta::specta] pub async fn debug_scopes(state: State<'_, AppState>, session: DebugSessionId, frame_id: i64) -> IdeResult> { - state.debugger.scopes(session, frame_id).await + ide_host::ops::debug::debug_scopes(&state.host, "", session, frame_id).await } #[tauri::command] @@ -65,7 +58,7 @@ pub async fn debug_variables( session: DebugSessionId, reference: i64, ) -> IdeResult> { - state.debugger.variables(session, reference).await + ide_host::ops::debug::debug_variables(&state.host, "", session, reference).await } #[tauri::command] @@ -76,19 +69,19 @@ pub async fn debug_evaluate( frame_id: Option, expression: String, ) -> IdeResult { - state.debugger.evaluate(session, frame_id, expression).await + ide_host::ops::debug::debug_evaluate(&state.host, "", session, frame_id, expression).await } #[tauri::command] #[specta::specta] pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> { - state.debugger.resume(session, thread_id).await + ide_host::ops::debug::debug_resume(&state.host, "", session, thread_id).await } #[tauri::command] #[specta::specta] pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> { - state.debugger.pause(session, thread_id).await + ide_host::ops::debug::debug_pause(&state.host, "", session, thread_id).await } #[tauri::command] @@ -99,7 +92,7 @@ pub async fn debug_step( thread_id: i64, kind: StepKind, ) -> IdeResult<()> { - state.debugger.step(session, thread_id, kind).await + ide_host::ops::debug::debug_step(&state.host, "", session, thread_id, kind).await } #[tauri::command] @@ -112,13 +105,13 @@ pub async fn debug_run_to( line: u32, force: bool, ) -> IdeResult<()> { - state.debugger.run_to(session, thread_id, path, line, force).await + ide_host::ops::debug::debug_run_to(&state.host, "", session, thread_id, path, line, force).await } #[tauri::command] #[specta::specta] pub async fn debug_stop(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<()> { - state.debugger.stop(session).await + ide_host::ops::debug::debug_stop(&state.host, "", session).await } /// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`. @@ -131,27 +124,27 @@ pub async fn debug_evaluate_in( expression: String, context: Option, ) -> IdeResult { - state.debugger.evaluate_in(session, frame_id, expression, context).await + ide_host::ops::debug::debug_evaluate_in(&state.host, "", session, frame_id, expression, context).await } /// Last known verification states of a file's breakpoints (refetch on `breakpointsChanged`). #[tauri::command] #[specta::specta] pub fn debug_breakpoint_states(state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - Ok(state.debugger.breakpoint_states(&path)) + ide_host::ops::debug::debug_breakpoint_states(&state.host, "", path) } /// This window's live sessions, including adapter-started child sessions. #[tauri::command] #[specta::specta] pub fn debug_sessions(window: Window, state: State<'_, AppState>) -> IdeResult> { - Ok(state.debugger.sessions(Some(AppState::owner_window(window.label())))) + ide_host::ops::debug::debug_sessions(&state.host, window.label()) } #[tauri::command] #[specta::specta] pub fn debug_exception_filters(window: Window, state: State<'_, AppState>) -> IdeResult> { - Ok(state.debugger.exception_filters(Some(AppState::owner_window(window.label())))) + ide_host::ops::debug::debug_exception_filters(&state.host, window.label()) } #[tauri::command] @@ -160,7 +153,7 @@ pub async fn debug_set_exception_breakpoints( state: State<'_, AppState>, choices: std::collections::BTreeMap, ) -> IdeResult<()> { - state.debugger.set_exception_breakpoints(choices).await + ide_host::ops::debug::debug_set_exception_breakpoints(&state.host, "", choices).await } #[tauri::command] @@ -174,7 +167,8 @@ pub async fn debug_set_variable( frame_id: Option, value: String, ) -> IdeResult { - state.debugger.set_variable(session, container, name, evaluate_name, frame_id, value).await + ide_host::ops::debug::debug_set_variable(&state.host, "", session, container, name, evaluate_name, frame_id, value) + .await } /// Force Step Over: step over with every breakpoint suspended until the step ends. @@ -185,7 +179,7 @@ pub async fn debug_force_step_over( session: DebugSessionId, thread_id: i64, ) -> IdeResult<()> { - state.debugger.force_step_over(session, thread_id).await + ide_host::ops::debug::debug_force_step_over(&state.host, "", session, thread_id).await } /// Smart Step Into: the calls on the current line of `frame_id`. @@ -196,7 +190,7 @@ pub async fn debug_step_in_targets( session: DebugSessionId, frame_id: i64, ) -> IdeResult> { - state.debugger.step_in_targets(session, frame_id).await + ide_host::ops::debug::debug_step_in_targets(&state.host, "", session, frame_id).await } #[tauri::command] @@ -207,20 +201,20 @@ pub async fn debug_step_in_target( thread_id: i64, target_id: i64, ) -> IdeResult<()> { - state.debugger.step_in_target(session, thread_id, target_id).await + ide_host::ops::debug::debug_step_in_target(&state.host, "", session, thread_id, target_id).await } /// Reset Frame (Drop Frame). #[tauri::command] #[specta::specta] pub async fn debug_restart_frame(state: State<'_, AppState>, session: DebugSessionId, frame_id: i64) -> IdeResult<()> { - state.debugger.restart_frame(session, frame_id).await + ide_host::ops::debug::debug_restart_frame(&state.host, "", session, frame_id).await } #[tauri::command] #[specta::specta] pub fn debug_capabilities(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult { - state.debugger.capabilities(session) + ide_host::ops::debug::debug_capabilities(&state.host, "", session) } /// Method breakpoints (all files); states aligned with `breakpoints`. @@ -230,7 +224,7 @@ pub async fn debug_set_function_breakpoints( state: State<'_, AppState>, breakpoints: Vec, ) -> IdeResult> { - state.debugger.set_function_breakpoints(breakpoints).await + ide_host::ops::debug::debug_set_function_breakpoints(&state.host, "", breakpoints).await } /// Field watchpoints; states aligned with `breakpoints`. @@ -240,7 +234,7 @@ pub async fn debug_set_data_breakpoints( state: State<'_, AppState>, breakpoints: Vec, ) -> IdeResult> { - state.debugger.set_data_breakpoints(breakpoints).await + ide_host::ops::debug::debug_set_data_breakpoints(&state.host, "", breakpoints).await } #[tauri::command] @@ -252,16 +246,14 @@ pub async fn debug_data_breakpoint_info( name: String, frame_id: Option, ) -> IdeResult { - state.debugger.data_breakpoint_info(session, container, name, frame_id).await + ide_host::ops::debug::debug_data_breakpoint_info(&state.host, "", session, container, name, frame_id).await } /// Attach to Process: the user's processes a debugger can attach to. #[tauri::command] #[specta::specta] -pub async fn debug_attachable_processes() -> IdeResult> { - tauri::async_runtime::spawn_blocking(attach::attachable_processes) - .await - .map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))? +pub async fn debug_attachable_processes(state: State<'_, AppState>) -> IdeResult> { + ide_host::ops::debug::debug_attachable_processes(&state.host, "").await } /// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote @@ -274,15 +266,12 @@ pub async fn debug_listen_for_jvm( host: String, port: u16, ) -> IdeResult { - let token = state.begin_request(request); - let result = jdwp::listen_for_jvm(&host, port, token).await; - state.end_request(request); - result + ide_host::ops::debug::debug_listen_for_jvm(&state.host, "", request, host, port).await } /// HotSwap (Reload Changed Classes) in a java-debug session. #[tauri::command] #[specta::specta] pub async fn debug_redefine_classes(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult { - state.debugger.redefine_classes(session).await + ide_host::ops::debug::debug_redefine_classes(&state.host, "", session).await } diff --git a/src-tauri/src/commands/deploy.rs b/src-tauri/src/commands/deploy.rs new file mode 100644 index 000000000..f72a4d6d8 --- /dev/null +++ b/src-tauri/src/commands/deploy.rs @@ -0,0 +1,182 @@ +use crate::state::AppState; +use ide_core::{IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; +use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; +use ide_ssh::RemoteEntry; +use std::path::PathBuf; +use tauri::{State, Window}; + +/// Reports a transfer as a background task (status bar progress). +fn progress(sink: SharedSink, title: &str) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) { + let id = TaskId::next(); + let title = title.to_owned(); + let (s, t) = (sink.clone(), title.clone()); + let report = move |path: &str, i: usize, n: usize| { + let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) }; + s.emit(IdeEvent::Task { id, title: t.clone(), message: Some(path.to_owned()), fraction, done: false }); + }; + let done = move || sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true }); + (report, done) +} + +fn local(paths: Vec) -> Vec { + paths.into_iter().map(|p| p.to_path_buf()).collect() +} + +/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). +#[tauri::command] +#[specta::specta] +pub async fn deploy_upload( + window: Window, + state: State<'_, AppState>, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window.label())?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Uploading"); + let r = state + .remote + .upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Download from…: the remote counterparts of files and folders. +#[tauri::command] +#[specta::specta] +pub async fn deploy_download( + window: Window, + state: State<'_, AppState>, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window.label())?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Downloading"); + let r = state + .remote + .download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Deletes the remote counterparts of files and folders. +#[tauri::command] +#[specta::specta] +pub async fn deploy_delete_remote( + window: Window, + state: State<'_, AppState>, + paths: Vec, + server: Option, +) -> IdeResult { + let project = state.project(window.label())?; + state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await +} + +/// Sync with Deployed: local and remote files under `path`, side by side. +#[tauri::command] +#[specta::specta] +pub async fn deploy_compare( + window: Window, + state: State<'_, AppState>, + path: IpcPath, + server: Option, + request: RequestId, +) -> IdeResult> { + let project = state.project(window.label())?; + let token = state.begin_request(request); + let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await; + state.end_request(request); + r +} + +/// Applies the Sync with Deployed choices. +#[tauri::command] +#[specta::specta] +pub async fn deploy_sync( + window: Window, + state: State<'_, AppState>, + steps: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window.label())?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Synchronizing"); + let r = state + .remote + .sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Where `path` is deployed (remote path, web URL); `None` when not mapped. +#[tauri::command] +#[specta::specta] +pub async fn deploy_info( + window: Window, + state: State<'_, AppState>, + path: IpcPath, + server: Option, +) -> IdeResult> { + let project = state.project(window.label())?; + state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await +} + +/// Remote Host tool window: the server root of `server`, resolved on the host. +#[tauri::command] +#[specta::specta] +pub async fn deploy_server_root(window: Window, state: State<'_, AppState>, server: String) -> IdeResult { + let settings = match state.project(window.label()) { + Ok(p) => p.settings.effective(), + Err(_) => state.global_settings.effective(), + }; + state.remote.server_root(&settings, &server).await +} + +/// Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). +#[tauri::command] +#[specta::specta] +pub async fn remote_list(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult> { + state.remote.list(&ssh, &path).await +} + +/// Opens a remote file for editing: a local copy, uploaded back on save. +#[tauri::command] +#[specta::specta] +pub async fn remote_open(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult { + Ok(state.remote.open_remote(&ssh, &path).await?.into()) +} + +/// The remote file a local copy stands for: (SSH configuration id, remote path). +#[tauri::command] +#[specta::specta] +pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(String, String)> { + state.remote.remote_of(&path) +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_delete(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { + state.remote.remote_delete(&ssh, &path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_mkdir(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { + state.remote.remote_mkdir(&ssh, &path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_rename(state: State<'_, AppState>, ssh: String, from: String, to: String) -> IdeResult<()> { + state.remote.remote_rename(&ssh, &from, &to).await +} diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index 0a69fad34..027ba3247 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -1,46 +1,27 @@ +//! IPC commands of `ide_host::ops::doc` (the shared implementation). + use crate::state::AppState; -use ide_core::{ChangeSet, DocId, IdeError, IdeResult, OutlineItem, Range}; +use ide_core::{ChangeSet, DocId, IdeResult, OutlineItem, Range}; +#[allow(unused_imports)] +pub use ide_host::ops::doc::*; use ide_language::{FoldRange, SyntaxHighlights}; use ide_text::LineEnding; use ide_workspace::{DocumentInfo, HistoryEntry}; use std::path::PathBuf; use tauri::{State, Window}; -/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool, -/// keeping it off the main thread and the async runtime's workers. -pub(crate) async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { - tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? -} - /// Opens a document. Async: reading and decoding a large file must not block /// the main thread (synchronous commands run on it). #[tauri::command] #[specta::specta] pub async fn doc_open(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || { - let info = project.documents.open(&path)?; - project.index.mark_recent(&path); - Ok(info) - }) - .await -} - -/// Opens a read-only virtual document (see `lsp_virtual_document`) as an -/// editor buffer, so syntax features and language-server requests work in it. -#[tauri::command] -#[specta::specta] -pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult { - let project = state.project(window.label())?; - let vd = project.lsp.virtual_document(&uri).await?; - blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await + ide_host::ops::doc::open(&state.host, window.label(), path).await } #[tauri::command] #[specta::specta] pub fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> { - state.project(window.label())?.documents.close(doc); - Ok(()) + ide_host::ops::doc::close(&state.host, window.label(), doc) } /// Applies a CodeMirror change set; returns the new version. Rejects with @@ -54,28 +35,32 @@ pub fn doc_edit( base_version: u32, changes: ChangeSet, ) -> IdeResult { - state.project(window.label())?.documents.edit(doc, base_version, changes) + ide_host::ops::doc::edit(&state.host, window.label(), doc, base_version, changes) } #[tauri::command] #[specta::specta] pub async fn doc_save(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.documents.save(doc)).await + ide_host::ops::doc::save(&state.host, window.label(), doc).await } #[tauri::command] #[specta::specta] pub async fn doc_save_all(window: Window, state: State<'_, AppState>) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.documents.save_all()).await + ide_host::ops::doc::save_all(&state.host, window.label()).await } #[tauri::command] #[specta::specta] pub async fn doc_snapshot(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.documents.snapshot(doc)).await + ide_host::ops::doc::snapshot(&state.host, window.label(), doc).await +} +/// Opens a read-only virtual document (see `lsp_virtual_document`) as an +/// editor buffer, so syntax features and language-server requests work in it. +#[tauri::command] +#[specta::specta] +pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult { + ide_host::ops::doc::doc_open_virtual(&state.host, window.label(), uri).await } /// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label. @@ -88,7 +73,7 @@ pub fn doc_set_encoding( encoding: String, reload: bool, ) -> IdeResult { - state.project(window.label())?.documents.set_encoding(doc, &encoding, reload) + ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload) } #[tauri::command] @@ -99,7 +84,7 @@ pub fn doc_set_line_ending( doc: DocId, line_ending: LineEnding, ) -> IdeResult<()> { - state.project(window.label())?.documents.set_line_ending(doc, line_ending) + ide_host::ops::doc::doc_set_line_ending(&state.host, window.label(), doc, line_ending) } /// Structure view: LSP document symbols when available (with tree-sitter @@ -109,41 +94,13 @@ pub fn doc_set_line_ending( #[tauri::command] #[specta::specta] pub async fn doc_outline(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult> { - let project = state.project(window.label())?; - let lsp = project.lsp.document_symbols(doc).await.ok().flatten(); - blocking(move || { - let snap = match project.documents.text_snapshot(doc) { - Ok(snap) => snap, - Err(e) => return lsp.ok_or(e), - }; - let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text); - Ok(match lsp { - // Server symbols carry no visibility; borrow it from the syntax tree. - Some(mut items) => { - if !snap.large { - ide_language::merge_visibility(&mut items, &syntax()); - } - items - } - None if snap.large => Vec::new(), - None => syntax(), - }) - }) - .await + ide_host::ops::doc::doc_outline(&state.host, window.label(), doc).await } #[tauri::command] #[specta::specta] pub async fn doc_folding(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || { - let snap = project.documents.text_snapshot(doc)?; - if snap.large { - return Ok(Vec::new()); - } - Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text)) - }) - .await + ide_host::ops::doc::doc_folding(&state.host, window.label(), doc).await } /// Tree-sitter highlighting for languages the UI has no CodeMirror mode for; @@ -151,15 +108,7 @@ pub async fn doc_folding(window: Window, state: State<'_, AppState>, doc: DocId) #[tauri::command] #[specta::specta] pub async fn doc_highlights(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || { - let snap = project.documents.text_snapshot(doc)?; - if snap.large { - return Ok(SyntaxHighlights::default()); - } - Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text)) - }) - .await + ide_host::ops::doc::doc_highlights(&state.host, window.label(), doc).await } /// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first. @@ -172,23 +121,17 @@ pub async fn doc_selection_ranges( doc: DocId, offset: u32, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || { - let snap = project.documents.text_snapshot(doc)?; - let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language }; - Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset)) - }) - .await + ide_host::ops::doc::doc_selection_ranges(&state.host, window.label(), doc, offset).await } #[tauri::command] #[specta::specta] pub fn history_list(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - state.project(window.label())?.history.list(&path) + ide_host::ops::doc::history_list(&state.host, window.label(), path) } #[tauri::command] #[specta::specta] pub fn history_content(window: Window, state: State<'_, AppState>, id: String) -> IdeResult { - state.project(window.label())?.history.content(&id) + ide_host::ops::doc::history_content(&state.host, window.label(), id) } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 5a8f6636e..d8cc93823 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -1,6 +1,6 @@ -use crate::commands::doc::blocking; use crate::state::AppState; use ide_core::IdeResult; +use ide_host::ops::fs; use ide_vfs::FileEntry; use std::path::PathBuf; use tauri::{State, Window}; @@ -8,23 +8,7 @@ use tauri::{State, Window}; #[tauri::command] #[specta::specta] pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - let excludes = state - .project(window.label()) - .map(|p| { - let globs = p.exclude_globs(); - // `read_dir` matches relative to the enclosing repository: re-anchor - // project-root patterns (`/build`) when the project is a repo subfolder. - let repo = p.root.ancestors().find(|d| d.join(".git").exists()); - match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) { - Some(rel) => { - let rel = rel.to_string_lossy().replace('\\', "/"); - globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect() - } - None => globs, - } - }) - .unwrap_or_default(); - ide_vfs::read_dir(&path, &excludes) + fs::read_dir(&state.host, window.label(), path) } /// Creates an empty file; inside a project, honours the EditorConfig @@ -32,24 +16,7 @@ pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> #[tauri::command] #[specta::specta] pub fn fs_create_file(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { - match state.project(window.label()) { - Ok(p) => { - let defaults = p.documents.file_defaults(); - if defaults.editorconfig { - ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?; - } else { - ide_vfs::create_file(&path)?; - } - // No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM". - let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false); - let bom = defaults.new_file_bytes(); - if empty && !bom.is_empty() { - std::fs::write(&path, bom)?; - } - Ok(()) - } - Err(_) => ide_vfs::create_file(&path), - } + fs::create_file(&state.host, window.label(), path) } #[tauri::command] @@ -69,34 +36,20 @@ pub fn fs_rename(from: PathBuf, to: PathBuf) -> IdeResult<()> { #[tauri::command] #[specta::specta] pub fn fs_set_writable(window: Window, state: State<'_, AppState>, path: PathBuf, writable: bool) -> IdeResult { - match state.project(window.label()) { - Ok(p) => p.documents.set_writable(&path, writable), - Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable), - } + fs::set_writable(&state.host, window.label(), path, writable) } /// Moving to the trash can take a while (large trees): kept off the main thread. #[tauri::command] #[specta::specta] pub async fn fs_delete(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { - // Local History keeps the contents so deleted files can be restored. - let history = state.project(window.label()).ok().map(|p| p.history.clone()); - blocking(move || { - if let Some(h) = history { - h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT); - } - ide_vfs::delete(&path) - }) - .await + fs::delete(&state.host, window.label(), path).await } -/// Files of a deleted folder snapshotted in Local History. -const DELETE_SNAPSHOT_LIMIT: usize = 2000; - #[tauri::command] #[specta::specta] -pub async fn fs_copy(from: PathBuf, to: PathBuf) -> IdeResult<()> { - blocking(move || ide_vfs::copy(&from, &to)).await +pub async fn fs_copy(state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> { + fs::copy(&state.host, from, to).await } /// Reads a text file without opening it as a document (previews, quick @@ -104,10 +57,5 @@ pub async fn fs_copy(from: PathBuf, to: PathBuf) -> IdeResult<()> { #[tauri::command] #[specta::specta] pub async fn fs_read_text(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult { - let project = state.project(window.label()).ok(); - blocking(move || match project { - Some(p) => p.text_of(&path), - None => Ok(ide_vfs::load_text(&path)?.text), - }) - .await + fs::read_text(&state.host, window.label(), path).await } diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index f33d12f15..d12bc7986 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -1,13 +1,13 @@ -//! Local History beyond single files: folders, labels, restoring deleted files. +//! IPC commands of `ide_host::ops::history` (the shared implementation). use crate::state::AppState; use ide_core::IdeResult; +#[allow(unused_imports)] +pub use ide_host::ops::history::*; use ide_workspace::DirectoryHistory; use std::path::PathBuf; use tauri::{State, Window}; -use super::doc::blocking; - /// Show History on a folder: snapshots of the files under it (deleted ones included) and labels. #[tauri::command] #[specta::specta] @@ -16,16 +16,14 @@ pub async fn history_list_dir( state: State<'_, AppState>, path: PathBuf, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.history.list_dir(&path)).await + ide_host::ops::history::history_list_dir(&state.host, window.label(), path).await } /// Local History › Put Label…: a named point in the history of every file. #[tauri::command] #[specta::specta] pub fn history_put_label(window: Window, state: State<'_, AppState>, label: String) -> IdeResult<()> { - let project = state.project(window.label())?; - project.history.put_label(&project.root, &label) + ide_host::ops::history::history_put_label(&state.host, window.label(), label) } /// Revert to a snapshot: writes its content to `path` (re-creating a deleted @@ -33,23 +31,5 @@ pub fn history_put_label(window: Window, state: State<'_, AppState>, label: Stri #[tauri::command] #[specta::specta] pub async fn history_restore(window: Window, state: State<'_, AppState>, path: PathBuf, id: String) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - let text = project.history.content(&id)?; - if let Some(doc) = project.documents.by_path(&path) { - project.documents.replace_all(doc, &text)?; - return project.documents.save(doc); - } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - if !path.exists() { - ide_vfs::create_file(&path)?; - } - let doc = project.documents.open(&path)?.id; - let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc)); - project.documents.close(doc); - result - }) - .await + ide_host::ops::history::history_restore(&state.host, window.label(), path, id).await } diff --git a/src-tauri/src/commands/java.rs b/src-tauri/src/commands/java.rs index c75dd7b39..c1794b33c 100644 --- a/src-tauri/src/commands/java.rs +++ b/src-tauri/src/commands/java.rs @@ -1,9 +1,9 @@ -//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls). +//! IPC commands of `ide_host::ops::java` (the shared implementation). -use super::build::load; use crate::state::AppState; -use ide_build::compile; -use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_core::IdeResult; +#[allow(unused_imports)] +pub use ide_host::ops::java::*; use ide_lsp::jdtls::JavaLaunchInfo; use std::path::PathBuf; use tauri::{State, Window}; @@ -20,63 +20,14 @@ pub async fn java_resolve_launch( test: bool, build: bool, ) -> IdeResult { - let project = state.project(window.label())?; - let module = project.root.join(module); - let settings = project.settings.effective(); - let built = if build && compile::has_non_java_sources(&module) { - let (root, cache, module, settings) = - (project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone()); - tauri::async_runtime::spawn_blocking(move || { - compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new()) - }) - .await - .map_err(|e| IdeError::internal(e.to_string()))?? - } else { - Vec::new() - }; - let mut info = project.lsp.java_launch(&module, test, build).await?; - let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); - for p in std::mem::take(&mut info.class_paths) { - if !class_paths.contains(&p) { - class_paths.push(p); - } - } - info.class_paths = class_paths; - // The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version. - let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings); - let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file()); - let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| { - let release = ide_sdk::java::project_java_version(&module); - let jdks = ide_sdk::java::installed_jdks(); - info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release)); - ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) - }); - info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); - let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java")); - let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows)); - info.shorten_command_line = Some(shorten.as_str().to_owned()); - if info.error_count > 0 { - let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace()); - let managed = ide_tools::managed_bundle(&state.data_dir, "lombok"); - let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings); - let root = project.root.clone(); - // Detection may read the project's sources. - let lombok = - tauri::async_runtime::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo)) - .await - .map_err(|e| IdeError::internal(e.to_string()))?; - info.hints.extend(lombok.problem()); - } - Ok(info) + ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, build).await } /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). #[tauri::command] #[specta::specta] pub async fn java_debug_server(window: Window, state: State<'_, AppState>, module: PathBuf) -> IdeResult { - let project = state.project(window.label())?; - let module = project.root.join(module); - project.lsp.java_debug_server(&module).await + ide_host::ops::java::java_debug_server(&state.host, window.label(), module).await } /// Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap). @@ -87,7 +38,5 @@ pub async fn java_build_workspace( state: State<'_, AppState>, module: PathBuf, ) -> IdeResult { - let project = state.project(window.label())?; - let module = project.root.join(module); - project.lsp.java_build(&module).await + ide_host::ops::java::java_build_workspace(&state.host, window.label(), module).await } diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index a4448ccee..44e8a4a71 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -1,14 +1,12 @@ -use crate::state::{AppState, apply_workspace_edit as apply_edit}; +//! IPC commands of `ide_host::ops::lsp` (the shared implementation). + +use crate::state::AppState; use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit}; +#[allow(unused_imports)] +pub use ide_host::ops::lsp::*; use ide_lsp::types::*; use tauri::{State, Window}; -macro_rules! project { - ($window:expr, $state:expr) => { - $state.project($window.label())? - }; -} - #[tauri::command] #[specta::specta] pub async fn lsp_completion( @@ -19,11 +17,7 @@ pub async fn lsp_completion( pos: Pos, trigger: Option, ) -> IdeResult { - let lsp = project!(window, state).lsp.clone(); - let cancel = state.begin_request(request); - let result = lsp.completion(doc, pos, trigger, cancel).await; - state.end_request(request); - result + ide_host::ops::lsp::lsp_completion(&state.host, window.label(), request, doc, pos, trigger).await } /// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing. @@ -37,11 +31,7 @@ pub async fn lsp_inline_completion( pos: Pos, automatic: bool, ) -> IdeResult> { - let lsp = project!(window, state).lsp.clone(); - let cancel = state.begin_request(request); - let result = lsp.inline_completion(doc, pos, automatic, cancel).await; - state.end_request(request); - result + ide_host::ops::lsp::lsp_inline_completion(&state.host, window.label(), request, doc, pos, automatic).await } #[tauri::command] @@ -52,7 +42,7 @@ pub async fn lsp_resolve_completion( doc: DocId, item: CompletionItem, ) -> IdeResult { - project!(window, state).lsp.resolve_completion(doc, item).await + ide_host::ops::lsp::lsp_resolve_completion(&state.host, window.label(), doc, item).await } #[tauri::command] @@ -64,11 +54,7 @@ pub async fn lsp_hover( doc: DocId, pos: Pos, ) -> IdeResult> { - let lsp = project!(window, state).lsp.clone(); - let cancel = state.begin_request(request); - let result = lsp.hover(doc, pos, cancel).await; - state.end_request(request); - result + ide_host::ops::lsp::lsp_hover(&state.host, window.label(), request, doc, pos).await } #[tauri::command] @@ -79,7 +65,7 @@ pub async fn lsp_signature_help( doc: DocId, pos: Pos, ) -> IdeResult> { - project!(window, state).lsp.signature_help(doc, pos).await + ide_host::ops::lsp::lsp_signature_help(&state.host, window.label(), doc, pos).await } #[tauri::command] @@ -91,7 +77,7 @@ pub async fn lsp_goto( pos: Pos, kind: GotoKind, ) -> IdeResult> { - project!(window, state).lsp.goto(doc, pos, kind).await + ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await } /// Implementations of the symbols at `positions`, one list per position (the @@ -106,11 +92,7 @@ pub async fn lsp_implementations( doc: DocId, positions: Vec, ) -> IdeResult>> { - let lsp = project!(window, state).lsp.clone(); - let cancel = state.begin_request(request); - let result = lsp.implementations(doc, &positions, cancel).await; - state.end_request(request); - result + ide_host::ops::lsp::lsp_implementations(&state.host, window.label(), request, doc, positions).await } /// Contents of a virtual document (library class, decompiled code) that a @@ -122,7 +104,7 @@ pub async fn lsp_virtual_document( state: State<'_, AppState>, uri: String, ) -> IdeResult { - project!(window, state).lsp.virtual_document(&uri).await + ide_host::ops::lsp::lsp_virtual_document(&state.host, window.label(), uri).await } /// References of the symbol at `pos`. `project_wide` also searches project files @@ -139,11 +121,16 @@ pub async fn lsp_references( include_declaration: bool, project_wide: bool, ) -> IdeResult { - let lsp = project!(window, state).lsp.clone(); - let cancel = state.begin_request(request); - let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await; - state.end_request(request); - result + ide_host::ops::lsp::lsp_references( + &state.host, + window.label(), + request, + doc, + pos, + include_declaration, + project_wide, + ) + .await } #[tauri::command] @@ -154,7 +141,7 @@ pub async fn lsp_document_highlights( doc: DocId, pos: Pos, ) -> IdeResult> { - project!(window, state).lsp.document_highlights(doc, pos).await + ide_host::ops::lsp::lsp_document_highlights(&state.host, window.label(), doc, pos).await } #[tauri::command] @@ -165,11 +152,7 @@ pub async fn lsp_workspace_symbols( request: RequestId, query: String, ) -> IdeResult> { - let lsp = project!(window, state).lsp.clone(); - let cancel = state.begin_request(request); - let result = lsp.workspace_symbols(&query, cancel).await; - state.end_request(request); - result + ide_host::ops::lsp::lsp_workspace_symbols(&state.host, window.label(), request, query).await } #[tauri::command] @@ -181,7 +164,7 @@ pub async fn lsp_code_actions( range: Range, only: Vec, ) -> IdeResult> { - project!(window, state).lsp.code_actions(doc, range, only).await + ide_host::ops::lsp::lsp_code_actions(&state.host, window.label(), doc, range, only).await } /// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). @@ -195,11 +178,7 @@ pub async fn lsp_code_action_query( range: Range, only: Vec, ) -> IdeResult { - let lsp = project!(window, state).lsp.clone(); - let cancel = state.begin_request(request); - let result = lsp.code_action_query(doc, range, only, cancel).await; - state.end_request(request); - result + ide_host::ops::lsp::lsp_code_action_query(&state.host, window.label(), request, doc, range, only).await } /// Resolves a code action's edit without applying it (intention preview). @@ -211,10 +190,7 @@ pub async fn lsp_resolve_code_action( doc: DocId, action: CodeAction, ) -> IdeResult { - if action.edit.is_some() { - return Ok(action); - } - project!(window, state).lsp.resolve_code_action(doc, action).await + ide_host::ops::lsp::lsp_resolve_code_action(&state.host, window.label(), doc, action).await } /// Resolves (if needed), applies the edit, then runs the command of a code action. @@ -226,19 +202,7 @@ pub async fn lsp_apply_code_action( doc: DocId, action: CodeAction, ) -> IdeResult<()> { - let project = project!(window, state); - let action = if action.edit.is_none() && action.command.is_none() { - project.lsp.resolve_code_action(doc, action).await? - } else { - action - }; - if let Some(edit) = &action.edit { - apply_edit(&project.documents, edit)?; - } - if let Some(command) = action.command { - project.lsp.execute_command(doc, command).await?; - } - Ok(()) + ide_host::ops::lsp::lsp_apply_code_action(&state.host, window.label(), doc, action).await } #[tauri::command] @@ -249,7 +213,7 @@ pub async fn lsp_prepare_rename( doc: DocId, pos: Pos, ) -> IdeResult> { - project!(window, state).lsp.prepare_rename(doc, pos).await + ide_host::ops::lsp::lsp_prepare_rename(&state.host, window.label(), doc, pos).await } /// Computes the rename edit without applying it, so the UI can preview conflicts. @@ -262,7 +226,7 @@ pub async fn lsp_rename( pos: Pos, new_name: String, ) -> IdeResult { - project!(window, state).lsp.rename(doc, pos, new_name).await + ide_host::ops::lsp::lsp_rename(&state.host, window.label(), doc, pos, new_name).await } #[tauri::command] @@ -274,7 +238,7 @@ pub async fn lsp_format( range: Option, options: FormatOptions, ) -> IdeResult> { - project!(window, state).lsp.format(doc, range, options).await + ide_host::ops::lsp::lsp_format(&state.host, window.label(), doc, range, options).await } #[tauri::command] @@ -285,7 +249,7 @@ pub async fn lsp_inlay_hints( doc: DocId, range: Range, ) -> IdeResult> { - project!(window, state).lsp.inlay_hints(doc, range).await + ide_host::ops::lsp::lsp_inlay_hints(&state.host, window.label(), doc, range).await } #[tauri::command] @@ -295,13 +259,13 @@ pub async fn lsp_semantic_tokens( state: State<'_, AppState>, doc: DocId, ) -> IdeResult> { - project!(window, state).lsp.semantic_tokens(doc).await + ide_host::ops::lsp::lsp_semantic_tokens(&state.host, window.label(), doc).await } #[tauri::command] #[specta::specta] pub async fn lsp_code_lens(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult> { - project!(window, state).lsp.code_lens(doc).await + ide_host::ops::lsp::lsp_code_lens(&state.host, window.label(), doc).await } /// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers. @@ -312,7 +276,7 @@ pub async fn lsp_document_links( state: State<'_, AppState>, doc: DocId, ) -> IdeResult> { - project!(window, state).lsp.document_links(doc).await + ide_host::ops::lsp::lsp_document_links(&state.host, window.label(), doc).await } #[tauri::command] @@ -324,7 +288,7 @@ pub async fn lsp_call_hierarchy( pos: Pos, direction: HierarchyDirection, ) -> IdeResult> { - project!(window, state).lsp.call_hierarchy(doc, pos, direction).await + ide_host::ops::lsp::lsp_call_hierarchy(&state.host, window.label(), doc, pos, direction).await } #[tauri::command] @@ -336,39 +300,26 @@ pub async fn lsp_type_hierarchy( pos: Pos, direction: HierarchyDirection, ) -> IdeResult> { - project!(window, state).lsp.type_hierarchy(doc, pos, direction).await + ide_host::ops::lsp::lsp_type_hierarchy(&state.host, window.label(), doc, pos, direction).await } #[tauri::command] #[specta::specta] pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult> { - Ok(project!(window, state).lsp.servers()) + ide_host::ops::lsp::lsp_servers(&state.host, window.label()) } #[tauri::command] #[specta::specta] pub async fn lsp_restart(window: Window, state: State<'_, AppState>, server: ServerId) -> IdeResult<()> { - project!(window, state).lsp.restart(server).await + ide_host::ops::lsp::lsp_restart(&state.host, window.label(), server).await } /// Applies a (previewed) workspace edit, e.g. after rename confirmation. #[tauri::command] #[specta::specta] pub fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> { - let project = project!(window, state); - // Multi-file refactorings leave a Local History point to go back to. - let paths: Vec<&std::path::Path> = edit - .files - .iter() - .filter_map(|f| match f { - ide_core::FileEdit::Edit { path, .. } => Some(&**path), - _ => None, - }) - .collect(); - if edit.files.len() > 1 { - project.record_before_refactoring(&paths, "Apply changes"); - } - apply_edit(&project.documents, &edit) + ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit) } /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's @@ -381,5 +332,5 @@ pub async fn lsp_execute_command( doc: DocId, command: Command, ) -> IdeResult<()> { - project!(window, state).lsp.execute_command(doc, command).await + ide_host::ops::lsp::lsp_execute_command(&state.host, window.label(), doc, command).await } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f99d3e3d3..7048be4a1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod clipboard; pub mod coverage; pub mod db; pub mod debug; +pub mod deploy; pub mod doc; pub mod docker; pub mod fs; @@ -33,6 +34,7 @@ pub mod run; pub mod sdk; pub mod search; pub mod spell; +pub mod ssh; pub mod syntax; pub mod term; pub mod tools; @@ -42,6 +44,15 @@ pub mod window; use tauri_specta::collect_commands; +/// A Tauri channel as the shared implementation's streaming argument. +pub(crate) fn sender( + channel: tauri::ipc::Channel, +) -> ide_host::ops::Channel { + ide_host::ops::Channel::new(move |v| { + let _ = channel.send(v); + }) +} + pub fn builder() -> tauri_specta::Builder { tauri_specta::Builder::::new().commands(collect_commands![ app::app_info, @@ -376,6 +387,31 @@ pub fn builder() -> tauri_specta::Builder { db::db_set_password, db::db_set_ssh_secret, db::db_trust_ssh_host, + ssh::ssh_test, + ssh::ssh_set_secret, + ssh::ssh_needs_secret, + ssh::ssh_trust_host, + ssh::ssh_connected, + ssh::ssh_disconnect, + ssh::ssh_askpass_answer, + ssh::ssh_terminal_command, + deploy::deploy_upload, + deploy::deploy_download, + deploy::deploy_delete_remote, + deploy::deploy_compare, + deploy::deploy_sync, + deploy::deploy_info, + deploy::deploy_server_root, + deploy::remote_list, + deploy::remote_open, + deploy::remote_of, + deploy::remote_delete, + deploy::remote_mkdir, + deploy::remote_rename, + crate::remote_dev::remote_connect, + crate::remote_dev::remote_current, + crate::remote_dev::remote_disconnect, + crate::remote_dev::remote_invoke, db::db_test, db::db_connected, db::db_connect, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index 252d0f656..669fe850a 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -1,24 +1,25 @@ -use super::doc::blocking; -use crate::state::{AppState, Project}; -use ide_core::{DocId, IdeResult, IpcPath, Location, Pos}; +//! IPC commands of `ide_host::ops::nav` (the shared implementation). + +use crate::state::AppState; +use ide_core::{DocId, IdeResult, IpcPath, Pos}; +#[allow(unused_imports)] +pub use ide_host::ops::nav::*; use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch}; -use ide_language::{FileReference, WebReference}; -use ide_workspace::file_refs::{self, PathCompletion}; -use serde::{Deserialize, Serialize}; -use specta::Type; +use ide_language::FileReference; +use ide_workspace::file_refs::PathCompletion; use std::path::PathBuf; use tauri::{State, Window}; #[tauri::command] #[specta::specta] pub fn index_status(window: Window, state: State<'_, AppState>) -> IdeResult { - Ok(state.project(window.label())?.index.status()) + ide_host::ops::nav::index_status(&state.host, window.label()) } #[tauri::command] #[specta::specta] pub fn find_files(window: Window, state: State<'_, AppState>, query: String, limit: u32) -> IdeResult> { - Ok(state.project(window.label())?.index.find_files(&query, limit as usize)) + ide_host::ops::nav::find_files(&state.host, window.label(), query, limit) } #[tauri::command] @@ -30,85 +31,33 @@ pub fn find_symbols( filter: SymbolFilter, limit: u32, ) -> IdeResult> { - Ok(state.project(window.label())?.index.find_symbols(&query, filter, limit as usize)) + ide_host::ops::nav::find_symbols(&state.host, window.label(), query, filter, limit) } #[tauri::command] #[specta::specta] pub fn mark_recent(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { - state.project(window.label())?.index.mark_recent(&path); - Ok(()) + ide_host::ops::nav::mark_recent(&state.host, window.label(), path) } #[tauri::command] #[specta::specta] pub fn remove_recent(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { - state.project(window.label())?.index.remove_recent(&path); - Ok(()) + ide_host::ops::nav::remove_recent(&state.host, window.label(), path) } /// Recently opened files, most recent first (⌘E). #[tauri::command] #[specta::specta] pub fn recent_files(window: Window, state: State<'_, AppState>) -> IdeResult> { - Ok(state.project(window.label())?.index.recent_files().into_iter().map(Into::into).collect()) + ide_host::ops::nav::recent_files(&state.host, window.label()) } -/// A web reference no language server resolves and where it points: `class="card"` → -/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` → -/// `--x:` declarations. -#[derive(Clone, Debug, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct WebGoto { - pub reference: WebReference, - pub targets: Vec, -} - -const MAX_WEB_TARGETS: usize = 200; - /// The web reference at `pos` of `doc` and its targets; `None` when there is no reference. #[tauri::command] #[specta::specta] pub async fn web_goto(window: Window, state: State<'_, AppState>, doc: DocId, pos: Pos) -> IdeResult> { - let project = state.project(window.label())?; - let syntax = state.syntax(); - blocking(move || { - let snap = project.documents.snapshot(doc)?; - if snap.large { - return Ok(None); - } - let Some(reference) = syntax.web_reference_at(&snap.language, &snap.text, pos) else { return Ok(None) }; - let here = |range| Location { path: snap.path.clone(), range, uri: None }; - let targets = match reference.symbol() { - None => syntax.web_anchors(&snap.language, &snap.text, &reference.name).into_iter().map(here).collect(), - Some((name, kind)) => { - // The open document from its (possibly unsaved) text, other files from the index. - let mut targets: Vec = syntax - .web_definitions(&snap.language, &snap.text) - .into_iter() - .filter(|t| t.name == name && t.kind == kind) - .map(|t| here(t.range)) - .collect(); - targets.extend( - project - .index - .symbols_named(&name, Some(kind), MAX_WEB_TARGETS) - .into_iter() - .filter(|m| *m.path != *snap.path) - .map(|m| Location { path: m.path, range: m.range, uri: None }), - ); - targets - } - }; - Ok(Some(WebGoto { reference, targets })) - }) - .await -} - -/// The project root and its resource roots (served at `/`), for file references. -fn reference_roots(project: &Project) -> (PathBuf, Vec) { - let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect(); - (project.root.clone(), resources) + ide_host::ops::nav::web_goto(&state.host, window.label(), doc, pos).await } /// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal). @@ -120,13 +69,7 @@ pub async fn fs_resolve_reference( from: IpcPath, value: String, ) -> IdeResult> { - let project = state.project(window.label()).ok(); - blocking(move || { - let (root, resources) = project.as_deref().map(reference_roots).unzip(); - let resources = resources.unwrap_or_default(); - Ok(file_refs::resolve(&value, &from, root.as_deref(), &resources).map(Into::into)) - }) - .await + ide_host::ops::nav::fs_resolve_reference(&state.host, window.label(), from, value).await } /// Files and folders that continue `prefix`, a path being typed in `from`. @@ -138,13 +81,7 @@ pub async fn path_completions( from: IpcPath, prefix: String, ) -> IdeResult> { - let project = state.project(window.label()).ok(); - blocking(move || { - let (root, resources) = project.as_deref().map(reference_roots).unzip(); - let resources = resources.unwrap_or_default(); - Ok(file_refs::complete(&prefix, &from, root.as_deref(), &resources)) - }) - .await + ide_host::ops::nav::path_completions(&state.host, window.label(), from, prefix).await } /// Paths in an HTML document or stylesheet that name no existing file. @@ -155,23 +92,5 @@ pub async fn web_unresolved_references( state: State<'_, AppState>, doc: DocId, ) -> IdeResult> { - let project = state.project(window.label())?; - let syntax = state.syntax(); - blocking(move || { - let snap = project.documents.snapshot(doc)?; - if snap.large { - return Ok(Vec::new()); - } - let (root, resources) = reference_roots(&project); - Ok(syntax - .web_file_references(&snap.language, &snap.text) - .into_iter() - .filter(|r| { - file_refs::is_file_reference(&r.value) - && !file_refs::path_part(&r.value).is_empty() - && file_refs::resolve(&r.value, &snap.path, Some(&root), &resources).is_none() - }) - .collect()) - }) - .await + ide_host::ops::nav::web_unresolved_references(&state.host, window.label(), doc).await } diff --git a/src-tauri/src/commands/refactor.rs b/src-tauri/src/commands/refactor.rs index bcb5ec50f..b471e44fa 100644 --- a/src-tauri/src/commands/refactor.rs +++ b/src-tauri/src/commands/refactor.rs @@ -1,19 +1,13 @@ -//! File move / rename refactoring: reference updates from the language -//! servers, the move itself (one undoable step) and its undo. +//! IPC commands of `ide_host::ops::refactor` (the shared implementation). -use crate::state::{AppState, apply_workspace_edit}; +use crate::state::AppState; use ide_core::{IdeResult, RequestId, WorkspaceEdit}; +#[allow(unused_imports)] +pub use ide_host::ops::refactor::*; use ide_lsp::types::FileRenameEdits; use ide_workspace::{FileMove, UndoOutcome}; -use std::path::PathBuf; use tauri::{State, Window}; -use super::doc::blocking; - -fn pairs(moves: &[FileMove]) -> Vec<(PathBuf, PathBuf)> { - moves.iter().map(|m| (m.from.to_path_buf(), m.to.to_path_buf())).collect() -} - /// `workspace/willRenameFiles` on every interested server (before the move). Cancellable. #[tauri::command] #[specta::specta] @@ -23,11 +17,7 @@ pub async fn refactor_file_move_edits( request: RequestId, moves: Vec, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let result = project.lsp.will_rename_files(&pairs(&moves), token).await; - state.end_request(request); - result + ide_host::ops::refactor::refactor_file_move_edits(&state.host, window.label(), request, moves).await } /// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the @@ -41,34 +31,21 @@ pub async fn refactor_move_files( edit: WorkspaceEdit, label: String, ) -> IdeResult { - let project = state.project(window.label())?; - let p = project.clone(); - let moved = moves.clone(); - let id = blocking(move || { - let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e); - p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply) - }) - .await?; - project.lsp.did_rename_files(&pairs(&moves)); - Ok(id) + ide_host::ops::refactor::refactor_move_files(&state.host, window.label(), moves, edit, label).await } /// Undoes a file move: moves back and reverts the reference updates. #[tauri::command] #[specta::specta] pub async fn refactor_undo_file_move(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult { - let project = state.project(window.label())?; - let p = project.clone(); - let outcome = blocking(move || p.moves.undo(&p.documents, id)).await?; - project.lsp.did_rename_files(&pairs(&outcome.moves)); - Ok(outcome) + ide_host::ops::refactor::refactor_undo_file_move(&state.host, window.label(), id).await } /// Whether the file move `id` can still be undone. #[tauri::command] #[specta::specta] pub fn refactor_can_undo_file_move(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult { - Ok(state.project(window.label())?.moves.can_undo(id)) + ide_host::ops::refactor::refactor_can_undo_file_move(&state.host, window.label(), id) } /// "Search in comments and strings": textual occurrences of the moved files' @@ -81,20 +58,5 @@ pub async fn refactor_file_move_text_edits( request: RequestId, moves: Vec, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let cancel = token.clone(); - let result = blocking(move || { - let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves); - let edit = ide_workspace::text_refs::find_text_occurrences( - &project.root, - &project.exclude_globs(), - &replacements, - &|| cancel.is_cancelled(), - ); - if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) } - }) - .await; - state.end_request(request); - result + ide_host::ops::refactor::refactor_file_move_text_edits(&state.host, window.label(), request, moves).await } diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index 84493b4dd..eccc12bfe 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -1,6 +1,10 @@ +//! IPC commands of `ide_host::ops::run` (the shared implementation). + use crate::state::AppState; -use ide_core::{IdeError, IdeResult, RunId}; -use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions}; +use ide_core::{IdeResult, RunId}; +#[allow(unused_imports)] +pub use ide_host::ops::run::*; +use ide_run::{OutputChunk, RunConfig, RunInfo}; use tauri::ipc::Channel; use tauri::{State, Window}; @@ -8,113 +12,77 @@ use tauri::{State, Window}; #[tauri::command] #[specta::specta] pub fn run_configs(window: Window, state: State<'_, AppState>) -> IdeResult> { - let project = state.project(window.label())?; - let root = project.root.clone(); - let user = ide_run::load_user_configs(&root)?; - let mut all: Vec = - ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect(); - all.extend(user); - // Project interpreter / package manager / toolchains from Languages & Frameworks. - ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective()); - Ok(all) + ide_host::ops::run::configs(&state.host, window.label()) } #[tauri::command] #[specta::specta] pub fn run_save_configs(window: Window, state: State<'_, AppState>, configs: Vec) -> IdeResult<()> { - let root = state.project(window.label())?.root.clone(); - ide_run::save_user_configs(&root, &configs) -} - -/// Configuration templates: per-type defaults applied to new configurations. -#[tauri::command] -#[specta::specta] -pub fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult> { - let root = state.project(window.label())?.root.clone(); - ide_run::load_templates(&root) + ide_host::ops::run::save_configs(&state.host, window.label(), &configs) } #[tauri::command] #[specta::specta] -pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: Vec) -> IdeResult<()> { - let root = state.project(window.label())?.root.clone(); - ide_run::save_templates(&root, &templates) -} - -/// The environment runs inherit when "Include system environment variables" is on. -#[tauri::command] -#[specta::specta] -pub fn run_system_env() -> std::collections::BTreeMap { - ide_run::system_env() -} - -#[tauri::command] -#[specta::specta] -pub fn run_start( +pub async fn run_start( window: Window, state: State<'_, AppState>, config: RunConfig, on_output: Channel, ) -> IdeResult { - let project = state.project(window.label())?; - let root = project.root.clone(); - if config.command.is_empty() { - return Err(IdeError::invalid("run configuration has no command")); - } - let settings = project.settings.effective(); - // Configurations made in the UI name bare tools (`godot`) that may live outside PATH. - let mut config = config; - ide_sdk::apply::resolve_run_command(&mut config, &root, &settings); - if config.command == ide_sdk::godot::COMMAND { - return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); - } - let encoding = settings.console.encoding; - let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) }; - state.processes.start( - &root, - &config, - &options, - project.sink.clone(), - Box::new(move |chunk| { - let _ = on_output.send(chunk); - }), - ) + let emit = std::sync::Arc::new(move |chunk| { + let _ = on_output.send(chunk); + }); + ide_host::ops::run::start(&state.host, window.label(), config, emit).await } #[tauri::command] #[specta::specta] pub fn run_stop(window: Window, state: State<'_, AppState>, id: RunId) -> IdeResult<()> { - let settings = match state.project(window.label()) { - Ok(p) => p.settings.effective(), - Err(_) => state.global_settings.effective(), - }; - let grace = std::time::Duration::from_millis(u64::from(settings.run.termination_timeout_ms)); - state.processes.stop_with_grace(id, grace) + ide_host::ops::run::stop(&state.host, window.label(), id) } #[tauri::command] #[specta::specta] pub fn run_stdin(state: State<'_, AppState>, id: RunId, data: String) -> IdeResult<()> { - state.processes.write_stdin(id, &data) + ide_host::ops::run::stdin(&state.host, id, &data) } /// Terminal size of a run started with "Emulate terminal in output console". #[tauri::command] #[specta::specta] pub fn run_resize(state: State<'_, AppState>, id: RunId, cols: u16, rows: u16) -> IdeResult<()> { - state.processes.resize(id, cols, rows) + ide_host::ops::run::resize(&state.host, id, cols, rows) } #[tauri::command] #[specta::specta] pub fn run_list(state: State<'_, AppState>) -> Vec { - state.processes.list() + ide_host::ops::run::list(&state.host) } #[tauri::command] #[specta::specta] pub fn run_close_stdin(state: State<'_, AppState>, id: RunId) -> IdeResult<()> { - state.processes.close_stdin(id) + ide_host::ops::run::close_stdin(&state.host, id) +} +/// Configuration templates: per-type defaults applied to new configurations. +#[tauri::command] +#[specta::specta] +pub fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult> { + ide_host::ops::run::run_templates(&state.host, window.label()) +} + +#[tauri::command] +#[specta::specta] +pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: Vec) -> IdeResult<()> { + ide_host::ops::run::run_save_templates(&state.host, window.label(), templates) +} + +/// The environment runs inherit when "Include system environment variables" is on. +#[tauri::command] +#[specta::specta] +pub fn run_system_env(state: State<'_, AppState>) -> std::collections::BTreeMap { + ide_host::ops::run::run_system_env(&state.host, "") } /// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at @@ -127,12 +95,5 @@ pub async fn run_test_reports( module: Option, since_ms: f64, ) -> IdeResult> { - let root = state.project(window.label())?.root.clone(); - let dir = module.map(|m| root.join(m)).unwrap_or(root); - let since = std::time::UNIX_EPOCH + std::time::Duration::from_millis(since_ms.max(0.0) as u64); - tauri::async_runtime::spawn_blocking(move || { - ide_run::junit::read_reports(&ide_run::junit::report_dirs(&dir), since) - }) - .await - .map_err(|e| IdeError::internal(e.to_string())) + ide_host::ops::run::run_test_reports(&state.host, window.label(), module, since_ms).await } diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index 009ae004a..92a8ea471 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -1,31 +1,27 @@ -//! Interpreters / toolchains (`ide-sdk`): discovery, Python virtualenvs and packages. +//! IPC commands of `ide_host::ops::sdk` (the shared implementation). use crate::state::AppState; -use ide_core::{IdeError, IdeResult, RequestId}; +use ide_core::{IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::sdk::*; use ide_sdk::python::PythonPackage; use ide_sdk::{Sdk, SdkKind}; use std::path::PathBuf; use tauri::ipc::Channel; use tauri::{State, Window}; -fn join_err(e: impl std::fmt::Display) -> IdeError { - IdeError::internal(e.to_string()) -} - /// Installed interpreters / toolchains of `kind`; project-local ones first. #[tauri::command] #[specta::specta] pub async fn sdk_detect(window: Window, state: State<'_, AppState>, kind: SdkKind) -> IdeResult> { - let root = state.project(window.label()).ok().map(|p| p.root.clone()); - tauri::async_runtime::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err) + ide_host::ops::sdk::sdk_detect(&state.host, window.label(), kind).await } /// The interpreter the project uses: the configured one, else its virtualenv. #[tauri::command] #[specta::specta] pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeResult> { - let project = state.project(window.label())?; - Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into)) + ide_host::ops::sdk::sdk_python_effective(&state.host, window.label()) } /// SDK / library source folders shown under "External Libraries" in the Project view, @@ -36,43 +32,18 @@ pub async fn sdk_project_libraries( window: Window, state: State<'_, AppState>, ) -> IdeResult> { - let project = state.project(window.label())?; - let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); - tauri::async_runtime::spawn_blocking(move || { - let model = crate::commands::build::load(&root, &cache, &settings); - let deps: Vec = ide_build::dependency_coordinates(&root, &model) - .into_iter() - .map(|c| ide_sdk::libraries::BuildDependency { - gradle: c.gradle, - group: c.group_id, - artifact: c.artifact_id, - version: c.version, - }) - .collect(); - ide_sdk::libraries::project_libraries(&root, &settings, &deps) - }) - .await - .map_err(join_err) -} - -/// Executables found for "run this file", and where single files are compiled to. -#[derive(Clone, Debug, serde::Serialize, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct LocatedTools { - pub tools: Vec, - pub compile_dir: ide_core::IpcPath, + ide_host::ops::sdk::sdk_project_libraries(&state.host, window.label()).await } /// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path. #[tauri::command] #[specta::specta] -pub async fn sdk_locate_tools(names: Vec, near: Option) -> IdeResult { - tauri::async_runtime::spawn_blocking(move || LocatedTools { - tools: ide_sdk::locate::locate_tools(&names, near.as_deref()), - compile_dir: ide_sdk::locate::compile_dir().into(), - }) - .await - .map_err(join_err) +pub async fn sdk_locate_tools( + state: State<'_, AppState>, + names: Vec, + near: Option, +) -> IdeResult { + ide_host::ops::sdk::sdk_locate_tools(&state.host, "", names, near).await } /// `base -m venv location`; output lines stream through `on_output`. @@ -85,16 +56,15 @@ pub async fn sdk_python_create_venv( request: RequestId, on_output: Channel, ) -> IdeResult { - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - ide_sdk::python::create_venv(&base, &location, &cancel, &|line| { - let _ = on_output.send(line); - }) - }) + ide_host::ops::sdk::sdk_python_create_venv( + &state.host, + "", + base, + location, + request, + crate::commands::sender(on_output), + ) .await - .map_err(join_err); - state.end_request(request); - result? } #[tauri::command] @@ -104,12 +74,7 @@ pub async fn sdk_python_packages( python: PathBuf, request: RequestId, ) -> IdeResult> { - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)) - .await - .map_err(join_err); - state.end_request(request); - result? + ide_host::ops::sdk::sdk_python_packages(&state.host, "", python, request).await } /// Installs (`uninstall = false`) or removes a package; output streams through `on_output`. @@ -124,19 +89,15 @@ pub async fn sdk_python_pip( request: RequestId, on_output: Channel, ) -> IdeResult<()> { - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - let send = |line: String| { - let _ = on_output.send(line); - }; - if uninstall { - ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send) - } else { - ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send) - } - }) + ide_host::ops::sdk::sdk_python_pip( + &state.host, + "", + python, + requirement, + uninstall, + upgrade, + request, + crate::commands::sender(on_output), + ) .await - .map_err(join_err); - state.end_request(request); - result? } diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index 212014d51..0ec1ec10a 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -1,22 +1,14 @@ +//! IPC commands of `ide_host::ops::search` (the shared implementation). + use crate::state::AppState; -use ide_core::{IdeResult, Pos, RequestId, TextEdit}; -use ide_search::{ - FileMatches, ProjectFileSet, ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceTarget, SearchQuery, SearchSummary, -}; -use ide_workspace::Documents; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::path::{Path, PathBuf}; +use ide_core::{IdeResult, Pos, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::search::*; +use ide_search::{ReplaceEvent, ReplacePreview, ReplaceTarget, SearchQuery}; +use std::path::PathBuf; use tauri::ipc::Channel; use tauri::{State, Window}; -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum SearchEvent { - File { file: FileMatches }, - Done { summary: SearchSummary }, -} - /// Find in Files. Results stream through `on_event`; ends with `Done`. /// Cancel with `cancel_request(request)`. #[tauri::command] @@ -25,74 +17,14 @@ pub async fn search_start( window: Window, state: State<'_, AppState>, request: RequestId, - mut query: SearchQuery, + query: SearchQuery, on_event: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - // Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and - // ignored names are never searched. - query.exclude.extend(project.search_excludes()); - let roots = project.roots(); - let set = match query.scope { - ide_search::SearchScope::FileSet { set } => Some(set), - _ => None, - }; - let cancel = state.begin_request(request); - let documents = project.documents.clone(); - let root = project.root.clone(); - // The whole project also covers the Project Structure content roots. - let content_roots = match query.scope { - ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => { - project.content_roots() - } - _ => Vec::new(), - }; - let languages = project.languages.clone(); - let syntax = state.syntax(); - let events = on_event.clone(); - let summary = tauri::async_runtime::spawn_blocking(move || { - let filter = |rel: &str| match set { - Some(ProjectFileSet::Production) => !roots.is_test(rel), - Some(ProjectFileSet::Test) => roots.is_test(rel), - Some(ProjectFileSet::All) | None => true, - }; - // Comments and string literals for the search context filter. - let literals = |path: &Path, text: &str| { - let language = languages.detect(path, text.lines().next()); - let found = syntax.literals(&language, text)?; - Some(found.into_iter().map(literal_range).collect()) - }; - let mut total = ide_search::SearchSummary::default(); - for dir in std::iter::once(&root).chain(&content_roots) { - let s = ide_search::search_in_context( - dir, - &query, - Some(&filter), - Some(&literals), - &|p| documents.text_of(p), - &cancel, - &|file| { - let _ = events.send(SearchEvent::File { file }); - }, - )?; - total.files += s.files; - total.matches += s.matches; - total.elapsed_ms += s.elapsed_ms; - total.limit_hit |= s.limit_hit; - total.cancelled |= s.cancelled; - if total.limit_hit || total.cancelled { - break; - } - } - Ok::<_, ide_core::IdeError>(total) - }) - .await - .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; - state.end_request(request); - let _ = on_event.send(SearchEvent::Done { summary: summary? }); - Ok(()) + let emit = std::sync::Arc::new(move |e| { + let _ = on_event.send(e); + }); + ide_host::ops::search::start(&state.host, window.label(), request, query, emit).await } - /// Replace in Files. Runs off the command thread; progress streams through /// `on_event` and ends with `Done`. Cancel with `cancel_request(request)`. /// @@ -113,21 +45,17 @@ pub async fn replace_in_files( targets: Vec, on_event: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let events = on_event.clone(); - let summary = tauri::async_runtime::spawn_blocking(move || { - let host = DocumentsHost(&project.documents); - ide_search::replace_files(&host, &query, &replacement, preserve_case, &targets, &cancel, &|e| { - let _ = events.send(e); - }) - }) + ide_host::ops::search::replace_in_files( + &state.host, + window.label(), + request, + query, + replacement, + preserve_case, + targets, + crate::commands::sender(on_event), + ) .await - .map_err(|e| ide_core::IdeError::internal(e.to_string())); - state.end_request(request); - let summary = summary??; - let _ = on_event.send(ReplaceEvent::Done { summary }); - Ok(()) } /// Before / after text of `path` for Replace › Preview (unsaved buffer included). @@ -142,61 +70,13 @@ pub async fn replace_preview( path: PathBuf, only: Option>, ) -> IdeResult { - let project = state.project(window.label())?; - tauri::async_runtime::spawn_blocking(move || { - let text = project.text_of(&path)?; - ide_search::preview(&text, &query, &replacement, preserve_case, only.as_deref()) - }) - .await - .map_err(|e| ide_core::IdeError::internal(e.to_string()))? -} - -fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange { - let kind = match l.kind { - ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment, - ide_language::LiteralKind::String => ide_search::LiteralKind::String, - }; - ide_search::LiteralRange { start: l.start, end: l.end, kind } -} - -struct DocumentsHost<'a>(&'a Documents); - -impl ReplaceHost for DocumentsHost<'_> { - fn edit_file(&self, path: &Path, compute: &mut dyn FnMut(&str) -> IdeResult>) -> IdeResult { - let docs = self.0; - let (doc, transient) = match docs.by_path(path) { - Some(doc) => (doc, false), - None => (docs.open(path)?.id, true), - }; - let result = (|| { - // Recompute if the user typed between reading the text and applying. - for _ in 0..3 { - let snap = docs.text_snapshot(doc)?; - let edits = compute(&snap.text)?; - if edits.is_empty() { - return Ok(0); - } - if docs.text_snapshot(doc)?.version != snap.version { - continue; - } - docs.apply_edits(doc, &edits)?; - if transient { - docs.save(doc)?; - } - return Ok(edits.len() as u32); - } - Err(ide_core::IdeError::unavailable("the file kept changing during Replace")) - })(); - if transient { - docs.close(doc); - } - result - } + ide_host::ops::search::replace_preview(&state.host, window.label(), query, replacement, preserve_case, path, only) + .await } /// Validates a query (e.g. regex syntax) while the user types. #[tauri::command] #[specta::specta] -pub fn search_validate(query: SearchQuery) -> IdeResult<()> { - ide_search::validate_query(&query) +pub fn search_validate(state: State<'_, AppState>, query: SearchQuery) -> IdeResult<()> { + ide_host::ops::search::search_validate(&state.host, "", query) } diff --git a/src-tauri/src/commands/spell.rs b/src-tauri/src/commands/spell.rs index 715f94faf..12a5388de 100644 --- a/src-tauri/src/commands/spell.rs +++ b/src-tauri/src/commands/spell.rs @@ -1,37 +1,12 @@ -//! Proofreading › Typo: spell checking of open documents. +//! IPC commands of `ide_host::ops::spell` (the shared implementation). -use super::doc::blocking; -use crate::state::{AppState, Project}; -use ide_core::settings::SpellingSettings; -use ide_core::{DocId, IdeError, IdeResult, RequestId}; -use ide_language::SpellingKind; -use ide_spell::{Region, RegionKind, SpellScope, Speller, Typo}; -use std::path::{Path, PathBuf}; +use crate::state::AppState; +use ide_core::{DocId, IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::spell::*; +use ide_spell::{SpellScope, Typo}; use tauri::{State, Window}; -/// Languages checked as prose (all text but code spans). -const PROSE: &[&str] = &["plaintext", "markdown", "restructuredtext", "asciidoc", "latex"]; - -/// Custom dictionary paths: `~/…` from home, relative ones from the project root. -fn dictionary_paths(settings: &SpellingSettings, root: &Path) -> Vec { - settings - .custom_dictionaries - .iter() - .map(|p| p.trim()) - .filter(|p| !p.is_empty()) - .map(|p| match p.strip_prefix("~/") { - Some(rest) => dirs::home_dir().unwrap_or_default().join(rest), - None => root.join(p), - }) - .collect() -} - -fn speller(project: &Project) -> Speller { - let s = project.settings.effective().spelling; - let words = s.ide_words.iter().chain(&s.project_words); - Speller::new(words, &dictionary_paths(&s, &project.root)) -} - /// Misspelt words in the comments, string literals and declared names of /// `doc` that `scope` includes (all of its text for prose). Cancel with /// `cancel_request(request)`. @@ -44,50 +19,12 @@ pub async fn spell_check( scope: SpellScope, request: RequestId, ) -> IdeResult> { - let project = state.project(window.label())?; - let syntax = state.syntax(); - let cancel = state.begin_request(request); - let result = blocking(move || { - let snap = project.documents.text_snapshot(doc)?; - if snap.large { - return Ok(Vec::new()); - } - let regions = if PROSE.contains(&snap.language.as_str()) { - ide_spell::prose_regions(&snap.text) - } else { - let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) }; - found - .into_iter() - .map(|r| Region { - start: r.start, - end: r.end, - kind: match r.kind { - SpellingKind::Comment => RegionKind::Comment, - SpellingKind::String => RegionKind::String, - SpellingKind::Name => RegionKind::Name, - }, - }) - .filter(|r| scope.includes(r.kind)) - .collect() - }; - if cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } - speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled) - }) - .await; - state.end_request(request); - result + ide_host::ops::spell::spell_check(&state.host, window.label(), doc, scope, request).await } /// Replacements for a misspelt `word`, best first (Typo › Change to…). #[tauri::command] #[specta::specta] pub async fn spell_suggest(window: Window, state: State<'_, AppState>, word: String) -> IdeResult> { - let project = state.project(window.label()).ok(); - blocking(move || { - let speller = project.as_deref().map(speller).unwrap_or_default(); - Ok(speller.suggest(&word, 8)) - }) - .await + ide_host::ops::spell::spell_suggest(&state.host, window.label(), word).await } diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs new file mode 100644 index 000000000..0c58ebc75 --- /dev/null +++ b/src-tauri/src/commands/ssh.rs @@ -0,0 +1,96 @@ +use crate::state::AppState; +use ide_core::remote_settings::SshConfiguration; +use ide_core::{IdeError, IdeEvent, IdeResult}; +use ide_ssh::SshTarget; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::State; + +/// Settings › SSH Configurations: the configurations in effect. +pub(crate) fn apply_ssh_settings(state: &AppState) { + state.ssh.set_configs(state.global_settings.effective().ssh.configs); +} + +/// Test Connection: connects with `config` as edited (not necessarily saved). +#[tauri::command] +#[specta::specta] +pub async fn ssh_test( + state: State<'_, AppState>, + config: SshConfiguration, + secret: Option, +) -> IdeResult { + let ssh = state.ssh.clone(); + ssh.test(&config, secret).await +} + +/// Remembers the password / passphrase of configuration `id` (`None` forgets it). +#[tauri::command] +#[specta::specta] +pub fn ssh_set_secret(state: State<'_, AppState>, id: String, secret: Option, save: bool) { + state.ssh.set_secret(&id, secret, save); +} + +/// Whether connecting with configuration `id` needs a password first. +#[tauri::command] +#[specta::specta] +pub fn ssh_needs_secret(state: State<'_, AppState>, id: String) -> IdeResult { + Ok(state.ssh.needs_secret(&state.ssh.config(&id)?)) +} + +/// Trusts the host key that failed the last connection of configuration `id`. +#[tauri::command] +#[specta::specta] +pub async fn ssh_trust_host(state: State<'_, AppState>, id: String) -> IdeResult<()> { + let ssh = state.ssh.clone(); + ssh.trust_host(&id).await +} + +/// Ids of the configurations with an open connection. +#[tauri::command] +#[specta::specta] +pub fn ssh_connected(state: State<'_, AppState>) -> Vec { + state.ssh.connected() +} + +#[tauri::command] +#[specta::specta] +pub fn ssh_disconnect(state: State<'_, AppState>, id: String) { + state.ssh.disconnect(&id); +} + +/// Answers an `IdeEvent::Askpass` prompt (`None`: cancelled); every window drops it. +#[tauri::command] +#[specta::specta] +pub fn ssh_askpass_answer(state: State<'_, AppState>, id: u64, answer: Option) { + if let Some(server) = &state.askpass { + server.answer(id, answer); + } + state.sink.emit(IdeEvent::AskpassDone { id }); +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SshTerminalCommand { + pub shell: String, + pub args: Vec, + pub title: String, +} + +/// Tools › Start SSH Session: the system `ssh` command line for configuration `id` +/// (run in a terminal tab, so prompts and host-key questions are answered there). +#[tauri::command] +#[specta::specta] +pub fn ssh_terminal_command(state: State<'_, AppState>, id: String) -> IdeResult { + let config = state.ssh.config(&id)?; + if config.host.trim().is_empty() { + return Err(IdeError::invalid(format!("SSH configuration {} has no host", config.name))); + } + let path = std::env::var_os("PATH").unwrap_or_default(); + let shell = ide_run::which::find_executable("ssh", &path) + .map(|p| p.to_string_lossy().into_owned()) + .ok_or_else(|| IdeError::unavailable("ssh is not installed (OpenSSH client not found on PATH)"))?; + let mut args = vec!["-t".to_owned()]; + args.extend(ide_ssh::openssh_args(&SshTarget::from(&config))); + let title = if config.name.trim().is_empty() { config.label() } else { config.name.clone() }; + Ok(SshTerminalCommand { shell, args, title }) +} diff --git a/src-tauri/src/commands/syntax.rs b/src-tauri/src/commands/syntax.rs index a43a3dd6b..d3292ceee 100644 --- a/src-tauri/src/commands/syntax.rs +++ b/src-tauri/src/commands/syntax.rs @@ -1,54 +1,22 @@ -//! Tree-sitter structure queries (`ide-syntax`): the fallback editor features -//! use when the language server is unavailable, not ready or failing. -//! -//! All positions are 0-based lines / UTF-16 columns. Document queries run on -//! the blocking pool against the document's cached, incrementally re-parsed -//! tree; an optional `request` id makes them cancellable via `cancel_request`. -//! Languages without a grammar (and documents too large to parse) reject with -//! `unavailable`. +//! IPC commands of `ide_host::ops::syntax` (the shared implementation). -use super::doc::blocking; -use crate::state::{AppState, Project}; -use ide_core::{CancellationToken, DocId, IdeError, IdeResult, LanguageId, Pos, Range, RequestId}; +use crate::state::AppState; +use ide_core::{DocId, IdeResult, LanguageId, Pos, Range, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::syntax::*; use ide_syntax::{ - ExpressionAt, FileIdentifiers, IdentifierOccurrence, Locals, NodeAt, Snapshot, StructuralRanges, StructureResult, - StructureService, StructureSupport, SyntaxErrors, + ExpressionAt, FileIdentifiers, IdentifierOccurrence, Locals, NodeAt, StructuralRanges, StructureSupport, + SyntaxErrors, }; use std::path::PathBuf; -use std::sync::Arc; use tauri::{State, Window}; -/// Runs `f` on the current snapshot of `doc` off the command thread. -async fn on_doc( - state: &AppState, - window: &Window, - doc: DocId, - request: Option, - f: impl FnOnce(&StructureService, &Snapshot, &CancellationToken) -> StructureResult + Send + 'static, -) -> IdeResult { - let project: Arc = state.project(window.label())?; - let cancel = request.map_or_else(CancellationToken::new, |id| state.begin_request(id)); - let result = blocking(move || { - let snap = project.documents.text_snapshot(doc)?; - if snap.large { - return Err(IdeError::unavailable("file is too large for syntax analysis")); - } - let s = Snapshot::doc(doc, snap.version, &snap.language, &snap.text); - Ok(f(&project.structure, &s, &cancel)?) - }) - .await; - if let Some(id) = request { - state.end_request(id); - } - result -} - /// What the structure service supports for a language (`tree`: every query, /// `locals`: scope analysis). #[tauri::command] #[specta::specta] pub fn syntax_support(window: Window, state: State<'_, AppState>, language: LanguageId) -> IdeResult { - Ok(state.project(window.label())?.structure.support(&language)) + ide_host::ops::syntax::syntax_support(&state.host, window.label(), language) } /// The smallest named node covering `range`, with its ancestors (parent first). @@ -61,7 +29,7 @@ pub async fn syntax_node_at( range: Range, request: Option, ) -> IdeResult> { - on_doc(&state, &window, doc, request, move |s, snap, c| s.node_at(snap, range, c)).await + ide_host::ops::syntax::syntax_node_at(&state.host, window.label(), doc, range, request).await } /// The expression a selection denotes (exact or minimally enclosing), with its @@ -75,7 +43,7 @@ pub async fn syntax_expression_at( range: Range, request: Option, ) -> IdeResult { - on_doc(&state, &window, doc, request, move |s, snap, c| s.expression_at(snap, range, c)).await + ide_host::ops::syntax::syntax_expression_at(&state.host, window.label(), doc, range, request).await } /// `ERROR` / `MISSING` nodes (e.g. a missing `;`) in document order. @@ -87,7 +55,7 @@ pub async fn syntax_errors( doc: DocId, request: Option, ) -> IdeResult { - on_doc(&state, &window, doc, request, |s, snap, c| s.syntax_errors(snap, c)).await + ide_host::ops::syntax::syntax_errors(&state.host, window.label(), doc, request).await } /// Scope analysis of the identifier at `pos`: its local definition and @@ -101,7 +69,7 @@ pub async fn syntax_locals( pos: Pos, request: Option, ) -> IdeResult { - on_doc(&state, &window, doc, request, move |s, snap, c| s.locals(snap, pos, c)).await + ide_host::ops::syntax::syntax_locals(&state.host, window.label(), doc, pos, request).await } /// Identifier tokens spelled `name` in a document, excluding comments and strings. @@ -114,7 +82,7 @@ pub async fn syntax_identifiers( name: String, request: Option, ) -> IdeResult> { - on_doc(&state, &window, doc, request, move |s, snap, c| s.identifiers_named(snap, &name, c)).await + ide_host::ops::syntax::syntax_identifiers(&state.host, window.label(), doc, name, request).await } /// [`syntax_identifiers`] over files (open buffers win over disk); files @@ -128,24 +96,7 @@ pub async fn syntax_identifiers_in_files( name: String, request: RequestId, ) -> IdeResult> { - let project = state.project(window.label())?; - let languages = project.languages.clone(); - let cancel = state.begin_request(request); - let result = blocking(move || { - let load = |path: &std::path::Path| { - if let Some(doc) = project.documents.by_path(path) { - let snap = project.documents.text_snapshot(doc).ok()?; - return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text)); - } - let text = ide_vfs::load_text(path).ok()?.text; - let first_line = text.lines().next(); - Some((languages.detect(path, first_line), None, text)) - }; - Ok(project.structure.identifiers_in_files(&paths, &name, load, &cancel)?) - }) - .await; - state.end_request(request); - result + ide_host::ops::syntax::syntax_identifiers_in_files(&state.host, window.label(), paths, name, request).await } /// Extend Selection chain plus statement / declaration ranges at `pos` @@ -159,5 +110,5 @@ pub async fn syntax_structural_ranges( pos: Pos, request: Option, ) -> IdeResult { - on_doc(&state, &window, doc, request, move |s, snap, c| s.structural_ranges(snap, pos, c)).await + ide_host::ops::syntax::syntax_structural_ranges(&state.host, window.label(), doc, pos, request).await } diff --git a/src-tauri/src/commands/term.rs b/src-tauri/src/commands/term.rs index 07401d208..23e195264 100644 --- a/src-tauri/src/commands/term.rs +++ b/src-tauri/src/commands/term.rs @@ -12,22 +12,14 @@ pub fn term_spawn( options: TerminalOptions, on_output: Channel, ) -> IdeResult { - // Python › "Activate virtualenv": typed into the new shell, like PyCharm. - let activation = state - .project(window.label()) - .ok() - .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())); - let info = state.terminals.spawn( + ide_host::ops::term::spawn( + &state.host, + window.label(), options, - state.window_sink(window.label()), - Box::new(move |chunk| { + std::sync::Arc::new(move |chunk| { let _ = on_output.send(chunk); }), - )?; - if let Some(line) = activation { - let _ = state.terminals.write(info.id, &format!(" {line}\r")); - } - Ok(info) + ) } #[tauri::command] diff --git a/src-tauri/src/commands/tools.rs b/src-tauri/src/commands/tools.rs index c8ccd15d2..4570968da 100644 --- a/src-tauri/src/commands/tools.rs +++ b/src-tauri/src/commands/tools.rs @@ -1,42 +1,21 @@ -//! Language server / debug adapter installer and JDK downloads (`ide-tools`). +//! IPC commands of `ide_host::ops::tools` (the shared implementation). use crate::state::AppState; -use ide_core::{IdeError, IdeResult, RequestId}; +use ide_core::{IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::tools::*; use ide_sdk::Sdk; use ide_tools::jdk::{JdkReleases, JdkVendor}; -use ide_tools::{InstallEvent, ToolInfo, Tools}; +use ide_tools::{InstallEvent, ToolInfo}; use std::path::PathBuf; use tauri::ipc::Channel; -use tauri::{AppHandle, Manager, State, Window}; - -/// The installer, reporting the executables chosen in the window's settings. -fn tools(app: &AppHandle, state: &AppState, window: &Window) -> IdeResult { - let data = app.path().app_data_dir().map_err(|e| IdeError::internal(e.to_string()))?; - let (settings, root) = match state.project(window.label()) { - Ok(project) => (project.settings.effective(), project.root.clone()), - Err(_) => (state.global_settings.effective(), Default::default()), - }; - let custom = settings - .tools - .iter() - .filter_map(|(command, t)| Some((command.clone(), ide_sdk::apply::setting_path(&root, &t.path)?))) - .collect(); - // Godot is usually an app bundle / Flatpak outside the search path. - let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p)); - Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect())) -} - -fn join_err(e: impl std::fmt::Display) -> IdeError { - IdeError::internal(e.to_string()) -} +use tauri::{State, Window}; /// Status of every known language server / debug adapter and the languages using it. #[tauri::command] #[specta::specta] -pub async fn tools_list(app: AppHandle, window: Window, state: State<'_, AppState>) -> IdeResult> { - let tools = tools(&app, &state, &window)?; - let langs = state.languages.all(); - tauri::async_runtime::spawn_blocking(move || tools.list(&langs)).await.map_err(join_err) +pub async fn tools_list(window: Window, state: State<'_, AppState>) -> IdeResult> { + ide_host::ops::tools::tools_list(&state.host, window.label()).await } /// Installs (or reinstalls) a tool into `/tools`, streaming output through @@ -44,79 +23,47 @@ pub async fn tools_list(app: AppHandle, window: Window, state: State<'_, AppStat #[tauri::command] #[specta::specta] pub async fn tools_install( - app: AppHandle, window: Window, state: State<'_, AppState>, name: String, request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = tools(&app, &state, &window)?; - let langs = state.languages.all(); - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - tools.install(&name, &langs, &cancel, &|e| { - let _ = on_event.send(e); - }) - }) - .await - .map_err(join_err); - state.end_request(request); - let info = result??; - // Language servers whose launch depends on installed tools (jdtls loads the - // java-debug bundle) pick the change up. - reconfigure_servers(&state); - Ok(info) + ide_host::ops::tools::tools_install(&state.host, window.label(), name, request, crate::commands::sender(on_event)) + .await } /// Removes a tool the IDE installed. #[tauri::command] #[specta::specta] -pub async fn tools_uninstall( - app: AppHandle, - window: Window, - state: State<'_, AppState>, - name: String, -) -> IdeResult { - let tools = tools(&app, &state, &window)?; - let langs = state.languages.all(); - let info = - tauri::async_runtime::spawn_blocking(move || tools.uninstall(&name, &langs)).await.map_err(join_err)??; - reconfigure_servers(&state); - Ok(info) +pub async fn tools_uninstall(window: Window, state: State<'_, AppState>, name: String) -> IdeResult { + ide_host::ops::tools::tools_uninstall(&state.host, window.label(), name).await } /// JDK distributions Download JDK offers. #[tauri::command] #[specta::specta] -pub fn jdk_download_vendors() -> Vec { - ide_tools::jdk::vendors() +pub fn jdk_download_vendors(state: State<'_, AppState>) -> Vec { + ide_host::ops::tools::jdk_download_vendors(&state.host, "") } /// Java versions `vendor` publishes; cancellable with `cancel_request(request)`. #[tauri::command] #[specta::specta] pub async fn jdk_download_releases( - app: AppHandle, window: Window, state: State<'_, AppState>, vendor: String, request: RequestId, ) -> IdeResult { - let tools = tools(&app, &state, &window)?; - let cancel = state.begin_request(request); - let result = - tauri::async_runtime::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); - state.end_request(request); - result? + ide_host::ops::tools::jdk_download_releases(&state.host, window.label(), vendor, request).await } /// Where Download JDK puts JDKs unless told otherwise. #[tauri::command] #[specta::specta] -pub fn jdk_download_location(app: AppHandle) -> IdeResult { - let data = app.path().app_data_dir().map_err(|e| IdeError::internal(e.to_string()))?; - Ok(ide_tools::jdk::jdks_dir(&data).into()) +pub fn jdk_download_location(state: State<'_, AppState>) -> IdeResult { + ide_host::ops::tools::jdk_download_location(&state.host, "") } /// Downloads the latest `vendor` JDK of Java `version` into `location` (default: @@ -127,7 +74,6 @@ pub fn jdk_download_location(app: AppHandle) -> IdeResult { #[specta::specta] #[allow(clippy::too_many_arguments)] pub async fn jdk_download( - app: AppHandle, window: Window, state: State<'_, AppState>, vendor: String, @@ -136,34 +82,14 @@ pub async fn jdk_download( request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = tools(&app, &state, &window)?; - let location = location.filter(|p| !p.as_os_str().is_empty()); - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - let jdk = tools.download_jdk(&vendor, version, location.as_deref(), &cancel, &|e| { - let _ = on_event.send(e); - })?; - let home = jdk.home.into_path_buf(); - // A chosen location is scanned from now on too (the default one is from startup). - if let Some(location) = &location { - ide_sdk::java::add_jdk_root(location); - } - ide_sdk::java::rescan_jdks(); - let jdk = ide_sdk::java::jdk_at(&home) - .ok_or_else(|| IdeError::internal(format!("{} is not a JDK home", home.display())))?; - IdeResult::Ok(ide_sdk::jdk_sdk(&jdk)) - }) + ide_host::ops::tools::jdk_download( + &state.host, + window.label(), + vendor, + version, + location, + request, + crate::commands::sender(on_event), + ) .await - .map_err(join_err); - state.end_request(request); - let sdk = result??; - reconfigure_servers(&state); - Ok(sdk) -} - -fn reconfigure_servers(state: &AppState) { - for project in state.projects() { - let lsp = project.lsp.clone(); - tauri::async_runtime::spawn(async move { lsp.reconfigure().await }); - } } diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index dc0582c2b..32e9c1057 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -1,67 +1,25 @@ -use crate::state::{AppState, Project}; +//! IPC commands of `ide_host::ops::vcs` (the shared implementation). + +use crate::state::AppState; use ide_core::{IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::vcs::*; use ide_vcs::*; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tauri::ipc::Channel; use tauri::{State, Window}; -/// Settings › Version Control › Git › Path to Git executable (empty = auto-detect). -pub(crate) fn apply_git_executable(settings: &ide_core::settings::Settings) { - let exe = settings.vcs.git.executable.trim(); - ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe))); -} - /// Changes of every repository of the project. #[tauri::command] #[specta::specta] pub async fn vcs_status(window: Window, state: State<'_, AppState>) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || { - let settings = project.settings.effective(); - apply_git_executable(&settings); - let repos = project.repos(); - if repos.is_empty() { - return Err(ide_core::IdeError::unavailable("project is not a git repository")); - } - for r in repos.all() { - r.set_ignored_names(settings.ignored_names()); - } - repos.status() - }) - .await + ide_host::ops::vcs::status(&state.host, window.label()).await } - -/// A repository of the project (IDEA's VCS root). -#[derive(Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct VcsRoot { - /// Worktree root. - pub root: ide_core::IpcPath, - /// Merge / rebase / cherry-pick / revert in progress. - pub operation: Option, - /// Current branch; `None` when HEAD is detached (or unreadable). - pub head: Option, - /// HEAD commit; `None` before the first commit. - pub head_commit: Option, -} - /// The project's repositories, the primary one first. #[tauri::command] #[specta::specta] pub fn vcs_roots(window: Window, state: State<'_, AppState>) -> IdeResult> { - let repos = state.project(window.label())?.repos(); - let primary = repos.primary().map(|r| r.root().to_path_buf()); - let mut all: Vec<&std::sync::Arc> = repos.all().iter().collect(); - all.sort_by_key(|r| Some(r.root()) != primary.as_deref()); - Ok(all - .into_iter() - .map(|r| { - let (head, head_commit) = r.head().unwrap_or_default(); - VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten(), head, head_commit } - }) - .collect()) + ide_host::ops::vcs::vcs_roots(&state.host, window.label()) } #[tauri::command] @@ -71,17 +29,14 @@ pub async fn vcs_branch_info( state: State<'_, AppState>, repo: Option, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await + ide_host::ops::vcs::vcs_branch_info(&state.host, window.label(), repo).await } /// Gutter change markers for a file, computed against its current (unsaved) text. #[tauri::command] #[specta::specta] pub fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - let project = state.project(window.label())?; - let text = project.text_of(&path)?; - project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false)) + ide_host::ops::vcs::vcs_line_hunks(&state.host, window.label(), path) } #[tauri::command] @@ -92,16 +47,13 @@ pub async fn vcs_file_at( path: PathBuf, rev: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await + ide_host::ops::vcs::vcs_file_at(&state.host, window.label(), path, rev).await } #[tauri::command] #[specta::specta] pub async fn vcs_blame(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - let project = state.project(window.label())?; - let text = project.documents.text_of(&path); - blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await + ide_host::ops::vcs::vcs_blame(&state.host, window.label(), path).await } #[tauri::command] @@ -112,8 +64,7 @@ pub async fn vcs_log( repo: Option, query: LogQuery, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await + ide_host::ops::vcs::vcs_log(&state.host, window.label(), repo, query).await } #[tauri::command] @@ -124,8 +75,7 @@ pub async fn vcs_commit_files( repo: Option, commit: String, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await + ide_host::ops::vcs::vcs_commit_files(&state.host, window.label(), repo, commit).await } /// Combined changes of several commits, `commits` oldest first. @@ -137,50 +87,43 @@ pub async fn vcs_commits_changes( repo: Option, commits: Vec, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await + ide_host::ops::vcs::vcs_commits_changes(&state.host, window.label(), repo, commits).await } #[tauri::command] #[specta::specta] pub async fn vcs_branches(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await + ide_host::ops::vcs::vcs_branches(&state.host, window.label(), repo).await } #[tauri::command] #[specta::specta] pub async fn vcs_stashes(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stashes())).await + ide_host::ops::vcs::vcs_stashes(&state.host, window.label(), repo).await } #[tauri::command] #[specta::specta] pub async fn vcs_stage(window: Window, state: State<'_, AppState>, paths: Vec) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await + ide_host::ops::vcs::vcs_stage(&state.host, window.label(), paths).await } #[tauri::command] #[specta::specta] pub async fn vcs_unstage(window: Window, state: State<'_, AppState>, paths: Vec) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await + ide_host::ops::vcs::vcs_unstage(&state.host, window.label(), paths).await } #[tauri::command] #[specta::specta] pub async fn vcs_stage_patch(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await + ide_host::ops::vcs::vcs_stage_patch(&state.host, window.label(), patch).await } #[tauri::command] #[specta::specta] pub async fn vcs_discard(window: Window, state: State<'_, AppState>, paths: Vec) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await + ide_host::ops::vcs::vcs_discard(&state.host, window.label(), paths).await } #[tauri::command] @@ -192,9 +135,7 @@ pub async fn vcs_commit( message: String, options: CommitOptions, ) -> IdeResult { - // Commit hooks may run for a long time. - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await + ide_host::ops::vcs::vcs_commit(&state.host, window.label(), repo, message, options).await } #[tauri::command] @@ -207,13 +148,7 @@ pub async fn vcs_checkout( create: bool, options: Option, ) -> IdeResult { - let project = state.project(window.label())?; - let options = options.unwrap_or_default(); - blocking(move || { - project - .with_repo_recorded(repo.as_deref(), Some("Git checkout"), |r| r.checkout_with(&branch, create, &options)) - }) - .await + ide_host::ops::vcs::vcs_checkout(&state.host, window.label(), repo, branch, create, options).await } /// New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. @@ -227,12 +162,7 @@ pub async fn vcs_create_branch( start: Option, checkout: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - let recorded = checkout.then_some("Git checkout"); - blocking(move || { - project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout)) - }) - .await + ide_host::ops::vcs::vcs_create_branch(&state.host, window.label(), repo, name, start, checkout).await } /// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged. @@ -245,8 +175,7 @@ pub async fn vcs_undo_commit( repo: Option, commit: String, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await + ide_host::ops::vcs::vcs_undo_commit(&state.host, window.label(), repo, commit).await } #[tauri::command] @@ -257,8 +186,7 @@ pub async fn vcs_delete_branch( repo: Option, branch: String, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await + ide_host::ops::vcs::vcs_delete_branch(&state.host, window.label(), repo, branch).await } /// "Restore" of the branch-deleted notification. @@ -270,8 +198,7 @@ pub async fn vcs_restore_branch( repo: Option, deleted: ide_vcs::DeletedBranch, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await + ide_host::ops::vcs::vcs_restore_branch(&state.host, window.label(), repo, deleted).await } #[tauri::command] @@ -283,8 +210,7 @@ pub async fn vcs_rename_branch( from: String, to: String, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await + ide_host::ops::vcs::vcs_rename_branch(&state.host, window.label(), repo, from, to).await } #[tauri::command] @@ -295,8 +221,7 @@ pub async fn vcs_merge( repo: Option, branch: String, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await + ide_host::ops::vcs::vcs_merge(&state.host, window.label(), repo, branch).await } #[tauri::command] @@ -307,8 +232,7 @@ pub async fn vcs_rebase( repo: Option, onto: String, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await + ide_host::ops::vcs::vcs_rebase(&state.host, window.label(), repo, onto).await } #[tauri::command] @@ -321,13 +245,7 @@ pub async fn vcs_stash_push( include_untracked: bool, keep_index: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| { - r.stash_push(message.as_deref(), include_untracked, keep_index) - }) - }) - .await + ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index).await } #[tauri::command] @@ -341,14 +259,7 @@ pub async fn vcs_stash_apply( reinstate_index: bool, branch: Option, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() { - Some(name) => r.stash_branch(index, name), - None => r.stash_apply(index, pop, reinstate_index), - }) - }) - .await + ide_host::ops::vcs::vcs_stash_apply(&state.host, window.label(), repo, index, pop, reinstate_index, branch).await } #[tauri::command] @@ -359,48 +270,7 @@ pub async fn vcs_stash_drop( repo: Option, index: u32, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await -} - -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(tag = "op", rename_all = "camelCase")] -pub enum RemoteOp { - Fetch, - Pull { - rebase: bool, - }, - Push { - force_with_lease: bool, - set_upstream: bool, - /// Push dialog › Push tags. - #[serde(default)] - tags: Option, - }, - /// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`). - DeleteBranch { - branch: String, - }, - /// Pushes local `branch` (only up to `revision` when set) to its upstream, or to - /// remote branch `target` of the same remote. - PushBranch { - branch: String, - revision: Option, - force_with_lease: bool, - #[serde(default)] - target: Option, - }, - /// Fast-forwards local `branch` (not checked out) to its upstream. - UpdateBranch { - branch: String, - }, - /// Pull dialog: `branches` of `remote` (none = the configured merge branch). - PullFrom { - remote: String, - branches: Vec, - rebase: bool, - options: MergeOptions, - }, + ide_host::ops::vcs::vcs_stash_drop(&state.host, window.label(), repo, index).await } /// Network operation; progress lines stream through `on_output`. Cancellable. @@ -414,36 +284,8 @@ pub async fn vcs_remote( op: RemoteOp, on_output: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - let out = |line: String| { - let _ = on_output.send(line); - }; - let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull"); - project.with_repo_recorded(repo.as_deref(), recorded, |r| match op { - RemoteOp::Fetch => r.fetch(&cancel, &out), - RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out), - RemoteOp::Push { force_with_lease, set_upstream, tags } => { - r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out) - } - RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out), - RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target { - Some(target) => { - r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out) - } - None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out), - }, - RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out), - RemoteOp::PullFrom { remote, branches, rebase, options } => { - r.pull_from(&remote, &branches, rebase, &options, &cancel, &out) - } - }) - }) - .await - .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; - state.end_request(request); - result + ide_host::ops::vcs::vcs_remote(&state.host, window.label(), repo, request, op, crate::commands::sender(on_output)) + .await } /// The user's side is `ours` (sides swapped during a rebase, as in IDEA). @@ -454,8 +296,7 @@ pub async fn vcs_conflict_versions( state: State<'_, AppState>, path: PathBuf, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await + ide_host::ops::vcs::vcs_conflict_versions(&state.host, window.label(), path).await } #[tauri::command] @@ -466,15 +307,14 @@ pub async fn vcs_diff_text( path: PathBuf, staged: bool, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await + ide_host::ops::vcs::vcs_diff_text(&state.host, window.label(), path, staged).await } /// Line hunks between two arbitrary texts (diff viewer, commit dialog). #[tauri::command] #[specta::specta] -pub fn vcs_diff_hunks(old_text: String, new_text: String) -> Vec { - ide_vcs::diff_hunks(&old_text, &new_text) +pub fn vcs_diff_hunks(state: State<'_, AppState>, old_text: String, new_text: String) -> Vec { + ide_host::ops::vcs::vcs_diff_hunks(&state.host, "", old_text, new_text) } /// Stages only the selected hunks of a file's working-tree changes. @@ -482,15 +322,7 @@ pub fn vcs_diff_hunks(old_text: String, new_text: String) -> Vec { #[tauri::command] #[specta::specta] pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec) -> IdeResult<()> { - let project = state.project(window.label())?; - let current = ide_vfs::load_text(&path)?.text; - project.with_repo_of(&path, |r| { - let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default(); - let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/"); - let selected: Vec = hunks.iter().map(|&h| h as usize).collect(); - let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected); - if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) } - }) + ide_host::ops::vcs::vcs_stage_hunks(&state.host, window.label(), path, hunks) } /// Clones a repository (Get from VCS). Works without an open project. @@ -503,61 +335,29 @@ pub async fn vcs_clone( dest: PathBuf, on_output: Channel, ) -> IdeResult<()> { - apply_git_executable(&state.global_settings.effective()); - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - ide_vcs::clone(&url, &dest, &cancel, &|line| { - let _ = on_output.send(line); - }) - }) - .await - .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; - state.end_request(request); - result + ide_host::ops::vcs::vcs_clone(&state.host, "", request, url, dest, crate::commands::sender(on_output)).await } /// `git init` in `dir` (File › New Project… "Create Git repository"). #[tauri::command] #[specta::specta] pub async fn vcs_init(state: State<'_, AppState>, dir: PathBuf) -> IdeResult<()> { - apply_git_executable(&state.global_settings.effective()); - blocking(move || ide_vcs::init(&dir)).await -} - -#[derive(Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct GitExecutableInfo { - /// `git version …` output. - pub version: String, - /// The executable that was run. - pub path: String, + ide_host::ops::vcs::vcs_init(&state.host, "", dir).await } /// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected /// executable when `path` is empty. #[tauri::command] #[specta::specta] -pub async fn vcs_git_version(path: String) -> IdeResult { - tauri::async_runtime::spawn_blocking(move || { - let explicit = path.trim(); - let exe = if explicit.is_empty() { - ide_vcs::detected_git_executable() - .ok_or_else(|| ide_core::IdeError::unavailable("git executable not found on PATH"))? - } else { - PathBuf::from(explicit) - }; - let version = ide_vcs::git_version(Some(&exe))?; - Ok(GitExecutableInfo { version, path: exe.to_string_lossy().into_owned() }) - }) - .await - .map_err(|e| ide_core::IdeError::internal(e.to_string()))? +pub async fn vcs_git_version(state: State<'_, AppState>, path: String) -> IdeResult { + ide_host::ops::vcs::vcs_git_version(&state.host, "", path).await } /// The auto-detected git executable (shown as the field's placeholder). #[tauri::command] #[specta::specta] -pub fn vcs_detected_git() -> Option { - ide_vcs::detected_git_executable().map(|p| p.to_string_lossy().into_owned()) +pub fn vcs_detected_git(state: State<'_, AppState>) -> Option { + ide_host::ops::vcs::vcs_detected_git(&state.host, "") } /// `git config --get ` in the project repository; `None` when unset. @@ -569,36 +369,7 @@ pub async fn vcs_config_get( repo: Option, key: String, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await -} - -// ---- in-progress operations, history rewriting (Git Log actions) ---- - -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(tag = "op", rename_all = "camelCase")] -pub enum GitOperation { - /// Continue the merge / rebase / cherry-pick / revert in progress. - Continue, - Abort, - Skip, - /// Commits oldest first. - CherryPick { - commits: Vec, - }, - /// Commits newest first. - Revert { - commits: Vec, - }, - Reset { - commit: String, - mode: ResetMode, - }, - /// Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first. - InteractiveRebase { - base: Option, - entries: Vec, - }, + ide_host::ops::vcs::vcs_config_get(&state.host, window.label(), repo, key).await } /// Local history operation that may take a while (hooks, large checkouts); git's @@ -613,35 +384,15 @@ pub async fn vcs_operation( op: GitOperation, on_output: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - let out = |line: String| { - let _ = on_output.send(line); - }; - let recorded = match &op { - GitOperation::CherryPick { .. } => Some("Git cherry-pick"), - GitOperation::Revert { .. } => Some("Git revert"), - GitOperation::Reset { .. } => Some("Git reset"), - GitOperation::InteractiveRebase { .. } => Some("Git rebase"), - _ => None, - }; - project.with_repo_recorded(repo.as_deref(), recorded, |r| match op { - GitOperation::Continue => r.continue_operation(&cancel, &out), - GitOperation::Abort => r.abort_operation(&cancel, &out), - GitOperation::Skip => r.skip_operation(&cancel, &out), - GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out), - GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out), - GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out), - GitOperation::InteractiveRebase { base, entries } => { - r.interactive_rebase(base.as_deref(), &entries, &cancel, &out) - } - }) - }) + ide_host::ops::vcs::vcs_operation( + &state.host, + window.label(), + repo, + request, + op, + crate::commands::sender(on_output), + ) .await - .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; - state.end_request(request); - result } #[tauri::command] @@ -655,16 +406,13 @@ pub async fn vcs_create_tag( message: Option, force: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force))) - .await + ide_host::ops::vcs::vcs_create_tag(&state.host, window.label(), repo, name, commit, message, force).await } #[tauri::command] #[specta::specta] pub async fn vcs_tags(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await + ide_host::ops::vcs::vcs_tags(&state.host, window.label(), repo).await } /// Merge dialog: merges `branches` into the current branch. @@ -677,11 +425,7 @@ pub async fn vcs_merge_with( branches: Vec, options: MergeOptions, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options)) - }) - .await + ide_host::ops::vcs::vcs_merge_with(&state.host, window.label(), repo, branches, options).await } /// Rebase dialog: rebases the current branch onto `upstream` (or `--onto`). @@ -694,18 +438,13 @@ pub async fn vcs_rebase_with( upstream: String, options: RebaseOptions, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options)) - }) - .await + ide_host::ops::vcs::vcs_rebase_with(&state.host, window.label(), repo, upstream, options).await } #[tauri::command] #[specta::specta] pub async fn vcs_remotes(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await + ide_host::ops::vcs::vcs_remotes(&state.host, window.label(), repo).await } #[tauri::command] @@ -716,8 +455,7 @@ pub async fn vcs_edit_remote( repo: Option, edit: RemoteEdit, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await + ide_host::ops::vcs::vcs_edit_remote(&state.host, window.label(), repo, edit).await } #[tauri::command] @@ -727,8 +465,7 @@ pub async fn vcs_worktrees( state: State<'_, AppState>, repo: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.worktrees())).await + ide_host::ops::vcs::vcs_worktrees(&state.host, window.label(), repo).await } #[tauri::command] @@ -741,11 +478,7 @@ pub async fn vcs_add_worktree( commitish: String, new_branch: Option, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) - }) - .await + ide_host::ops::vcs::vcs_add_worktree(&state.host, window.label(), repo, path, commitish, new_branch).await } #[tauri::command] @@ -757,8 +490,7 @@ pub async fn vcs_remove_worktree( path: PathBuf, force: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await + ide_host::ops::vcs::vcs_remove_worktree(&state.host, window.label(), repo, path, force).await } /// Compare with Local: tracked files that differ between `rev` and the working tree. @@ -770,8 +502,7 @@ pub async fn vcs_diff_with_worktree( repo: Option, rev: String, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await + ide_host::ops::vcs::vcs_diff_with_worktree(&state.host, window.label(), repo, rev).await } /// Files that differ between two revisions (the "N files updated" of Update Project). @@ -784,99 +515,25 @@ pub async fn vcs_diff_revisions( from: String, to: String, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await -} - -/// Runs `f` once per repository with the `paths` that belong to it. -fn per_repo( - project: &Project, - paths: &[PathBuf], - recorded: Option<&str>, - f: impl Fn(&Repo, &[PathBuf]) -> IdeResult<()>, -) -> IdeResult<()> { - let groups = project.repos().group(paths); - if groups.is_empty() { - return project.with_repo_recorded(None, recorded, |r| f(r, paths)); - } - for (repo, paths) in groups { - project.with_repo_recorded(Some(repo.root()), recorded, |r| f(r, &paths))?; - } - Ok(()) -} - -/// Shelf and patches work in the primary repository only: changes of a nested one -/// fail instead of being left out silently. -fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> { - let repos = project.repos(); - let primary = repos.primary().map(|r| r.root()); - match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) { - Some(p) => Err(ide_core::IdeError::invalid(format!( - "{} is in another git repository; shelving and patches support the project's main repository only", - p.display() - ))), - None => Ok(()), - } -} - -async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { - tauri::async_runtime::spawn_blocking(f).await.map_err(|e| ide_core::IdeError::internal(e.to_string()))? -} - -// ---- patches ---- - -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(tag = "from", rename_all = "camelCase")] -pub enum PatchSource { - /// Local changes of these files (absolute paths). - Changes { paths: Vec, reverse: bool }, - /// Changes made by these commits (oldest first). - Commits { commits: Vec, reverse: bool }, + ide_host::ops::vcs::vcs_diff_revisions(&state.host, window.label(), repo, from, to).await } #[tauri::command] #[specta::specta] pub async fn vcs_create_patch(window: Window, state: State<'_, AppState>, source: PatchSource) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo(|r| match source { - PatchSource::Changes { paths, reverse } => { - let paths: Vec = paths.into_iter().map(Into::into).collect(); - in_primary_repo(&project, &paths)?; - r.create_patch(&paths, reverse) - } - PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse), - }) - }) - .await + ide_host::ops::vcs::vcs_create_patch(&state.host, window.label(), source).await } #[tauri::command] #[specta::specta] pub async fn vcs_patch_preview(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await + ide_host::ops::vcs::vcs_patch_preview(&state.host, window.label(), patch).await } #[tauri::command] #[specta::specta] pub async fn vcs_apply_patch(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await -} - -// ---- shelf (`.openidea/shelf`) and change lists (`.openidea/changelists.json`) ---- - -const PRIVATE_ENTRIES: [&str; 2] = ["/shelf/", "/changelists.json"]; - -fn shelf_dir(root: &Path) -> PathBuf { - ide_core::project_dir::path(root, "shelf") -} - -fn keep_private(root: &Path) { - if let Err(e) = ide_vcs::changelists::ensure_ignored(&ide_core::project_dir::path(root, ""), &PRIVATE_ENTRIES) { - tracing::warn!("could not update .openidea/.gitignore: {e}"); - } + ide_host::ops::vcs::vcs_apply_patch(&state.host, window.label(), patch).await } /// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. @@ -888,20 +545,13 @@ pub async fn vcs_shelve( name: String, paths: Vec, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || { - in_primary_repo(&project, &paths)?; - keep_private(&project.root); - project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths)) - }) - .await + ide_host::ops::vcs::vcs_shelve(&state.host, window.label(), name, paths).await } #[tauri::command] #[specta::specta] pub fn vcs_shelves(window: Window, state: State<'_, AppState>) -> IdeResult> { - let project = state.project(window.label())?; - ide_vcs::shelf::list(&shelf_dir(&project.root)) + ide_host::ops::vcs::vcs_shelves(&state.host, window.label()) } /// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the @@ -914,59 +564,42 @@ pub async fn vcs_unshelve( id: String, remove: bool, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove)) - }) - .await + ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove).await } #[tauri::command] #[specta::specta] pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> { - let project = state.project(window.label())?; - ide_vcs::shelf::delete(&shelf_dir(&project.root), &id) + ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id) } #[tauri::command] #[specta::specta] pub fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, name: String) -> IdeResult<()> { - let project = state.project(window.label())?; - ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name) + ide_host::ops::vcs::vcs_shelf_rename(&state.host, window.label(), id, name) } #[tauri::command] #[specta::specta] pub fn vcs_shelf_patch(window: Window, state: State<'_, AppState>, id: String) -> IdeResult { - let project = state.project(window.label())?; - ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id) + ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id) } #[tauri::command] #[specta::specta] pub fn vcs_changelists_load(window: Window, state: State<'_, AppState>) -> IdeResult { - let project = state.project(window.label())?; - Ok(ide_vcs::changelists::load(&ide_core::project_dir::path(&project.root, "changelists.json"))) + ide_host::ops::vcs::vcs_changelists_load(&state.host, window.label()) } #[tauri::command] #[specta::specta] pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changelists: ChangelistState) -> IdeResult<()> { - let project = state.project(window.label())?; - keep_private(&project.root); - ide_vcs::changelists::save(&ide_core::project_dir::path(&project.root, "changelists.json"), &changelists) + ide_host::ops::vcs::vcs_changelists_save(&state.host, window.label(), changelists) } /// Create Patch › Save to file. #[tauri::command] #[specta::specta] -pub async fn vcs_write_patch(path: PathBuf, patch: String) -> IdeResult<()> { - blocking(move || { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; - } - std::fs::write(&path, patch)?; - Ok(()) - }) - .await +pub async fn vcs_write_patch(state: State<'_, AppState>, path: PathBuf, patch: String) -> IdeResult<()> { + ide_host::ops::vcs::vcs_write_patch(&state.host, "", path, patch).await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2c8be4a1..9593cbea4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,7 +7,8 @@ mod commands; mod events; mod logging; mod menu; -mod shell_env; +mod remote_dev; + mod state; mod theme_frame; mod windows; @@ -37,7 +38,7 @@ pub fn run() { let context = tauri::generate_context!(); logging::init(&context.config().identifier); - let refresh_shell_path = shell_env::inherit_login_shell_path(&context.config().identifier); + let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier); // Managed LSP/DAP installs; must run before other threads start and before PATH is cached. if let Some(data) = dirs::data_dir() { ide_tools::extend_process_path(&data.join(&context.config().identifier)); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 79b345178..661b614e7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Started by `ssh` / `git` as their askpass program: answer and exit, no UI. + if let Some(code) = ide_ssh::askpass::run_helper() { + std::process::exit(code); + } openidea_lib::run() } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 6bc4aa344..4b1fc5f6d 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -216,6 +216,9 @@ pub fn spec(os: Os) -> Vec { act("file.closeProject", "Close Project"), act("file.renameProject", "Rename Project…"), SEP, + act("remote.connect", "Remote Development…"), + act("remote.disconnect", "Disconnect from Remote Host"), + SEP, act("file.save", "Save All"), act("file.reloadAllFromDisk", "Reload All from Disk"), sub("fileProperties", "File Properties", vec![act("file.toggleReadOnly", "Make File Read-only / Writable")]), @@ -631,6 +634,26 @@ pub fn spec(os: Os) -> Vec { act("db.newDataSource", "New Data Source…"), act("db.openConsole", "Open Query Console"), SEP, + act("ssh.startSession", "Start SSH Session…"), + act("ssh.configurations", "SSH Configurations…"), + sub( + "deployment", + "Deployment", + vec![ + act("deployment.uploadToDefault", "Upload to Default Server"), + act("deployment.uploadTo", "Upload to…"), + act("deployment.downloadFromDefault", "Download from Default Server"), + act("deployment.downloadFrom", "Download from…"), + act("deployment.syncWith", "Sync with Deployed…"), + act("deployment.deleteFromServer", "Delete from Server"), + SEP, + act("deployment.toggleAutoUpload", "Automatic Upload"), + act("deployment.browseRemoteHost", "Browse Remote Host"), + act("deployment.openInBrowser", "Open in Browser"), + act("deployment.configure", "Configuration…"), + ], + ), + SEP, act("vim.toggle", "Vim"), ], )); diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs new file mode 100644 index 000000000..c0966dfc0 --- /dev/null +++ b/src-tauri/src/remote_dev.rs @@ -0,0 +1,438 @@ +//! Remote development (client side): a window whose project lives on another +//! machine. The backend there is `openidea-server`, started over SSH; the +//! frontend's remote-capable commands go through [`remote_invoke`] instead of +//! running locally. + +use crate::events::TauriSink; +use crate::state::AppState; +use ide_core::{EventSink, IdeError, IdeResult}; +use ide_host::rpc::{ChannelMessage, EventMessage, Invoke, ServerInfo}; +use ide_jsonrpc::{Handler, Peer}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use specta::Type; +use std::collections::HashMap; +use std::sync::Arc; +use tauri::ipc::Channel; +use tauri::{AppHandle, Manager, State, Window}; + +/// Where the server is installed on the remote host (per version). +fn install_dir(version: &str) -> String { + format!("~/.openidea/server/{version}") +} + +/// One remote window's connection. +pub struct Connection { + peer: Peer, + pub ssh_config: String, + pub root: String, + pub host: String, + pub commands: Vec, + /// Streaming arguments by connection-wide id: the frontend channel and the id + /// it expects (each webview numbers its own, and a settings window shares this + /// connection with its parent). + channels: Channels, + next_channel: std::sync::atomic::AtomicU64, + _session: Arc, +} + +type Channels = Arc, u64)>>>; + +#[derive(Default)] +pub struct Connections(Mutex>>); + +impl Connections { + pub fn get(&self, window: &str) -> Option> { + self.0.lock().get(window).cloned() + } + + pub fn disconnect(&self, window: &str) { + if let Some(c) = self.0.lock().remove(window) { + c.peer.kill(); + } + } +} + +/// Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload). +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum RemoteStage { + Connecting, + Checking, + Downloading, + Uploading, + Starting, + Opening, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteProgress { + pub stage: RemoteStage, + pub fraction: Option, +} + +type Progress = dyn Fn(RemoteStage, Option) + Send + Sync; + +/// A value for one streaming argument of a remote command (`data`: JSON text). +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ChannelFrame { + pub id: u64, + pub data: String, +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteProject { + pub ssh_config: String, + pub root: String, + /// `user@host:port` of the SSH configuration. + pub host: String, + pub server_version: String, + pub os: String, + /// Commands the server answers (the rest run locally). + pub commands: Vec, + pub project: ide_host::ops::project::ProjectInfo, +} + +struct ClientHandler { + app: AppHandle, + window: String, + channels: Channels, +} + +impl Handler for ClientHandler { + fn on_notification(&self, method: &str, params: Value) { + match method { + "event" => match serde_json::from_value::(params) { + Ok(m) => { + // The server's windows are this window. + let _ = m.window; + TauriSink { app: self.app.clone(), window: Some(self.window.clone()) }.emit(m.event); + } + Err(e) => tracing::warn!("remote event: {e}"), + }, + "channel" => match serde_json::from_value::(params) { + Ok(m) => { + let channel = self.channels.lock().get(&m.id).cloned(); + if let Some((c, id)) = channel { + let _ = c.send(ChannelFrame { id, data: m.data.to_string() }); + } + } + Err(e) => tracing::warn!("remote channel: {e}"), + }, + _ => {} + } + } + + fn on_request(&self, method: &str, _: Value) -> Result { + Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))) + } +} + +/// The server binary for a remote `os`/`arch`: `OPENIDEA_SERVER_BINARY`, then +/// `/server/-/openidea-server`, then one next to this app +/// when the platforms match. +fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option { + if let Some(p) = std::env::var_os("OPENIDEA_SERVER_BINARY").map(std::path::PathBuf::from).filter(|p| p.is_file()) { + return Some(p); + } + let bundled = state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server"); + if bundled.is_file() { + return Some(bundled); + } + if os == std::env::consts::OS && arch == std::env::consts::ARCH { + let exe = std::env::current_exe().ok()?; + let name = if cfg!(windows) { "openidea-server.exe" } else { "openidea-server" }; + let sibling = exe.parent()?.join(name); + if sibling.is_file() { + return Some(sibling); + } + } + None +} + +/// The release build of the server for `os`/`arch`, downloaded once into the data dir. +async fn download_server( + state: &AppState, + version: &str, + os: &str, + arch: &str, + progress: Arc, +) -> IdeResult { + let (data, version, os, arch) = (state.data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned()); + tokio::task::spawn_blocking(move || { + ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| match e { + ide_tools::InstallEvent::Line { text } => tracing::info!("openidea-server download: {text}"), + ide_tools::InstallEvent::Progress { fraction, .. } => progress(RemoteStage::Downloading, fraction), + }) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + +/// `uname -sm` → (`linux` | `macos`, `x86_64` | `aarch64`), as Rust names them. +fn platform(uname: &str) -> (String, String) { + let mut parts = uname.split_whitespace(); + let os = match parts.next().unwrap_or_default().to_ascii_lowercase().as_str() { + "darwin" => "macos".to_owned(), + other => other.to_owned(), + }; + let arch = match parts.next().unwrap_or_default() { + "arm64" => "aarch64".to_owned(), + "amd64" => "x86_64".to_owned(), + other => other.to_owned(), + }; + (os, arch) +} + +/// Makes sure this version of the server is installed on the host; returns its path. +async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: Arc) -> IdeResult { + progress(RemoteStage::Checking, None); + let version = env!("CARGO_PKG_VERSION"); + let dir = install_dir(version); + let q = ide_ssh::shell::tilde_quote; + // Between markers: shell start-up files may print to stdout too. + const MARK: &str = "__OPENIDEA_PROBE__"; + let probe = session + .exec(&format!("echo {MARK}; uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None) + .await? + .stdout_str(); + let probe = probe.split_once(MARK).map_or(probe.as_str(), |(_, rest)| rest); + let mut lines = probe.lines().map(str::trim).filter(|l| !l.is_empty()); + let (os, arch) = platform(lines.next().unwrap_or_default()); + let installed: Vec<&str> = lines.collect(); + let local = match local_server_binary(state, &os, &arch) { + Some(local) => local, + None => match download_server(state, version, &os, &arch, progress.clone()).await { + Ok(local) => local, + // Offline: any build of this version already on the host will do. + Err(e) => { + return installed.first().map(|b| b.to_string()).ok_or_else(|| { + IdeError::unavailable(format!( + "no openidea-server {version} for {os}-{arch} ({}). Build it with \ + `cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \ + (or set OPENIDEA_SERVER_BINARY)", + e.message, + state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server").display() + )) + }); + } + }, + }; + // One folder per build, so a rebuilt server of the same version is installed again. + let build = tokio::task::spawn_blocking({ + let local = local.clone(); + move || build_id(&local) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + if let Some(bin) = installed.iter().find(|b| b.ends_with(&format!("/{build}/openidea-server"))) { + return Ok(bin.to_string()); + } + let fs = ide_ssh::RemoteFs::new(session.sftp().await?); + let remote_dir = fs.resolve(&dir).await; + fs.close().await; + let remote_dir = remote_dir?; + let remote_bin = ide_ssh::sftp::join(&ide_ssh::sftp::join(&remote_dir, &build), "openidea-server"); + // Per install: another window may be installing the same build right now. + let tmp = format!( + "{remote_bin}.{}-{}.part", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() + ); + let started = std::time::Instant::now(); + progress(RemoteStage::Uploading, Some(0.0)); + session + .put_with_progress(&tmp, &tokio::fs::read(&local).await?, &|f| progress(RemoteStage::Uploading, Some(f))) + .await?; + tracing::info!("installed openidea-server on {} in {:?}", session.label(), started.elapsed()); + let q = ide_ssh::shell::quote; + // Older builds of this version go (a running one keeps its open file). + session + .exec( + &format!( + "chmod 755 {tmp} && mv -f {tmp} {bin} && find {dir} -mindepth 1 -maxdepth 1 ! -name {build} -exec rm -rf {{}} +", + tmp = q(&tmp), + bin = q(&remote_bin), + dir = q(&remote_dir), + build = q(&build), + ), + None, + ) + .await? + .into_stdout("installing openidea-server")?; + Ok(remote_bin) +} + +/// A short content hash of the server binary (cached per path, size and time). +fn build_id(path: &std::path::Path) -> IdeResult { + static CACHE: Mutex> = Mutex::new(None); + let meta = std::fs::metadata(path)?; + let modified = meta.modified()?; + if let Some((p, len, time, id)) = &*CACHE.lock() + && p == path + && *len == meta.len() + && *time == modified + { + return Ok(id.clone()); + } + let id = blake3::hash(&std::fs::read(path)?).to_hex()[..16].to_owned(); + *CACHE.lock() = Some((path.to_path_buf(), meta.len(), modified, id.clone())); + Ok(id) +} + +/// File › Remote Development: opens `path` on the host of SSH configuration +/// `ssh_config` in this window (installs / starts the server as needed). +#[tauri::command] +#[specta::specta] +pub async fn remote_connect( + window: Window, + state: State<'_, AppState>, + ssh_config: String, + path: String, + on_progress: Channel, +) -> IdeResult { + let progress: Arc = Arc::new(move |stage, fraction| { + let _ = on_progress.send(RemoteProgress { stage, fraction }); + }); + progress(RemoteStage::Connecting, None); + let label = AppState::owner_window(window.label()).to_owned(); + state.remote_dev.disconnect(&label); + state.host.close_project(&label); + let config = state.ssh.config(&ssh_config)?; + let session = state.ssh.session(&ssh_config).await?; + let bin = ensure_server(&state, &session, progress.clone()).await?; + progress(RemoteStage::Starting, None); + let stream = session + .exec_stream(&format!("{} --stdio", ide_ssh::shell::tilde_quote(&bin))) + .await + .map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?; + let (reader, writer) = tokio::io::split(stream); + let channels: Channels = Arc::default(); + let handler = + Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() }); + let peer = Peer::connect(reader, writer, handler); + let info: ServerInfo = peer + .request("initialize", serde_json::json!({ "version": ide_host::rpc::PROTOCOL_VERSION }), None) + .await + .map_err(ide_host::rpc::error_from_reply)?; + if info.protocol != ide_host::rpc::PROTOCOL_VERSION { + return Err(IdeError::unavailable(format!( + "openidea-server {} speaks protocol {}, this app {}", + info.version, + info.protocol, + ide_host::rpc::PROTOCOL_VERSION + ))); + } + let commands: Vec = + peer.request("commands", Value::Null, None).await.map_err(ide_host::rpc::error_from_reply)?; + let root = if path.trim().is_empty() || path.trim() == "~" { + info.home.clone() + } else if let Some(rest) = path.trim().strip_prefix("~/") { + format!("{}/{rest}", info.home.trim_end_matches('/')) + } else { + path.trim().to_owned() + }; + progress(RemoteStage::Opening, None); + let call = Invoke { window: label.clone(), cmd: "open_project".into(), args: serde_json::json!({ "path": root }) }; + let project: ide_host::ops::project::ProjectInfo = + peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?; + // As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's. + let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root); + let _ = window.set_title(&format!("{} [{}]", project.name, config.label())); + let connection = Arc::new(Connection { + peer, + ssh_config: ssh_config.clone(), + root: root.clone(), + host: config.label(), + commands: commands.clone(), + channels, + next_channel: std::sync::atomic::AtomicU64::new(1), + _session: session, + }); + state.remote_dev.0.lock().insert(label, connection); + Ok(RemoteProject { + ssh_config, + root, + host: config.label(), + server_version: info.version, + os: info.os, + commands, + project, + }) +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteSession { + pub ssh_config: String, + pub root: String, + pub host: String, + pub commands: Vec, +} + +/// The remote session of this window (a settings window: its parent's), if any. +#[tauri::command] +#[specta::specta] +pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option { + let c = state.remote_dev.get(AppState::owner_window(window.label()))?; + Some(RemoteSession { + ssh_config: c.ssh_config.clone(), + root: c.root.clone(), + host: c.host.clone(), + commands: c.commands.clone(), + }) +} + +#[tauri::command] +#[specta::specta] +pub fn remote_disconnect(window: Window, state: State<'_, AppState>) { + state.remote_dev.disconnect(AppState::owner_window(window.label())); +} + +/// Runs a command on the window's remote server; `args` and the result are JSON +/// text. Streaming arguments are `{ "$channel": id }`; their values arrive on +/// `on_channel` tagged with the id. +#[tauri::command] +#[specta::specta] +pub async fn remote_invoke( + window: Window, + state: State<'_, AppState>, + cmd: String, + args: String, + on_channel: Channel, +) -> IdeResult { + let mut args: Value = serde_json::from_str(&args).map_err(|e| IdeError::invalid(format!("arguments: {e}")))?; + let label = AppState::owner_window(window.label()).to_owned(); + let c = state + .remote_dev + .get(&label) + .ok_or_else(|| IdeError::unavailable("this window is not connected to a remote host"))?; + if let Value::Object(map) = &mut args { + for v in map.values_mut() { + if let Some(id) = v.get("$channel").and_then(Value::as_u64) { + let own = c.next_channel.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + c.channels.lock().insert(own, (on_channel.clone(), id)); + *v = serde_json::json!({ "$channel": own }); + } + } + } + let call = Invoke { window: label, cmd, args }; + let result: Value = c.peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?; + Ok(result.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_uname_to_rust_platform_names() { + assert_eq!(platform("Linux x86_64"), ("linux".into(), "x86_64".into())); + assert_eq!(platform("Darwin arm64"), ("macos".into(), "aarch64".into())); + assert_eq!(platform("Linux aarch64"), ("linux".into(), "aarch64".into())); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 4707ddc3d..7508e8494 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,85 +1,38 @@ use crate::events::TauriSink; -use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, RequestId, SharedSink}; -use ide_dap::DebugManager; -use ide_index::ProjectIndex; -use ide_language::{FileTypes, LanguageRegistry, Syntax, SyntaxCache}; -use ide_lsp::LspManager; -use ide_run::ProcessManager; -use ide_syntax::StructureService; -use ide_terminal::TerminalManager; -use ide_text::{AppliedChange, ByteEdit}; -use ide_vcs::{Repo, Repos}; -use ide_vfs::Watcher; -use ide_workspace::documents::DocumentObserver; -use ide_workspace::{ - DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, RecentProjects, SettingsStore, SourceFolder, -}; -use parking_lot::{Mutex, RwLock}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use ide_core::{EventSink, IdeEvent, IdeResult, SharedSink}; +use ide_language::{LanguageRegistry, Syntax}; +use ide_workspace::RecentProjects; +use std::path::Path; use std::sync::{Arc, OnceLock}; use std::time::Duration; use tauri::{AppHandle, Manager}; -/// Process-wide state shared by all windows. +pub use ide_host::{Host, Project}; + +/// Process-wide state shared by all windows: the backend [`Host`] (reachable +/// through `Deref`) plus what only the desktop shell has. pub struct AppState { pub app: AppHandle, - pub config_dir: PathBuf, - pub data_dir: PathBuf, - pub languages: Arc, - /// Built on a background thread at start-up; see [`AppState::syntax`]. - syntax: Arc>>, + pub host: Arc, pub startup: Startup, pub recent: RecentProjects, - pub global_settings: SettingsStore, - pub terminals: TerminalManager, - pub processes: ProcessManager, - pub debugger: Arc, /// WASM plugins (`ide-plugin`), shared by all windows. pub plugins: Arc, /// The built-in web server (Open In › Browser, the editor's browser bar). pub web: ide_web::WebServer, + /// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start). + pub askpass: Option, + /// Remote development connections, by window label. + pub remote_dev: crate::remote_dev::Connections, /// Broadcasts to every window. pub sink: SharedSink, - /// One project per window, keyed by window label. - projects: RwLock>>, - requests: Mutex>, } -/// Everything bound to one open project folder. -pub struct Project { - pub root: PathBuf, - /// Window-scoped event sink, for commands that emit project events. - pub sink: SharedSink, - /// The app's language definitions with this project's File Types applied. - pub languages: Arc, - pub settings: Arc, - pub documents: Arc, - pub history: Arc, - /// Undo stack of file move / rename refactorings. - pub moves: FileMoves, - pub index: Arc, - pub lsp: Arc, - /// Git repositories: the one containing the project root and those nested below it. - pub repos: Arc>, - /// Incrementally maintained syntax trees of the open documents. - pub syntax: Arc, - /// Tree-sitter structure queries (LSP fallback) over `syntax`. - pub structure: Arc, - /// Roots auto-detected from build files; see [`Project::roots`]. - pub detected_roots: Arc>>, - /// Per-project cache directory (build tool sync results…). - pub cache_dir: PathBuf, - /// Database tool window: data sources and their connections. - pub db: Arc, - /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. - pub http_history: Arc, - /// Coding agent tasks (Agents tool window). - pub agents: Arc, - watcher: Watcher, - /// Watchers of the Project Structure content roots outside the project folder. - content_watchers: Mutex>, - _scratch_watcher: Option, +impl std::ops::Deref for AppState { + type Target = Host; + fn deref(&self) -> &Host { + &self.host + } } /// Start-up timings (`app_metrics`). @@ -120,37 +73,36 @@ impl AppState { } let global_sink: SharedSink = Arc::new(TauriSink { app: app.clone(), window: None }); let languages = Arc::new(LanguageRegistry::load(Some(&config_dir.join("languages")))?); + let sink_app = app.clone(); + let host = Arc::new(Host::new(ide_host::HostConfig { + config_dir: config_dir.clone(), + data_dir: data_dir.clone(), + languages: languages.clone(), + syntax, + sink_for: Box::new(move |window| { + Arc::new(TauriSink { app: sink_app.clone(), window: Some(window.to_owned()) }) + }), + owner: Self::owner_window, + runtime: tauri::async_runtime::handle().inner().clone(), + })); Ok(Self { recent: RecentProjects::load(&config_dir), - global_settings: SettingsStore::load(&config_dir, None), - // Sessions, runs and terminals report to the window that started them (`window_sink`). - terminals: TerminalManager::new(), - processes: ProcessManager::new(), - debugger: DebugManager::new(languages.clone()), + askpass: start_askpass(&app), sink: global_sink, plugins: crate::commands::plugin::create_host(&app, &data_dir)?, web: ide_web::WebServer::new(), - syntax, + remote_dev: crate::remote_dev::Connections::default(), + host, startup: Startup { process_to_main, process_to_setup, setup: started.elapsed(), first_ipc: OnceLock::new(), }, - languages, - config_dir, - data_dir, app, - projects: RwLock::default(), - requests: Mutex::default(), }) } - /// The tree-sitter grammars (blocks only if start-up warm-up is still running). - pub fn syntax(&self) -> Arc { - self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone() - } - /// Records the first IPC call for the start-up metric. pub fn note_ipc(&self) { self.startup.first_ipc.get_or_init(|| { @@ -167,704 +119,48 @@ impl AppState { }); } - /// Every open project (all windows). - pub fn projects(&self) -> Vec> { - self.projects.read().values().cloned().collect() - } - - /// The project of `window`. A settings window (`settings-`) shares - /// its parent window's project. - pub fn project(&self, window: &str) -> IdeResult> { - let window = Self::owner_window(window); - self.projects - .read() - .get(window) - .cloned() - .ok_or_else(|| IdeError::not_found("no project is open in this window")) - } - /// The window that owns what `window` starts: a settings window's parent, else `window`. pub fn owner_window(window: &str) -> &str { crate::windows::settings_parent(window).unwrap_or(window) } - /// Event sink for things `window` starts (debug sessions, runs, terminals): its - /// project's window-scoped sink, or a sink for that window when no project is open. - pub fn window_sink(&self, window: &str) -> SharedSink { - match self.project(window) { - Ok(p) => p.sink.clone(), - Err(_) => { - Arc::new(TauriSink { app: self.app.clone(), window: Some(Self::owner_window(window).to_owned()) }) - } - } - } - + /// Opens `root` in `window` and records it in the recent projects. pub fn open_project(&self, window: &str, root: &Path) -> IdeResult> { - let started = std::time::Instant::now(); - let mut lap = started; - let mut laps: Vec<(&str, u128)> = Vec::new(); - let mut mark = |name: &'static str| { - laps.push((name, lap.elapsed().as_millis())); - lap = std::time::Instant::now(); - }; - let root = dunce::canonicalize(root)?; - // The window reloaded (or asked again) for the project it already has: keep - // it, with its warm index, language servers and unsaved documents. - if let Ok(current) = self.project(window) - && current.root == root - { - return Ok(current); - } - self.close_project(window); - let sink: SharedSink = Arc::new(TauriSink { app: self.app.clone(), window: Some(window.to_owned()) }); - let settings = SettingsStore::load(&self.config_dir, Some(&root)); - let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root))); - let excludes = exclude_globs(&settings.effective(), &detected_roots.read()); - let extra_roots = content_roots(&settings.effective(), &root); - mark("roots"); - - // The project's own registry: File Types associations may be project-scoped. - let languages = Arc::new(self.languages.fork()); - languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root))); - let documents = { - let languages = languages.clone(); - Arc::new(Documents::new( - sink.clone(), - Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)), - )) - }; - documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root))); - let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?); - { - let history = history.clone(); - let retention = history_retention(&settings.effective()); - std::thread::Builder::new().name("openidea-history-prune".into()).spawn(move || { - if let Err(e) = history.apply_retention(retention) { - tracing::warn!("local history: {e}"); - } - })?; - } - let project_key = project_key(&root); - let index = ProjectIndex::open( - &root, - &self.data_dir.join("index").join(&project_key), - extra_roots.clone(), - excludes.clone(), - languages.clone(), - self.syntax(), - sink.clone(), - )?; - mark("index"); - let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone()); - let settings = Arc::new(settings); - { - let settings = settings.clone(); - let root = root.clone(); - let data_dir = self.data_dir.clone(); - lsp.set_config_hook(Box::new(move |cfg, server_root| { - let effective = settings.effective(); - ide_sdk::apply::configure_server(cfg, &root, server_root, &effective); - if cfg.name == "jdtls" { - let bundles: Vec = - ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); - let min_java = ide_sdk::java::jdtls_min_java(cfg); - let jdks = ide_sdk::java::jdtls_jdks(min_java); - let lombok = ide_sdk::lombok::lombok_agent( - server_root, - ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)), - ide_tools::managed_bundle(&data_dir, "lombok"), - ide_sdk::apply::maven_local_repository(&root, &effective), - ); - if let Some(problem) = lombok.problem() { - tracing::warn!("{problem}"); - } - let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| { - cfg.launch_error = Some(e); - None - }); - ide_sdk::java::configure_jdtls( - cfg, - server_root, - &data_dir, - &jdks, - ide_sdk::apply::project_jdk(&root, &effective).as_ref(), - server_jdk.as_ref(), - min_java, - &bundles, - lombok.jar(), - ); - ide_sdk::jdtls::launch_directly(cfg); - } - })); - } - { - let documents = documents.clone(); - lsp.set_apply_edit_hook(Box::new(move |edit| apply_workspace_edit(&documents, &edit).is_ok())); - } - let syntax = Arc::new(SyntaxCache::new(self.syntax())); - documents.add_observer(Arc::new(SyntaxObserver(syntax.clone()))); - documents.add_observer(Arc::new(LspObserver(lsp.clone()))); - documents.add_observer(Arc::new(HistoryObserver(history.clone()))); - - let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); - let watcher = { - let repos = repos.clone(); - let settings = settings.clone(); - let index = index.clone(); - let documents = documents.clone(); - let sink = sink.clone(); - let root = root.clone(); - let detected_roots = detected_roots.clone(); - let history = history.clone(); - Watcher::start( - &root.clone(), - excludes.clone(), - sink.clone(), - Box::new(move |batch| { - let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); - index.update(&changed, &batch.removed); - // Atomic saves by external tools surface as `created`. - documents.on_disk_changed(&changed); - record_external_changes(&history, &changed, &batch.removed); - // Build files / conventional folders changed: re-detect roots. - let touched = changed.iter().chain(&batch.removed).filter_map(|p| p.strip_prefix(&root).ok()); - if touched.into_iter().any(ide_workspace::roots::affects_roots) { - let fresh = ide_workspace::roots::detect(&root); - if *detected_roots.read() != fresh { - // Detected excluded folders (`target`, `build`) leave the index too. - let effective = settings.effective(); - index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh)); - *detected_roots.write() = fresh; - sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); - } - } - // A repository was cloned / initialised / removed below the project: its - // `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went. - let known = repos.read().roots(); - let git_dirs = changed.iter().chain(&batch.removed).any(|p| { - let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten() - else { - return false; - }; - known.iter().any(|r| r == dir) != p.is_file() - }); - if git_dirs { - scan_repositories(&root, &repos, &settings, &sink); - } - sink.emit(IdeEvent::VcsChanged { root: root.clone().into() }); - }), - )? - }; - - // Scratch files live outside the project; watch them too so the - // Project view and open editors follow changes made there. - let scratch_watcher = { - let dir = self.data_dir.join("scratches"); - let documents = documents.clone(); - std::fs::create_dir_all(&dir).ok().and_then(|()| { - Watcher::start( - &dir, - Vec::new(), - sink.clone(), - Box::new(move |batch| { - let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); - documents.on_disk_changed(&changed); - }), - ) - .map_err(|e| tracing::warn!("scratches watcher: {e}")) - .ok() - }) - }; - - let content_watchers = extra_roots - .iter() - .filter_map(|r| { - let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?; - Some((r.clone(), w)) - }) - .collect(); - mark("watcher"); - let branch = repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head); - { - let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone()); - std::thread::Builder::new() - .name("openidea-vcs-roots".into()) - .spawn(move || scan_repositories(&root, &repos, &settings, &sink))?; - } - mark("git"); - self.recent.touch(&root, branch)?; - let display_name = settings.effective().general.project_name; + // A local project replaces a remote one in this window. + self.remote_dev.disconnect(window); + let project = self.host.open_project(window, root)?; + let branch = project.repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head); + self.recent.touch(&project.root, branch)?; + let display_name = project.settings.effective().general.project_name; if !display_name.trim().is_empty() { - self.recent.rename(&root, Some(display_name.trim()))?; + self.recent.rename(&project.root, Some(display_name.trim()))?; } - mark("recent"); - tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis()); - - let db = Arc::new(ide_db::DbManager::new( - root.clone(), - self.data_dir.join("db-passwords.json"), - self.data_dir.join("db-history.json"), - )); - let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key))); - let agents = Arc::new(ide_agent::AgentManager::new( - ide_agent::AgentPaths { - project_root: root.clone(), - store_dir: self.data_dir.join("agents").join(&project_key), - // Outside the app data dir: its path has spaces on macOS, which trips up build scripts. - worktrees_dir: dirs::home_dir() - .unwrap_or_else(|| self.data_dir.clone()) - .join(".openidea") - .join("worktrees") - .join(&project_key), - models_file: self.data_dir.join("agents").join("models.json"), - custom_agents_dir: Some(self.config_dir.join("agents")), - home_dir: dirs::home_dir(), - }, - tauri::async_runtime::handle().inner().clone(), - )); - let project = Arc::new(Project { - root, - sink, - languages, - settings, - documents, - history, - moves: FileMoves::new(), - index, - lsp, - repos, - structure: Arc::new(StructureService::new(syntax.clone())), - syntax, - detected_roots, - cache_dir: self.data_dir.join("cache").join(&project_key), - db, - http_history, - agents, - watcher, - content_watchers: Mutex::new(content_watchers), - _scratch_watcher: scratch_watcher, - }); - { - let weak = Arc::downgrade(&project); - project.lsp.set_candidates_hook(Box::new(move |q, cancel| { - weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default() - })); - } - self.projects.write().insert(window.to_owned(), project.clone()); Ok(project) } pub fn close_project(&self, window: &str) { - if let Some(project) = self.projects.write().remove(window) { - project.agents.shutdown(); - let lsp = project.lsp.clone(); - tauri::async_runtime::spawn(async move { lsp.shutdown_all().await }); - } - } - - // ---- cancellable requests: the UI allocates ids, `cancel_request` cancels ---- - - pub fn begin_request(&self, id: RequestId) -> CancellationToken { - let token = CancellationToken::new(); - if let Some(old) = self.requests.lock().insert(id, token.clone()) { - old.cancel(); - } - token - } - - pub fn end_request(&self, id: RequestId) { - self.requests.lock().remove(&id); - } - - pub fn cancel_request(&self, id: RequestId) { - if let Some(token) = self.requests.lock().remove(&id) { - token.cancel(); - } + self.remote_dev.disconnect(window); + self.host.close_project(window); } } -/// Looks for repositories nested below the project (and the one containing it); -/// emits `VcsChanged` when the set of roots changed. -fn scan_repositories(root: &Path, repos: &RwLock, settings: &SettingsStore, sink: &SharedSink) { - let ignored = settings.effective().ignored_names(); - let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy())); - match Repos::discover(root, &skip) { - Ok(found) => { - if found.roots() != repos.read().roots() { - *repos.write() = found; - sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() }); - } +/// Serves askpass prompts to the focused window and routes `git`'s prompts there. +fn start_askpass(app: &AppHandle) -> Option { + let exe = std::env::current_exe().ok()?; + let handle = app.clone(); + let on_prompt = Arc::new(move |p: ide_ssh::askpass::AskpassPrompt| { + // A focused Settings window hands the prompt to its project window. + let window = crate::windows::target_window(&handle).map(|w| AppState::owner_window(w.label()).to_owned()); + TauriSink { app: handle.clone(), window }.emit(IdeEvent::Askpass { id: p.id, prompt: p.prompt, kind: p.kind }); + }); + match ide_ssh::askpass::AskpassServer::start(exe, on_prompt) { + Ok(server) => { + ide_vcs::set_askpass_env(server.env()); + Some(server) } - Err(e) => tracing::warn!("looking for git repositories: {e}"), - } -} - -impl Project { - /// The project's git repositories. - pub fn repos(&self) -> Repos { - self.repos.read().clone() - } - - /// Runs `f` with the repository whose root is `at`, the project's primary repository - /// when `None`; fails when there is none (a repository-scoped command never falls back - /// to another repository). - pub fn with_repo_at(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - let repos = self.repos(); - match at { - Some(root) => match repos.at_root(root) { - Some(repo) => self.run_in(repo, f), - None => { - Err(IdeError::unavailable(format!("{} is not a git repository of the project", root.display()))) - } - }, - None => match repos.primary() { - Some(repo) => self.run_in(repo, f), - None => Err(IdeError::unavailable("project is not a git repository")), - }, - } - } - - /// Runs `f` with the repository `path` (a file or folder) belongs to. - pub fn with_repo_of(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - match self.repos().for_path(path) { - Some(repo) => self.run_in(repo, f), - None => Err(IdeError::unavailable(format!("{} is not in a git repository", path.display()))), - } - } - - fn run_in(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - let settings = self.settings.effective(); - crate::commands::vcs::apply_git_executable(&settings); - repo.set_ignored_names(settings.ignored_names()); - f(repo) - } - - /// Runs `f` with the project's primary git repository, or fails when the project is not under git. - pub fn with_repo(&self, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - self.with_repo_at(None, f) - } - - /// Runs a VCS operation that rewrites the working tree (checkout, merge, - /// rebase, pull, reset, stash, rollback): Local History gets a label and a - /// snapshot of every locally changed file before, and a label after. - /// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree). - pub fn with_repo_recorded( - &self, - at: Option<&Path>, - operation: Option<&str>, - f: impl FnOnce(&Repo) -> IdeResult, - ) -> IdeResult { - let Some(name) = operation else { return self.with_repo_at(at, f) }; - self.history_label(&format!("Before {name}")); - if let Ok(changed) = self.with_repo_at(at, |r| r.status()) { - for file in changed.iter().take(HISTORY_BATCH_LIMIT) { - if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) { - tracing::debug!("local history: {e}"); - } - } - } - let result = self.with_repo_at(at, f); - self.history_label(&format!("After {name}")); - result - } - - fn history_label(&self, label: &str) { - if let Err(e) = self.history.put_label(&self.root, label) { - tracing::warn!("local history: {e}"); - } - } - - /// Local History snapshots of `paths` before a refactoring changes them. - pub fn record_before_refactoring(&self, paths: &[&Path], name: &str) { - let label = format!("Before refactoring: {name}"); - for path in paths { - let result = match self.documents.text_of(path) { - Some(text) => self.history.record_event(path, &text, &label), - None => self.history.record_file(path, &label, true).map(|_| ()), - }; - if let Err(e) = result { - tracing::debug!("local history: {e}"); - } - } - } - - /// Effective source / test / resource / excluded roots (detected + Project Structure marks). - pub fn roots(&self) -> ProjectRoots { - ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure) - } - - /// Where the Maven / Gradle tool windows cache what the build tools reported. - pub fn build_cache_dir(&self) -> PathBuf { - self.cache_dir.join("build") - } - - /// File masks (`ide_search::FileMask`) for Find in Files and inspections: the - /// excluded roots, the ignored names and the "Exclude files" patterns (files, - /// and everything under such folders). - pub fn search_excludes(&self) -> Vec { - let mut out: Vec = - self.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect(); - let settings = self.settings.effective(); - let names = - settings.files.ignored_names.iter().cloned().chain(settings.project_structure.exclude_file_patterns()); - for name in names { - let name = name.trim(); - if !name.is_empty() && !name.contains(['/', ',']) { - out.push(name.to_owned()); - out.push(format!("**/{name}/**")); - } - } - out - } - - /// Project files of `q.languages` under `q.root` containing `q.word` as a whole - /// word (unsaved buffers included; excluded roots, ignored names and - /// `.gitignore`d files skipped): the files a references search opens on its server. - pub fn reference_candidates( - &self, - q: &ide_lsp::CandidateQuery, - cancel: &CancellationToken, - ) -> ide_lsp::CandidateFiles { - if !q.root.starts_with(&self.root) { - return ide_lsp::CandidateFiles::default(); - } - let query = ide_search::WordQuery { - word: q.word.clone(), - dir: q.root.clone(), - exclude: self.search_excludes(), - limit: q.limit, - }; - let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None)); - match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) { - Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit }, - Err(e) => { - tracing::debug!("reference candidates: {}", e.message); - ide_lsp::CandidateFiles::default() - } - } - } - - /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). - pub fn exclude_globs(&self) -> Vec { - exclude_globs(&self.settings.effective(), &self.detected_roots.read()) - } - - /// Project Structure content roots besides the project folder (existing directories). - pub fn content_roots(&self) -> Vec { - content_roots(&self.settings.effective(), &self.root) - } - - /// Applies Settings › File Types; open documents whose language changed are - /// re-detected (language servers and syntax trees follow) and the index - /// re-parses the files whose language changed. - pub fn apply_file_types(&self, settings: &ide_core::settings::Settings) { - let before = Arc::new(self.languages.snapshot()); - if self.languages.set_file_types(FileTypes::from_settings(&settings.files, Some(&self.root))) { - self.documents.redetect_languages(); - self.index.languages_changed(before); - } - } - - /// Applies changed content roots and exclude settings to the index (which - /// rescans in the background) and the file watchers, without reopening the project. - pub fn refresh_index_scope(&self) { - let (roots, excludes) = (self.content_roots(), self.exclude_globs()); - self.index.set_scope(roots.clone(), excludes.clone()); - self.watcher.set_excludes(&excludes); - let mut watchers = self.content_watchers.lock(); - watchers.retain(|(p, _)| roots.contains(p)); - for (_, w) in watchers.iter() { - w.set_excludes(&excludes); - } - for r in roots { - if watchers.iter().any(|(p, _)| *p == r) { - continue; - } - if let Some(w) = - content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history) - { - watchers.push((r, w)); - } - } - } - - /// Current text of `path`: the open buffer when available, else the file on disk. - pub fn text_of(&self, path: &Path) -> IdeResult { - match self.documents.text_of(path) { - Some(text) => Ok(text), - None => Ok(ide_vfs::load_text(path)?.text), - } - } -} - -/// Applies a multi-file edit through the document layer so open editors update live. -pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> { - use ide_core::FileEdit; - for file in &edit.files { - match file { - FileEdit::Edit { path, edits } => { - // Files not open in an editor are opened transiently and closed again. - let (doc, transient) = match documents.by_path(path) { - Some(doc) => (doc, false), - None => (documents.open(path)?.id, true), - }; - let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc)); - if transient { - documents.close(doc); - } - result?; - } - FileEdit::Create { path, overwrite } => { - if path.exists() { - if *overwrite { - ide_vfs::save_text(path, "", "utf-8", false)?; - } - } else { - ide_vfs::create_file(path)?; - } - } - FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?, - FileEdit::Delete { path, .. } => ide_vfs::delete(path)?, - } - } - Ok(()) -} - -/// Files the watcher reports per batch that Local History snapshots. -const HISTORY_BATCH_LIMIT: usize = 500; - -/// Local History for changes made outside the IDE (other tools, VCS, deletions). -/// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped. -fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) { - for path in changed.iter().take(HISTORY_BATCH_LIMIT) { - if let Err(e) = history.record_file(path, "External change", false) { - tracing::debug!("local history: {e}"); - } - } - if !removed.is_empty() - && let Err(e) = history.note_removed(removed) - { - tracing::debug!("local history: {e}"); - } -} - -pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspace::Retention { - ide_workspace::Retention { - working_days: settings.files.local_history_days, - max_bytes: u64::from(settings.files.local_history_max_mb.max(1)) * 1024 * 1024, - } -} - -/// Project Structure content roots outside `root` (existing absolute directories). -fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec { - let mut out: Vec = Vec::new(); - for r in &settings.project_structure.content_roots { - let p = PathBuf::from(r.trim()); - if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) { - out.push(p); - } - } - out -} - -/// Watches a content root outside the project: the index, open documents and -/// Local History follow its changes like the project folder's. -fn content_root_watcher( - dir: &Path, - excludes: &[String], - sink: &SharedSink, - index: &Arc, - documents: &Arc, - history: &Arc, -) -> Option { - let (index, documents, history) = (index.clone(), documents.clone(), history.clone()); - Watcher::start( - dir, - excludes.to_vec(), - sink.clone(), - Box::new(move |batch| { - let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); - index.update(&changed, &batch.removed); - documents.on_disk_changed(&changed); - record_external_changes(&history, &changed, &batch.removed); - }), - ) - .map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display())) - .ok() -} - -fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec { - let mut globs = settings.file_excludes(); - globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs()); - globs -} - -fn project_key(root: &Path) -> String { - // Stable FNV-1a directory name per project for caches. - let s = root.to_string_lossy(); - let mut h: u64 = 0xcbf29ce484222325; - for b in s.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - format!("{h:016x}") -} - -struct LspObserver(Arc); - -impl DocumentObserver for LspObserver { - fn opened(&self, info: &DocumentInfo) { - // Language servers choke on huge files (IDEA disables code insight too); - // changes to documents never opened on a server are ignored. - if info.large { - return; - } - match &info.uri { - Some(uri) => self.0.did_open_virtual(info.id, uri, &info.language, info.version, &info.text), - None => self.0.did_open(info.id, &info.path, &info.language, info.version, &info.text), - } - } - fn changed( - &self, - doc: ide_core::DocId, - _path: &Path, - version: u32, - changes: &[AppliedChange], - full_text: &dyn Fn() -> String, - ) { - self.0.did_change(doc, version, changes, full_text); - } - fn saved(&self, doc: ide_core::DocId, _path: &Path, text: &str) { - self.0.did_save(doc, text); - } - fn closed(&self, doc: ide_core::DocId, _path: &Path) { - self.0.did_close(doc); - } -} - -/// Feeds document edits into the per-project syntax tree cache. -struct SyntaxObserver(Arc); - -impl DocumentObserver for SyntaxObserver { - fn opened(&self, info: &DocumentInfo) { - if !info.large { - self.0.open(info.id, &info.language, info.version); - } - } - fn edited(&self, doc: ide_core::DocId, version: u32, edits: &[ByteEdit]) { - self.0.edit(doc, version, edits); - } - fn closed(&self, doc: ide_core::DocId, _path: &Path) { - self.0.close(doc); - } -} - -struct HistoryObserver(Arc); - -impl DocumentObserver for HistoryObserver { - fn saved(&self, _doc: ide_core::DocId, path: &Path, text: &str) { - if let Err(e) = self.0.record(path, text, "Saved") { - tracing::warn!("local history: {e}"); + Err(e) => { + tracing::warn!("askpass unavailable: {}", e.message); + None } } } diff --git a/src/bindings.ts b/src/bindings.ts index 7ce47b757..2114830d4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -805,6 +805,70 @@ export const commands = { * "Unknown SSH host key" (records it in `~/.ssh/known_hosts`). */ dbTrustSshHost: (id: string) => typedError(__TAURI_INVOKE("db_trust_ssh_host", { id })), + /** Test Connection: connects with `config` as edited (not necessarily saved). */ + sshTest: (config: SshConfiguration, secret: string | null) => typedError(__TAURI_INVOKE("ssh_test", { config, secret })), + /** Remembers the password / passphrase of configuration `id` (`None` forgets it). */ + sshSetSecret: (id: string, secret: string | null, save: boolean) => __TAURI_INVOKE("ssh_set_secret", { id, secret, save }), + /** Whether connecting with configuration `id` needs a password first. */ + sshNeedsSecret: (id: string) => typedError(__TAURI_INVOKE("ssh_needs_secret", { id })), + /** Trusts the host key that failed the last connection of configuration `id`. */ + sshTrustHost: (id: string) => typedError(__TAURI_INVOKE("ssh_trust_host", { id })), + /** Ids of the configurations with an open connection. */ + sshConnected: () => __TAURI_INVOKE("ssh_connected"), + sshDisconnect: (id: string) => __TAURI_INVOKE("ssh_disconnect", { id }), + /** Answers an `IdeEvent::Askpass` prompt (`None`: cancelled); every window drops it. */ + sshAskpassAnswer: (id: number, answer: string | null) => __TAURI_INVOKE("ssh_askpass_answer", { id, answer }), + /** + * Tools › Start SSH Session: the system `ssh` command line for configuration `id` + * (run in a terminal tab, so prompts and host-key questions are answered there). + */ + sshTerminalCommand: (id: string) => typedError(__TAURI_INVOKE("ssh_terminal_command", { id })), + /** Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). */ + deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_upload", { paths, server, request })), + /** Download from…: the remote counterparts of files and folders. */ + deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_download", { paths, server, request })), + /** Deletes the remote counterparts of files and folders. */ + deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError(__TAURI_INVOKE("deploy_delete_remote", { paths, server })), + /** Sync with Deployed: local and remote files under `path`, side by side. */ + deployCompare: (path: IpcPath, server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_compare", { path, server, request })), + /** Applies the Sync with Deployed choices. */ + deploySync: (steps: SyncStep[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_sync", { steps, server, request })), + /** Where `path` is deployed (remote path, web URL); `None` when not mapped. */ + deployInfo: (path: IpcPath, server: string | null) => typedError<{ + server: string, + remotePath: string, + webUrl: string | null, +} | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })), + /** Remote Host tool window: the server root of `server`, resolved on the host. */ + deployServerRoot: (server: string) => typedError(__TAURI_INVOKE("deploy_server_root", { server })), + /** Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). */ + remoteList: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_list", { ssh, path })), + /** Opens a remote file for editing: a local copy, uploaded back on save. */ + remoteOpen: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_open", { ssh, path })), + /** The remote file a local copy stands for: (SSH configuration id, remote path). */ + remoteOf: (path: IpcPath) => __TAURI_INVOKE<[string, string] | null>("remote_of", { path }), + remoteDelete: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_delete", { ssh, path })), + remoteMkdir: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { ssh, path })), + remoteRename: (ssh: string, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { ssh, from, to })), + /** + * File › Remote Development: opens `path` on the host of SSH configuration + * `ssh_config` in this window (installs / starts the server as needed). + */ + remoteConnect: (sshConfig: string, path: string, onProgress: Channel) => typedError(__TAURI_INVOKE("remote_connect", { sshConfig, path, onProgress })), + /** The remote session of this window (a settings window: its parent's), if any. */ + remoteCurrent: () => __TAURI_INVOKE<{ + sshConfig: string, + root: string, + host: string, + commands: string[], +} | null>("remote_current"), + remoteDisconnect: () => __TAURI_INVOKE("remote_disconnect"), + /** + * Runs a command on the window's remote server; `args` and the result are JSON + * text. Streaming arguments are `{ "$channel": id }`; their values arrive on + * `on_channel` tagged with the id. + */ + remoteInvoke: (cmd: string, args: string, onChannel: Channel) => typedError(__TAURI_INVOKE("remote_invoke", { cmd, args, onChannel })), /** Test Connection: connects with unsaved settings and returns the server version. */ dbTest: (source: DataSource, password: string | null, sshSecret: string | null) => typedError(__TAURI_INVOKE("db_test", { source, password, sshSecret })), dbConnected: () => typedError(__TAURI_INVOKE("db_connected")), @@ -1127,6 +1191,15 @@ export type ArtifactKey = { artifactId: string, }; +/** What an askpass prompt asks for. */ +export type AskpassKind = +/** Password or passphrase: hidden input. */ +"secret" | +/** User name: visible input. */ +"text" | +/** Yes / no (an unknown host key); answered with `yes` or `no`. */ +"confirm"; + export type AttachKind = "java" | "node" | "python" | "go" | "native"; export type AttachableProcess = { @@ -1148,6 +1221,13 @@ export type AutoImportSettings = { optimizeOnTheFly?: boolean, }; +/** When local changes are uploaded to the default deployment server. */ +export type AutoUpload = "never" | +/** Files saved with Save / Save All (⌘S). */ +"onExplicitSave" | +/** Every save, including autosave and external changes. */ +"always"; + /** A step run before a configuration launches; a failing step aborts the launch. */ export type BeforeLaunchTask = { /** @@ -1340,6 +1420,12 @@ export type ChangelistState = { files?: { [key in string]: string }, }; +/** A value for one streaming argument of a remote command (`data`: JSON text). */ +export type ChannelFrame = { + id: number, + data: string, +}; + export type CheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped" | "neutral"; /** @@ -1912,24 +1998,19 @@ export type DbSortKey = { /** Connect through an SSH tunnel: the database host and port are reached from `host`. */ export type DbSsh = { enabled?: boolean, + /** A Settings › SSH Configurations id to connect with; empty = the fields below. */ + config?: string, /** Host name, or a `Host` alias of `~/.ssh/config` with [`DbSshAuth::OpenSsh`]. */ host?: string, port?: number | null, user?: string, - auth?: DbSshAuth, + auth?: SshAuth, /** Private key for [`DbSshAuth::KeyPair`]; `~/` is the home directory. */ keyFile?: string, /** Keep the SSH password / key passphrase in the saved passwords. */ saveSecret?: boolean, }; -/** How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type"). */ -export type DbSshAuth = "password" | -/** A private key file, optionally protected by a passphrase. */ -"keyPair" | -/** `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. */ -"openSsh"; - export type DbSslMode = "disable" | "prefer" | "require" | /** Require, and check the server certificate against the CA file. */ "verifyCa" | @@ -2028,6 +2109,51 @@ export type DeletedBranch = { unmerged: boolean, }; +/** Where a local file is deployed. */ +export type DeployedPath = { + server: string, + remotePath: string, + webUrl: string | null, +}; + +export type DeploymentMapping = { + /** Project-relative folder; empty = the project root. */ + local?: string, + /** Remote folder, relative to the server root (a leading `/` is the root too). */ + deployment?: string, + /** Web path, relative to the server's URL. */ + web?: string, +}; + +/** An SFTP server and how project folders map onto it. */ +export type DeploymentServer = { + id?: string, + name?: string, + /** [`SshConfiguration::id`]. */ + sshConfig?: string, + /** Absolute remote folder the mappings are relative to; empty = the login directory. */ + rootPath?: string, + /** Web server URL of the root (Open in Browser on the deployed file). */ + webUrl?: string, + mappings?: DeploymentMapping[], + /** Remote paths (relative to the root) never touched. */ + excludedPaths?: string[], +}; + +/** Build, Execution, Deployment › Deployment (and its Options page). */ +export type DeploymentSettings = { + servers?: DeploymentServer[], + /** Server used by Upload / Download / Sync and automatic upload; empty = none. */ + defaultServer?: string, + autoUpload?: AutoUpload, + /** Delete remote files when their local file is deleted (automatic upload). */ + deleteRemote?: boolean, + /** Keep local modification times on uploaded files. */ + preserveTimestamps?: boolean, + /** File names never uploaded or downloaded (`*` and `?` wildcards; `;`-separated in the UI). */ + exclude?: string[], +}; + export type Diagnostic = { range: Range, severity: Severity, @@ -2616,6 +2742,11 @@ export type FileSettings = { fileTypeOverrides?: { [key in string]: string }, }; +export type FileStamp = { + size: number, + mtime: number | null, +}; + export type FileStatus = { path: IpcPath, relPath: string, @@ -2961,7 +3092,11 @@ export type IdeEvent = * The project's source / test / resource / excluded roots changed (build files * changed or folders were marked); the UI re-reads them with `project_roots`. */ -{ type: "projectRootsChanged"; root: IpcPath }; +{ type: "projectRootsChanged"; root: IpcPath } | +/** A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`. */ +{ type: "askpass"; id: number; prompt: string; kind: AskpassKind } | +/** Prompt `id` was answered (in any window) or gave up. */ +{ type: "askpassDone"; id: number }; /** An identifier-like token whose text equals the searched name. */ export type IdentifierOccurrence = { @@ -4248,6 +4383,20 @@ export type RemoteEdit = { op: "add"; name: string; url: string } | { op: "remov /** Renames `name` to `new_name` (when different) and sets its URL. */ { op: "edit"; name: string; new_name: string; url: string }; +/** A remote directory entry. */ +export type RemoteEntry = { + name: string, + /** Absolute remote path. */ + path: string, + isDir: boolean, + isSymlink: boolean, + size: number, + /** Seconds since the Unix epoch. */ + mtime: number | null, + /** `rwxr-xr-x`. */ + permissions: string, +}; + export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | { op: "push"; force_with_lease: boolean; set_upstream: boolean; /** Push dialog › Push tags. */ tags?: PushTags | null } | @@ -4263,6 +4412,47 @@ tags?: PushTags | null } | /** Pull dialog: `branches` of `remote` (none = the configured merge branch). */ { op: "pullFrom"; remote: string; branches: string[]; rebase: boolean; options: MergeOptions }; +export type RemoteProgress = { + stage: RemoteStage, + fraction: number | null, +}; + +export type RemoteProject = RemoteProject_Serialize | RemoteProject_Deserialize; + +export type RemoteProject_Deserialize = { + sshConfig: string, + root: string, + /** `user@host:port` of the SSH configuration. */ + host: string, + serverVersion: string, + os: string, + /** Commands the server answers (the rest run locally). */ + commands: string[], + project: ProjectInfo_Deserialize, +}; + +export type RemoteProject_Serialize = { + sshConfig: string, + root: string, + /** `user@host:port` of the SSH configuration. */ + host: string, + serverVersion: string, + os: string, + /** Commands the server answers (the rest run locally). */ + commands: string[], + project: ProjectInfo_Serialize, +}; + +export type RemoteSession = { + sshConfig: string, + root: string, + host: string, + commands: string[], +}; + +/** Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload). */ +export type RemoteStage = "connecting" | "checking" | "downloading" | "uploading" | "starting" | "opening"; + export type ReplaceEvent = /** A file was changed (`replacements` > 0) or had nothing left to replace. */ { type: "file"; path: IpcPath; replacements: number; done: number; total: number } | { type: "failed"; path: IpcPath; message: string; done: number; total: number } | { type: "done"; summary: ReplaceSummary }; @@ -4394,6 +4584,8 @@ export type RunExtras_Deserialize = { * with the run's working directory and environment. */ stopCommand?: string | null, + /** Settings › Run Targets id the process runs on (over SSH); `None` = this machine. */ + target?: string | null, }; export type RunExtras_Serialize = { @@ -4429,6 +4621,8 @@ export type RunExtras_Serialize = { * with the run's working directory and environment. */ stopCommand?: string | null, + /** Settings › Run Targets id the process runs on (over SSH); `None` = this machine. */ + target?: string | null, }; /** A process started from a run configuration. */ @@ -4454,6 +4648,22 @@ export type RunSettings = { terminationTimeoutMs?: number, }; +/** Build, Execution, Deployment › Run Targets: run configurations on another machine. */ +export type RunTarget = { + id?: string, + name?: string, + /** [`SshConfiguration::id`]. */ + sshConfig?: string, + /** Remote folder the project is synchronised to and run from. */ + projectRoot?: string, + /** Upload changed project files before each run. */ + syncBeforeRun?: boolean, + /** Interpreters on the target; empty = the one on the remote `PATH`. */ + python?: string, + node?: string, + java?: string, +}; + export type RustSettings = { /** Directory holding `cargo` / `rustc`; empty = `PATH` / rustup default. */ toolchain?: string, @@ -4653,6 +4863,12 @@ export type Settings_Deserialize = { inspections?: InspectionSettings, /** Editor › Natural Languages › Spelling. */ spelling?: SpellingSettings, + /** Tools › SSH Configurations. */ + ssh?: SshSettings, + /** Build, Execution, Deployment › Deployment. */ + deployment?: DeploymentSettings, + /** Build, Execution, Deployment › Run Targets. */ + runTargets?: RunTarget[], }; export type Settings_Serialize = { @@ -4695,6 +4911,12 @@ export type Settings_Serialize = { inspections: InspectionSettings, /** Editor › Natural Languages › Spelling. */ spelling: SpellingSettings, + /** Tools › SSH Configurations. */ + ssh: SshSettings, + /** Build, Execution, Deployment › Deployment. */ + deployment: DeploymentSettings, + /** Build, Execution, Deployment › Run Targets. */ + runTargets: RunTarget[], }; export type Severity = "error" | "warning" | "info" | "hint"; @@ -4779,6 +5001,39 @@ export type SqlStatement = { text: string, }; +/** How an SSH connection authenticates. */ +export type SshAuth = "password" | +/** A private key file, optionally protected by a passphrase. */ +"keyPair" | +/** `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. */ +"openSsh"; + +/** A named SSH host, shared by terminals, deployment, run targets and data sources. */ +export type SshConfiguration = { + id?: string, + name?: string, + /** Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`]. */ + host?: string, + port?: number | null, + user?: string, + auth?: SshAuth, + /** Private key for [`SshAuth::KeyPair`]; `~/` is the home directory. */ + keyFile?: string, + /** Keep the password / key passphrase in the saved passwords. */ + saveSecret?: boolean, +}; + +/** Tools › SSH Configurations. */ +export type SshSettings = { + configs?: SshConfiguration[], +}; + +export type SshTerminalCommand = { + shell: string, + args: string[], + title: string, +}; + export type StackFrame = { id: number, name: string, @@ -4905,12 +5160,34 @@ export type SymbolMatch = { indices: number[], }; +export type SyncAction = "upload" | "download" | "deleteRemote" | "deleteLocal"; + +/** One file of Sync with Deployed. */ +export type SyncEntry = { + /** Project-relative, `/`-separated. */ + path: string, + remotePath: string, + local: FileStamp | null, + remote: FileStamp | null, + status: SyncStatus, +}; + export type SyncProgress = { message: string, /** 0..1, or `None` when indeterminate. */ fraction: number | null, }; +export type SyncStatus = "localOnly" | "remoteOnly" | "localNewer" | "remoteNewer" | +/** Same time, different size. */ +"different" | "same"; + +export type SyncStep = { + /** Project-relative, `/`-separated. */ + path: string, + action: SyncAction, +}; + /** Counters for diagnostics (`app_metrics`). */ export type SyntaxCacheStats = { /** Documents tracked (open and observed). */ @@ -5148,6 +5425,18 @@ export type ToolStatus = /** The executable chosen in settings. */ "custom" | "missing"; +export type TransferError = { + path: string, + message: string, +}; + +export type TransferReport = { + files: number, + /** Files left alone: excluded, or outside every mapping. */ + skipped: number, + errors: TransferError[], +}; + export type TypeScriptSettings = { /** * `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`), diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css index e2eb069e5..b52ea5300 100644 --- a/src/features/agents/Agents.module.css +++ b/src/features/agents/Agents.module.css @@ -9,6 +9,14 @@ min-height: 0; } +.loading { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-muted); +} + .row { display: flex; align-items: center; diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx index c4bd39177..04e2a5a5c 100644 --- a/src/features/agents/AgentsView.tsx +++ b/src/features/agents/AgentsView.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import type { AgentStatus as Status, AgentTask } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { confirmChecked } from "@/workbench/messages"; -import { Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui"; +import { EmptyState, Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui"; import { age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model"; import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store"; import styles from "./Agents.module.css"; @@ -35,13 +35,20 @@ export function TaskStatusIcon({ status }: { status: Status }) { export function AgentsView() { const loaded = useAgents((s) => s.loaded); + const error = useAgents((s) => s.error); const tasks = useAgents((s) => s.tasks); const providers = useAgents((s) => s.providers); const [collapsed, setCollapsed] = useState>(() => new Set(["g:archived"])); const [selected, setSelected] = useState>(() => new Set()); const roots = useMemo(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]); const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]); - if (!loaded) return ; + if (!loaded) + return ( +
+ +
+ ); + if (error) return {error}; const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id; return ( diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 9606211ba..aa31e2ad3 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -31,6 +31,8 @@ export const HOME = "home"; interface AgentsState { loaded: boolean; + /** Why the tasks could not be loaded. */ + error: string | null; tasks: Map; /** Entries of the tasks opened in this window. */ entries: Map; @@ -69,6 +71,7 @@ const fallbackDraft: Draft = { provider: "claude", config: {}, isolation: "workt export const useAgents = create(() => ({ loaded: false, + error: null, tasks: new Map(), entries: new Map(), changes: new Map(), @@ -111,13 +114,17 @@ export async function connect(): Promise { channel.onmessage = (e) => { if (gen === generation) onEvent(e); }; - const [tasks, providers] = await Promise.all([ - call(commands.agentSubscribe(channel)), - call(commands.agentProviders()), - ]); + let tasks: AgentTask[], providers: AgentProvider[]; + try { + [tasks, providers] = await Promise.all([call(commands.agentSubscribe(channel)), call(commands.agentProviders())]); + } catch (e) { + if (gen === generation) useAgents.setState({ loaded: true, error: e instanceof Error ? e.message : String(e) }); + return; + } if (gen !== generation) return; useAgents.setState({ loaded: true, + error: null, tasks: new Map(tasks.map((t) => [t.id, t])), entries: new Map(), changes: new Map(), @@ -133,7 +140,7 @@ export async function connect(): Promise { export function disconnect() { generation++; - useAgents.setState({ loaded: false, tasks: new Map(), entries: new Map(), changes: new Map() }); + useAgents.setState({ loaded: false, error: null, tasks: new Map(), entries: new Map(), changes: new Map() }); } function onEvent(e: AgentEvent) { diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 2a0b1915c..45ed7bf60 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -9,7 +9,7 @@ */ import { commands, isTauri, onIdeEvent, type Settings } from "@/platform/ipc"; import { t } from "@/platform/i18n"; -import { provideLeaveProjectGuard, useProject } from "@/platform/project"; +import { closeProject, provideLeaveProjectGuard, useProject } from "@/platform/project"; import { maybeEditorService, notifications } from "@/workbench/contributions"; import { ask, type Ask } from "@/workbench/messages"; @@ -99,6 +99,12 @@ async function onCloseRequested(deps: CloseDeps): Promise { try { const ctx = await commands.windowCloseContext(); const s = useProject.getState(); + // Closing the last project window closes its project and shows the welcome screen + // (to open another one); closing the welcome screen, or quitting, closes the window. + if (!ctx.quitting && ctx.windows <= 1 && s.project) { + if (await confirmLeave(deps)) await closeProject(); + return false; + } const ok = await decideClose( { quitting: ctx.quitting, diff --git a/src/features/database/SourceDialog.tsx b/src/features/database/SourceDialog.tsx index aadf563bc..2abff8b48 100644 --- a/src/features/database/SourceDialog.tsx +++ b/src/features/database/SourceDialog.tsx @@ -13,13 +13,26 @@ import { type DbOptions, type DbProperty, type DbSsh, - type DbSshAuth, + type SshAuth, type DbSslMode, type DbTxMode, } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; import { tx, useTx, useTxTemplate } from "@/platform/i18n"; +import { sshLabel as configLabel } from "@/platform/remoteSettings"; +import { useWorkflowSettings } from "@/platform/workflowSettings"; import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui"; -import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, sshLabel } from "./model"; +import { + colorVar, + connectRemedy, + effectiveSsh, + fileSourceName, + newSourceId, + parseSourceUrl, + sshConfigOf, + sshLabel, + sshTunnelReady, +} from "./model"; import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store"; import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors"; import styles from "./Database.module.css"; @@ -52,7 +65,7 @@ const TX: Array> = [ { value: "manual", label: "Manual" }, ]; -const SSH_AUTH: Array> = [ +const SSH_AUTH: Array> = [ { value: "openSsh", label: "OpenSSH config and authentication agent" }, { value: "password", label: "Password" }, { value: "keyPair", label: "Key pair" }, @@ -340,6 +353,11 @@ export function SourceDialog({ const set = (patch: Partial) => setS((x) => ({ ...x, ...patch })); const ssh: DbSsh = { ...blankSsh(), ...s.ssh }; + const sshConfigs = useWorkflowSettings((w) => w.ssh.configs); + const sshConfigId = ssh.config?.trim() ?? ""; + const sshConfig = sshConfigOf(ssh, sshConfigs); + // The host / user / authentication in effect (the chosen SSH configuration's). + const sshUsed: DbSsh = effectiveSsh(ssh, sshConfigs) ?? ssh; const setSsh = (patch: Partial) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } })); const options: DbOptions = { ...blankOptions(), ...s.options }; const setOptions = (patch: Partial) => @@ -362,7 +380,9 @@ export function SourceDialog({ const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`); const sshOn = !sqlite && !!ssh.enabled; - const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || !!ssh.host?.trim()); + const valid = sqlite + ? !!s.file?.trim() + : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || sshTunnelReady(ssh, sshConfigs)); const sslOn = (s.ssl ?? "prefer") !== "disable"; /** What is saved / tested: a preset's port when the field was left empty. */ @@ -408,7 +428,7 @@ export function SourceDialog({ setTest({ ok: true, text: tx`Succeeded: ${v}` }); } catch (e) { const text = e instanceof Error ? e.message : String(e); - const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey"; + const trust = e instanceof IpcError && connectRemedy(e.kind, e.message, { ssh: sshUsed }) === "trustHostKey"; setTest({ ok: false, text, trust }); } finally { setBusy(false); @@ -416,7 +436,8 @@ export function SourceDialog({ }; const trustAndRetry = async () => { - await call(commands.dbTrustSshHost(s.id ?? "")); + // A shared SSH configuration records the failed host key under its own id. + await call(sshConfigId ? commands.sshTrustHost(sshConfigId) : commands.dbTrustSshHost(s.id ?? "")); await runTest(); }; @@ -426,7 +447,7 @@ export function SourceDialog({ await saveSource( { ...effective(), properties: properties.filter((p) => p.name?.trim()) }, sqlite ? undefined : password === undefined ? undefined : password || null, - !sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null, + !sshOn || sshSecret === undefined || sshUsed.auth === "openSsh" ? undefined : sshSecret || null, ); closeDialog(); } catch (e) { @@ -513,7 +534,7 @@ export function SourceDialog({ {sshOn && ( <> - Reached from the SSH host {sshLabel({ ssh })}. + Reached from the SSH host {sshLabel({ ssh: sshUsed })}. )}