diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1b20b90ea..fa0bc7034 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,68 +66,22 @@ jobs: java-version: 17 cache: gradle - # Set environment variables - - name: Export Properties - id: properties - shell: bash - run: | - PROPERTIES="$(./gradlew properties --console=plain -q)" - VERSION="$(echo "$PROPERTIES" | grep "^version:" | cut -f2- -d ' ')" - NAME="$(echo "$PROPERTIES" | grep "^pluginName:" | cut -f2- -d ' ')" - CHANGELOG="$(./gradlew getChangelog --unreleased --no-header --console=plain -q)" - CHANGELOG="${CHANGELOG//'%'/'%25'}" - CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" - CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" - echo "::set-output name=version::$VERSION" - echo "::set-output name=name::$NAME" - echo "::set-output name=changelog::$CHANGELOG" - echo "::set-output name=pluginVerifierHomeDir::~/.pluginVerifier" - ./gradlew listProductsReleases # prepare list of IDEs for Plugin Verifier - # Run plugin build - name: Run Build - run: ./gradlew clean buildPlugin --info - -# until https://github.com/JetBrains/gradle-intellij-plugin/issues/1027 is solved - -# # Cache Plugin Verifier IDEs -# - name: Setup Plugin Verifier IDEs Cache -# uses: actions/cache@v2.1.7 -# with: -# path: ${{ steps.properties.outputs.pluginVerifierHomeDir }}/ides -# key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} -# -# # Run Verify Plugin task and IntelliJ Plugin Verifier tool -# - name: Run Plugin Verification tasks -# run: ./gradlew runPluginVerifier -Pplugin.verifier.home.dir=${{ steps.properties.outputs.pluginVerifierHomeDir }} -# -# # Collect Plugin Verifier Result -# - name: Collect Plugin Verifier Result -# if: ${{ always() }} -# uses: actions/upload-artifact@v4 -# with: -# name: pluginVerifier-result -# path: ${{ github.workspace }}/build/reports/pluginVerifier + run: ./gradlew clean build --info # Run Qodana inspections - name: Qodana - Code Inspection uses: JetBrains/qodana-action@v2023.3.2 - # Prepare plugin archive content for creating artifact - - name: Prepare Plugin Artifact - id: artifact - shell: bash - run: | - cd ${{ github.workspace }}/build/distributions - FILENAME=`ls *.zip` - unzip "$FILENAME" -d content - echo "::set-output name=filename::${FILENAME:0:-4}" # Store already-built plugin as an artifact for downloading - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.artifact.outputs.filename }} - path: ./build/distributions/content/*/* + # TODO: Need a modified copyPlugin task or something like that to copy all + # the required jar files. + #- name: Upload artifact + # uses: actions/upload-artifact@v4 + # with: + # name: ${{ steps.artifact.outputs.filename }} + # path: ./build/distributions/content/*/* # Prepare a draft release for GitHub Releases page for the manual verification # If accepted and published, release workflow would be triggered @@ -142,24 +96,26 @@ jobs: - name: Fetch Sources uses: actions/checkout@v4.1.7 + # TODO: If we keep the two plugins in the same repository, we need a way + # to differentiate the tags and releases. # Remove old release drafts by using the curl request for the available releases with draft flag - name: Remove Old Release Drafts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh api repos/{owner}/{repo}/releases \ - --jq '.[] | select(.draft == true) | .id' \ - | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} + run: echo "Not implemented" ; exit 1 #| + #gh api repos/{owner}/{repo}/releases \ + # --jq '.[] | select(.draft == true) | .id' \ + # | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} # Create new release draft - which is not publicly visible and requires manual acceptance - name: Create Release Draft env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create v${{ needs.build.outputs.version }} \ - --draft \ - --target ${GITHUB_REF_NAME} \ - --title "v${{ needs.build.outputs.version }}" \ - --notes "$(cat << 'EOM' - ${{ needs.build.outputs.changelog }} - EOM - )" + run: echo "Not implemented" ; exit 1 #| + #gh release create v${{ needs.build.outputs.version }} \ + # --draft \ + # --target ${GITHUB_REF_NAME} \ + # --title "v${{ needs.build.outputs.version }}" \ + # --notes "$(cat << 'EOM' + #${{ needs.build.outputs.changelog }} + #EOM + #)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f5355a9c..021da5f69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,63 +27,20 @@ jobs: java-version: 17 cache: gradle - # Set environment variables - - name: Export Properties - id: properties - shell: bash - run: | - CHANGELOG="$(cat << 'EOM' | sed -e 's/^[[:space:]]*$//g' -e '/./,$!d' - ${{ github.event.release.body }} - EOM - )" - - CHANGELOG="${CHANGELOG//'%'/'%25'}" - CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" - CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" - - echo "::set-output name=changelog::$CHANGELOG" - - # Update Unreleased section with the current release note - - name: Patch Changelog - if: ${{ steps.properties.outputs.changelog != '' }} - env: - CHANGELOG: ${{ steps.properties.outputs.changelog }} - run: | - ./gradlew patchChangelog --release-note="$CHANGELOG" - # Publish the plugin to the Marketplace + # TODO@JB: Not sure if Toolbox will go to the same marketplace. - name: Publish Plugin env: PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} - run: ./gradlew publishPlugin --info + run: echo "Not implemented" ; exit 1 #./gradlew publishPlugin --info # Upload artifact as a release asset + # TODO: Need a modified copyPlugin task or something like that to copy all + # the required jar files. - name: Upload Release Asset env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* - - # Create pull request - - name: Create Pull Request - if: ${{ steps.properties.outputs.changelog != '' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ github.event.release.tag_name }}" - BRANCH="changelog-update-$VERSION" - - git config user.email "action@github.com" - git config user.name "GitHub Action" - - git checkout -b $BRANCH - git commit -am "Changelog update - $VERSION" - git push --set-upstream origin $BRANCH - - gh pr create \ - --title "Changelog update - \`$VERSION\`" \ - --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ - --base main \ - --head $BRANCH \ No newline at end of file + run: echo "Not implemented" ; exit 1 #gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* diff --git a/CHANGELOG.md b/CHANGELOG.md index cf398b99b..1f4eb10fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,570 +1,5 @@ -<!-- Keep a Changelog guide -> https://keepachangelog.com --> +<!-- Keep a changelog guide: https://keepachangelog.com --> -# coder-gateway Changelog +# jetbrains-toolbox-coder changelog ## Unreleased - -### Fixed - -- When the `CODER_URL` environment variable is set but you connect to a - different URL in Gateway, force the Coder CLI used in the SSH proxy command to - use the current URL instead of `CODER_URL`. This fixes connection issues such - as "failed to retrieve IDEs". To aply this fix, you must add the connection - again through the "Connect to Coder" flow or by using the dashboard link (the - recent connections do not reconfigure SSH). - -### Changed - -- The "Recents" view has been updated to have a new flow. Before, there were - separate controls for managing the workspace and then you could click a link - to launch a project (clicking a link would also start a stopped workspace - automatically). Now, there are no workspace controls, just links which start - the workspace automatically when needed. The links are enabled when the - workspace is STOPPED, CANCELED, FAILED, STARTING, RUNNING. These states - represent valid times to start a workspace and connect, or to simply connect - to a running one or one that's already starting. We also use a spinner icon - when workspaces are in a transition state (STARTING, CANCELING, DELETING, - STOPPING) to give context for why a link might be disabled or a connection - might take longer than usual to establish. - -## 2.13.1 - 2024-07-19 - -### Changed - -- Previously, the plugin would try to respawn the IDE if we fail to get a join - link after five seconds. However, it seems sometimes we do not get a join link - that quickly. Now the plugin will wait indefinitely for a join link as long as - the process is still alive. If the process never comes alive after 30 seconds - or it dies after coming alive, the plugin will attempt to respawn the IDE. - -### Added - -- Extra logging around the IDE spawn to help debugging. -- Add setting to enable logging connection diagnostics from the Coder CLI for - debugging connectivity issues. - -## 2.13.0 - 2024-07-16 - -### Added - -- When using a recent workspace connection, check if there is an update to the - IDE and prompt to upgrade if an upgrade exists. - -## 2.12.2 - 2024-07-12 - -### Fixed - -- On Windows, expand the home directory when paths use `/` separators (for - example `~/foo/bar` or `$HOME/foo/bar`). This results in something like - `c:\users\coder/foo/bar`, but Windows appears to be fine with the mixed - separators. As before, you can still use `\` separators (for example - `~\foo\bar` or `$HOME\foo\bar`. - -## 2.12.1 - 2024-07-09 - -### Changed - -- Allow connecting when the agent state is "connected" but the lifecycle state - is "created". This may resolve issues when trying to connect to an updated - workspace where the agent has restarted but lifecycle scripts have not been - ran again. - -## 2.12.0 - 2024-07-02 - -### Added - -- Set `--usage-app` on the proxy command if the Coder CLI supports it - (>=2.13.0). To make use of this, you must add the connection again through the - "Connect to Coder" flow or by using the dashboard link (the recents - connections do not reconfigure SSH). - -### Changed - -- Add support for latest Gateway 242.* EAP. - -### Fixed - -- The version column now displays "Up to date" or "Outdated" instead of - duplicating the status column. - -## 2.11.7 - 2024-05-22 - -### Fixed - -- Polling and workspace action buttons when running from File > Remote - Development within a local IDE. - -## 2.11.6 - 2024-05-08 - -### Fixed - -- Multiple clients being launched when a backend was already running. - -## 2.11.5 - 2024-05-06 - -### Added - -- Automatically restart and reconnect to the IDE backend when it disappears. - -## 2.11.4 - 2024-05-01 - -### Fixed - -- All recent connections show their status now, not just the first. - -## 2.11.3 - 2024-04-30 - -### Fixed - -- Default URL setting was showing the help text for the setup command instead of - its own description. -- Exception when there is no default or last used URL. - -## 2.11.2 - 2024-04-30 - -### Fixed - -- Sort IDEs by version (latest first). -- Recent connections window will try to recover after encountering an error. - There is still a known issue where if a token expires there is no way to enter - a new one except to go back through the "Connect to Coder" flow. -- Header command ignores stderr and does not error if nothing is output. It - will still error if any blank lines are output. -- Remove "from jetbrains.com" from the download text since the download source - can be configured. - -### Changed - -- If using a certificate and key, it is assumed that token authentication is not - required, all token prompts are skipped, and the token header is not sent. -- Recent connections to deleted workspaces are automatically deleted. -- Display workspace name instead of the generated host name in the recents - window. -- Add deployment URL, IDE product, and build to the recents window. -- Display status and error in the recents window under the workspace name - instead of hiding them in tooltips. -- Truncate the path in the recents window if it is too long to prevent - needing to scroll to press the workspace actions. -- If there is no default URL, coder.example.com will no longer be used. The - field will just be blank, to remove the need to first delete the example URL. - -### Added - -- New setting for a setup command that will run in the directory of the IDE - before connecting to it. By default if this command fails the plugin will - display the command's exit code and output then abort the connection, but - there is an additional setting to ignore failures. -- New setting for extra SSH options. This is arbitrary text and is not - validated in any way. If this setting is left empty, the environment variable - CODER_SSH_CONFIG_OPTIONS will be used if set. -- New setting for the default URL. If this setting is left empty, the - environment variable CODER_URL will be used. If CODER_URL is also empty, the - URL in the global CLI config directory will be used, if it exists. - -## 2.10.0 - 2024-03-12 - -### Changed - -- If IDE details or the folder are missing from a Gateway link, the plugin will - now show the IDE selection screen to allow filling in these details. - -### Fixed - -- Fix matching on the wrong workspace/agent name. If a Gateway link was failing, - this could be why. -- Make errors when starting/stopping/updating a workspace visible. - -## 2.9.4 - 2024-02-26 - -### Changed - -- Disable autostarting workspaces by default on macOS to prevent an issue where - it wakes periodically and keeps the workspace on. This can be toggled via the - "Disable autostart" setting. -- CLI configuration is now reported in the progress indicator. Before it - happened in the background so it made the "Select IDE and project" button - appear to hang for a short time while it completed. - -### Fixed - -- Prevent environment variables being expanded too early in the header - command. This will make header commands like `auth --url=$CODER_URL` work. -- Stop workspaces before updating them. This is necessary in some cases where - the update changes parameters and the old template needs to be stopped with - the existing parameter values first or where the template author was not - diligent about making sure the agent gets restarted with the new ID and token - when doing two build starts in a row. -- Errors from API requests are now read and reported rather than only reporting - the HTTP status code. -- Data and binary directories are expanded so things like `~` can be used now. - -## 2.9.3 - 2024-02-10 - -### Fixed - -- Plugin will now use proxy authorization settings. - -## 2.9.2 - 2023-12-19 - -### Fixed - -- Listing IDEs when using the plugin from the File > Remote Development option - within a local IDE should now work. -- Recent connections are now preserved. - -## 2.9.1 - 2023-11-06 - -### Fixed - -- Set the `CODER_HEADER_COMMAND` environment variable when executing the CLI with the setting value. - -## 2.9.0 - 2023-10-27 - -### Added - -- Configuration options for mTLS. -- Configuration options for adding a CA cert to the trust store and an alternate - hostname. -- Agent ID can be used in place of the name when using the Gateway link. If - both are present the name will be ignored. - -### Fixed - -- Configuring SSH will include all agents even on workspaces that are off. - -## 2.8.0 - 2023-10-03 - -### Added - -- Add a setting for a command to run to get headers that will be set on all - requests to the Coder deployment. -- Support for Gateway 2023.3. - -## 2.6.0 - 2023-09-06 - -### Added - -- Initial support for Gateway links (jetbrains-gateway://). See the readme for - the expected parameters. -- Support for Gateway 232.9921. - -## 2.5.2 - 2023-08-06 - -### Fixed - -- Inability to connect to a workspace after going back to the workspaces view. -- Remove version warning for 2.x release. - -### Changed - -- Add a message to distinguish between connecting to the worker and querying for - IDEs. - -## 2.5.1 - 2023-07-07 - -### Fixed - -- Inability to download new editors in older versions of Gateway. - -## 2.5.0 - 2023-06-29 - -### Added - -- Support for Gateway 2023.2. - -## 2.4.0 - 2023-06-02 - -### Added - -- Allow configuring the binary directory separately from data. -- Add status and start/stop buttons to the recent connections view. - -### Changed - -- Check binary version with `version --output json` (if available) since this is - faster than waiting for the round trip checking etags. It also covers cases - where the binary is hosted somewhere that does not support etags. -- Move the template link from the row to a dedicated button on the toolbar. - -## 2.3.0 - 2023-05-03 - -### Added - -- Support connecting to multiple deployments (existing connections will still be - using the old method; please re-add them if you connect to multiple - deployments) -- Settings page for configuring both the source and destination of the CLI -- Listing editors and connecting will retry automatically on failure -- Surface various errors in the UI to make them more immediately visible - -### Changed - -- A token dialog and browser will not be launched when automatically connecting - to the last known deployment; these actions will only take place when you - explicitly interact by pressing "connect" -- Token dialog has been widened so the entire token can be seen at once - -### Fixed - -- The help text under the IDE dropdown now takes into account whether the IDE is - already installed -- Various minor alignment issues -- Workspaces table now updates when the agent status changes -- Connecting when the directory contains a tilde -- Selection getting lost when a workspace starts or stops -- Wait for the agent to become fully ready before connecting -- Avoid populating the token dialog with the last known token if it was for a - different deployment - -## 2.2.1 - 2023-03-23 - -### Fixed - -- Reading an existing config would sometimes use the wrong directory on Linux -- Two separate SSH sessions would spawn when connecting to a workspace through - the main flow - -## 2.2.0 - 2023-03-08 - -### Added - -- Support for Gateway 2023 - -### Fixed - -- The "Select IDE and Project" button is no longer disabled for a time after - going back a step - -### Changed - -- Initial authentication is now asynchronous which means no hang on the main - screen while that happens and it shows in the progress bar - -## 2.1.7 - 2023-02-28 - -### Fixed - -- Terminal link is now correct when host ends in `/` -- Improved resiliency and error handling when trying to open the last successful connection - -## 2.1.6-eap.0 - 2023-02-02 - -### Fixed - -- Improved resiliency and error handling when resolving installed IDE's - -## 2.1.6 - 2023-02-01 - -### Fixed - -- Improved resiliency and error handling when resolving installed IDE's - -## 2.1.5-eap.0 - 2023-01-24 - -### Fixed - -- Support for `Remote Development` in the Jetbrains IDE's - -## 2.1.5 - 2023-01-24 - -### Fixed - -- Support for `Remote Development` in the Jetbrains IDE's - -## 2.1.4-eap.0 - 2022-12-23 - -Bug fixes and enhancements included in `2.1.4` release: - -### Added - -- Ability to open a template in the Dashboard -- Ability to sort by workspace name, or by template name or by workspace status -- A new token is requested when the one persisted is expired -- Support for re-using already installed IDE backends - -### Changed - -- Renamed the plugin from `Coder Gateway` to `Gateway` -- Workspaces and agents are now resolved and displayed progressively - -### Fixed - -- Icon rendering on `macOS` -- `darwin` agents are now recognized as `macOS` -- Unsupported OS warning is displayed only for running workspaces - -## 2.1.4 - 2022-12-23 - -### Added - -- Ability to open a template in the Dashboard -- Ability to sort by workspace name, or by template name or by workspace status -- A new token is requested when the one persisted is expired -- Support for re-using already installed IDE backends - -### Changed - -- Renamed the plugin from `Coder Gateway` to `Gateway` -- Workspaces and agents are now resolved and displayed progressively - -### Fixed - -- Icon rendering on `macOS` -- `darwin` agents are now recognized as `macOS` -- Unsupported OS warning is displayed only for running workspaces - -## 2.1.3-eap.0 - 2022-12-12 - -Bug fixes and enhancements included in `2.1.3` release: - -### Added - -- Warning system when plugin might not be compatible with Coder REST API -- A `Create workspace` button which links to Coder's templates page -- Workspace icons -- Quick toolbar action to open Coder Dashboard in the browser -- Custom user agent for the HTTP client - -### Changed - -- Redesigned the information&warning banner. Messages can now include hyperlinks - -### Removed - -- Connection handle window is no longer displayed - -### Fixed - -- Outdated Coder CLI binaries are cleaned up -- Workspace status color style: running workspaces are green, failed ones should be red, everything else is gray -- Typos in plugin description - -## 2.1.3 - 2022-12-09 - -### Added - -- Warning system when plugin might not be compatible with Coder REST API -- A `Create workspace` button which links to Coder's templates page -- Workspace icons -- Quick toolbar action to open Coder Dashboard in the browser -- Custom user agent for the HTTP client - -### Changed - -- Redesigned the information&warning banner. Messages can now include hyperlinks - -### Removed - -- Connection handle window is no longer displayed - -### Fixed - -- Outdated Coder CLI binaries are cleaned up -- Workspace status color style: running workspaces are green, failed ones should be red, everything else is gray -- Typos in plugin description - -## 2.1.2-eap.0 - 2022-11-29 - -### Added - -- Support for Gateway 2022.3 RC -- Upgraded support for the latest Coder REST API -- Support for latest Gateway 2022.2.x builds - -### Fixed - -- Authentication flow is now done using HTTP headers - -## 2.1.2 - 2022-11-23 - -### Added - -- Upgraded support for the latest Coder REST API -- Support for latest Gateway 2022.2.x builds - -### Fixed - -- Authentication flow is now done using HTTP headers - -## 2.1.1 - -### Added - -- Support for remembering last opened Coder session - -### Changed - -- Minimum supported Gateway build is now 222.3739.54 -- Some dialog titles - -## 2.1.0 - -### Added - -- Support for displaying workspace version -- Support for managing the lifecycle of a workspace, i.e. start and stop and update workspace to the latest template version - -### Changed - -- Workspace panel is now updated every 5 seconds -- Combinations of workspace names and agent names are now listed even when a workspace is down -- Minimum supported Gateway build is now 222.3739.40 - -### Fixed - -- Terminal link for workspaces with a single agent -- No longer allow users to open a connection to a Windows or macOS workspace. It's not yet supported by Gateway - -## 2.0.2 - -### Added - -- Support for displaying working and non-working workspaces -- Better support for Light and Dark themes in the "Status" column - -### Fixed - -- Left panel is no longer visible when a new connection is triggered from Coder's "Recent Workspaces" panel. - This provides consistency with other plugins compatible with Gateway -- The "Select IDE and Project" button in the "Coder Workspaces" view is now disabled when no workspace is selected - -### Changed - -- The authentication view is now merged with the "Coder Workspaces" view allowing users to quickly change the host - -## 2.0.1 - -### Fixed - -- `Recent Coder Workspaces` label overlaps with the search bar in the `Connections` view -- Working workspaces are now listed when there are issues with resolving agents -- List only workspaces owned by the logged user - -### Changed - -- Links to documentation now point to the latest Coder OSS -- Simplified main action link text from `Connect to Coder Workspaces` to `Connect to Coder` -- Minimum supported Gateway build is now 222.3739.24 - -## 2.0.0 - -### Added - -- Support for Gateway 2022.2 - -### Changed - -- Java 17 is now required to run the plugin -- Adapted the code to the new SSH API provided by Gateway - -## 1.0.0 - -### Added - -- Initial scaffold for Gateway plugin -- Browser based authentication on Coder environments -- REST client for Coder V2 public API -- coder-cli orchestration for setting up the SSH configurations for Coder Workspaces -- Basic panel to display live Coder Workspaces -- Support for multi-agent Workspaces -- Gateway SSH connection to a Coder Workspace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f79e3d82f..e18c1ea57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,45 +2,32 @@ ## Architecture -The Coder Gateway plugin uses Gateway APIs to SSH into the remote machine, -download the requested IDE backend, run the backend, then launches a client that -connects to that backend using a port forward over SSH. If the backend goes down -due to a crash or a workspace restart, it will restart the backend and relaunch -the client. +The Coder Toolbox Gateway plugins provides some login pages, after which +it configures SSH and gives Toolbox a list of environments with their +host names. Toolbox then handles everything else. -There are three ways to get into a workspace: +There are two ways to get into a workspace: 1. Dashboard link. -2. "Connect to Coder" button. -3. Using a recent connection. - -Currently the first two will configure SSH but the third does not yet. +2. Through Toolbox. ## Development -To manually install a local build: - -1. Install [Jetbrains Gateway](https://www.jetbrains.com/remote-development/gateway/) -2. Run `./gradlew clean buildPlugin` to generate a zip distribution. -3. Locate the zip file in the `build/distributions` folder and follow [these - instructions](https://www.jetbrains.com/help/idea/managing-plugins.html#install_plugin_from_disk) - on how to install a plugin from disk. +You can get the latest build of Toolbox with Gateway support from our shared +Slack channel with JetBrains. Make sure you download the right version (check +[./gradle/libs.versions.toml](./gradle/libs.versions.toml)). -Alternatively, `./gradlew clean runIde` will deploy a Gateway distribution (the -one specified in `gradle.properties` - `platformVersion`) with the latest plugin -changes deployed. +To load the plugin into Toolbox, close Toolbox, run `./gradlew build copyPlugin`, +then launch Toolbox again. If you are not seeing your changes, try copying the +plugin into Toolbox's `cache/plugins` directory instead of `plugins`. -To simulate opening a workspace from the dashboard pass the Gateway link via -`--args`. For example: +To simulate opening a workspace from the dashboard you can use something like +`xdg-open` to launch a URL in this format: ``` -./gradlew clean runIDE --args="jetbrains-gateway://connect#type=coder&workspace=dev&agent=coder&folder=/home/coder&url=https://dev.coder.com&token=<redacted>&ide_product_code=IU&ide_build_number=223.8836.41&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2022.3.3.tar.gz" +jetbrains://gateway/com.coder.gateway/connect?workspace=dev&agent=coder&url=https://dev.coder.com&token=<redacted> ``` -Alternatively, if you have separately built the plugin and already installed it -in a Gateway distribution you can launch that distribution with the URL as the -first argument (no `--args` in this case). - If your change is something users ought to be aware of, add an entry in the changelog. @@ -48,41 +35,22 @@ Generally we prefer that PRs be squashed into `main` but you can rebase or merge if it is important to keep the individual commits (make sure to clean up the commits first if you are doing this). +We are using `ktlint` to format but this is not currently enforced. + ## Testing Run tests with `./gradlew test`. By default this will test against `https://dev.coder.com` but you can set `CODER_GATEWAY_TEST_DEPLOYMENT` to a URL of your choice or to `mock` to use mocks only. -There are two ways of using the plugin: from standalone Gateway, and from within -an IDE (`File` > `Remote Development`). There are subtle differences so it -makes usually sense to test both. We should also be testing both the latest -stable and latest EAP. - -## Plugin compatibility - -`./gradlew runPluginVerifier` can check the plugin compatibility against the specified Gateway. The integration with Github Actions is commented until [this gradle intellij plugin issue](https://github.com/JetBrains/gradle-intellij-plugin/issues/1027) is fixed. +Some investigation is needed to see what options we have for testing code +directly tied to the UI, as currently that code is untested. ## Releasing -1. Check that the changelog lists all the important changes. -2. Update the gradle.properties version. -3. Publish the resulting draft release after validating it. -4. Merge the resulting changelog PR. +We do not yet have a release workflow yet, but it will look like: -## `main` vs `eap` branch - -Sometimes there can be API incompatibilities between the latest stable version -of Gateway and EAP ones (Early Access Program). - -If this happens, use the `eap` branch to make a separate release. Once it -becomes stable, update the versions in `main`. - -## Supported Coder versions - -`Coder Gateway` includes checks for compatibility with a specified version -range. A warning is raised when the Coder deployment build version is outside of -compatibility range. - -At the moment the upper range is 3.0.0 so the check essentially has no effect, -but in the future we may want to keep this updated. +1. Check that the changelog lists all the important changes. +2. Update the extension.json version and changelog header. +3. Tag the commit made from the second step with the version. +4. Publish the resulting draft release after validating it. diff --git a/README.md b/README.md index fd67a38da..132ba53a9 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,33 @@ -# Coder Gateway Plugin - -[](https://discord.gg/coder) -[](https://twitter.com/coderhq) -[](https://github.com/coder/jetbrains-coder/actions/workflows/build.yml) - -<!-- Plugin description --> -The Coder Gateway plugin lets you open [Coder](https://github.com/coder/coder) -workspaces in your JetBrains IDEs with a single click. - -**Manage less** - -- Ensure your entire team is using the same tools and resources - - Rollout critical updates to your developers with one command -- Automatically shut down expensive cloud resources -- Keep your source code and data behind your firewall - -**Code more** - -- Build and test faster - - Leveraging cloud CPUs, RAM, network speeds, etc. -- Access your environment from any place on any client (even an iPad) -- Onboard instantly then stay up to date continuously - -<!-- Plugin description end --> - -## Getting Started - -1. Install [Jetbrains Gateway](https://www.jetbrains.com/remote-development/gateway/) -2. [Install this plugin from the JetBrains Marketplace](https://plugins.jetbrains.com/plugin/19620-coder/). - Alternatively, if you launch a JetBrains IDE from the Coder dashboard, this - plugin will be automatically installed. - -It is also possible to install this plugin in a local JetBrains IDE and then use -`File` > `Remote Development`. +# Coder Toolbox Gateway Plugin + +[](https://discord.gg/coder) +[](https://twitter.com/coderhq) +[](https://github.com/coder/jetbrains-coder/actions/workflows/build.yml) + +<!-- Plugin description --> +The Coder Toolbox Gateway plugin lets you open [Coder](https://github.com/coder/coder) +workspaces from Toolbox with a single click. + +**Manage less** + +- Ensure your entire team is using the same tools and resources + - Rollout critical updates to your developers with one command +- Automatically shut down expensive cloud resources +- Keep your source code and data behind your firewall + +**Code more** + +- Build and test faster + - Leveraging cloud CPUs, RAM, network speeds, etc. +- Access your environment from any place on any client (even an iPad) +- Onboard instantly then stay up to date continuously + +<!-- Plugin description end --> + +## Getting Started + +Gateway in Toolbox and this plugin are still in development. Steps to +use Toolbox with Coder will come soon, but see the contributing doc +if you want to contribute. diff --git a/build.gradle.kts b/build.gradle.kts index 5e791b5a8..96a0cf5de 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,159 +1,149 @@ -import org.jetbrains.changelog.markdownToHTML -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -fun properties(key: String) = project.findProperty(key).toString() +import com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter +import com.github.jk1.license.render.JsonReportRenderer +import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory +import org.jetbrains.kotlin.com.intellij.openapi.util.SystemInfoRt +import java.nio.file.Path +import kotlin.io.path.div plugins { - // Java support - id("java") - // Groovy support - id("groovy") - // Kotlin support - id("org.jetbrains.kotlin.jvm") version "1.9.23" - // Gradle IntelliJ Plugin - id("org.jetbrains.intellij") version "1.13.3" - // Gradle Changelog Plugin - id("org.jetbrains.changelog") version "2.2.1" - // Gradle Qodana Plugin - id("org.jetbrains.qodana") version "0.1.13" - // Generate Moshi adapters. - id("com.google.devtools.ksp") version "1.9.23-1.0.20" + alias(libs.plugins.kotlin) + alias(libs.plugins.serialization) + `java-library` + alias(libs.plugins.dependency.license.report) + alias(libs.plugins.ksp) } -group = properties("pluginGroup") -version = properties("pluginVersion") - -dependencies { - implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0")) - implementation("com.squareup.okhttp3:okhttp") - implementation("com.squareup.okhttp3:logging-interceptor") - - implementation("com.squareup.moshi:moshi:1.15.1") - ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1") - - implementation("com.squareup.retrofit2:retrofit:2.11.0") - implementation("com.squareup.retrofit2:converter-moshi:2.11.0") - - implementation("org.zeroturnaround:zt-exec:1.12") - - testImplementation(kotlin("test")) +buildscript { + dependencies { + classpath(libs.marketplace.client) + } } -// Configure project's dependencies repositories { mavenCentral() - maven(url = "https://www.jetbrains.com/intellij-repository/snapshots") + maven("https://packages.jetbrains.team/maven/p/tbx/gateway") } -// Configure Gradle IntelliJ Plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin -intellij { - pluginName.set(properties("pluginName")) - version.set(properties("platformVersion")) - type.set(properties("platformType")) +dependencies { + implementation(libs.gateway.api) + implementation(libs.slf4j) + implementation(libs.bundles.serialization) + implementation(libs.coroutines.core) + implementation(libs.okhttp) + implementation(libs.exec) + implementation(libs.moshi) + ksp(libs.moshi.codegen) + implementation(libs.retrofit) + implementation(libs.retrofit.moshi) + testImplementation(kotlin("test")) +} - downloadSources.set(properties("platformDownloadSources").toBoolean()) - // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file. - plugins.set(properties("platformPlugins").split(',').map(String::trim).filter(String::isNotEmpty)) +licenseReport { + renderers = arrayOf(JsonReportRenderer("dependencies.json")) + filters = arrayOf(ExcludeTransitiveDependenciesFilter()) + // jq script to convert to our format: + // `jq '[.dependencies[] | {name: .moduleName, version: .moduleVersion, url: .moduleUrl, license: .moduleLicense, licenseUrl: .moduleLicenseUrl}]' < build/reports/dependency-license/dependencies.json > src/main/resources/dependencies.json` } -// Configure Gradle Changelog Plugin - read more: https://github.com/JetBrains/gradle-changelog-plugin -changelog { - version.set(properties("pluginVersion")) - groups.set(emptyList()) +tasks.compileKotlin { + kotlinOptions.freeCompilerArgs += listOf( + "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", + ) } -// Configure Gradle Qodana Plugin - read more: https://github.com/JetBrains/gradle-qodana-plugin -qodana { - cachePath.set(projectDir.resolve(".qodana").canonicalPath) - reportPath.set(projectDir.resolve("build/reports/inspections").canonicalPath) - saveReport.set(true) - showReport.set(System.getenv("QODANA_SHOW_REPORT")?.toBoolean() ?: false) +tasks.test { + useJUnitPlatform() } -tasks { - buildPlugin { - exclude { "coroutines" in it.name } - } - prepareSandbox { - exclude { "coroutines" in it.name } - } +val pluginId = "com.coder.gateway" +val pluginVersion = "0.0.1" - // Set the JVM compatibility versions - properties("javaVersion").let { - withType<JavaCompile> { - sourceCompatibility = it - targetCompatibility = it - } - withType<KotlinCompile> { - kotlinOptions.jvmTarget = it - } - } +val assemblePlugin by tasks.registering(Jar::class) { + archiveBaseName.set(pluginId) + from(sourceSets.main.get().output) +} - wrapper { - gradleVersion = properties("gradleVersion") - } +val copyPlugin by tasks.creating(Sync::class.java) { + dependsOn(assemblePlugin) - instrumentCode { - compilerVersion.set(properties("instrumentationCompiler")) - } + val userHome = System.getProperty("user.home").let { Path.of(it) } + val toolboxCachesDir = when { + SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") + // currently this is the location that TBA uses on Linux + SystemInfoRt.isLinux -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share") + SystemInfoRt.isMac -> userHome / "Library" / "Caches" + else -> error("Unknown os") + } / "JetBrains" / "Toolbox" - // TODO - this fails with linkage error, but we don't need it now - // because the plugin does not provide anything to search for in Preferences - buildSearchableOptions { - isEnabled = false - } + val pluginsDir = when { + SystemInfoRt.isWindows -> toolboxCachesDir / "cache" + SystemInfoRt.isLinux || SystemInfoRt.isMac -> toolboxCachesDir + else -> error("Unknown os") + } / "plugins" - patchPluginXml { - version.set(properties("pluginVersion")) - sinceBuild.set(properties("pluginSinceBuild")) - untilBuild.set(properties("pluginUntilBuild")) - - // Extract the <!-- Plugin description --> section from README.md and provide for the plugin's manifest - pluginDescription.set( - projectDir.resolve("README.md").readText().lines().run { - val start = "<!-- Plugin description -->" - val end = "<!-- Plugin description end -->" - - if (!containsAll(listOf(start, end))) { - throw GradleException("Plugin description section not found in README.md:\n$start ... $end") - } - subList(indexOf(start) + 1, indexOf(end)) - }.joinToString("\n").run { markdownToHTML(this) }, - ) + val targetDir = pluginsDir / pluginId - // Get the latest available change notes from the changelog file - changeNotes.set( - provider { - changelog.run { - getOrNull(properties("pluginVersion")) ?: getLatest() - }.toHTML() - }, - ) - } + from(assemblePlugin.get().outputs.files) - runIde { - autoReloadPlugins.set(true) + from("src/main/resources") { + include("extension.json") + include("dependencies.json") + include("icon.svg") } - // Configure UI tests plugin - // Read more: https://github.com/JetBrains/intellij-ui-test-robot - runIdeForUiTests { - systemProperty("robot-server.port", "8082") - systemProperty("ide.mac.message.dialogs.as.sheets", "false") - systemProperty("jb.privacy.policy.text", "<!--999.999-->") - systemProperty("jb.consents.confirmation.enabled", "false") - } + // Copy dependencies, excluding those provided by Toolbox. + from( + configurations.compileClasspath.map { configuration -> + configuration.files.filterNot { file -> + listOf( + "kotlin", + "gateway", + "annotations", + "okhttp", + "okio", + "slf4j", + ).any { file.name.contains(it) } + } + }, + ) + + into(targetDir) +} - publishPlugin { - dependsOn("patchChangelog") - token.set(System.getenv("PUBLISH_TOKEN")) +val pluginZip by tasks.creating(Zip::class) { + dependsOn(assemblePlugin) + + from(assemblePlugin.get().outputs.files) + from("src/main/resources") { + include("extension.json") + include("dependencies.json") + } + from("src/main/resources") { + include("icon.svg") + rename("icon.svg", "pluginIcon.svg") } + archiveBaseName.set("$pluginId-$pluginVersion") +} + +val uploadPlugin by tasks.creating { + dependsOn(pluginZip) - test { - useJUnitPlatform() + doLast { + val instance = PluginRepositoryFactory.create("https://plugins.jetbrains.com", project.property("pluginMarketplaceToken").toString()) + + // first upload + // instance.uploader.uploadNewPlugin(pluginZip.outputs.files.singleFile, listOf("toolbox", "gateway"), LicenseUrl.APACHE_2_0, ProductFamily.TOOLBOX) + + // subsequent updates + instance.uploader.upload(pluginId, pluginZip.outputs.files.singleFile) } +} - runPluginVerifier { - ideVersions.set(properties("verifyVersions").split(",")) +// For use with kotlin-language-server. +tasks.register("classpath") { + doFirst { + File("classpath").writeText( + sourceSets["main"].runtimeClasspath.asPath + ) } } diff --git a/classpath b/classpath new file mode 100755 index 000000000..04c3331d9 --- /dev/null +++ b/classpath @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# No idea why kotlin-language-server cannot find these. +# Generated with ./gradlew classpath, except this header is manually added at +# the moment. +# Must be copied to ~/.config/kotlin-language-server/classpath +# TOOD: Automate all that. + +echo "/home/coder/src/jetbrains-coder/build/classes/java/main:/home/coder/src/jetbrains-coder/build/classes/kotlin/main:/home/coder/src/jetbrains-coder/build/generated/ksp/main/classes:/home/coder/src/jetbrains-coder/build/resources/main:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/com.jetbrains.toolbox.gateway/gateway-api/2.5.0.32871/3229b64b648a9f0125f1bc8589d60c5b66f5ad7d/gateway-api-2.5.0.32871.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-serialization-json-okio-jvm/1.5.0/2241ed280031e325cbc8c9e02d9b39e9bbe26539/kotlinx-serialization-json-okio-jvm-1.5.0.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-serialization-json-jvm/1.5.0/f2355f60f5c027da0326c8af2d9c724d39aa0ce9/kotlinx-serialization-json-jvm-1.5.0.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-serialization-core-jvm/1.5.0/d701e8cccd443a7cc1a0bcac53432f2745dcdbda/kotlinx-serialization-core-jvm-1.5.0.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/com.squareup.retrofit2/converter-moshi/2.8.2/7af80ce2fd7386db22e95aa5b69381099778c63b/converter-moshi-2.8.2.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/com.squareup.retrofit2/retrofit/2.8.2/8bdfa4e965d42e9156f50cd67dd889d63504d8d5/retrofit-2.8.2.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/com.squareup.okhttp3/okhttp/4.12.0/2f4525d4a200e97e1b87449c2cd9bd2e25b7e8cd/okhttp-4.12.0.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm/1.7.3/2b09627576f0989a436a00a4a54b55fa5026fb86/kotlinx-coroutines-core-jvm-1.7.3.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk8/1.8.21/67f57e154437cd9e6e9cf368394b95814836ff88/kotlin-stdlib-jdk8-1.8.21.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/com.squareup.moshi/moshi/1.15.1/753fe8158eae76508bf251afd645101f871680c4/moshi-1.15.1.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/com.squareup.okio/okio-jvm/3.7.0/276b999b41f7dcde00054848fc53af338d86b349/okio-jvm-3.7.0.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk7/1.8.21/7473b8cd3c0ef9932345baf569bc398e8a717046/kotlin-stdlib-jdk7-1.8.21.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.9.23/dbaadea1f5e68f790d242a91a38355a83ec38747/kotlin-stdlib-1.9.23.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.zeroturnaround/zt-exec/1.12/51a8d135518365a169a8c94e074c7eaaf864e147/zt-exec-1.12.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.3/deef7fc81f00bd5e6205bb097be1040b4094f007/slf4j-api-2.0.3.jar:/home/coder/.local/share/gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/23.0.0/8cc20c07506ec18e0834947b84a864bfc094484e/annotations-23.0.0.jar" diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index a4325041c..000000000 --- a/gradle.properties +++ /dev/null @@ -1,45 +0,0 @@ -# IntelliJ Platform Artifacts Repositories -# -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html -pluginGroup=com.coder.gateway -# Zip file name. -pluginName=coder-gateway -# SemVer format -> https://semver.org -pluginVersion=2.14.0 -# See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html -# for insight into build numbers and IntelliJ Platform versions. -pluginSinceBuild=233.6745 -# This should be kept up to date with the latest EAP. If the API is incompatible -# with the latest stable, use the eap branch temporarily instead. -pluginUntilBuild=242.* -# IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties -# Gateway available build versions https://www.jetbrains.com/intellij-repository/snapshots and https://www.jetbrains.com/intellij-repository/releases -# -# The platform version must match the "since build" version while the -# instrumentation version appears to be used in development. The plugin -# verifier should be used after bumping versions to ensure compatibility in the -# range. -# -# Occasionally the build of Gateway we are using disappears from JetBrains’s -# servers. When this happens, find the closest version match from -# https://www.jetbrains.com/intellij-repository/snapshots and update accordingly -# (for example if 233.14808-EAP-CANDIDATE-SNAPSHOT is missing then find a 233.* -# that exists, ideally the most recent one, for example -# 233.15325-EAP-CANDIDATE-SNAPSHOT). -platformType=GW -platformVersion=233.15325-EAP-CANDIDATE-SNAPSHOT -instrumentationCompiler=242.19533-EAP-CANDIDATE-SNAPSHOT -# Gateway does not have open sources. -platformDownloadSources=true -verifyVersions=2023.3,2024.1,2024.2 -# Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html -# Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 -platformPlugins= -# Java language level used to compile sources and to generate the files for - -# Java 17 is required since 2022.2 -javaVersion=17 -# Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion=7.4 -# Opt-out flag for bundling Kotlin standard library. -# See https://plugins.jetbrains.com/docs/intellij/kotlin.html#kotlin-standard-library for details. -# suppress inspection "UnusedProperty" -kotlin.stdlib.default.dependency=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..f1d7ef10b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,39 @@ +[versions] +gateway = "2.5.0.32871" +kotlin = "1.9.23" +coroutines = "1.7.3" +serialization = "1.5.0" +okhttp = "4.10.0" +slf4j = "2.0.3" +dependency-license-report = "2.5" +marketplace-client = "2.0.38" +exec = "1.12" +moshi = "1.15.1" +ksp = "1.9.23-1.0.19" +retrofit = "2.8.2" + +[libraries] +kotlin-stdlib = { module = "com.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +gateway-api = { module = "com.jetbrains.toolbox.gateway:gateway-api", version.ref = "gateway" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } +serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +exec = { module = "org.zeroturnaround:zt-exec", version.ref = "exec" } +moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi"} +moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi"} +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit"} +retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit"} + +marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" } + +[bundles] +serialization = [ "serialization-core", "serialization-json", "serialization-json-okio" ] + +[plugins] +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +dependency-license-report = { id = "com.github.jk1.dependency-license-report", version.ref = "dependency-license-report" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 41d9927a4..c1962a79e 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 41dfb8790..0c85a1f75 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c78733..aeb74cbb4 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -143,12 +140,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +194,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in @@ -205,6 +210,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index 107acd32c..93e3f59f1 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle.kts b/settings.gradle.kts index 6c1cb186e..87d5ca81e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "Coder Gateway" +rootProject.name = "jetbrains-toolbox-coder" diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayBundle.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayBundle.kt deleted file mode 100644 index d680f8624..000000000 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayBundle.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.coder.gateway - -import com.intellij.DynamicBundle -import org.jetbrains.annotations.NonNls -import org.jetbrains.annotations.PropertyKey - -@NonNls -private const val BUNDLE = "messages.CoderGatewayBundle" - -object CoderGatewayBundle : DynamicBundle(BUNDLE) { - @Suppress("SpreadOperator") - @JvmStatic - fun message( - @PropertyKey(resourceBundle = BUNDLE) key: String, - vararg params: Any, - ) = getMessage(key, *params) -} diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt deleted file mode 100644 index b421fc7a2..000000000 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ /dev/null @@ -1,38 +0,0 @@ -@file:Suppress("DialogTitleCapitalization") - -package com.coder.gateway - -import com.coder.gateway.services.CoderSettingsService -import com.coder.gateway.util.DialogUi -import com.coder.gateway.util.LinkHandler -import com.coder.gateway.util.isCoder -import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.Logger -import com.jetbrains.gateway.api.ConnectionRequestor -import com.jetbrains.gateway.api.GatewayConnectionHandle -import com.jetbrains.gateway.api.GatewayConnectionProvider - -// CoderGatewayConnectionProvider handles connecting via a Gateway link such as -// jetbrains-gateway://connect#type=coder. -class CoderGatewayConnectionProvider : - LinkHandler(service<CoderSettingsService>(), null, DialogUi(service<CoderSettingsService>())), - GatewayConnectionProvider { - override suspend fun connect( - parameters: Map<String, String>, - requestor: ConnectionRequestor, - ): GatewayConnectionHandle? { - CoderRemoteConnectionHandle().connect { indicator -> - logger.debug("Launched Coder link handler", parameters) - handle(parameters) { - indicator.text = it - } - } - return null - } - - override fun isApplicable(parameters: Map<String, String>): Boolean = parameters.isCoder() - - companion object { - val logger = Logger.getInstance(CoderGatewayConnectionProvider::class.java.simpleName) - } -} diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt deleted file mode 100644 index 6344aca68..000000000 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.coder.gateway - -object CoderGatewayConstants { - const val GATEWAY_CONNECTOR_ID = "Coder.Gateway.Connector" - const val GATEWAY_RECENT_CONNECTIONS_ID = "Coder.Gateway.Recent.Connections" -} diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayExtension.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayExtension.kt new file mode 100644 index 000000000..e305b4ab7 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayExtension.kt @@ -0,0 +1,28 @@ +package com.coder.gateway + +import com.jetbrains.toolbox.gateway.GatewayExtension +import com.jetbrains.toolbox.gateway.PluginSecretStore +import com.jetbrains.toolbox.gateway.PluginSettingsStore +import com.jetbrains.toolbox.gateway.RemoteEnvironmentConsumer +import com.jetbrains.toolbox.gateway.RemoteProvider +import com.jetbrains.toolbox.gateway.ToolboxServiceLocator +import com.jetbrains.toolbox.gateway.ui.ObservablePropertiesFactory +import com.jetbrains.toolbox.gateway.ui.ToolboxUi +import kotlinx.coroutines.CoroutineScope +import okhttp3.OkHttpClient + +/** + * Entry point into the extension. + */ +class CoderGatewayExtension : GatewayExtension { + // All services must be passed in here and threaded as necessary. + override fun createRemoteProviderPluginInstance(serviceLocator: ToolboxServiceLocator): RemoteProvider = CoderRemoteProvider( + serviceLocator.getService(OkHttpClient::class.java), + serviceLocator.getService(RemoteEnvironmentConsumer::class.java), + serviceLocator.getService(CoroutineScope::class.java), + serviceLocator.getService(ToolboxUi::class.java), + serviceLocator.getService(PluginSettingsStore::class.java), + serviceLocator.getService(PluginSecretStore::class.java), + serviceLocator.getService(ObservablePropertiesFactory::class.java), + ) +} diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayMainView.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayMainView.kt deleted file mode 100644 index e72968891..000000000 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayMainView.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.coder.gateway - -import com.coder.gateway.help.ABOUT_HELP_TOPIC -import com.coder.gateway.icons.CoderIcons -import com.coder.gateway.views.CoderGatewayConnectorWizardWrapperView -import com.coder.gateway.views.CoderGatewayRecentWorkspaceConnectionsView -import com.intellij.openapi.help.HelpManager -import com.jetbrains.gateway.api.GatewayConnector -import com.jetbrains.gateway.api.GatewayConnectorDocumentation -import com.jetbrains.gateway.api.GatewayConnectorView -import com.jetbrains.gateway.api.GatewayRecentConnections -import com.jetbrains.rd.util.lifetime.Lifetime -import java.awt.Component -import javax.swing.Icon - -class CoderGatewayMainView : GatewayConnector { - override fun getConnectorId() = CoderGatewayConstants.GATEWAY_CONNECTOR_ID - - override val icon: Icon - get() = CoderIcons.LOGO - - override fun createView(lifetime: Lifetime): GatewayConnectorView = CoderGatewayConnectorWizardWrapperView() - - override fun getActionText(): String = CoderGatewayBundle.message("gateway.connector.action.text") - - override fun getDescription(): String = CoderGatewayBundle.message("gateway.connector.description") - - override fun getDocumentationAction(): GatewayConnectorDocumentation = GatewayConnectorDocumentation(true) { - HelpManager.getInstance().invokeHelp(ABOUT_HELP_TOPIC) - } - - override fun getRecentConnections(setContentCallback: (Component) -> Unit): GatewayRecentConnections = CoderGatewayRecentWorkspaceConnectionsView(setContentCallback) - - override fun getTitle(): String = CoderGatewayBundle.message("gateway.connector.title") - - override fun isAvailable(): Boolean = true -} diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt deleted file mode 100644 index d71c5f791..000000000 --- a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt +++ /dev/null @@ -1,527 +0,0 @@ -@file:Suppress("DialogTitleCapitalization") - -package com.coder.gateway - -import com.coder.gateway.cli.CoderCLIManager -import com.coder.gateway.models.WorkspaceProjectIDE -import com.coder.gateway.models.toIdeWithStatus -import com.coder.gateway.models.toRawString -import com.coder.gateway.models.withWorkspaceProject -import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService -import com.coder.gateway.services.CoderSettingsService -import com.coder.gateway.util.DialogUi -import com.coder.gateway.util.SemVer -import com.coder.gateway.util.humanizeDuration -import com.coder.gateway.util.isCancellation -import com.coder.gateway.util.isWorkerTimeout -import com.coder.gateway.util.suspendingRetryWithExponentialBackOff -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.rd.util.launchUnderBackgroundProgress -import com.intellij.openapi.ui.Messages -import com.intellij.remote.AuthType -import com.intellij.remote.RemoteCredentialsHolder -import com.intellij.remoteDev.hostStatus.UnattendedHostStatus -import com.jetbrains.gateway.ssh.CachingProductsJsonWrapper -import com.jetbrains.gateway.ssh.ClientOverSshTunnelConnector -import com.jetbrains.gateway.ssh.HighLevelHostAccessor -import com.jetbrains.gateway.ssh.IdeWithStatus -import com.jetbrains.gateway.ssh.IntelliJPlatformProduct -import com.jetbrains.gateway.ssh.ReleaseType -import com.jetbrains.gateway.ssh.SshHostTunnelConnector -import com.jetbrains.gateway.ssh.deploy.DeployException -import com.jetbrains.gateway.ssh.deploy.ShellArgument -import com.jetbrains.gateway.ssh.deploy.TransferProgressTracker -import com.jetbrains.gateway.ssh.util.validateIDEInstallPath -import com.jetbrains.rd.util.lifetime.LifetimeDefinition -import com.jetbrains.rd.util.lifetime.LifetimeStatus -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.connection.ConnectionException -import org.zeroturnaround.exec.ProcessExecutor -import java.net.URI -import java.nio.file.Path -import java.time.Duration -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -// CoderRemoteConnection uses the provided workspace SSH parameters to launch an -// IDE against the workspace. If successful the connection is added to recent -// connections. -@Suppress("UnstableApiUsage") -class CoderRemoteConnectionHandle { - private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>() - private val settings = service<CoderSettingsService>() - - private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") - private val dialogUi = DialogUi(settings) - - fun connect(getParameters: (indicator: ProgressIndicator) -> WorkspaceProjectIDE) { - val clientLifetime = LifetimeDefinition() - clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title")) { - try { - var parameters = getParameters(indicator) - var oldParameters: WorkspaceProjectIDE? = null - logger.debug("Creating connection handle", parameters) - indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting") - suspendingRetryWithExponentialBackOff( - action = { attempt -> - logger.info("Connecting to remote worker on ${parameters.hostname}... (attempt $attempt)") - if (attempt > 1) { - // indicator.text is the text above the progress bar. - indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.retry", attempt) - } else { - indicator.text = "Connecting to remote worker..." - } - // This establishes an SSH connection to a remote worker binary. - // TODO: Can/should accessors to the same host be shared? - val accessor = HighLevelHostAccessor.create( - RemoteCredentialsHolder().apply { - setHost(CoderCLIManager.getBackgroundHostName(parameters.hostname)) - userName = "coder" - port = 22 - authType = AuthType.OPEN_SSH - }, - true, - ) - if (attempt == 1) { - // See if there is a newer (non-EAP) version of the IDE available. - checkUpdate(accessor, parameters, indicator)?.let { update -> - // Store the old IDE to delete later. - oldParameters = parameters - // Continue with the new IDE. - parameters = update.withWorkspaceProject( - name = parameters.name, - hostname = parameters.hostname, - projectPath = parameters.projectPath, - deploymentURL = parameters.deploymentURL, - ) - } - } - doConnect( - accessor, - parameters, - indicator, - clientLifetime, - settings.setupCommand, - settings.ignoreSetupFailure, - ) - // If successful, delete the old IDE and connection. - oldParameters?.let { - indicator.text = "Deleting ${it.ideName} backend..." - try { - it.idePathOnHost?.let { path -> - accessor.removePathOnRemote(accessor.makeRemotePath(ShellArgument.PlainText(path))) - } - recentConnectionsService.removeConnection(it.toRecentWorkspaceConnection()) - } catch (ex: Exception) { - logger.error("Failed to delete old IDE or connection", ex) - } - } - indicator.text = "Connecting ${parameters.ideName} client..." - // The presence handler runs a good deal earlier than the client - // actually appears, which results in some dead space where it can look - // like opening the client silently failed. This delay janks around - // that, so we can keep the progress indicator open a bit longer. - delay(5000) - }, - retryIf = { - it is ConnectionException || - it is TimeoutException || - it is SSHException || - it is DeployException - }, - onException = { attempt, nextMs, e -> - logger.error("Failed to connect (attempt $attempt; will retry in $nextMs ms)") - // indicator.text2 is the text below the progress bar. - indicator.text2 = - if (isWorkerTimeout(e)) { - "Failed to upload worker binary...it may have timed out" - } else { - e.message ?: e.javaClass.simpleName - } - }, - onCountdown = { remainingMs -> - indicator.text = - CoderGatewayBundle.message( - "gateway.connector.coder.connecting.failed.retry", - humanizeDuration(remainingMs), - ) - }, - ) - logger.info("Adding ${parameters.ideName} for ${parameters.hostname}:${parameters.projectPath} to recent connections") - recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection()) - } catch (e: Exception) { - if (isCancellation(e)) { - logger.info("Connection canceled due to ${e.javaClass.simpleName}") - } else { - logger.error("Failed to connect (will not retry)", e) - // The dialog will close once we return so write the error - // out into a new dialog. - ApplicationManager.getApplication().invokeAndWait { - Messages.showMessageDialog( - e.message ?: e.javaClass.simpleName ?: "Aborted", - CoderGatewayBundle.message("gateway.connector.coder.connection.failed"), - Messages.getErrorIcon(), - ) - } - } - } - } - } - - /** - * Return a new (non-EAP) IDE if we should update. - */ - private suspend fun checkUpdate( - accessor: HighLevelHostAccessor, - workspace: WorkspaceProjectIDE, - indicator: ProgressIndicator, - ): IdeWithStatus? { - indicator.text = "Checking for updates..." - val workspaceOS = accessor.guessOs() - logger.info("Got $workspaceOS for ${workspace.hostname}") - val latest = CachingProductsJsonWrapper.getInstance().getAvailableIdes( - IntelliJPlatformProduct.fromProductCode(workspace.ideProduct.productCode) - ?: throw Exception("invalid product code ${workspace.ideProduct.productCode}"), - workspaceOS, - ) - .filter { it.releaseType == ReleaseType.RELEASE } - .minOfOrNull { it.toIdeWithStatus() } - if (latest != null && SemVer.parse(latest.buildNumber) > SemVer.parse(workspace.ideBuildNumber)) { - logger.info("Got newer version: ${latest.buildNumber} versus current ${workspace.ideBuildNumber}") - if (dialogUi.confirm("Update IDE", "There is a new version of this IDE: ${latest.buildNumber}. Would you like to update?")) { - return latest - } - } - return null - } - - /** - * Check for updates, deploy (if needed), connect to the IDE, and update the - * last opened date. - */ - private suspend fun doConnect( - accessor: HighLevelHostAccessor, - workspace: WorkspaceProjectIDE, - indicator: ProgressIndicator, - lifetime: LifetimeDefinition, - setupCommand: String, - ignoreSetupFailure: Boolean, - timeout: Duration = Duration.ofMinutes(10), - ) { - workspace.lastOpened = localTimeFormatter.format(LocalDateTime.now()) - - // Deploy if we need to. - val ideDir = deploy(accessor, workspace, indicator, timeout) - workspace.idePathOnHost = ideDir.toRawString() - - // Run the setup command. - setup(workspace, indicator, setupCommand, ignoreSetupFailure) - - // Wait for the IDE to come up. - indicator.text = "Waiting for ${workspace.ideName} backend..." - val remoteProjectPath = accessor.makeRemotePath(ShellArgument.PlainText(workspace.projectPath)) - val logsDir = accessor.getLogsDir(workspace.ideProduct.productCode, remoteProjectPath) - var status = ensureIDEBackend(accessor, workspace, ideDir, remoteProjectPath, logsDir, lifetime, null) - - // We wait for non-null, so this only happens on cancellation. - val joinLink = status?.joinLink - if (joinLink.isNullOrBlank()) { - logger.info("Connection to ${workspace.ideName} on ${workspace.hostname} was canceled") - return - } - - // Makes sure the ssh log directory exists. - if (settings.sshLogDirectory.isNotBlank()) { - Path.of(settings.sshLogDirectory).toFile().mkdirs() - } - - // Make the initial connection. - indicator.text = "Connecting ${workspace.ideName} client..." - logger.info("Connecting ${workspace.ideName} client to coder@${workspace.hostname}:22") - val client = ClientOverSshTunnelConnector( - lifetime, - SshHostTunnelConnector( - RemoteCredentialsHolder().apply { - setHost(workspace.hostname) - userName = "coder" - port = 22 - authType = AuthType.OPEN_SSH - }, - ), - ) - val handle = client.connect(URI(joinLink)) // Downloads the client too, if needed. - - // Reconnect if the join link changes. - logger.info("Launched ${workspace.ideName} client; beginning backend monitoring") - lifetime.coroutineScope.launch { - while (isActive) { - delay(5000) - val newStatus = ensureIDEBackend(accessor, workspace, ideDir, remoteProjectPath, logsDir, lifetime, status) - val newLink = newStatus?.joinLink - if (newLink != null && newLink != status?.joinLink) { - logger.info("${workspace.ideName} backend join link changed; updating") - // Unfortunately, updating the link is not a smooth - // reconnection. The client closes and is relaunched. - // Trying to reconnect without updating the link results in - // a fingerprint mismatch error. - handle.updateJoinLink(URI(newLink), true) - status = newStatus - } - } - } - - // Tie the lifetime and client together, and wait for the initial open. - suspendCancellableCoroutine { continuation -> - // Close the client if the user cancels. - lifetime.onTermination { - logger.info("Connection to ${workspace.ideName} on ${workspace.hostname} canceled") - if (continuation.isActive) { - continuation.cancel() - } - handle.close() - } - // Kill the lifetime if the client is closed by the user. - handle.clientClosed.advise(lifetime) { - logger.info("${workspace.ideName} client to ${workspace.hostname} closed") - if (lifetime.status == LifetimeStatus.Alive) { - if (continuation.isActive) { - continuation.resumeWithException(Exception("${workspace.ideName} client was closed")) - } - lifetime.terminate() - } - } - // Continue once the client is present. - handle.onClientPresenceChanged.advise(lifetime) { - logger.info("${workspace.ideName} client to ${workspace.hostname} presence: ${handle.clientPresent}") - if (handle.clientPresent && continuation.isActive) { - continuation.resume(true) - } - } - } - } - - /** - * Deploy the IDE if necessary and return the path to its location on disk. - */ - private suspend fun deploy( - accessor: HighLevelHostAccessor, - workspace: WorkspaceProjectIDE, - indicator: ProgressIndicator, - timeout: Duration, - ): ShellArgument.RemotePath { - // The backend might already exist at the provided path. - if (!workspace.idePathOnHost.isNullOrBlank()) { - indicator.text = "Verifying ${workspace.ideName} installation..." - logger.info("Verifying ${workspace.ideName} exists at ${workspace.hostname}:${workspace.idePathOnHost}") - val validatedPath = validateIDEInstallPath(workspace.idePathOnHost, accessor).pathOrNull - if (validatedPath != null) { - logger.info("${workspace.ideName} exists at ${workspace.hostname}:${validatedPath.toRawString()}") - return validatedPath - } - } - - // The backend might already be installed somewhere on the system. - indicator.text = "Searching for ${workspace.ideName} installation..." - logger.info("Searching for ${workspace.ideName} on ${workspace.hostname}") - val installed = - accessor.getInstalledIDEs().find { - it.product == workspace.ideProduct && it.buildNumber == workspace.ideBuildNumber - } - if (installed != null) { - logger.info("${workspace.ideName} found at ${workspace.hostname}:${installed.pathToIde}") - return accessor.makeRemotePath(ShellArgument.PlainText(installed.pathToIde)) - } - - // Otherwise we have to download it. - if (workspace.downloadSource.isNullOrBlank()) { - throw Exception("${workspace.ideName} could not be found on the remote and no download source was provided") - } - - // TODO: Should we download to idePathOnHost if set? That would require - // symlinking instead of creating the sentinel file if the path is - // outside the default dist directory. - indicator.text = "Downloading ${workspace.ideName}..." - indicator.text2 = workspace.downloadSource - val distDir = accessor.getDefaultDistDir() - - // HighLevelHostAccessor.downloadFile does NOT create the directory. - logger.info("Creating ${workspace.hostname}:${distDir.toRawString()}") - accessor.createPathOnRemote(distDir) - - // Download the IDE. - val fileName = workspace.downloadSource.split("/").last() - val downloadPath = distDir.join(listOf(ShellArgument.PlainText(fileName))) - logger.info("Downloading ${workspace.ideName} to ${workspace.hostname}:${downloadPath.toRawString()} from ${workspace.downloadSource}") - accessor.downloadFile( - indicator, - URI(workspace.downloadSource), - downloadPath, - object : TransferProgressTracker { - override var isCancelled: Boolean = false - - override fun updateProgress( - transferred: Long, - speed: Long?, - ) { - // Since there is no total size, this is useless. - } - }, - ) - - // Extract the IDE to its final resting place. - val ideDir = distDir.join(listOf(ShellArgument.PlainText(workspace.ideName))) - indicator.text = "Extracting ${workspace.ideName}..." - indicator.text2 = "" - logger.info("Extracting ${workspace.ideName} to ${workspace.hostname}:${ideDir.toRawString()}") - accessor.removePathOnRemote(ideDir) - accessor.expandArchive(downloadPath, ideDir, timeout.toMillis()) - accessor.removePathOnRemote(downloadPath) - - // Without this file it does not show up in the installed IDE list. - val sentinelFile = ideDir.join(listOf(ShellArgument.PlainText(".expandSucceeded"))).toRawString() - logger.info("Creating ${workspace.hostname}:$sentinelFile") - accessor.fileAccessor.uploadFileFromLocalStream( - sentinelFile, - "".byteInputStream(), - null, - ) - - logger.info("Successfully installed ${workspace.ideName} on ${workspace.hostname}") - return ideDir - } - - /** - * Run the setup command in the IDE's bin directory. - */ - private fun setup( - workspace: WorkspaceProjectIDE, - indicator: ProgressIndicator, - setupCommand: String, - ignoreSetupFailure: Boolean, - ) { - if (setupCommand.isNotBlank()) { - indicator.text = "Running setup command..." - try { - exec(workspace, setupCommand) - } catch (ex: Exception) { - if (!ignoreSetupFailure) { - throw ex - } - } - } else { - logger.info("No setup command to run on ${workspace.hostname}") - } - } - - /** - * Execute a command in the IDE's bin directory. - * This exists since the accessor does not provide a generic exec. - */ - private fun exec(workspace: WorkspaceProjectIDE, command: String): String { - logger.info("Running command `$command` in ${workspace.hostname}:${workspace.idePathOnHost}/bin...") - return ProcessExecutor() - .command("ssh", "-t", CoderCLIManager.getBackgroundHostName(workspace.hostname), "cd '${workspace.idePathOnHost}' ; cd bin ; $command") - .exitValues(0) - .readOutput(true) - .execute() - .outputUTF8() - } - - /** - * Ensure the backend is started. It will not return until a join link is - * received or the lifetime expires. - */ - private suspend fun ensureIDEBackend( - accessor: HighLevelHostAccessor, - workspace: WorkspaceProjectIDE, - ideDir: ShellArgument.RemotePath, - remoteProjectPath: ShellArgument.RemotePath, - logsDir: ShellArgument.RemotePath, - lifetime: LifetimeDefinition, - currentStatus: UnattendedHostStatus?, - ): UnattendedHostStatus? { - val details = "${workspace.hostname}:${ideDir.toRawString()}, project=${remoteProjectPath.toRawString()}" - val wait = TimeUnit.SECONDS.toMillis(5) - - // Check if the current IDE is alive. - if (currentStatus != null) { - while (lifetime.status == LifetimeStatus.Alive) { - try { - val isAlive = accessor.isPidAlive(currentStatus.appPid.toInt()) - logger.info("${workspace.ideName} status: pid=${currentStatus.appPid}, alive=$isAlive") - if (isAlive) { - // Use the current status and join link. - return currentStatus - } else { - logger.info("Relaunching ${workspace.ideName} since it is not alive...") - break - } - } catch (ex: Exception) { - logger.info("Failed to check if ${workspace.ideName} is alive on $details; waiting $wait ms to try again: pid=${currentStatus.appPid}", ex) - } - delay(wait) - } - } else { - logger.info("Launching ${workspace.ideName} for the first time on ${workspace.hostname}...") - } - - // This means we broke out because the user canceled or closed the IDE. - if (lifetime.status != LifetimeStatus.Alive) { - return null - } - - // If the PID is not alive, spawn a new backend. This may not be - // idempotent, so only call if we are really sure we need to. - accessor.startHostIdeInBackgroundAndDetach(lifetime, ideDir, remoteProjectPath, logsDir) - - // Get the newly spawned PID and join link. - var attempts = 0 - val maxAttempts = 6 - while (lifetime.status == LifetimeStatus.Alive) { - try { - attempts++ - val status = accessor.getHostIdeStatus(ideDir, remoteProjectPath) - if (!status.joinLink.isNullOrBlank()) { - logger.info("Found join link for ${workspace.ideName}; proceeding to connect: pid=${status.appPid}") - return status - } - // If we did not get a join link, see if the IDE is alive in - // case it died and we need to respawn. - val isAlive = status.appPid > 0 && accessor.isPidAlive(status.appPid.toInt()) - logger.info("${workspace.ideName} status: pid=${status.appPid}, alive=$isAlive, unresponsive=${status.backendUnresponsive}, attempt=$attempts") - // It is not clear whether the PID can be trusted because we get - // one even when there is no backend at all. For now give it - // some time and if it is still dead, only then try to respawn. - if (!isAlive && attempts >= maxAttempts) { - logger.info("${workspace.ideName} is still not alive after $attempts checks, respawning backend and waiting $wait ms to try again") - accessor.startHostIdeInBackgroundAndDetach(lifetime, ideDir, remoteProjectPath, logsDir) - attempts = 0 - } else { - logger.info("No join link found in status; waiting $wait ms to try again") - } - } catch (ex: Exception) { - logger.info("Failed to get ${workspace.ideName} status from $details; waiting $wait ms to try again", ex) - } - delay(wait) - } - - // This means the lifetime is no longer alive. - logger.info("Connection to ${workspace.ideName} on $details aborted by user") - return null - } - - companion object { - val logger = Logger.getInstance(CoderRemoteConnectionHandle::class.java.simpleName) - } -} diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteEnvironment.kt new file mode 100644 index 000000000..b4d7c43ed --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteEnvironment.kt @@ -0,0 +1,130 @@ +package com.coder.gateway + +import com.coder.gateway.models.WorkspaceAndAgentStatus +import com.coder.gateway.sdk.CoderRestClient +import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceAgent +import com.coder.gateway.util.withPath +import com.coder.gateway.views.Action +import com.coder.gateway.views.EnvironmentView +import com.jetbrains.toolbox.gateway.AbstractRemoteProviderEnvironment +import com.jetbrains.toolbox.gateway.EnvironmentVisibilityState +import com.jetbrains.toolbox.gateway.environments.EnvironmentContentsView +import com.jetbrains.toolbox.gateway.states.EnvironmentStateConsumer +import com.jetbrains.toolbox.gateway.ui.ObservablePropertiesFactory +import com.jetbrains.toolbox.gateway.ui.ToolboxUi +import java.util.concurrent.CompletableFuture + +/** + * Represents an agent and workspace combination. + * + * Used in the environment list view. + */ +class CoderRemoteEnvironment( + private val client: CoderRestClient, + private var workspace: Workspace, + private var agent: WorkspaceAgent, + private val ui: ToolboxUi, + observablePropertiesFactory: ObservablePropertiesFactory, +) : AbstractRemoteProviderEnvironment(observablePropertiesFactory) { + override fun getId(): String = "${workspace.name}.${agent.name}" + override fun getName(): String = "${workspace.name}.${agent.name}" + private var status = WorkspaceAndAgentStatus.from(workspace, agent) + + init { + actionsList.add( + Action("Open web terminal") { + ui.openUrl(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) + }, + ) + actionsList.add( + Action("Open in dashboard") { + ui.openUrl(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()) + }, + ) + actionsList.add( + Action("View template") { + ui.openUrl(client.url.withPath("/templates/${workspace.templateName}").toString()) + }, + ) + actionsList.add( + Action("Start", enabled = { status.canStart() }) { + val build = client.startWorkspace(workspace) + workspace = workspace.copy(latestBuild = build) + update(workspace, agent) + }, + ) + actionsList.add( + Action("Stop", enabled = { status.ready() || status.pending() }) { + val build = client.stopWorkspace(workspace) + workspace = workspace.copy(latestBuild = build) + update(workspace, agent) + }, + ) + actionsList.add( + Action("Update", enabled = { workspace.outdated }) { + val build = client.updateWorkspace(workspace) + workspace = workspace.copy(latestBuild = build) + update(workspace, agent) + }, + ) + } + + /** + * Update the workspace/agent status to the listeners, if it has changed. + */ + fun update(workspace: Workspace, agent: WorkspaceAgent) { + this.workspace = workspace + this.agent = agent + val newStatus = WorkspaceAndAgentStatus.from(workspace, agent) + if (newStatus != status) { + status = newStatus + val state = status.toRemoteEnvironmentState() + listenerSet.forEach { it.consume(state) } + } + } + + /** + * The contents are provided by the SSH view provided by Toolbox, all we + * have to do is provide it a host name. + */ + override fun getContentsView(): CompletableFuture<EnvironmentContentsView> = + CompletableFuture.completedFuture(EnvironmentView(client.url, workspace, agent)) + + /** + * Does nothing. In theory we could do something like start the workspace + * when you click into the workspace but you would still need to press + * "connect" anyway before the content is populated so there does not seem + * to be much value. + */ + override fun setVisible(visibilityState: EnvironmentVisibilityState) {} + + /** + * Immediately send the state to the listener and store for updates. + */ + override fun addStateListener(consumer: EnvironmentStateConsumer): Boolean { + // TODO@JB: It would be ideal if we could have the workspace state and + // the connected state listed separately, since right now the + // connected state can mask the workspace state. + // TODO@JB: You can still press connect if the environment is + // unreachable. Is that expected? + consumer.consume(status.toRemoteEnvironmentState()) + return super.addStateListener(consumer) + } + + /** + * An environment is equal if it has the same ID. + */ + override fun equals(other: Any?): Boolean { + if (other == null) return false + if (this === other) return true // Note the triple === + if (other !is CoderRemoteEnvironment) return false + if (getId() != other.getId()) return false + return true + } + + /** + * Companion to equals, for sets. + */ + override fun hashCode(): Int = getId().hashCode() +} diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteProvider.kt new file mode 100644 index 000000000..777b1f812 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteProvider.kt @@ -0,0 +1,361 @@ +package com.coder.gateway + +import com.coder.gateway.cli.CoderCLIManager +import com.coder.gateway.sdk.CoderRestClient +import com.coder.gateway.sdk.v2.models.WorkspaceStatus +import com.coder.gateway.services.CoderSecretsService +import com.coder.gateway.services.CoderSettingsService +import com.coder.gateway.settings.CoderSettings +import com.coder.gateway.settings.Source +import com.coder.gateway.util.DialogUi +import com.coder.gateway.util.LinkHandler +import com.coder.gateway.util.toQueryParameters +import com.coder.gateway.views.Action +import com.coder.gateway.views.CoderSettingsPage +import com.coder.gateway.views.ConnectPage +import com.coder.gateway.views.NewEnvironmentPage +import com.coder.gateway.views.SignInPage +import com.coder.gateway.views.TokenPage +import com.jetbrains.toolbox.gateway.PluginSecretStore +import com.jetbrains.toolbox.gateway.PluginSettingsStore +import com.jetbrains.toolbox.gateway.ProviderVisibilityState +import com.jetbrains.toolbox.gateway.RemoteEnvironmentConsumer +import com.jetbrains.toolbox.gateway.RemoteProvider +import com.jetbrains.toolbox.gateway.ui.AccountDropdownField +import com.jetbrains.toolbox.gateway.ui.ObservablePropertiesFactory +import com.jetbrains.toolbox.gateway.ui.RunnableActionDescription +import com.jetbrains.toolbox.gateway.ui.ToolboxUi +import com.jetbrains.toolbox.gateway.ui.UiPage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.slf4j.LoggerFactory +import java.net.URI +import java.net.URL +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds + +class CoderRemoteProvider( + private val httpClient: OkHttpClient, + private val consumer: RemoteEnvironmentConsumer, + private val coroutineScope: CoroutineScope, + private val ui: ToolboxUi, + settingsStore: PluginSettingsStore, + secretsStore: PluginSecretStore, + private val observablePropertiesFactory: ObservablePropertiesFactory, +) : RemoteProvider { + private val logger = LoggerFactory.getLogger(javaClass) + + // Current polling job. + private var pollJob: Job? = null + private var lastEnvironments: Set<CoderRemoteEnvironment>? = null + + // Create our services from the Toolbox ones. + private val settingsService = CoderSettingsService(settingsStore) + private val settings: CoderSettings = CoderSettings(settingsService) + private val secrets: CoderSecretsService = CoderSecretsService(secretsStore) + private val settingsPage: CoderSettingsPage = CoderSettingsPage(settingsService) + private val dialogUi = DialogUi(settings, ui) + private val linkHandler = LinkHandler(settings, httpClient, dialogUi) + + // The REST client, if we are signed in. + private var client: CoderRestClient? = null + + // If we have an error in the polling we store it here before going back to + // sign-in page, so we can display it there. This is mainly because there + // does not seem to be a mechanism to show errors on the environment list. + private var pollError: Exception? = null + + // On the first load, automatically log in if we can. + private var firstRun = true + + /** + * With the provided client, start polling for workspaces. Every time a new + * workspace is added, reconfigure SSH using the provided cli (including the + * first time). + */ + private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = coroutineScope.launch { + while (isActive) { + try { + logger.debug("Fetching workspace agents from {}", client.url) + val environments = client.workspaces().flatMap { ws -> + // Agents are not included in workspaces that are off + // so fetch them separately. + when (ws.latestBuild.status) { + WorkspaceStatus.RUNNING -> ws.latestBuild.resources + else -> emptyList() + }.ifEmpty { + client.resources(ws) + }.flatMap { resource -> + resource.agents?.distinctBy { + // There can be duplicates with coder_agent_instance. + // TODO: Can we just choose one or do they hold + // different information? + it.name + }?.map { agent -> + // If we have an environment already, update that. + val env = CoderRemoteEnvironment(client, ws, agent, ui, observablePropertiesFactory) + lastEnvironments?.firstOrNull { it == env }?.let { + it.update(ws, agent) + it + } ?: env + } ?: emptyList() + } + }.toSet() + + // In case we logged out while running the query. + if (!isActive) { + return@launch + } + + // Reconfigure if a new environment is found. + // TODO@JB: Should we use the add/remove listeners instead? + val newEnvironments = lastEnvironments + ?.let { environments.subtract(it) } + ?: environments + if (newEnvironments.isNotEmpty()) { + logger.info("Found new environment(s), reconfiguring CLI: {}", newEnvironments) + cli.configSsh(newEnvironments.map { it.name }.toSet()) + } + + consumer.consumeEnvironments(environments) + + lastEnvironments = environments + } catch (_: CancellationException) { + logger.debug("{} polling loop canceled", client.url) + break + } catch (ex: Exception) { + logger.info("setting exception $ex") + pollError = ex + logout() + break + } + // TODO: Listening on a web socket might be better? + delay(5.seconds) + } + } + + /** + * Stop polling, clear the client and environments, then go back to the + * first page. + */ + private fun logout() { + // Keep the URL and token to make it easy to log back in, but set + // rememberMe to false so we do not try to automatically log in. + secrets.rememberMe = "false" + close() + reset() + } + + /** + * A dropdown that appears at the top of the environment list to the right. + */ + override fun getAccountDropDown(): AccountDropdownField? { + val username = client?.me?.username + if (username != null) { + return AccountDropdownField(username) { + logout() + } + } + return null + } + + /** + * List of actions that appear next to the account. + */ + override fun getAdditionalPluginActions(): List<RunnableActionDescription> = listOf( + Action("Settings", closesPage = false) { + ui.showUiPage(settingsPage) + }, + ) + + /** + * Cancel polling and clear the client and environments. + * + * Called as part of our own logout but it is unclear where it is called by + * Toolbox. Maybe on uninstall? + */ + override fun close() { + pollJob?.cancel() + client = null + lastEnvironments = null + consumer.consumeEnvironments(emptyList()) + } + + override fun getName(): String = "Coder Gateway" + override fun getSvgIcon(): ByteArray = + this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf() + override fun getNoEnvironmentsSvgIcon(): ByteArray = + this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf() + + /** + * TODO@JB: It would be nice to show "loading workspaces" at first but it + * appears to be only called once. + */ + override fun getNoEnvironmentsDescription(): String = "No workspaces yet" + + /** + * TODO@JB: Supposedly, setting this to false causes the new environment + * page to not show but it shows anyway. For now we have it + * displaying the deployment URL, which is actually useful, so if + * this changes it would be nice to have a new spot to show the + * URL. + */ + override fun canCreateNewEnvironments(): Boolean = false + + /** + * Just displays the deployment URL at the moment, but we could use this as + * a form for creating new environments. + */ + override fun getNewEnvironmentUiPage(): UiPage = NewEnvironmentPage(client?.url?.toString()) + + /** + * We always show a list of environments. + */ + override fun isSingleEnvironment(): Boolean = false + + /** + * TODO: Possibly a good idea to start/stop polling based on visibility, at + * the cost of momentarily stale data. It would not be bad if we had + * a place to put a timer ("last updated 10 seconds ago" for example) + * and a manual refresh button. + */ + override fun setVisible(visibilityState: ProviderVisibilityState) {} + + /** + * Ignored; unsure if we should use this over the consumer we get passed in. + */ + override fun addEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} + + /** + * Ignored; unsure if we should use this over the consumer we get passed in. + */ + override fun removeEnvironmentsListener(listener: RemoteEnvironmentConsumer) {} + + /** + * Handle incoming links (like from the dashboard). + */ + override fun handleUri(uri: URI) { + val params = uri.toQueryParameters() + val name = linkHandler.handle(params) + // TODO@JB: Now what? How do we actually connect this workspace? + logger.debug("External request for {}: {}", name, uri) + } + + /** + * Make Toolbox ask for the page again. Use any time we need to change the + * root page (for example, sign-in or the environment list). + * + * When moving between related pages, instead use ui.showUiPage() and + * ui.hideUiPage() which stacks and has built-in back navigation, rather + * than using multiple root pages. + */ + private fun reset() { + ui.showPluginEnvironmentsPage() + } + + /** + * Return the sign-in page if we do not have a valid client. + + * Otherwise return null, which causes Toolbox to display the environment + * list. + */ + override fun getOverrideUiPage(): UiPage? { + // Show sign in page if we have not configured the client yet. + if (client == null) { + // When coming back to the application, authenticate immediately. + val autologin = firstRun && secrets.rememberMe == "true" + var autologinEx: Exception? = null + secrets.lastToken.let { lastToken -> + secrets.lastDeploymentURL.let { lastDeploymentURL -> + if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { + try { + return createConnectPage(URL(lastDeploymentURL), lastToken) + } catch (ex: Exception) { + autologinEx = ex + } + } + } + } + firstRun = false + + // Login flow. + val signInPage = SignInPage(getDeploymentURL()) { deploymentURL -> + ui.showUiPage( + TokenPage(deploymentURL, getToken(deploymentURL)) { selectedToken -> + ui.showUiPage(createConnectPage(deploymentURL, selectedToken)) + }, + ) + } + + // We might have tried and failed to automatically log in. + autologinEx?.let { signInPage.notify("Error logging in", it) } + // We might have navigated here due to a polling error. + pollError?.let { signInPage.notify("Error fetching workspaces", it) } + + return signInPage + } + return null + } + + /** + * Create a connect page that starts polling and resets the UI on success. + */ + private fun createConnectPage(deploymentURL: URL, token: String?): ConnectPage = ConnectPage( + deploymentURL, + token, + settings, + httpClient, + coroutineScope, + { reset() }, + ) { client, cli -> + // Store the URL and token for use next time. + secrets.lastDeploymentURL = client.url.toString() + secrets.lastToken = client.token ?: "" + // Currently we always remember, but this could be made an option. + secrets.rememberMe = "true" + this.client = client + pollError = null + pollJob?.cancel() + pollJob = poll(client, cli) + reset() + } + + /** + * Try to find a token. + * + * Order of preference: + * + * 1. Last used token, if it was for this deployment. + * 2. Token on disk for this deployment. + * 3. Global token for Coder, if it matches the deployment. + */ + private fun getToken(deploymentURL: URL): Pair<String, Source>? = secrets.lastToken.let { + if (it.isNotBlank() && secrets.lastDeploymentURL == deploymentURL.toString()) { + it to Source.LAST_USED + } else { + settings.token(deploymentURL) + } + } + + /** + * Try to find a URL. + * + * In order of preference: + * + * 1. Last used URL. + * 2. URL in settings. + * 3. CODER_URL. + * 4. URL in global cli config. + */ + private fun getDeploymentURL(): Pair<String, Source>? = secrets.lastDeploymentURL.let { + if (it.isNotBlank()) { + it to Source.LAST_USED + } else { + settings.defaultURL() + } + } +} diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt deleted file mode 100644 index 5fb9e428c..000000000 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.coder.gateway - -import com.coder.gateway.services.CoderSettingsService -import com.coder.gateway.services.CoderSettingsStateService -import com.coder.gateway.settings.CODER_SSH_CONFIG_OPTIONS -import com.coder.gateway.util.canCreateDirectory -import com.intellij.openapi.components.service -import com.intellij.openapi.options.BoundConfigurable -import com.intellij.openapi.ui.DialogPanel -import com.intellij.openapi.ui.ValidationInfo -import com.intellij.ui.components.JBTextField -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.RowLayout -import com.intellij.ui.dsl.builder.bindSelected -import com.intellij.ui.dsl.builder.bindText -import com.intellij.ui.dsl.builder.panel -import com.intellij.ui.layout.ValidationInfoBuilder -import java.net.URL -import java.nio.file.Path - -class CoderSettingsConfigurable : BoundConfigurable("Coder") { - override fun createPanel(): DialogPanel { - val state: CoderSettingsStateService = service() - val settings: CoderSettingsService = service<CoderSettingsService>() - return panel { - row(CoderGatewayBundle.message("gateway.connector.settings.data-directory.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::dataDirectory) - .validationOnApply(validateDataDirectory()) - .validationOnInput(validateDataDirectory()) - .comment( - CoderGatewayBundle.message( - "gateway.connector.settings.data-directory.comment", - settings.dataDir.toString(), - ), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.binary-source.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::binarySource) - .comment( - CoderGatewayBundle.message( - "gateway.connector.settings.binary-source.comment", - settings.binSource(URL("http://localhost")).path, - ), - ) - }.layout(RowLayout.PARENT_GRID) - row { - cell() // For alignment. - checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-downloads.title")) - .bindSelected(state::enableDownloads) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.enable-downloads.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - // The binary directory is not validated because it could be a - // read-only path that is pre-downloaded by admins. - row(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::binaryDirectory) - .comment(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.comment")) - }.layout(RowLayout.PARENT_GRID) - row { - cell() // For alignment. - checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.title")) - .bindSelected(state::enableBinaryDirectoryFallback) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::headerCommand) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.header-command.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.tls-cert-path.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::tlsCertPath) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.tls-cert-path.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.tls-key-path.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::tlsKeyPath) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.tls-key-path.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.tls-ca-path.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::tlsCAPath) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.tls-ca-path.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.tls-alt-name.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::tlsAlternateHostname) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.tls-alt-name.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.heading")) { - checkBox(CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.title")) - .bindSelected(state::disableAutostart) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.title")) { - textArea().resizableColumn().align(AlignX.FILL) - .bindText(state::sshConfigOptions) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.comment", CODER_SSH_CONFIG_OPTIONS), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.setup-command.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::setupCommand) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.setup-command.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row { - cell() // For alignment. - checkBox(CoderGatewayBundle.message("gateway.connector.settings.ignore-setup-failure.title")) - .bindSelected(state::ignoreSetupFailure) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.ignore-setup-failure.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.default-url.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::defaultURL) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.default-url.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row(CoderGatewayBundle.message("gateway.connector.settings.ssh-log-directory.title")) { - textField().resizableColumn().align(AlignX.FILL) - .bindText(state::sshLogDirectory) - .comment(CoderGatewayBundle.message("gateway.connector.settings.ssh-log-directory.comment")) - }.layout(RowLayout.PARENT_GRID) - } - } - - private fun validateDataDirectory(): ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = - { - if (it.text.isNotBlank() && !Path.of(it.text).canCreateDirectory()) { - error("Cannot create this directory") - } else { - null - } - } -} diff --git a/src/main/kotlin/com/coder/gateway/CoderSupportedVersions.kt b/src/main/kotlin/com/coder/gateway/CoderSupportedVersions.kt deleted file mode 100644 index a955f7c9f..000000000 --- a/src/main/kotlin/com/coder/gateway/CoderSupportedVersions.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.coder.gateway - -import com.coder.gateway.util.SemVer -import com.intellij.DynamicBundle -import org.jetbrains.annotations.NonNls -import org.jetbrains.annotations.PropertyKey - -@NonNls -private const val BUNDLE = "version.CoderSupportedVersions" - -object CoderSupportedVersions : DynamicBundle(BUNDLE) { - val minCompatibleCoderVersion = SemVer.parse(message("minCompatibleCoderVersion")) - val maxCompatibleCoderVersion = SemVer.parse(message("maxCompatibleCoderVersion")) - - @JvmStatic - @Suppress("SpreadOperator") - private fun message( - @PropertyKey(resourceBundle = BUNDLE) key: String, - vararg params: Any, - ) = getMessage(key, *params) -} diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index adef3871f..73e62e6ec 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -16,11 +16,11 @@ import com.coder.gateway.util.getHeaders import com.coder.gateway.util.getOS import com.coder.gateway.util.safeHost import com.coder.gateway.util.sha1 -import com.intellij.openapi.diagnostic.Logger import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonDataException import com.squareup.moshi.Moshi +import org.slf4j.LoggerFactory import org.zeroturnaround.exec.ProcessExecutor import java.io.EOFException import java.io.FileInputStream @@ -126,6 +126,8 @@ class CoderCLIManager( // manager to download to the data directory instead. forceDownloadToData: Boolean = false, ) { + private val logger = LoggerFactory.getLogger(javaClass) + val remoteBinaryURL: URL = settings.binSource(deploymentURL) val localBinaryPath: Path = settings.binPath(deploymentURL, forceDownloadToData) val coderConfigPath: Path = settings.dataDir(deploymentURL).resolve("config") @@ -474,8 +476,6 @@ class CoderCLIManager( } companion object { - val logger = Logger.getInstance(CoderCLIManager::class.java.simpleName) - private val tokenRegex = "--token [^ ]+".toRegex() @JvmStatic diff --git a/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt b/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt deleted file mode 100644 index 3f512ff3b..000000000 --- a/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.coder.gateway.help - -import com.intellij.openapi.help.WebHelpProvider - -const val ABOUT_HELP_TOPIC = "com.coder.gateway.about" - -class CoderWebHelp : WebHelpProvider() { - override fun getHelpPageUrl(helpTopicId: String): String = when (helpTopicId) { - ABOUT_HELP_TOPIC -> "https://coder.com/docs/coder-oss/latest" - else -> "https://coder.com/docs/coder-oss/latest" - } -} diff --git a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt deleted file mode 100644 index 9026af526..000000000 --- a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.coder.gateway.icons - -import com.intellij.openapi.util.IconLoader -import com.intellij.ui.JreHiDpiUtil -import com.intellij.ui.paint.PaintUtil -import com.intellij.ui.scale.JBUIScale -import java.awt.Component -import java.awt.Graphics -import java.awt.Graphics2D -import java.awt.image.BufferedImage -import javax.swing.Icon - -object CoderIcons { - val LOGO = IconLoader.getIcon("logo/coder_logo.svg", javaClass) - val LOGO_16 = IconLoader.getIcon("logo/coder_logo_16.svg", javaClass) - - val OPEN_TERMINAL = IconLoader.getIcon("icons/open_terminal.svg", javaClass) - - val HOME = IconLoader.getIcon("icons/homeFolder.svg", javaClass) - val CREATE = IconLoader.getIcon("icons/create.svg", javaClass) - val RUN = IconLoader.getIcon("icons/run.svg", javaClass) - val STOP = IconLoader.getIcon("icons/stop.svg", javaClass) - val UPDATE = IconLoader.getIcon("icons/update.svg", javaClass) - val DELETE = IconLoader.getIcon("icons/delete.svg", javaClass) - - val UNKNOWN = IconLoader.getIcon("icons/unknown.svg", javaClass) - - private val ZERO = IconLoader.getIcon("symbols/0.svg", javaClass) - private val ONE = IconLoader.getIcon("symbols/1.svg", javaClass) - private val TWO = IconLoader.getIcon("symbols/2.svg", javaClass) - private val THREE = IconLoader.getIcon("symbols/3.svg", javaClass) - private val FOUR = IconLoader.getIcon("symbols/4.svg", javaClass) - private val FIVE = IconLoader.getIcon("symbols/5.svg", javaClass) - private val SIX = IconLoader.getIcon("symbols/6.svg", javaClass) - private val SEVEN = IconLoader.getIcon("symbols/7.svg", javaClass) - private val EIGHT = IconLoader.getIcon("symbols/8.svg", javaClass) - private val NINE = IconLoader.getIcon("symbols/9.svg", javaClass) - - private val A = IconLoader.getIcon("symbols/a.svg", javaClass) - private val B = IconLoader.getIcon("symbols/b.svg", javaClass) - private val C = IconLoader.getIcon("symbols/c.svg", javaClass) - private val D = IconLoader.getIcon("symbols/d.svg", javaClass) - private val E = IconLoader.getIcon("symbols/e.svg", javaClass) - private val F = IconLoader.getIcon("symbols/f.svg", javaClass) - private val G = IconLoader.getIcon("symbols/g.svg", javaClass) - private val H = IconLoader.getIcon("symbols/h.svg", javaClass) - private val I = IconLoader.getIcon("symbols/i.svg", javaClass) - private val J = IconLoader.getIcon("symbols/j.svg", javaClass) - private val K = IconLoader.getIcon("symbols/k.svg", javaClass) - private val L = IconLoader.getIcon("symbols/l.svg", javaClass) - private val M = IconLoader.getIcon("symbols/m.svg", javaClass) - private val N = IconLoader.getIcon("symbols/n.svg", javaClass) - private val O = IconLoader.getIcon("symbols/o.svg", javaClass) - private val P = IconLoader.getIcon("symbols/p.svg", javaClass) - private val Q = IconLoader.getIcon("symbols/q.svg", javaClass) - private val R = IconLoader.getIcon("symbols/r.svg", javaClass) - private val S = IconLoader.getIcon("symbols/s.svg", javaClass) - private val T = IconLoader.getIcon("symbols/t.svg", javaClass) - private val U = IconLoader.getIcon("symbols/u.svg", javaClass) - private val V = IconLoader.getIcon("symbols/v.svg", javaClass) - private val W = IconLoader.getIcon("symbols/w.svg", javaClass) - private val X = IconLoader.getIcon("symbols/x.svg", javaClass) - private val Y = IconLoader.getIcon("symbols/y.svg", javaClass) - private val Z = IconLoader.getIcon("symbols/z.svg", javaClass) - - fun fromChar(c: Char) = - when (c) { - '0' -> ZERO - '1' -> ONE - '2' -> TWO - '3' -> THREE - '4' -> FOUR - '5' -> FIVE - '6' -> SIX - '7' -> SEVEN - '8' -> EIGHT - '9' -> NINE - - 'a' -> A - 'b' -> B - 'c' -> C - 'd' -> D - 'e' -> E - 'f' -> F - 'g' -> G - 'h' -> H - 'i' -> I - 'j' -> J - 'k' -> K - 'l' -> L - 'm' -> M - 'n' -> N - 'o' -> O - 'p' -> P - 'q' -> Q - 'r' -> R - 's' -> S - 't' -> T - 'u' -> U - 'v' -> V - 'w' -> W - 'x' -> X - 'y' -> Y - 'z' -> Z - - else -> UNKNOWN - } -} - -fun alignToInt(g: Graphics) { - if (g !is Graphics2D) { - return - } - - val rm = PaintUtil.RoundingMode.ROUND_FLOOR_BIAS - PaintUtil.alignTxToInt(g, null, true, true, rm) - PaintUtil.alignClipToInt(g, true, true, rm, rm) -} - -// We could replace this with com.intellij.ui.icons.toRetinaAwareIcon at -// some point if we want to break support for Gateway < 232. -fun toRetinaAwareIcon(image: BufferedImage): Icon { - val sysScale = JBUIScale.sysScale() - return object : Icon { - override fun paintIcon( - c: Component?, - g: Graphics, - x: Int, - y: Int, - ) { - if (isJreHiDPI) { - val newG = g.create(x, y, image.width, image.height) as Graphics2D - alignToInt(newG) - newG.scale(1.0 / sysScale, 1.0 / sysScale) - newG.drawImage(image, 0, 0, null) - newG.dispose() - } else { - g.drawImage(image, x, y, null) - } - } - - override fun getIconWidth(): Int = if (isJreHiDPI) (image.width / sysScale).toInt() else image.width - - override fun getIconHeight(): Int = if (isJreHiDPI) (image.height / sysScale).toInt() else image.height - - private val isJreHiDPI: Boolean - get() = JreHiDpiUtil.isJreHiDPI(sysScale) - - override fun toString(): String = "TemplateIconDownloader.toRetinaAwareIcon for $image" - } -} diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt deleted file mode 100644 index 17e03977f..000000000 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.coder.gateway.models - -import com.intellij.openapi.components.BaseState -import com.intellij.util.xmlb.annotations.Attribute - -/** - * A workspace, project, and IDE. - * - * This is read from a file so values could be missing, and names must not be - * changed to maintain backwards compatibility. - */ -class RecentWorkspaceConnection( - coderWorkspaceHostname: String? = null, - projectPath: String? = null, - lastOpened: String? = null, - ideProductCode: String? = null, - ideBuildNumber: String? = null, - downloadSource: String? = null, - idePathOnHost: String? = null, - // webTerminalLink and configDirectory are deprecated by deploymentURL. - webTerminalLink: String? = null, - configDirectory: String? = null, - name: String? = null, - deploymentURL: String? = null, -) : BaseState(), - Comparable<RecentWorkspaceConnection> { - @get:Attribute - var coderWorkspaceHostname by string() - - @get:Attribute - var projectPath by string() - - @get:Attribute - var lastOpened by string() - - @get:Attribute - var ideProductCode by string() - - @get:Attribute - var ideBuildNumber by string() - - @get:Attribute - var downloadSource by string() - - @get:Attribute - var idePathOnHost by string() - - @Deprecated("Derive from deploymentURL instead.") - @get:Attribute - var webTerminalLink by string() - - @Deprecated("Derive from deploymentURL instead.") - @get:Attribute - var configDirectory by string() - - @get:Attribute - var name by string() - - @get:Attribute - var deploymentURL by string() - - init { - this.coderWorkspaceHostname = coderWorkspaceHostname - this.projectPath = projectPath - this.lastOpened = lastOpened - this.ideProductCode = ideProductCode - this.ideBuildNumber = ideBuildNumber - this.downloadSource = downloadSource - this.idePathOnHost = idePathOnHost - @Suppress("DEPRECATION") - this.webTerminalLink = webTerminalLink - @Suppress("DEPRECATION") - this.configDirectory = configDirectory - this.deploymentURL = deploymentURL - this.name = name - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - if (!super.equals(other)) return false - - other as RecentWorkspaceConnection - - if (coderWorkspaceHostname != other.coderWorkspaceHostname) return false - if (projectPath != other.projectPath) return false - if (ideProductCode != other.ideProductCode) return false - if (ideBuildNumber != other.ideBuildNumber) return false - - return true - } - - override fun hashCode(): Int { - var result = super.hashCode() - result = 31 * result + (coderWorkspaceHostname?.hashCode() ?: 0) - result = 31 * result + (projectPath?.hashCode() ?: 0) - result = 31 * result + (ideProductCode?.hashCode() ?: 0) - result = 31 * result + (ideBuildNumber?.hashCode() ?: 0) - - return result - } - - override fun compareTo(other: RecentWorkspaceConnection): Int { - val i = other.coderWorkspaceHostname?.let { coderWorkspaceHostname?.compareTo(it) } - if (i != null && i != 0) return i - - val j = other.projectPath?.let { projectPath?.compareTo(it) } - if (j != null && j != 0) return j - - val k = other.ideProductCode?.let { ideProductCode?.compareTo(it) } - if (k != null && k != 0) return k - - val l = other.ideBuildNumber?.let { ideBuildNumber?.compareTo(it) } - if (l != null && l != 0) return l - - return 0 - } -} diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnectionState.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnectionState.kt deleted file mode 100644 index 0df1518d5..000000000 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnectionState.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.coder.gateway.models - -import com.intellij.openapi.components.BaseState -import com.intellij.util.xmlb.annotations.XCollection - -/** - * Store recent workspace connections. - */ -class RecentWorkspaceConnectionState : BaseState() { - @get:XCollection - var recentConnections by treeSet<RecentWorkspaceConnection>() - - fun add(connection: RecentWorkspaceConnection): Boolean { - // If the item is already there but with a different last updated - // timestamp or config directory, remove it. - recentConnections.remove(connection) - val result = recentConnections.add(connection) - if (result) incrementModificationCount() - return result - } - - fun remove(connection: RecentWorkspaceConnection): Boolean { - val result = recentConnections.remove(connection) - if (result) incrementModificationCount() - return result - } -} diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentListModel.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentListModel.kt deleted file mode 100644 index 3c7abadad..000000000 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentListModel.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.coder.gateway.models - -import com.coder.gateway.sdk.v2.models.Workspace -import com.coder.gateway.sdk.v2.models.WorkspaceAgent -import javax.swing.Icon - -// This represents a single row in the flattened agent list. It is either an -// agent with its associated workspace or a workspace with no agents, in which -// case it acts as a placeholder for performing actions on the workspace but -// cannot be connected to. -data class WorkspaceAgentListModel( - val workspace: Workspace, - // If this is missing, assume the workspace is off or has no agents. - val agent: WorkspaceAgent? = null, - // The icon of the template from which this workspace was created. - var icon: Icon? = null, - // The combined status of the workspace and agent to display on the row. - val status: WorkspaceAndAgentStatus = WorkspaceAndAgentStatus.from(workspace, agent), - // The combined `workspace.agent` name to display on the row. - val name: String = if (agent != null) "${workspace.name}.${agent.name}" else workspace.name, -) diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt index cbf331d95..5425e94ca 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt @@ -5,7 +5,8 @@ import com.coder.gateway.sdk.v2.models.WorkspaceAgent import com.coder.gateway.sdk.v2.models.WorkspaceAgentLifecycleState import com.coder.gateway.sdk.v2.models.WorkspaceAgentStatus import com.coder.gateway.sdk.v2.models.WorkspaceStatus -import com.intellij.ui.JBColor +import com.jetbrains.toolbox.gateway.states.Color +import com.jetbrains.toolbox.gateway.states.CustomRemoteEnvironmentState /** * WorkspaceAndAgentStatus represents the combined status of a single agent and @@ -47,13 +48,27 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { READY("Ready", "The agent is ready to accept connections."), ; - fun statusColor(): JBColor = - when (this) { - READY, AGENT_STARTING_READY, START_TIMEOUT_READY -> JBColor.GREEN - CREATED, START_ERROR, START_TIMEOUT, SHUTDOWN_TIMEOUT -> JBColor.YELLOW - FAILED, DISCONNECTED, TIMEOUT, SHUTDOWN_ERROR -> JBColor.RED - else -> if (JBColor.isBright()) JBColor.LIGHT_GRAY else JBColor.DARK_GRAY - } + /** + * Return the environment state for Toolbox, which tells it the label, color + * and whether the environment is reachable. + * + * Note that a reachable environment will always display "connected" or + * "disconnected" regardless of the label we give that status. + */ + fun toRemoteEnvironmentState(): CustomRemoteEnvironmentState { + // Use comments; no named arguments for non-Kotlin functions. + // TODO@JB: Is there a set of default colors we could use? + return CustomRemoteEnvironmentState( + label, + Color(200, 200, 200, 200), // darkThemeColor + Color(104, 112, 128, 255), // lightThemeColor + Color(224, 224, 240, 26), // darkThemeBackgroundColor + Color(224, 224, 245, 250), // lightThemeBackgroundColor + ready(), // reachable + // TODO@JB: How does this work? Would like a spinner for pending states. + null, // iconId + ) + } /** * Return true if the agent is in a connectable state. @@ -73,10 +88,16 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { */ fun pending(): Boolean { // See ready() for why `CREATED` is not in this list. - return listOf(CONNECTING, TIMEOUT, AGENT_STARTING, START_TIMEOUT) + return listOf(CONNECTING, TIMEOUT, AGENT_STARTING, START_TIMEOUT, QUEUED, STARTING) .contains(this) } + /** + * Return true if the workspace can be started. + */ + fun canStart(): Boolean = listOf(STOPPED, FAILED, CANCELED) + .contains(this) + // We want to check that the workspace is `running`, the agent is // `connected`, and the agent lifecycle state is `ready` to ensure the best // possible scenario for attempting a connection. diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt deleted file mode 100644 index c9ecd0b21..000000000 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.coder.gateway.models - -import com.intellij.openapi.diagnostic.Logger -import com.jetbrains.gateway.ssh.AvailableIde -import com.jetbrains.gateway.ssh.IdeStatus -import com.jetbrains.gateway.ssh.IdeWithStatus -import com.jetbrains.gateway.ssh.InstalledIdeUIEx -import com.jetbrains.gateway.ssh.IntelliJPlatformProduct -import com.jetbrains.gateway.ssh.deploy.ShellArgument -import java.net.URL -import java.nio.file.Path -import kotlin.io.path.name - -/** - * Validated parameters for downloading and opening a project using an IDE on a - * workspace. - */ -class WorkspaceProjectIDE( - val name: String, - val hostname: String, - val projectPath: String, - val ideProduct: IntelliJPlatformProduct, - val ideBuildNumber: String, - // One of these must exist; enforced by the constructor. - var idePathOnHost: String?, - val downloadSource: String?, - // These are used in the recent connections window. - val deploymentURL: URL, - var lastOpened: String?, // Null if never opened. -) { - val ideName = "${ideProduct.productCode}-$ideBuildNumber" - - private val maxDisplayLength = 35 - - /** - * A shortened path for displaying where space is tight. - */ - val projectPathDisplay = - if (projectPath.length <= maxDisplayLength) { - projectPath - } else { - "…" + projectPath.substring(projectPath.length - maxDisplayLength, projectPath.length) - } - - init { - if (idePathOnHost.isNullOrBlank() && downloadSource.isNullOrBlank()) { - throw Exception("A path to the IDE on the host or a download source is required") - } - } - - /** - * Convert parameters into a recent workspace connection (for storage). - */ - fun toRecentWorkspaceConnection(): RecentWorkspaceConnection = RecentWorkspaceConnection( - name = name, - coderWorkspaceHostname = hostname, - projectPath = projectPath, - ideProductCode = ideProduct.productCode, - ideBuildNumber = ideBuildNumber, - downloadSource = downloadSource, - idePathOnHost = idePathOnHost, - deploymentURL = deploymentURL.toString(), - lastOpened = lastOpened, - ) - - companion object { - val logger = Logger.getInstance(WorkspaceProjectIDE::class.java.simpleName) - - /** - * Create from unvalidated user inputs. - */ - @JvmStatic - fun fromInputs( - name: String?, - hostname: String?, - projectPath: String?, - deploymentURL: String?, - lastOpened: String?, - ideProductCode: String?, - ideBuildNumber: String?, - downloadSource: String?, - idePathOnHost: String?, - ): WorkspaceProjectIDE { - if (name.isNullOrBlank()) { - throw Exception("Workspace name is missing") - } else if (deploymentURL.isNullOrBlank()) { - throw Exception("Deployment URL is missing") - } else if (hostname.isNullOrBlank()) { - throw Exception("Host name is missing") - } else if (projectPath.isNullOrBlank()) { - throw Exception("Project path is missing") - } else if (ideProductCode.isNullOrBlank()) { - throw Exception("IDE product code is missing") - } else if (ideBuildNumber.isNullOrBlank()) { - throw Exception("IDE build number is missing") - } - - return WorkspaceProjectIDE( - name = name, - hostname = hostname, - projectPath = projectPath, - ideProduct = IntelliJPlatformProduct.fromProductCode(ideProductCode) ?: throw Exception("invalid product code"), - ideBuildNumber = ideBuildNumber, - idePathOnHost = idePathOnHost, - downloadSource = downloadSource, - deploymentURL = URL(deploymentURL), - lastOpened = lastOpened, - ) - } - } -} - -/** - * Convert into parameters for making a connection to a project using an IDE - * on a workspace. Throw if invalid. - */ -fun RecentWorkspaceConnection.toWorkspaceProjectIDE(): WorkspaceProjectIDE { - val hostname = coderWorkspaceHostname - - @Suppress("DEPRECATION") - val dir = configDirectory - return WorkspaceProjectIDE.fromInputs( - // The name was added to query the workspace status on the recent - // connections page, so it could be missing. Try to get it from the - // host name. - name = - if (name.isNullOrBlank() && !hostname.isNullOrBlank()) { - hostname - .removePrefix("coder-jetbrains--") - .removeSuffix("--${hostname.split("--").last()}") - } else { - name - }, - hostname = hostname, - projectPath = projectPath, - ideProductCode = ideProductCode, - ideBuildNumber = ideBuildNumber, - idePathOnHost = idePathOnHost, - downloadSource = downloadSource, - // The deployment URL was added to replace storing the web terminal link - // and config directory, as we can construct both from the URL and the - // config directory might not always exist (for example, authentication - // might happen with mTLS, and we can skip login which normally creates - // the config directory). For backwards compatibility with existing - // entries, extract the URL from the config directory or host name. - deploymentURL = - if (deploymentURL.isNullOrBlank()) { - if (!dir.isNullOrBlank()) { - "https://${Path.of(dir).parent.name}" - } else if (!hostname.isNullOrBlank()) { - "https://${hostname.split("--").last()}" - } else { - deploymentURL - } - } else { - deploymentURL - }, - lastOpened = lastOpened, - ) -} - -/** - * Convert an IDE into parameters for making a connection to a project using - * that IDE on a workspace. Throw if invalid. - */ -fun IdeWithStatus.withWorkspaceProject( - name: String, - hostname: String, - projectPath: String, - deploymentURL: URL, -): WorkspaceProjectIDE = WorkspaceProjectIDE( - name = name, - hostname = hostname, - projectPath = projectPath, - ideProduct = this.product, - ideBuildNumber = this.buildNumber, - downloadSource = this.download?.link, - idePathOnHost = this.pathOnHost, - deploymentURL = deploymentURL, - lastOpened = null, -) - -/** - * Convert an available IDE to an IDE with status. - */ -fun AvailableIde.toIdeWithStatus(): IdeWithStatus = IdeWithStatus( - product = product, - buildNumber = buildNumber, - status = IdeStatus.DOWNLOAD, - download = download, - pathOnHost = null, - presentableVersion = presentableVersion, - remoteDevType = remoteDevType, -) - -/** - * Convert an installed IDE to an IDE with status. - */ -fun InstalledIdeUIEx.toIdeWithStatus(): IdeWithStatus = IdeWithStatus( - product = product, - buildNumber = buildNumber, - status = IdeStatus.ALREADY_INSTALLED, - download = null, - pathOnHost = pathToIde, - presentableVersion = presentableVersion, - remoteDevType = remoteDevType, -) - -val remotePathRe = Regex("^[^(]+\\((.+)\\)$") - -fun ShellArgument.RemotePath.toRawString(): String { - // TODO: Surely there is an actual way to do this. - val remotePath = flatten().toString() - return remotePathRe.find(remotePath)?.groupValues?.get(1) - ?: throw Exception("Got invalid path $remotePath") -} diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt index 3969461ed..8df6fe88d 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt @@ -1,7 +1,5 @@ package com.coder.gateway.sdk -import com.coder.gateway.icons.CoderIcons -import com.coder.gateway.icons.toRetinaAwareIcon import com.coder.gateway.sdk.convertors.ArchConverter import com.coder.gateway.sdk.convertors.InstantConverter import com.coder.gateway.sdk.convertors.OSConverter @@ -24,15 +22,9 @@ import com.coder.gateway.util.coderTrustManagers import com.coder.gateway.util.getArch import com.coder.gateway.util.getHeaders import com.coder.gateway.util.getOS -import com.coder.gateway.util.toURL -import com.coder.gateway.util.withPath -import com.intellij.util.ImageLoader -import com.intellij.util.ui.ImageUtil import com.squareup.moshi.Moshi import okhttp3.Credentials import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import org.imgscalr.Scalr import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import java.net.HttpURLConnection @@ -40,7 +32,6 @@ import java.net.ProxySelector import java.net.URL import java.util.UUID import javax.net.ssl.X509TrustManager -import javax.swing.Icon /** * Holds proxy information. @@ -126,8 +117,6 @@ open class CoderRestClient( } it.proceed(request) } - // This should always be last if we want to see previous interceptors logged. - .addInterceptor(HttpLoggingInterceptor().apply { setLevel(HttpLoggingInterceptor.Level.BASIC) }) .build() retroRestClient = @@ -266,33 +255,4 @@ open class CoderRestClient( } return buildResponse.body()!! } - - private val iconCache = mutableMapOf<Pair<String, String>, Icon>() - - fun loadIcon( - path: String, - workspaceName: String, - ): Icon { - var iconURL: URL? = null - if (path.startsWith("http")) { - iconURL = path.toURL() - } else if (!path.contains(":") && !path.contains("//")) { - iconURL = url.withPath(path) - } - - if (iconURL != null) { - val cachedIcon = iconCache[Pair(workspaceName, path)] - if (cachedIcon != null) { - return cachedIcon - } - val img = ImageLoader.loadFromUrl(iconURL) - if (img != null) { - val icon = toRetinaAwareIcon(Scalr.resize(ImageUtil.toBufferedImage(img), Scalr.Method.ULTRA_QUALITY, 32)) - iconCache[Pair(workspaceName, path)] = icon - return icon - } - } - - return CoderIcons.fromChar(workspaceName.lowercase().first()) - } } diff --git a/src/main/kotlin/com/coder/gateway/sdk/ex/APIResponseException.kt b/src/main/kotlin/com/coder/gateway/sdk/ex/APIResponseException.kt index eceb972fa..55bea1706 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/ex/APIResponseException.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/ex/APIResponseException.kt @@ -7,9 +7,20 @@ import java.net.URL class APIResponseException(action: String, url: URL, res: retrofit2.Response<*>) : IOException( "Unable to $action: url=$url, code=${res.code()}, details=${ - res.errorBody()?.charStream()?.use { - it.readText() - } ?: "no details provided"}", + when (res.code()) { + HttpURLConnection.HTTP_NOT_FOUND -> "The requested resource could not be found" + else -> res.errorBody()?.charStream()?.use { + val text = it.readText() + // Be careful with the length because if you try to show a + // notification in Toolbox that is too large it crashes the + // application. + if (text.length > 500) { + "${text.substring(0, 500)}…" + } else { + text + } + } ?: "no details provided" + }}", ) { val isUnauthorized = res.code() == HttpURLConnection.HTTP_UNAUTHORIZED } diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt index 84b641d45..fad62c92b 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt @@ -1,6 +1,5 @@ package com.coder.gateway.sdk.v2.models -import com.coder.gateway.models.WorkspaceAgentListModel import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import java.util.UUID @@ -19,14 +18,5 @@ data class Workspace( @Json(name = "latest_build") val latestBuild: WorkspaceBuild, @Json(name = "outdated") val outdated: Boolean, @Json(name = "name") val name: String, + @Json(name = "owner_name") val ownerName: String, ) - -/** - * Return a list of agents combined with this workspace to display in the list. - * If the workspace has no agents, return just itself with a null agent. - */ -fun Workspace.toAgentList(resources: List<WorkspaceResource> = this.latestBuild.resources): List<WorkspaceAgentListModel> = resources.filter { it.agents != null }.flatMap { it.agents!! }.map { agent -> - WorkspaceAgentListModel(this, agent) -}.ifEmpty { - listOf(WorkspaceAgentListModel(this)) -} diff --git a/src/main/kotlin/com/coder/gateway/services/CoderRecentWorkspaceConnectionsService.kt b/src/main/kotlin/com/coder/gateway/services/CoderRecentWorkspaceConnectionsService.kt deleted file mode 100644 index 72ef4a168..000000000 --- a/src/main/kotlin/com/coder/gateway/services/CoderRecentWorkspaceConnectionsService.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.coder.gateway.services - -import com.coder.gateway.models.RecentWorkspaceConnection -import com.coder.gateway.models.RecentWorkspaceConnectionState -import com.intellij.openapi.components.PersistentStateComponent -import com.intellij.openapi.components.RoamingType -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage -import com.intellij.openapi.diagnostic.Logger - -@Service(Service.Level.APP) -@State( - name = "CoderRecentWorkspaceConnections", - storages = [Storage("coder-recent-workspace-connections.xml", roamingType = RoamingType.DISABLED, exportable = true)], -) -class CoderRecentWorkspaceConnectionsService : PersistentStateComponent<RecentWorkspaceConnectionState> { - private var myState = RecentWorkspaceConnectionState() - - fun addRecentConnection(connection: RecentWorkspaceConnection) = myState.add(connection) - - fun removeConnection(connection: RecentWorkspaceConnection) = myState.remove(connection) - - fun getAllRecentConnections() = myState.recentConnections - - override fun getState(): RecentWorkspaceConnectionState = myState - - override fun loadState(loadedState: RecentWorkspaceConnectionState) { - myState = loadedState - } - - override fun noStateLoaded() { - logger.info("No Coder recent connections loaded") - } - - companion object { - val logger = Logger.getInstance(CoderRecentWorkspaceConnectionsService::class.java.simpleName) - } -} diff --git a/src/main/kotlin/com/coder/gateway/services/CoderRestClientService.kt b/src/main/kotlin/com/coder/gateway/services/CoderRestClientService.kt deleted file mode 100644 index 77374c4e2..000000000 --- a/src/main/kotlin/com/coder/gateway/services/CoderRestClientService.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.coder.gateway.services - -import com.coder.gateway.sdk.CoderRestClient -import com.coder.gateway.sdk.ProxyValues -import com.intellij.ide.plugins.PluginManagerCore -import com.intellij.openapi.components.service -import com.intellij.openapi.extensions.PluginId -import com.intellij.util.net.HttpConfigurable -import okhttp3.OkHttpClient -import java.net.URL - -/** - * A client instance that hooks into global JetBrains services for default - * settings. - */ -class CoderRestClientService(url: URL, token: String?, httpClient: OkHttpClient? = null) : - CoderRestClient( - url, - token, - service<CoderSettingsService>(), - ProxyValues( - HttpConfigurable.getInstance().proxyLogin, - HttpConfigurable.getInstance().plainProxyPassword, - HttpConfigurable.getInstance().PROXY_AUTHENTICATION, - HttpConfigurable.getInstance().onlyBySettingsSelector, - ), - PluginManagerCore.getPlugin(PluginId.getId("com.coder.gateway"))!!.version, - httpClient, - ) diff --git a/src/main/kotlin/com/coder/gateway/services/CoderSecretsService.kt b/src/main/kotlin/com/coder/gateway/services/CoderSecretsService.kt new file mode 100644 index 000000000..9f5311c7e --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/services/CoderSecretsService.kt @@ -0,0 +1,28 @@ +package com.coder.gateway.services + +import com.jetbrains.toolbox.gateway.PluginSecretStore + +/** + * Provides Coder secrets backed by the secrets store service. + */ +class CoderSecretsService(private val store: PluginSecretStore) { + private fun get(key: String): String = store[key] ?: "" + + private fun set(key: String, value: String) { + if (value.isBlank()) { + store.clear(key) + } else { + store[key] = value + } + } + + var lastDeploymentURL: String + get() = get("last-deployment-url") + set(value) = set("last-deployment-url", value) + var lastToken: String + get() = get("last-token") + set(value) = set("last-token", value) + var rememberMe: String + get() = get("remember-me") + set(value) = set("remember-me", value) +} diff --git a/src/main/kotlin/com/coder/gateway/services/CoderSettingsService.kt b/src/main/kotlin/com/coder/gateway/services/CoderSettingsService.kt index e98e9a611..d1fb3a78e 100644 --- a/src/main/kotlin/com/coder/gateway/services/CoderSettingsService.kt +++ b/src/main/kotlin/com/coder/gateway/services/CoderSettingsService.kt @@ -1,14 +1,7 @@ package com.coder.gateway.services -import com.coder.gateway.settings.CoderSettings import com.coder.gateway.settings.CoderSettingsState -import com.intellij.openapi.components.PersistentStateComponent -import com.intellij.openapi.components.RoamingType -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage -import com.intellij.openapi.components.service -import com.intellij.util.xmlb.XmlSerializerUtil +import com.jetbrains.toolbox.gateway.PluginSettingsStore /** * Provides Coder settings backed by the settings state service. @@ -20,25 +13,48 @@ import com.intellij.util.xmlb.XmlSerializerUtil * while letting the settings page still read and mutate the underlying state, * prefer using CoderSettingsService over CoderSettingsStateService. */ -@Service(Service.Level.APP) -class CoderSettingsService : CoderSettings(service<CoderSettingsStateService>()) +class CoderSettingsService(private val store: PluginSettingsStore) : CoderSettingsState() { + private fun get(key: String): String? = store[key] -/** - * Controls serializing and deserializing raw settings to and from disk. Use - * only when you need to directly mutate the settings (such as from the settings - * page) and in tests, otherwise use CoderSettingsService. - */ -@Service(Service.Level.APP) -@State( - name = "CoderSettingsState", - storages = [Storage("coder-settings.xml", roamingType = RoamingType.DISABLED, exportable = true)], -) -class CoderSettingsStateService : - CoderSettingsState(), - PersistentStateComponent<CoderSettingsStateService> { - override fun getState(): CoderSettingsStateService = this - - override fun loadState(state: CoderSettingsStateService) { - XmlSerializerUtil.copyBean(state, this) + private fun set(key: String, value: String) { + if (value.isBlank()) { + store.remove(key) + } else { + store[key] = value + } } + + override var binarySource: String + get() = get("binarySource") ?: super.binarySource + set(value) = set("binarySource", value) + override var binaryDirectory: String + get() = get("binaryDirectory") ?: super.binaryDirectory + set(value) = set("binaryDirectory", value) + override var dataDirectory: String + get() = get("dataDirectory") ?: super.dataDirectory + set(value) = set("dataDirectory", value) + override var enableDownloads: Boolean + get() = get("enableDownloads")?.toBooleanStrictOrNull() ?: super.enableDownloads + set(value) = set("enableDownloads", value.toString()) + override var enableBinaryDirectoryFallback: Boolean + get() = get("enableBinaryDirectoryFallback")?.toBooleanStrictOrNull() ?: super.enableBinaryDirectoryFallback + set(value) = set("enableBinaryDirectoryFallback", value.toString()) + override var headerCommand: String + get() = store["headerCommand"] ?: super.headerCommand + set(value) = set("headerCommand", value) + override var tlsCertPath: String + get() = store["tlsCertPath"] ?: super.tlsCertPath + set(value) = set("tlsCertPath", value) + override var tlsKeyPath: String + get() = store["tlsKeyPath"] ?: super.tlsKeyPath + set(value) = set("tlsKeyPath", value) + override var tlsCAPath: String + get() = store["tlsCAPath"] ?: super.tlsCAPath + set(value) = set("tlsCAPath", value) + override var tlsAlternateHostname: String + get() = store["tlsAlternateHostname"] ?: super.tlsAlternateHostname + set(value) = set("tlsAlternateHostname", value) + override var disableAutostart: Boolean + get() = store["disableAutostart"]?.toBooleanStrictOrNull() ?: super.disableAutostart + set(value) = set("disableAutostart", value.toString()) } diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index f0f9cc62a..f74b727a5 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -8,7 +8,7 @@ import com.coder.gateway.util.getOS import com.coder.gateway.util.safeHost import com.coder.gateway.util.toURL import com.coder.gateway.util.withPath -import com.intellij.openapi.diagnostic.Logger +import org.slf4j.LoggerFactory import java.net.URL import java.nio.file.Files import java.nio.file.Path @@ -127,6 +127,8 @@ open class CoderSettings( // Overrides the default binary name (for tests). private val binaryName: String? = null, ) { + private val logger = LoggerFactory.getLogger(javaClass) + val tls = CoderTLSSettings(state) /** @@ -386,8 +388,4 @@ open class CoderSettings( } } } - - companion object { - val logger = Logger.getInstance(CoderSettings::class.java.simpleName) - } } diff --git a/src/main/kotlin/com/coder/gateway/util/Dialogs.kt b/src/main/kotlin/com/coder/gateway/util/Dialogs.kt index 72c1e5305..cee2d0375 100644 --- a/src/main/kotlin/com/coder/gateway/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/gateway/util/Dialogs.kt @@ -1,94 +1,10 @@ package com.coder.gateway.util -import com.coder.gateway.CoderGatewayBundle -import com.coder.gateway.cli.CoderCLIManager -import com.coder.gateway.models.WorkspaceProjectIDE -import com.coder.gateway.sdk.CoderRestClient -import com.coder.gateway.sdk.v2.models.Workspace -import com.coder.gateway.sdk.v2.models.WorkspaceAgent import com.coder.gateway.settings.CoderSettings import com.coder.gateway.settings.Source -import com.coder.gateway.views.steps.CoderWorkspaceProjectIDEStepView -import com.coder.gateway.views.steps.CoderWorkspacesStepSelection -import com.intellij.ide.BrowserUtil -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.openapi.ui.panel.ComponentPanelBuilder -import com.intellij.ui.AppIcon -import com.intellij.ui.components.JBTextField -import com.intellij.ui.components.dialog -import com.intellij.ui.dsl.builder.RowLayout -import com.intellij.ui.dsl.builder.panel -import com.intellij.util.applyIf -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.Dimension +import com.jetbrains.toolbox.gateway.ui.TextType +import com.jetbrains.toolbox.gateway.ui.ToolboxUi import java.net.URL -import javax.swing.JComponent -import javax.swing.border.Border - -/** - * A dialog wrapper around CoderWorkspaceStepView. - */ -private class CoderWorkspaceStepDialog( - name: String, - private val state: CoderWorkspacesStepSelection, -) : DialogWrapper(true) { - private val view = CoderWorkspaceProjectIDEStepView(showTitle = false) - - init { - init() - title = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.choose.text", name) - } - - override fun show() { - view.init(state) - view.onPrevious = { close(1) } - view.onNext = { close(0) } - super.show() - view.dispose() - } - - fun showAndGetData(): WorkspaceProjectIDE? { - if (showAndGet()) { - return view.data() - } - return null - } - - override fun createContentPaneBorder(): Border = JBUI.Borders.empty() - - override fun createCenterPanel(): JComponent = view - - override fun createSouthPanel(): JComponent { - // The plugin provides its own buttons. - // TODO: Is it more idiomatic to handle buttons out here? - return panel {}.apply { - border = JBUI.Borders.empty() - } - } -} - -fun askIDE( - name: String, - agent: WorkspaceAgent, - workspace: Workspace, - cli: CoderCLIManager, - client: CoderRestClient, - workspaces: List<Workspace>, -): WorkspaceProjectIDE? { - var data: WorkspaceProjectIDE? = null - ApplicationManager.getApplication().invokeAndWait { - val dialog = - CoderWorkspaceStepDialog( - name, - CoderWorkspacesStepSelection(agent, workspace, cli, client, workspaces), - ) - data = dialog.showAndGetData() - } - return data -} /** * Dialog implementation for standalone Gateway. @@ -97,74 +13,28 @@ fun askIDE( */ class DialogUi( private val settings: CoderSettings, + private val ui: ToolboxUi, ) { fun confirm(title: String, description: String): Boolean { - var inputFromUser = false - ApplicationManager.getApplication().invokeAndWait({ - AppIcon.getInstance().requestAttention(null, true) - if (!dialog( - title = title, - panel = panel { - row { - label(description) - } - }, - ).showAndGet() - ) { - return@invokeAndWait - } - inputFromUser = true - }, ModalityState.defaultModalityState()) - return inputFromUser + val f = ui.showOkCancelPopup(title, description, "Yes", "No") + return f.get() } fun ask( title: String, description: String, placeholder: String? = null, + // There is no link or error support in Toolbox so for now isError and + // link are unused. isError: Boolean = false, link: Pair<String, String>? = null, ): String? { - var inputFromUser: String? = null - ApplicationManager.getApplication().invokeAndWait({ - lateinit var inputTextField: JBTextField - AppIcon.getInstance().requestAttention(null, true) - if (!dialog( - title = title, - panel = panel { - row { - if (link != null) browserLink(link.first, link.second) - inputTextField = - textField() - .applyToComponent { - this.text = placeholder - minimumSize = Dimension(520, -1) - }.component - }.layout(RowLayout.PARENT_GRID) - row { - cell() // To align with the text box. - cell( - ComponentPanelBuilder.createCommentComponent(description, false, -1, true) - .applyIf(isError) { - apply { - foreground = UIUtil.getErrorForeground() - } - }, - ) - }.layout(RowLayout.PARENT_GRID) - }, - focusedComponent = inputTextField, - ).showAndGet() - ) { - return@invokeAndWait - } - inputFromUser = inputTextField.text - }, ModalityState.any()) - return inputFromUser + val f = ui.showTextInputPopup(title, description, placeholder, TextType.General, "OK", "Cancel") + return f.get() } private fun openUrl(url: URL) { - BrowserUtil.browse(url) + ui.openUrl(url.toString()) } /** diff --git a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt index 1a656391f..96d3c634e 100644 --- a/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt +++ b/src/main/kotlin/com/coder/gateway/util/LinkHandler.kt @@ -1,15 +1,12 @@ package com.coder.gateway.util -import com.coder.gateway.cli.CoderCLIManager import com.coder.gateway.cli.ensureCLI import com.coder.gateway.models.WorkspaceAndAgentStatus -import com.coder.gateway.models.WorkspaceProjectIDE import com.coder.gateway.sdk.CoderRestClient import com.coder.gateway.sdk.ex.APIResponseException import com.coder.gateway.sdk.v2.models.Workspace import com.coder.gateway.sdk.v2.models.WorkspaceAgent import com.coder.gateway.sdk.v2.models.WorkspaceStatus -import com.coder.gateway.services.CoderRestClientService import com.coder.gateway.settings.CoderSettings import com.coder.gateway.settings.Source import okhttp3.OkHttpClient @@ -31,7 +28,7 @@ open class LinkHandler( fun handle( parameters: Map<String, String>, indicator: ((t: String) -> Unit)? = null, - ): WorkspaceProjectIDE { + ): String { val deploymentURL = parameters.url() ?: dialogUi.ask("Deployment URL", "Enter the full URL of your Coder deployment") if (deploymentURL.isNullOrBlank()) { throw MissingArgumentException("Query parameter \"$URL\" is missing") @@ -109,30 +106,9 @@ open class LinkHandler( cli.configSsh(client.agentNames(workspaces)) val name = "${workspace.name}.${agent.name}" - val openDialog = - parameters.ideProductCode().isNullOrBlank() || - parameters.ideBuildNumber().isNullOrBlank() || - (parameters.idePathOnHost().isNullOrBlank() && parameters.ideDownloadLink().isNullOrBlank()) || - parameters.folder().isNullOrBlank() - - return if (openDialog) { - askIDE(name, agent, workspace, cli, client, workspaces) ?: throw MissingArgumentException("IDE selection aborted; unable to connect") - } else { - // Check that both the domain and the redirected domain are - // allowlisted. If not, check with the user whether to proceed. - verifyDownloadLink(parameters) - WorkspaceProjectIDE.fromInputs( - name = name, - hostname = CoderCLIManager.getHostName(deploymentURL.toURL(), name), - projectPath = parameters.folder(), - ideProductCode = parameters.ideProductCode(), - ideBuildNumber = parameters.ideBuildNumber(), - idePathOnHost = parameters.idePathOnHost(), - downloadSource = parameters.ideDownloadLink(), - deploymentURL = deploymentURL, - lastOpened = null, // Have not opened yet. - ) - } + // TODO@JB: Can we ask for the IDE and project path or how does + // this work? + return name } /** @@ -168,7 +144,10 @@ open class LinkHandler( if (settings.requireTokenAuth && token == null) { // User aborted. throw MissingArgumentException("Token is required") } - val client = CoderRestClientService(deploymentURL.toURL(), token?.first, httpClient = httpClient) + // The http client Toolbox gives us is already set up with the + // proxy config, so we do net need to explicitly add it. + // TODO: How to get the plugin version? + val client = CoderRestClient(deploymentURL.toURL(), token?.first, settings, proxyValues = null, "production", httpClient) return try { client.authenticate() client diff --git a/src/main/kotlin/com/coder/gateway/util/Retry.kt b/src/main/kotlin/com/coder/gateway/util/Retry.kt deleted file mode 100644 index 84663f9d9..000000000 --- a/src/main/kotlin/com/coder/gateway/util/Retry.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.coder.gateway.util - -import com.intellij.openapi.progress.ProcessCanceledException -import com.intellij.ssh.SshException -import com.jetbrains.gateway.ssh.deploy.DeployException -import kotlinx.coroutines.delay -import java.util.Random -import java.util.concurrent.TimeUnit -import kotlin.coroutines.cancellation.CancellationException -import kotlin.math.min - -fun unwrap(ex: Exception): Throwable { - var cause = ex.cause - while (cause?.cause != null) { - cause = cause.cause - } - return cause ?: ex -} - -/** - * Similar to Intellij's except it adds two new arguments: onCountdown (for - * displaying the time until the next try) and retryIf (to limit which - * exceptions can be retried). - * - * Exceptions that cannot be retried will be thrown. - * - * onException and onCountdown will be called immediately on retryable failures. - * onCountdown will also be called every second until the next try with the time - * left until that next try (the last interval might be less than one second if - * the total delay is not divisible by one second). - * - * Some other differences: - * - onException gives you the time until the next try (intended to be logged - * with the error). - * - Infinite tries. - * - SshException is unwrapped. - * - * It is otherwise identical. - */ -suspend fun <T> suspendingRetryWithExponentialBackOff( - initialDelayMs: Long = TimeUnit.SECONDS.toMillis(5), - backOffLimitMs: Long = TimeUnit.MINUTES.toMillis(3), - backOffFactor: Int = 2, - backOffJitter: Double = 0.1, - retryIf: (e: Throwable) -> Boolean, - onException: (attempt: Int, nextMs: Long, e: Throwable) -> Unit, - onCountdown: (remaining: Long) -> Unit, - action: suspend (attempt: Int) -> T, -): T { - val random = Random() - var delayMs = initialDelayMs - for (attempt in 1..Int.MAX_VALUE) { - try { - return action(attempt) - } catch (originalEx: Exception) { - // SshException can happen due to anything from a timeout to being - // canceled so unwrap to find out. - val unwrappedEx = if (originalEx is SshException) unwrap(originalEx) else originalEx - if (!retryIf(unwrappedEx)) { - throw unwrappedEx - } - onException(attempt, delayMs, unwrappedEx) - var remainingMs = delayMs - while (remainingMs > 0) { - onCountdown(remainingMs) - val next = min(remainingMs, TimeUnit.SECONDS.toMillis(1)) - remainingMs -= next - delay(next) - } - delayMs = min(delayMs * backOffFactor, backOffLimitMs) + (random.nextGaussian() * delayMs * backOffJitter).toLong() - } - } - error("Should never be reached") -} - -/** - * Convert a millisecond duration into a human-readable string. - * - * < 1 second: "now" - * 1 second: "in one second" - * > 1 second: "in <duration> seconds" - */ -fun humanizeDuration(durationMs: Long): String { - val seconds = TimeUnit.MILLISECONDS.toSeconds(durationMs) - return if (seconds < 1) "now" else "in $seconds second${if (seconds > 1) "s" else ""}" -} - -/** - * When the worker upload times out Gateway just says it failed. Even the root - * cause (IllegalStateException) is useless. The error also includes a very - * long useless tmp path. Return true if the error looks like this timeout. - */ -fun isWorkerTimeout(e: Throwable): Boolean = e is DeployException && e.message.contains("Worker binary deploy failed") - -/** - * Return true if the exception is some kind of cancellation. - */ -fun isCancellation(e: Throwable): Boolean = e is InterruptedException || - e is CancellationException || - e is ProcessCanceledException diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayConnectorWizardWrapperView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayConnectorWizardWrapperView.kt deleted file mode 100644 index 8b2a5a152..000000000 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayConnectorWizardWrapperView.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.coder.gateway.views - -import com.coder.gateway.CoderRemoteConnectionHandle -import com.coder.gateway.views.steps.CoderWorkspaceProjectIDEStepView -import com.coder.gateway.views.steps.CoderWorkspacesStepView -import com.intellij.ui.components.panels.Wrapper -import com.intellij.util.ui.JBUI -import com.jetbrains.gateway.api.GatewayConnectorView -import com.jetbrains.gateway.api.GatewayUI -import javax.swing.JComponent - -class CoderGatewayConnectorWizardWrapperView : GatewayConnectorView { - override val component: JComponent - get() { - val step1 = CoderWorkspacesStepView() - val step2 = CoderWorkspaceProjectIDEStepView() - val wrapper = Wrapper(step1).apply { border = JBUI.Borders.empty() } - step1.init() - - step1.onPrevious = { - GatewayUI.getInstance().reset() - step1.dispose() - step2.dispose() - } - step1.onNext = { - step1.stop() - step2.init(it) - wrapper.setContent(step2) - } - - step2.onPrevious = { - step2.stop() - step1.init() - wrapper.setContent(step1) - } - step2.onNext = { params -> - GatewayUI.getInstance().reset() - step1.dispose() - step2.dispose() - CoderRemoteConnectionHandle().connect { params } - } - - return wrapper - } -} diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt deleted file mode 100644 index 8abe6a8d7..000000000 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ /dev/null @@ -1,396 +0,0 @@ -@file:Suppress("DialogTitleCapitalization") - -package com.coder.gateway.views - -import com.coder.gateway.CoderGatewayBundle -import com.coder.gateway.CoderGatewayConstants -import com.coder.gateway.CoderRemoteConnectionHandle -import com.coder.gateway.icons.CoderIcons -import com.coder.gateway.models.WorkspaceAgentListModel -import com.coder.gateway.models.WorkspaceProjectIDE -import com.coder.gateway.models.toWorkspaceProjectIDE -import com.coder.gateway.sdk.CoderRestClient -import com.coder.gateway.sdk.v2.models.WorkspaceStatus -import com.coder.gateway.sdk.v2.models.toAgentList -import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService -import com.coder.gateway.services.CoderRestClientService -import com.coder.gateway.services.CoderSettingsService -import com.coder.gateway.util.humanizeConnectionError -import com.coder.gateway.util.toURL -import com.coder.gateway.util.withoutNull -import com.intellij.icons.AllIcons -import com.intellij.openapi.Disposable -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.asContextElement -import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.project.DumbAwareAction -import com.intellij.openapi.ui.panel.ComponentPanelBuilder -import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.DocumentAdapter -import com.intellij.ui.SearchTextField -import com.intellij.ui.components.ActionLink -import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.AlignY -import com.intellij.ui.dsl.builder.BottomGap -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.TopGap -import com.intellij.ui.dsl.builder.actionButton -import com.intellij.ui.dsl.builder.panel -import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import com.jetbrains.gateway.api.GatewayRecentConnections -import com.jetbrains.gateway.api.GatewayUI -import com.jetbrains.rd.util.lifetime.Lifetime -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.awt.Color -import java.awt.Component -import java.awt.Dimension -import java.util.Locale -import java.util.UUID -import javax.swing.JComponent -import javax.swing.event.DocumentEvent - -/** - * DeploymentInfo contains everything needed to query the API for a deployment - * along with the latest workspace responses. - */ -data class DeploymentInfo( - // Null if unable to create the client. - var client: CoderRestClient? = null, - // Null if we have not fetched workspaces yet. - var items: List<WorkspaceAgentListModel>? = null, - // Null if there have not been any errors yet. - var error: String? = null, -) - -class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: (Component) -> Unit) : - GatewayRecentConnections, - Disposable { - private val settings = service<CoderSettingsService>() - private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>() - private val cs = CoroutineScope(Dispatchers.Main) - private val jobs: MutableMap<UUID, Job> = mutableMapOf() - - private val recentWorkspacesContentPanel = JBScrollPane() - - private lateinit var searchBar: SearchTextField - private var filterString: String? = null - - override val id = CoderGatewayConstants.GATEWAY_RECENT_CONNECTIONS_ID - - override val recentsIcon = CoderIcons.LOGO_16 - - /** - * API clients and workspaces grouped by deployment and keyed by their - * config directory. - */ - private var deployments: MutableMap<String, DeploymentInfo> = mutableMapOf() - private var poller: Job? = null - - override fun createRecentsView(lifetime: Lifetime): JComponent = panel { - indent { - row { - label(CoderGatewayBundle.message("gateway.connector.recent-connections.title")).applyToComponent { - font = JBFont.h3().asBold() - } - searchBar = - cell(SearchTextField(false)).resizableColumn().align(AlignX.FILL).applyToComponent { - minimumSize = Dimension(350, -1) - textEditor.border = JBUI.Borders.empty(2, 5, 2, 0) - addDocumentListener( - object : DocumentAdapter() { - override fun textChanged(e: DocumentEvent) { - filterString = this@applyToComponent.text.trim() - updateContentView() - } - }, - ) - }.component - actionButton( - object : DumbAwareAction( - CoderGatewayBundle.message("gateway.connector.recent-connections.new.wizard.button.tooltip"), - null, - AllIcons.General.Add, - ) { - override fun actionPerformed(e: AnActionEvent) { - setContentCallback(CoderGatewayConnectorWizardWrapperView().component) - } - }, - ).gap(RightGap.SMALL) - }.bottomGap(BottomGap.SMALL) - separator(background = WelcomeScreenUIManager.getSeparatorColor()) - row { - resizableRow() - cell(recentWorkspacesContentPanel).resizableColumn().align(AlignX.FILL).align(AlignY.FILL).component - } - } - }.apply { - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(12, 0, 0, 12) - } - - override fun getRecentsTitle() = CoderGatewayBundle.message("gateway.connector.title") - - override fun updateRecentView() { - // Render immediately so we can display spinners for each connection - // that we have not fetched a workspace for yet. - updateContentView() - // After each poll, the content view will be updated again. - triggerWorkspacePolling() - } - - /** - * Render the most recent connections, matching with fetched workspaces. - */ - private fun updateContentView() { - var top = true - val connectionsByDeployment = getConnectionsByDeployment(true) - recentWorkspacesContentPanel.viewport.view = - panel { - connectionsByDeployment.forEach { (deploymentURL, connectionsByWorkspace) -> - var first = true - val deployment = deployments[deploymentURL] - val deploymentError = deployment?.error - connectionsByWorkspace.forEach { (workspaceName, connections) -> - // Show the error at the top of each deployment list. - val showError = if (first) { - first = false - true - } else { - false - } - val workspaceWithAgent = deployment?.items?.firstOrNull { it.workspace.name == workspaceName } - val status = - if (deploymentError != null) { - Triple(UIUtil.getErrorForeground(), deploymentError, UIUtil.getBalloonErrorIcon()) - } else if (workspaceWithAgent != null) { - val inLoadingState = listOf(WorkspaceStatus.STARTING, WorkspaceStatus.CANCELING, WorkspaceStatus.DELETING, WorkspaceStatus.STOPPING).contains(workspaceWithAgent?.workspace?.latestBuild?.status) - - Triple( - workspaceWithAgent.status.statusColor(), - workspaceWithAgent.status.description, - if (inLoadingState) { - AnimatedIcon.Default() - } else { - null - }, - ) - } else { - Triple(UIUtil.getContextHelpForeground(), "Querying workspace status...", AnimatedIcon.Default()) - } - val gap = - if (top) { - top = false - TopGap.NONE - } else { - TopGap.MEDIUM - } - row { - label(workspaceName).applyToComponent { - font = JBFont.h3().asBold() - }.align(AlignX.LEFT).gap(RightGap.SMALL) - label(deploymentURL).applyToComponent { - foreground = UIUtil.getContextHelpForeground() - font = ComponentPanelBuilder.getCommentFont(font) - } - label("").resizableColumn().align(AlignX.FILL) - }.topGap(gap) - - val enableLinks = listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELED, WorkspaceStatus.FAILED, WorkspaceStatus.STARTING, WorkspaceStatus.RUNNING).contains(workspaceWithAgent?.workspace?.latestBuild?.status) - - // We only display an API error on the first workspace rather than duplicating it on each workspace. - if (deploymentError == null || showError) { - row { - status.third?.let { - icon(it) - } - label("<html><body style='width:350px;'>" + status.second + "</html>").applyToComponent { - foreground = status.first - } - } - } - - connections.forEach { workspaceProjectIDE -> - row { - icon(workspaceProjectIDE.ideProduct.icon) - if (enableLinks) { - cell( - ActionLink(workspaceProjectIDE.projectPathDisplay) { - withoutNull(deployment?.client, workspaceWithAgent?.workspace) { client, workspace -> - CoderRemoteConnectionHandle().connect { - if (listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELED, WorkspaceStatus.FAILED).contains(workspace.latestBuild.status)) { - client.startWorkspace(workspace) - } - workspaceProjectIDE - } - GatewayUI.getInstance().reset() - } - }, - ) - } else { - label(workspaceProjectIDE.projectPathDisplay).applyToComponent { - foreground = Color.GRAY - } - } - label("").resizableColumn().align(AlignX.FILL) - label(workspaceProjectIDE.ideName).applyToComponent { - foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND - font = ComponentPanelBuilder.getCommentFont(font) - } - label(workspaceProjectIDE.lastOpened.toString()).applyToComponent { - foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND - font = ComponentPanelBuilder.getCommentFont(font) - } - actionButton( - object : DumbAwareAction( - CoderGatewayBundle.message("gateway.connector.recent-connections.remove.button.tooltip"), - "", - CoderIcons.DELETE, - ) { - override fun actionPerformed(e: AnActionEvent) { - recentConnectionsService.removeConnection(workspaceProjectIDE.toRecentWorkspaceConnection()) - updateRecentView() - } - }, - ) - } - } - } - } - }.apply { - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(12, 0, 12, 12) - } - } - - /** - * Get valid connections grouped by deployment and workspace. - */ - private fun getConnectionsByDeployment(filter: Boolean): Map<String, Map<String, List<WorkspaceProjectIDE>>> = recentConnectionsService.getAllRecentConnections() - // Validate and parse connections. - .mapNotNull { - try { - it.toWorkspaceProjectIDE() - } catch (e: Exception) { - logger.warn("Removing invalid recent connection $it", e) - recentConnectionsService.removeConnection(it) - null - } - } - .filter { !filter || matchesFilter(it) } - // Group by the deployment. - .groupBy { it.deploymentURL.toString() } - // Group the connections in each deployment by workspace. - .mapValues { (_, connections) -> - connections - .groupBy { it.name.split(".", limit = 2).first() } - } - - /** - * Return true if the connection matches the current filter. - */ - private fun matchesFilter(connection: WorkspaceProjectIDE): Boolean = filterString.let { - it.isNullOrBlank() || - connection.hostname.lowercase(Locale.getDefault()).contains(it) || - connection.projectPath.lowercase(Locale.getDefault()).contains(it) - } - - /** - * Start polling for workspaces if not already started. - */ - private fun triggerWorkspacePolling() { - if (poller?.isActive == true) { - logger.info("Refusing to start already-started poller") - return - } - - logger.info("Starting poll loop") - poller = - cs.launch(ModalityState.current().asContextElement()) { - while (isActive) { - if (recentWorkspacesContentPanel.isShowing) { - logger.info("View still visible; fetching workspaces") - fetchWorkspaces() - } else { - logger.info("View not visible; aborting poll") - poller?.cancel() - } - delay(5000) - } - } - } - - /** - * Update each deployment with their latest workspaces. - */ - private suspend fun fetchWorkspaces() { - withContext(Dispatchers.IO) { - val connectionsByDeployment = getConnectionsByDeployment(false) - connectionsByDeployment.forEach { (deploymentURL, connectionsByWorkspace) -> - val deployment = deployments.getOrPut(deploymentURL) { DeploymentInfo() } - try { - val client = deployment.client - ?: CoderRestClientService( - deploymentURL.toURL(), - settings.token(deploymentURL.toURL())?.first, - ) - - if (client.token == null && settings.requireTokenAuth) { - throw Exception("Unable to make request; token was not found in CLI config.") - } - - // Delete connections that have no workspace. - val items = client.workspaces().flatMap { it.toAgentList() } - connectionsByWorkspace.forEach { (name, connections) -> - if (items.firstOrNull { it.workspace.name == name } == null) { - logger.info("Removing recent connections for deleted workspace $name (found ${connections.size})") - connections.forEach { recentConnectionsService.removeConnection(it.toRecentWorkspaceConnection()) } - } - } - - deployment.client = client - deployment.items = items - deployment.error = null - } catch (e: Exception) { - val msg = humanizeConnectionError(deploymentURL.toURL(), settings.requireTokenAuth, e) - deployment.client = null - deployment.items = null - deployment.error = msg - logger.error(msg, e) - // TODO: Ask for a token and reconfigure the CLI. - // if (e is APIResponseException && e.isUnauthorized && settings.requireTokenAuth) { - // } - } - } - } - withContext(Dispatchers.Main) { - updateContentView() - } - } - - // Note that this is *not* called when you navigate away from the page so - // check for visibility if you want to avoid work while the panel is not - // displaying. - override fun dispose() { - cs.cancel() - poller?.cancel() - jobs.forEach { it.value.cancel() } - jobs.clear() - } - - companion object { - val logger = Logger.getInstance(CoderGatewayRecentWorkspaceConnectionsView::class.java.simpleName) - } -} diff --git a/src/main/kotlin/com/coder/gateway/views/CoderPage.kt b/src/main/kotlin/com/coder/gateway/views/CoderPage.kt new file mode 100644 index 000000000..1ee77849d --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/views/CoderPage.kt @@ -0,0 +1,129 @@ +package com.coder.gateway.views + +import com.jetbrains.toolbox.gateway.ui.RunnableActionDescription +import com.jetbrains.toolbox.gateway.ui.UiField +import com.jetbrains.toolbox.gateway.ui.UiPage +import com.jetbrains.toolbox.gateway.ui.ValidationErrorField +import org.slf4j.LoggerFactory +import java.util.function.BiConsumer +import java.util.function.Consumer +import java.util.function.Function + +/** + * Base page that handles the icon, displaying error notifications, and + * getting field values. + * + * Note that it seems only the first page displays the icon, even if we + * return an icon for every page. + * + * TODO: Any way to get the return key working for fields? Right now you have + * to use the mouse. + */ +abstract class CoderPage( + private val showIcon: Boolean = true, +) : UiPage { + private val logger = LoggerFactory.getLogger(javaClass) + + /** + * An error to display on the page. + * + * The current assumption is you only have one field per page. + */ + protected var errorField: ValidationErrorField? = null + + /** Toolbox uses this to show notifications on the page. */ + private var notifier: Consumer<Throwable>? = null + + /** Used to get field values. */ + private var getter: Function<UiField, *>? = null + + /** Let Toolbox know the fields should be updated. */ + protected var listener: Consumer<UiField?>? = null + + /** Stores errors until the notifier is attached. */ + private var errorBuffer: MutableList<Throwable> = mutableListOf() + + /** + * Return the icon, if showing one. + * + * This seems to only work on the first page. + */ + override fun getSvgIcon(): ByteArray = + if (showIcon) { + this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf() + } else { + byteArrayOf() + } + + /** + * Show an error as a popup on this page. + */ + fun notify(logPrefix: String, ex: Throwable) { + logger.error(logPrefix, ex) + // It is possible the error listener is not attached yet. + notifier?.accept(ex) ?: errorBuffer.add(ex) + } + + /** + * Get the value for a field. + * + * TODO@JB: Is this really meant to be used with casting? I kind of expected + * to be able to do `myField.value`. + */ + fun get(field: UiField): Any { + val getter = getter ?: throw Exception("Page is not being displayed") + return getter.apply(field) + } + + /** + * Used to update fields when they change (like validation fields). + */ + override fun setPageChangedListener(listener: Consumer<UiField?>) { + this.listener = listener + } + + /** + * The setter is unused but the getter is used to get field values. + */ + override fun setStateAccessor(setter: BiConsumer<UiField, Any>?, getter: Function<UiField, *>?) { + this.getter = getter + } + + /** + * Immediately notify any pending errors and store for later errors. + */ + override fun setActionErrorNotifier(notifier: Consumer<Throwable>?) { + this.notifier = notifier + notifier?.let { + errorBuffer.forEach { + notifier.accept(it) + } + errorBuffer.clear() + } + } + + /** + * Set/unset the field error and update the form. + */ + protected fun updateError(error: String?) { + errorField = error?.let { ValidationErrorField(error) } + listener?.accept(null) // Make Toolbox get the fields again. + } +} + +/** + * An action that simply runs the provided callback. + */ +class Action( + private val label: String, + private val closesPage: Boolean = false, + private val enabled: () -> Boolean = { true }, + private val cb: () -> Unit, +) : RunnableActionDescription { + override fun getLabel(): String = label + override fun getShouldClosePage(): Boolean = closesPage + override fun isEnabled(): Boolean = enabled() + override fun run() { + cb() + } +} diff --git a/src/main/kotlin/com/coder/gateway/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/gateway/views/CoderSettingsPage.kt new file mode 100644 index 000000000..723ef2b6f --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/views/CoderSettingsPage.kt @@ -0,0 +1,64 @@ +package com.coder.gateway.views + +import com.coder.gateway.services.CoderSettingsService +import com.jetbrains.toolbox.gateway.ui.CheckboxField +import com.jetbrains.toolbox.gateway.ui.RunnableActionDescription +import com.jetbrains.toolbox.gateway.ui.TextField +import com.jetbrains.toolbox.gateway.ui.TextType +import com.jetbrains.toolbox.gateway.ui.UiField + +/** + * A page for modifying Coder settings. + * + * TODO@JB: Even without an icon there is an unnecessary gap at the top. + * TODO@JB: There is no scroll, and our settings do not fit. As a consequence, + * I have not been able to test this page. + */ +class CoderSettingsPage(private val settings: CoderSettingsService) : CoderPage(false) { + // TODO: Copy over the descriptions, holding until I can test this page. + private val binarySourceField = TextField("Binary source", settings.binarySource, TextType.General) + private val binaryDirectoryField = TextField("Binary directory", settings.binaryDirectory, TextType.General) + private val dataDirectoryField = TextField("Data directory", settings.dataDirectory, TextType.General) + private val enableDownloadsField = CheckboxField(settings.enableDownloads, "Enable downloads") + private val enableBinaryDirectoryFallbackField = + CheckboxField(settings.enableBinaryDirectoryFallback, "Enable binary directory fallback") + private val headerCommandField = TextField("Header command", settings.headerCommand, TextType.General) + private val tlsCertPathField = TextField("TLS cert path", settings.tlsCertPath, TextType.General) + private val tlsKeyPathField = TextField("TLS key path", settings.tlsKeyPath, TextType.General) + private val tlsCAPathField = TextField("TLS CA path", settings.tlsCAPath, TextType.General) + private val tlsAlternateHostnameField = + TextField("TLS alternate hostname", settings.tlsAlternateHostname, TextType.General) + private val disableAutostartField = CheckboxField(settings.disableAutostart, "Disable autostart") + + override fun getFields(): MutableList<UiField> = mutableListOf( + binarySourceField, + enableDownloadsField, + binaryDirectoryField, + enableBinaryDirectoryFallbackField, + dataDirectoryField, + headerCommandField, + tlsCertPathField, + tlsKeyPathField, + tlsCAPathField, + tlsAlternateHostnameField, + disableAutostartField, + ) + + override fun getTitle(): String = "Coder Settings" + + override fun getActionButtons(): MutableList<RunnableActionDescription> = mutableListOf( + Action("Save", closesPage = true) { + settings.binarySource = get(binarySourceField) as String + settings.binaryDirectory = get(binaryDirectoryField) as String + settings.dataDirectory = get(dataDirectoryField) as String + settings.enableDownloads = get(enableDownloadsField) as Boolean + settings.enableBinaryDirectoryFallback = get(enableBinaryDirectoryFallbackField) as Boolean + settings.headerCommand = get(headerCommandField) as String + settings.tlsCertPath = get(tlsCertPathField) as String + settings.tlsKeyPath = get(tlsKeyPathField) as String + settings.tlsCAPath = get(tlsCAPathField) as String + settings.tlsAlternateHostname = get(tlsAlternateHostnameField) as String + settings.disableAutostart = get(disableAutostartField) as Boolean + }, + ) +} diff --git a/src/main/kotlin/com/coder/gateway/views/ConnectPage.kt b/src/main/kotlin/com/coder/gateway/views/ConnectPage.kt new file mode 100644 index 000000000..fe49c712e --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/views/ConnectPage.kt @@ -0,0 +1,106 @@ +package com.coder.gateway.views + +import com.coder.gateway.cli.CoderCLIManager +import com.coder.gateway.cli.ensureCLI +import com.coder.gateway.sdk.CoderRestClient +import com.coder.gateway.settings.CoderSettings +import com.coder.gateway.util.humanizeConnectionError +import com.jetbrains.toolbox.gateway.ui.LabelField +import com.jetbrains.toolbox.gateway.ui.RunnableActionDescription +import com.jetbrains.toolbox.gateway.ui.UiField +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import java.net.URL + +/** + * A page that connects a REST client and cli to Coder. + */ +class ConnectPage( + private val url: URL, + private val token: String?, + private val settings: CoderSettings, + private val httpClient: OkHttpClient, + private val coroutineScope: CoroutineScope, + private val onCancel: () -> Unit, + private val onConnect: ( + client: CoderRestClient, + cli: CoderCLIManager, + ) -> Unit, +) : CoderPage() { + private var signInJob: Job? = null + + private var statusField = LabelField("Connecting to ${url.host}...") + + override fun getTitle(): String = "Connecting to Coder" + override fun getDescription(): String = "Please wait while we configure Toolbox for ${url.host}." + + init { + connect() + } + + /** + * Fields for this page, displayed in order. + * + * TODO@JB: This looks kinda sparse. A centered spinner would be welcome. + */ + override fun getFields(): MutableList<UiField> = listOfNotNull( + statusField, + errorField, + ).toMutableList() + + /** + * Show a retry button on error. + */ + override fun getActionButtons(): MutableList<RunnableActionDescription> = listOfNotNull( + if (errorField != null) Action("Retry", closesPage = false) { retry() } else null, + if (errorField != null) Action("Cancel", closesPage = false) { onCancel() } else null, + ).toMutableList() + + /** + * Update the status and error fields then refresh. + */ + private fun updateStatus(newStatus: String, error: String?) { + statusField = LabelField(newStatus) + updateError(error) // Will refresh. + } + + /** + * Try connecting again after an error. + */ + private fun retry() { + updateStatus("Connecting to ${url.host}...", null) + connect() + } + + /** + * Try connecting to Coder with the provided URL and token. + */ + private fun connect() { + signInJob?.cancel() + signInJob = coroutineScope.launch { + try { + // The http client Toolbox gives us is already set up with the + // proxy config, so we do net need to explicitly add it. + // TODO: How to get the plugin version? + val client = CoderRestClient(url, token, settings, proxyValues = null, "production", httpClient) + client.authenticate() + updateStatus("Checking Coder binary...", error = null) + val cli = ensureCLI(client.url, client.buildVersion, settings) { status -> + updateStatus(status, error = null) + } + // We only need to log in if we are using token-based auth. + if (client.token != null) { + updateStatus("Configuring CLI...", error = null) + cli.login(client.token) + } + onConnect(client, cli) + } catch (ex: Exception) { + val msg = humanizeConnectionError(url, settings.requireTokenAuth, ex) + notify("Failed to configure ${url.host}", ex) + updateStatus("Failed to configure ${url.host}", msg) + } + } + } +} diff --git a/src/main/kotlin/com/coder/gateway/views/EnvironmentView.kt b/src/main/kotlin/com/coder/gateway/views/EnvironmentView.kt new file mode 100644 index 000000000..99f7b7804 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/views/EnvironmentView.kt @@ -0,0 +1,40 @@ +package com.coder.gateway.views + +import com.coder.gateway.cli.CoderCLIManager +import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceAgent +import com.jetbrains.toolbox.gateway.environments.SshEnvironmentContentsView +import com.jetbrains.toolbox.gateway.ssh.SshConnectionInfo +import java.net.URL +import java.util.concurrent.CompletableFuture + +/** + * A view for a single environment. It displays the projects and IDEs. + * + * This just delegates to the SSH view provided by Toolbox, all we have to do is + * provide the host name. + * + * SSH must be configured before this will work. + */ +class EnvironmentView( + private val url: URL, + private val workspace: Workspace, + private val agent: WorkspaceAgent, +) : SshEnvironmentContentsView { + override fun getConnectionInfo(): CompletableFuture<SshConnectionInfo> = CompletableFuture.completedFuture(object : SshConnectionInfo { + /** + * The host name generated by the cli manager for this workspace. + */ + override fun getHost() = CoderCLIManager.getHostName(url, "${workspace.name}.${agent.name}") + + /** + * The port is ignored by the Coder proxy command. + */ + override fun getPort() = 22 + + /** + * The username is ignored by the Coder proxy command. + */ + override fun getUserName() = "coder" + }) +} diff --git a/src/main/kotlin/com/coder/gateway/views/LazyBrowserLink.kt b/src/main/kotlin/com/coder/gateway/views/LazyBrowserLink.kt deleted file mode 100644 index acc630ae2..000000000 --- a/src/main/kotlin/com/coder/gateway/views/LazyBrowserLink.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.coder.gateway.views - -import com.intellij.icons.AllIcons -import com.intellij.ide.BrowserUtil -import com.intellij.ide.IdeBundle -import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.actionSystem.ex.ActionManagerEx -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.ide.CopyPasteManager -import com.intellij.openapi.project.DumbAwareAction -import com.intellij.ui.components.ActionLink -import org.jetbrains.annotations.Nls -import java.awt.datatransfer.StringSelection -import java.util.concurrent.ForkJoinPool -import java.util.function.Consumer -import javax.swing.Icon - -class LazyBrowserLink( - icon: Icon, - @Nls text: String, -) : ActionLink() { - init { - setIcon(icon, false) - setText(text) - } - - var url: String? = "" - set(value) { - field = value - if (value != null) { - actionListeners.forEach { - removeActionListener(it) - } - addActionListener { BrowserUtil.browse(value) } - - doWithLazyActionManager { instance -> - val group = DefaultActionGroup(OpenLinkInBrowser(value), CopyLinkAction(value)) - componentPopupMenu = instance.createActionPopupMenu("popup@browser.link.context.menu", group).component - } - } - } - - private fun doWithLazyActionManager(whatToDo: Consumer<in ActionManager>) { - val created = ApplicationManager.getApplication().getServiceIfCreated(ActionManager::class.java) - if (created == null) { - ForkJoinPool.commonPool().execute { - val actionManager: ActionManager = ActionManagerEx.getInstanceEx() - ApplicationManager.getApplication().invokeLater({ whatToDo.accept(actionManager) }, ModalityState.any()) - } - } else { - whatToDo.accept(created) - } - } -} - -private class CopyLinkAction(val url: String) : - DumbAwareAction( - IdeBundle.messagePointer("action.text.copy.link.address"), - AllIcons.Actions.Copy, - ) { - override fun actionPerformed(event: AnActionEvent) { - CopyPasteManager.getInstance().setContents(StringSelection(url)) - } -} - -private class OpenLinkInBrowser(val url: String) : - DumbAwareAction( - IdeBundle.messagePointer("action.text.open.link.in.browser"), - AllIcons.Nodes.PpWeb, - ) { - override fun actionPerformed(event: AnActionEvent) { - BrowserUtil.browse(url) - } -} diff --git a/src/main/kotlin/com/coder/gateway/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/gateway/views/NewEnvironmentPage.kt new file mode 100644 index 000000000..3d540a8c0 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/views/NewEnvironmentPage.kt @@ -0,0 +1,15 @@ +package com.coder.gateway.views + +import com.jetbrains.toolbox.gateway.ui.UiField + +/** + * A page for creating new environments. It displays at the top of the + * environments list. + * + * For now we just use this to display the deployment URL since we do not + * support creating environments from the plugin. + */ +class NewEnvironmentPage(private val deploymentURL: String?) : CoderPage() { + override fun getFields(): MutableList<UiField> = mutableListOf() + override fun getTitle(): String = deploymentURL ?: "" +} diff --git a/src/main/kotlin/com/coder/gateway/views/SignInPage.kt b/src/main/kotlin/com/coder/gateway/views/SignInPage.kt new file mode 100644 index 000000000..f9f3e0a38 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/views/SignInPage.kt @@ -0,0 +1,70 @@ +package com.coder.gateway.views + +import com.coder.gateway.settings.Source +import com.jetbrains.toolbox.gateway.ui.LabelField +import com.jetbrains.toolbox.gateway.ui.RunnableActionDescription +import com.jetbrains.toolbox.gateway.ui.TextField +import com.jetbrains.toolbox.gateway.ui.TextType +import com.jetbrains.toolbox.gateway.ui.UiField +import java.net.URL + +/** + * A page with a field for providing the Coder deployment URL. + * + * Populates with the provided URL, at which point the user can accept or + * enter their own. + */ +class SignInPage( + private val deploymentURL: Pair<String, Source>?, + private val onSignIn: (deploymentURL: URL) -> Unit, +) : CoderPage() { + private val urlField = TextField("Deployment URL", deploymentURL?.first ?: "", TextType.General) + + override fun getTitle(): String = "Sign In to Coder" + + /** + * Fields for this page, displayed in order. + * + * TODO@JB: Fields are reset when you navigate back. + * Ideally they remember what the user entered. + */ + override fun getFields(): MutableList<UiField> = listOfNotNull( + urlField, + deploymentURL?.let { LabelField(deploymentURL.second.description("URL")) }, + errorField, + ).toMutableList() + + /** + * Buttons displayed at the bottom of the page. + */ + override fun getActionButtons(): MutableList<RunnableActionDescription> = mutableListOf( + Action("Sign In", closesPage = false) { submit() }, + ) + + /** + * Call onSignIn with the URL, or error if blank. + */ + private fun submit() { + val urlRaw = get(urlField) as String + // Ensure the URL can be parsed. + try { + if (urlRaw.isBlank()) { + throw Exception("URL is required") + } + // Prefix the protocol if the user left it out. + // URL() will throw if the URL is invalid. + onSignIn( + URL( + if (!urlRaw.startsWith("http://") && !urlRaw.startsWith("https://")) { + "https://$urlRaw" + } else { + urlRaw + }, + ), + ) + } catch (ex: Exception) { + // TODO@JB: Works on the other page, but not this one. + updateError(ex.message) + } + } +} diff --git a/src/main/kotlin/com/coder/gateway/views/TokenPage.kt b/src/main/kotlin/com/coder/gateway/views/TokenPage.kt new file mode 100644 index 000000000..e822e64f2 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/views/TokenPage.kt @@ -0,0 +1,63 @@ +package com.coder.gateway.views + +import com.coder.gateway.settings.Source +import com.coder.gateway.util.withPath +import com.jetbrains.toolbox.gateway.ui.LabelField +import com.jetbrains.toolbox.gateway.ui.LinkField +import com.jetbrains.toolbox.gateway.ui.RunnableActionDescription +import com.jetbrains.toolbox.gateway.ui.TextField +import com.jetbrains.toolbox.gateway.ui.TextType +import com.jetbrains.toolbox.gateway.ui.UiField +import java.net.URL + +/** + * A page with a field for providing the token. + * + * Populate with the provided token, at which point the user can accept or + * enter their own. + */ +class TokenPage( + private val deploymentURL: URL, + private val token: Pair<String, Source>?, + private val onToken: ((token: String) -> Unit), +) : CoderPage() { + private val tokenField = TextField("Token", token?.first ?: "", TextType.General) + + override fun getTitle(): String = "Enter your token" + + /** + * Fields for this page, displayed in order. + * + * TODO@JB: Fields are reset when you navigate back. + * Ideally they remember what the user entered. + */ + override fun getFields(): MutableList<UiField> = listOfNotNull( + tokenField, + LabelField( + token?.second?.description("token") + ?: "No existing token for ${deploymentURL.host} found.", + ), + // TODO@JB: The link text displays twice. + LinkField("Get a token", deploymentURL.withPath("/login?redirect=%2Fcli-auth").toString()), + errorField, + ).toMutableList() + + /** + * Buttons displayed at the bottom of the page. + */ + override fun getActionButtons(): MutableList<RunnableActionDescription> = mutableListOf( + Action("Connect", closesPage = false) { submit(get(tokenField) as String) }, + ) + + /** + * Call onToken with the token, or error if blank. + */ + private fun submit(token: String) { + if (token.isBlank()) { + updateError("Token is required") + } else { + updateError(null) + onToken(token) + } + } +} diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWizardStep.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWizardStep.kt deleted file mode 100644 index 67f481ac4..000000000 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWizardStep.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.coder.gateway.views.steps - -import com.coder.gateway.util.withoutNull -import com.intellij.ide.IdeBundle -import com.intellij.openapi.Disposable -import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.BottomGap -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.panel -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.components.BorderLayoutPanel -import javax.swing.JButton - -sealed class CoderWizardStep<T>( - nextActionText: String, -) : BorderLayoutPanel(), - Disposable { - var onPrevious: (() -> Unit)? = null - var onNext: ((data: T) -> Unit)? = null - - private lateinit var previousButton: JButton - protected lateinit var nextButton: JButton - - private val buttons = - panel { - separator(background = WelcomeScreenUIManager.getSeparatorColor()) - row { - label("").resizableColumn().align(AlignX.FILL).gap(RightGap.SMALL) - previousButton = - button(IdeBundle.message("button.back")) { previous() } - .align(AlignX.RIGHT).gap(RightGap.SMALL) - .applyToComponent { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() }.component - nextButton = - button(nextActionText) { next() } - .align(AlignX.RIGHT) - .applyToComponent { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() }.component - }.bottomGap(BottomGap.SMALL) - }.apply { - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(0, 16) - } - - init { - nextButton.isEnabled = false - addToBottom(buttons) - } - - private fun previous() { - withoutNull(onPrevious) { - it() - } - } - - private fun next() { - withoutNull(onNext) { - it(data()) - } - } - - /** - * Return data gathered by this step. - */ - abstract fun data(): T - - /** - * Stop any background processes. Data will still be available. - */ - abstract fun stop() -} diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt deleted file mode 100644 index 629fe7a74..000000000 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspaceProjectIDEStepView.kt +++ /dev/null @@ -1,479 +0,0 @@ -package com.coder.gateway.views.steps - -import com.coder.gateway.CoderGatewayBundle -import com.coder.gateway.cli.CoderCLIManager -import com.coder.gateway.icons.CoderIcons -import com.coder.gateway.models.WorkspaceProjectIDE -import com.coder.gateway.models.toIdeWithStatus -import com.coder.gateway.models.withWorkspaceProject -import com.coder.gateway.sdk.v2.models.Workspace -import com.coder.gateway.sdk.v2.models.WorkspaceAgent -import com.coder.gateway.util.Arch -import com.coder.gateway.util.OS -import com.coder.gateway.util.humanizeDuration -import com.coder.gateway.util.isCancellation -import com.coder.gateway.util.isWorkerTimeout -import com.coder.gateway.util.suspendingRetryWithExponentialBackOff -import com.coder.gateway.util.withPath -import com.coder.gateway.util.withoutNull -import com.coder.gateway.views.LazyBrowserLink -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.asContextElement -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.ui.ComboBox -import com.intellij.openapi.ui.ComponentValidator -import com.intellij.openapi.ui.ValidationInfo -import com.intellij.openapi.ui.panel.ComponentPanelBuilder -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager -import com.intellij.remote.AuthType -import com.intellij.remote.RemoteCredentialsHolder -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.ColoredListCellRenderer -import com.intellij.ui.DocumentAdapter -import com.intellij.ui.components.JBTextField -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.BottomGap -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.RowLayout -import com.intellij.ui.dsl.builder.TopGap -import com.intellij.ui.dsl.builder.panel -import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import com.intellij.util.ui.update.MergingUpdateQueue -import com.intellij.util.ui.update.Update -import com.jetbrains.gateway.ssh.CachingProductsJsonWrapper -import com.jetbrains.gateway.ssh.DeployTargetOS -import com.jetbrains.gateway.ssh.DeployTargetOS.OSArch -import com.jetbrains.gateway.ssh.DeployTargetOS.OSKind -import com.jetbrains.gateway.ssh.HighLevelHostAccessor -import com.jetbrains.gateway.ssh.IdeStatus -import com.jetbrains.gateway.ssh.IdeWithStatus -import com.jetbrains.gateway.ssh.IntelliJPlatformProduct -import com.jetbrains.gateway.ssh.deploy.DeployException -import com.jetbrains.gateway.ssh.util.validateRemotePath -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.connection.ConnectionException -import java.awt.Component -import java.awt.Dimension -import java.awt.FlowLayout -import java.util.Locale -import java.util.concurrent.TimeoutException -import javax.swing.ComboBoxModel -import javax.swing.DefaultComboBoxModel -import javax.swing.Icon -import javax.swing.JLabel -import javax.swing.JList -import javax.swing.JPanel -import javax.swing.ListCellRenderer -import javax.swing.SwingConstants -import javax.swing.event.DocumentEvent - -/** - * View for a single workspace. In particular, show available IDEs and a button - * to select an IDE and project to run on the workspace. - */ -class CoderWorkspaceProjectIDEStepView( - private val showTitle: Boolean = true, -) : CoderWizardStep<WorkspaceProjectIDE>( - CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.next.text"), -) { - private val cs = CoroutineScope(Dispatchers.IO) - private var ideComboBoxModel = DefaultComboBoxModel<IdeWithStatus>() - private var state: CoderWorkspacesStepSelection? = null - - private lateinit var titleLabel: JLabel - private lateinit var cbIDE: IDEComboBox - private lateinit var cbIDEComment: JLabel - private var tfProject = JBTextField() - private lateinit var terminalLink: LazyBrowserLink - private var ideResolvingJob: Job? = null - private val pathValidationJobs = MergingUpdateQueue("remote-path-validation", 1000, true, tfProject) - - private val component = - panel { - row { - titleLabel = - label("").applyToComponent { - font = JBFont.h3().asBold() - icon = CoderIcons.LOGO_16 - }.component - }.topGap(TopGap.SMALL).bottomGap(BottomGap.NONE) - row { - label("IDE:") - cbIDE = - cell( - IDEComboBox(ideComboBoxModel).apply { - addActionListener { - nextButton.isEnabled = this.selectedItem != null - logger.info("Selected IDE: ${this.selectedItem}") - cbIDEComment.foreground = UIUtil.getContextHelpForeground() - when (this.selectedItem?.status) { - IdeStatus.ALREADY_INSTALLED -> - cbIDEComment.text = - CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.installed.comment") - - IdeStatus.DOWNLOAD -> - cbIDEComment.text = - CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.download.comment") - - else -> - cbIDEComment.text = - CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment") - } - } - }, - ).resizableColumn().align(AlignX.FILL).component - }.topGap(TopGap.SMALL).bottomGap(BottomGap.NONE).layout(RowLayout.PARENT_GRID) - row { - cell() // Empty cell for alignment. - cbIDEComment = - cell( - ComponentPanelBuilder.createCommentComponent( - CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment"), - false, - -1, - true, - ), - ).resizableColumn().align(AlignX.FILL).component - }.topGap(TopGap.NONE).bottomGap(BottomGap.NONE).layout(RowLayout.PARENT_GRID) - row { - label("Project directory:") - cell(tfProject).resizableColumn().align(AlignX.FILL).applyToComponent { - minimumSize = Dimension(520, -1) - }.component - }.topGap(TopGap.NONE).bottomGap(BottomGap.NONE).layout(RowLayout.PARENT_GRID) - row { - cell() // Empty cell for alignment. - terminalLink = - cell( - LazyBrowserLink( - CoderIcons.OPEN_TERMINAL, - "Open Terminal", - ), - ).component - }.topGap(TopGap.NONE).layout(RowLayout.PARENT_GRID) - gap(RightGap.SMALL) - }.apply { - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(0, 16) - } - - init { - addToCenter(component) - } - - /** - * Query the workspaces for IDEs. - */ - fun init(data: CoderWorkspacesStepSelection) { - // Clear contents from the last run, if any. - cbIDEComment.foreground = UIUtil.getContextHelpForeground() - cbIDEComment.text = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment") - ideComboBoxModel.removeAllElements() - - // We use this when returning the connection params from data(). - state = data - - val name = "${data.workspace.name}.${data.agent.name}" - logger.info("Initializing workspace step for $name") - - val homeDirectory = data.agent.expandedDirectory ?: data.agent.directory - tfProject.text = if (homeDirectory.isNullOrBlank()) "/home" else homeDirectory - titleLabel.text = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.choose.text", name) - titleLabel.isVisible = showTitle - terminalLink.url = data.client.url.withPath("/me/$name/terminal").toString() - - ideResolvingJob = - cs.launch(ModalityState.current().asContextElement()) { - try { - logger.info("Configuring Coder CLI...") - cbIDE.renderer = IDECellRenderer("Configuring Coder CLI...") - withContext(Dispatchers.IO) { - data.cliManager.configSsh(data.client.agentNames(data.workspaces)) - } - - val ides = - suspendingRetryWithExponentialBackOff( - action = { attempt -> - logger.info("Connecting with SSH and uploading worker if missing... (attempt $attempt)") - cbIDE.renderer = - if (attempt > 1) { - IDECellRenderer( - CoderGatewayBundle.message("gateway.connector.view.coder.connect-ssh.retry", attempt), - ) - } else { - IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.connect-ssh")) - } - val executor = createRemoteExecutor(CoderCLIManager.getBackgroundHostName(data.client.url, name)) - - if (ComponentValidator.getInstance(tfProject).isEmpty) { - logger.info("Installing remote path validator...") - installRemotePathValidator(executor) - } - - logger.info("Retrieving IDEs... (attempt $attempt)") - cbIDE.renderer = - if (attempt > 1) { - IDECellRenderer( - CoderGatewayBundle.message("gateway.connector.view.coder.retrieve-ides.retry", attempt), - ) - } else { - IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.retrieve-ides")) - } - retrieveIDEs(executor, data.workspace, data.agent) - }, - retryIf = { - it is ConnectionException || - it is TimeoutException || - it is SSHException || - it is DeployException - }, - onException = { attempt, nextMs, e -> - logger.error("Failed to retrieve IDEs (attempt $attempt; will retry in $nextMs ms)") - cbIDEComment.foreground = UIUtil.getErrorForeground() - cbIDEComment.text = - if (isWorkerTimeout(e)) { - "Failed to upload worker binary...it may have timed out. Check the command log for more details." - } else { - e.message ?: e.javaClass.simpleName - } - }, - onCountdown = { remainingMs -> - cbIDE.renderer = - IDECellRenderer( - CoderGatewayBundle.message( - "gateway.connector.view.coder.retrieve-ides.failed.retry", - humanizeDuration(remainingMs), - ), - ) - }, - ) - withContext(Dispatchers.IO) { - ideComboBoxModel.addAll(ides) - cbIDE.selectedIndex = 0 - } - } catch (e: Exception) { - if (isCancellation(e)) { - logger.info("Connection canceled due to ${e.javaClass.simpleName}") - } else { - logger.error("Failed to retrieve IDEs (will not retry)", e) - cbIDEComment.foreground = UIUtil.getErrorForeground() - cbIDEComment.text = e.message ?: e.javaClass.simpleName - cbIDE.renderer = - IDECellRenderer( - CoderGatewayBundle.message("gateway.connector.view.coder.retrieve-ides.failed"), - UIUtil.getBalloonErrorIcon(), - ) - } - } - } - } - - /** - * Validate the remote path whenever it changes. - */ - private fun installRemotePathValidator(executor: HighLevelHostAccessor) { - val disposable = Disposer.newDisposable(ApplicationManager.getApplication(), CoderWorkspaceProjectIDEStepView::class.java.name) - ComponentValidator(disposable).installOn(tfProject) - - tfProject.document.addDocumentListener( - object : DocumentAdapter() { - override fun textChanged(event: DocumentEvent) { - pathValidationJobs.queue( - Update.create("validate-remote-path") { - runBlocking { - try { - val isPathPresent = validateRemotePath(tfProject.text, executor) - if (isPathPresent.pathOrNull == null) { - ComponentValidator.getInstance(tfProject).ifPresent { - it.updateInfo(ValidationInfo("Can't find directory: ${tfProject.text}", tfProject)) - } - } else { - ComponentValidator.getInstance(tfProject).ifPresent { - it.updateInfo(null) - } - } - } catch (e: Exception) { - ComponentValidator.getInstance(tfProject).ifPresent { - it.updateInfo(ValidationInfo("Can't validate directory: ${tfProject.text}", tfProject)) - } - } - } - }, - ) - } - }, - ) - } - - /** - * Connect to the remote worker via SSH. - */ - private suspend fun createRemoteExecutor(host: String): HighLevelHostAccessor = HighLevelHostAccessor.create( - RemoteCredentialsHolder().apply { - setHost(host) - userName = "coder" - port = 22 - authType = AuthType.OPEN_SSH - }, - true, - ) - - /** - * Get a list of available IDEs. - */ - private suspend fun retrieveIDEs( - executor: HighLevelHostAccessor, - workspace: Workspace, - agent: WorkspaceAgent, - ): List<IdeWithStatus> { - val name = "${workspace.name}.${agent.name}" - logger.info("Retrieving available IDEs for $name...") - val workspaceOS = - if (agent.operatingSystem != null && agent.architecture != null) { - toDeployedOS(agent.operatingSystem, agent.architecture) - } else { - withContext(Dispatchers.IO) { - executor.guessOs() - } - } - - logger.info("Resolved OS and Arch for $name is: $workspaceOS") - val installedIdesJob = - cs.async(Dispatchers.IO) { - executor.getInstalledIDEs().map { it.toIdeWithStatus() } - } - val idesWithStatusJob = - cs.async(Dispatchers.IO) { - IntelliJPlatformProduct.entries - .filter { it.showInGateway } - .flatMap { CachingProductsJsonWrapper.getInstance().getAvailableIdes(it, workspaceOS) } - .map { it.toIdeWithStatus() } - } - - val installedIdes = installedIdesJob.await().sorted() - val idesWithStatus = idesWithStatusJob.await().sorted() - if (installedIdes.isEmpty()) { - logger.info("No IDE is installed in $name") - } - if (idesWithStatus.isEmpty()) { - logger.warn("Could not resolve any IDE for $name, probably $workspaceOS is not supported by Gateway") - } - return installedIdes + idesWithStatus - } - - private fun toDeployedOS( - os: OS, - arch: Arch, - ): DeployTargetOS = when (os) { - OS.LINUX -> - when (arch) { - Arch.AMD64 -> DeployTargetOS(OSKind.Linux, OSArch.X86_64) - Arch.ARM64 -> DeployTargetOS(OSKind.Linux, OSArch.ARM_64) - Arch.ARMV7 -> DeployTargetOS(OSKind.Linux, OSArch.UNKNOWN) - } - - OS.WINDOWS -> - when (arch) { - Arch.AMD64 -> DeployTargetOS(OSKind.Windows, OSArch.X86_64) - Arch.ARM64 -> DeployTargetOS(OSKind.Windows, OSArch.ARM_64) - Arch.ARMV7 -> DeployTargetOS(OSKind.Windows, OSArch.UNKNOWN) - } - - OS.MAC -> - when (arch) { - Arch.AMD64 -> DeployTargetOS(OSKind.MacOs, OSArch.X86_64) - Arch.ARM64 -> DeployTargetOS(OSKind.MacOs, OSArch.ARM_64) - Arch.ARMV7 -> DeployTargetOS(OSKind.MacOs, OSArch.UNKNOWN) - } - } - - /** - * Return the selected parameters. Throw if not configured. - */ - override fun data(): WorkspaceProjectIDE = withoutNull(cbIDE.selectedItem, state) { selectedIDE, state -> - val name = "${state.workspace.name}.${state.agent.name}" - selectedIDE.withWorkspaceProject( - name = name, - hostname = CoderCLIManager.getHostName(state.client.url, name), - projectPath = tfProject.text, - deploymentURL = state.client.url, - ) - } - - override fun stop() { - ideResolvingJob?.cancel() - } - - override fun dispose() { - stop() - cs.cancel() - } - - private class IDEComboBox(model: ComboBoxModel<IdeWithStatus>) : ComboBox<IdeWithStatus>(model) { - init { - putClientProperty(AnimatedIcon.ANIMATION_IN_RENDERER_ALLOWED, true) - } - - override fun getSelectedItem(): IdeWithStatus? = super.getSelectedItem() as IdeWithStatus? - } - - private class IDECellRenderer(message: String, cellIcon: Icon = AnimatedIcon.Default.INSTANCE) : ListCellRenderer<IdeWithStatus> { - private val loadingComponentRenderer: ListCellRenderer<IdeWithStatus> = - object : ColoredListCellRenderer<IdeWithStatus>() { - override fun customizeCellRenderer( - list: JList<out IdeWithStatus>, - value: IdeWithStatus?, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean, - ) { - background = UIUtil.getListBackground(isSelected, cellHasFocus) - icon = cellIcon - append(message) - } - } - - override fun getListCellRendererComponent( - list: JList<out IdeWithStatus>?, - ideWithStatus: IdeWithStatus?, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean, - ): Component = if (ideWithStatus == null && index == -1) { - loadingComponentRenderer.getListCellRendererComponent(list, null, -1, isSelected, cellHasFocus) - } else if (ideWithStatus != null) { - JPanel().apply { - layout = FlowLayout(FlowLayout.LEFT) - add(JLabel(ideWithStatus.product.ideName, ideWithStatus.product.icon, SwingConstants.LEFT)) - add( - JLabel( - "${ideWithStatus.product.productCode} ${ideWithStatus.presentableVersion} ${ideWithStatus.buildNumber} | ${ideWithStatus.status.name.lowercase( - Locale.getDefault(), - )}", - ).apply { - foreground = UIUtil.getLabelDisabledForeground() - }, - ) - background = UIUtil.getListBackground(isSelected, cellHasFocus) - } - } else { - panel { } - } - } - - companion object { - val logger = Logger.getInstance(CoderWorkspaceProjectIDEStepView::class.java.simpleName) - } -} diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt deleted file mode 100644 index 1ee62571e..000000000 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ /dev/null @@ -1,974 +0,0 @@ -package com.coder.gateway.views.steps - -import com.coder.gateway.CoderGatewayBundle -import com.coder.gateway.CoderSupportedVersions -import com.coder.gateway.cli.CoderCLIManager -import com.coder.gateway.cli.ensureCLI -import com.coder.gateway.icons.CoderIcons -import com.coder.gateway.models.WorkspaceAgentListModel -import com.coder.gateway.sdk.CoderRestClient -import com.coder.gateway.sdk.ex.APIResponseException -import com.coder.gateway.sdk.v2.models.Workspace -import com.coder.gateway.sdk.v2.models.WorkspaceAgent -import com.coder.gateway.sdk.v2.models.WorkspaceStatus -import com.coder.gateway.sdk.v2.models.toAgentList -import com.coder.gateway.services.CoderRestClientService -import com.coder.gateway.services.CoderSettingsService -import com.coder.gateway.settings.Source -import com.coder.gateway.util.DialogUi -import com.coder.gateway.util.InvalidVersionException -import com.coder.gateway.util.OS -import com.coder.gateway.util.SemVer -import com.coder.gateway.util.humanizeConnectionError -import com.coder.gateway.util.isCancellation -import com.coder.gateway.util.toURL -import com.coder.gateway.util.withoutNull -import com.intellij.icons.AllIcons -import com.intellij.ide.ActivityTracker -import com.intellij.ide.BrowserUtil -import com.intellij.ide.util.PropertiesComponent -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.asContextElement -import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.rd.util.launchUnderBackgroundProgress -import com.intellij.openapi.ui.panel.ComponentPanelBuilder -import com.intellij.openapi.ui.setEmptyState -import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager -import com.intellij.ui.AnActionButton -import com.intellij.ui.RelativeFont -import com.intellij.ui.ToolbarDecorator -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.AlignY -import com.intellij.ui.dsl.builder.BottomGap -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.RowLayout -import com.intellij.ui.dsl.builder.TopGap -import com.intellij.ui.dsl.builder.bindSelected -import com.intellij.ui.dsl.builder.bindText -import com.intellij.ui.dsl.builder.panel -import com.intellij.ui.table.TableView -import com.intellij.util.ui.ColumnInfo -import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.ListTableModel -import com.intellij.util.ui.UIUtil -import com.intellij.util.ui.table.IconTableCellRenderer -import com.jetbrains.rd.util.lifetime.LifetimeDefinition -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.awt.Component -import java.awt.Dimension -import java.net.URL -import java.time.Duration -import java.util.UUID -import javax.swing.Icon -import javax.swing.JCheckBox -import javax.swing.JLabel -import javax.swing.JTable -import javax.swing.JTextField -import javax.swing.ListSelectionModel -import javax.swing.table.DefaultTableCellRenderer -import javax.swing.table.TableCellRenderer - -// Used to store the most recently used URL and token (if any). -private const val CODER_URL_KEY = "coder-url" -private const val SESSION_TOKEN_KEY = "session-token" - -/** - * Form fields used in the step for the user to fill out. - */ -private data class CoderWorkspacesFormFields( - var coderURL: String = "", - var token: Pair<String, Source>? = null, - var useExistingToken: Boolean = false, -) - -/** - * The data gathered by this step. - */ -data class CoderWorkspacesStepSelection( - // The workspace and agent we want to view. - val agent: WorkspaceAgent, - val workspace: Workspace, - // This step needs the client and cliManager to configure SSH. - val cliManager: CoderCLIManager, - val client: CoderRestClient, - // Pass along the latest workspaces so we can configure the CLI a bit - // faster, otherwise this step would have to fetch the workspaces again. - val workspaces: List<Workspace>, -) - -/** - * A list of agents/workspaces belonging to a deployment. Has inputs for - * connecting and authorizing to different deployments. - */ -class CoderWorkspacesStepView : - CoderWizardStep<CoderWorkspacesStepSelection>( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.next.text"), - ) { - private val settings: CoderSettingsService = service<CoderSettingsService>() - private val dialogUi = DialogUi(settings) - private val cs = CoroutineScope(Dispatchers.Main) - private val jobs: MutableMap<UUID, Job> = mutableMapOf() - private val appPropertiesService: PropertiesComponent = service() - private var poller: Job? = null - - private val fields = CoderWorkspacesFormFields() - private var client: CoderRestClient? = null - private var cliManager: CoderCLIManager? = null - - private var tfUrl: JTextField? = null - private var tfUrlComment: JLabel? = null - private var cbExistingToken: JCheckBox? = null - - private val notificationBanner = NotificationBanner() - private var tableOfWorkspaces = - WorkspacesTable().apply { - setEnableAntialiasing(true) - rowSelectionAllowed = true - columnSelectionAllowed = false - tableHeader.reorderingAllowed = false - showVerticalLines = false - intercellSpacing = Dimension(0, 0) - columnModel.getColumn(0).apply { - maxWidth = JBUI.scale(52) - minWidth = JBUI.scale(52) - } - rowHeight = 48 - setEmptyState(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text.disconnected")) - setSelectionMode(ListSelectionModel.SINGLE_SELECTION) - selectionModel.addListSelectionListener { - nextButton.isEnabled = selectedObject?.status?.ready() == true && selectedObject?.agent?.operatingSystem == OS.LINUX - if (selectedObject?.status?.ready() == true && selectedObject?.agent?.operatingSystem != OS.LINUX) { - notificationBanner.apply { - component.isVisible = true - showInfo(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.unsupported.os.info")) - } - } else { - notificationBanner.component.isVisible = false - } - updateWorkspaceActions() - } - } - - private val goToDashboardAction = GoToDashboardAction() - private val goToTemplateAction = GoToTemplateAction() - private val startWorkspaceAction = StartWorkspaceAction() - private val stopWorkspaceAction = StopWorkspaceAction() - private val updateWorkspaceTemplateAction = UpdateWorkspaceTemplateAction() - private val createWorkspaceAction = CreateWorkspaceAction() - - private val toolbar = - ToolbarDecorator.createDecorator(tableOfWorkspaces) - .disableAddAction() - .disableRemoveAction() - .disableUpDownActions() - .addExtraActions( - goToDashboardAction, - startWorkspaceAction, - stopWorkspaceAction, - updateWorkspaceTemplateAction, - createWorkspaceAction, - goToTemplateAction as AnAction, - ) - - private val component = - panel { - row { - label(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.header.text")).applyToComponent { - font = JBFont.h3().asBold() - icon = CoderIcons.LOGO_16 - } - }.topGap(TopGap.SMALL) - row { - cell( - ComponentPanelBuilder.createCommentComponent( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.comment"), - false, - -1, - true, - ), - ) - } - row { - browserLink( - CoderGatewayBundle.message("gateway.connector.view.login.documentation.action"), - "https://coder.com/docs/coder-oss/latest/workspaces", - ) - } - row(CoderGatewayBundle.message("gateway.connector.view.login.url.label")) { - tfUrl = - textField().resizableColumn().align(AlignX.FILL).gap(RightGap.SMALL) - .bindText(fields::coderURL).applyToComponent { - addActionListener { - // Reconnect when the enter key is pressed. - maybeAskTokenThenConnect() - } - }.component - button(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text")) { - // Reconnect when the connect button is pressed. - maybeAskTokenThenConnect() - }.applyToComponent { - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - } - }.layout(RowLayout.PARENT_GRID) - row { - cell() // Empty cells for alignment. - tfUrlComment = - cell( - ComponentPanelBuilder.createCommentComponent( - CoderGatewayBundle.message( - "gateway.connector.view.coder.workspaces.connect.text.comment", - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text"), - ), - false, - -1, - true, - ), - ).resizableColumn().align(AlignX.FILL).component - }.layout(RowLayout.PARENT_GRID) - if (settings.requireTokenAuth) { - row { - cell() // Empty cell for alignment. - cbExistingToken = - checkBox(CoderGatewayBundle.message("gateway.connector.view.login.existing-token.label")) - .bindSelected(fields::useExistingToken) - .component - }.layout(RowLayout.PARENT_GRID) - row { - cell() // Empty cell for alignment. - cell( - ComponentPanelBuilder.createCommentComponent( - CoderGatewayBundle.message( - "gateway.connector.view.login.existing-token.tooltip", - CoderGatewayBundle.message("gateway.connector.view.login.existing-token.label"), - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text"), - ), - false, - -1, - true, - ), - ) - }.layout(RowLayout.PARENT_GRID) - } - row { - scrollCell( - toolbar.createPanel().apply { - add(notificationBanner.component.apply { isVisible = false }, "South") - }, - ).resizableColumn().align(AlignX.FILL).align(AlignY.FILL) - }.topGap(TopGap.NONE).bottomGap(BottomGap.NONE).resizableRow() - }.apply { - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(0, 16) - } - - private inner class GoToDashboardAction : - AnActionButton( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.dashboard.text"), - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.dashboard.description"), - CoderIcons.HOME, - ) { - override fun actionPerformed(p0: AnActionEvent) { - withoutNull(client) { BrowserUtil.browse(it.url) } - } - } - - private inner class GoToTemplateAction : - AnActionButton( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.template.text"), - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.template.description"), - AllIcons.Nodes.Template, - ) { - override fun actionPerformed(p0: AnActionEvent) { - withoutNull(client, tableOfWorkspaces.selectedObject?.workspace) { c, workspace -> - BrowserUtil.browse(c.url.toURI().resolve("/templates/${workspace.templateName}")) - } - } - } - - private inner class StartWorkspaceAction : - AnActionButton( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.start.text"), - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.start.description"), - CoderIcons.RUN, - ) { - override fun actionPerformed(p0: AnActionEvent) { - withoutNull(client, tableOfWorkspaces.selectedObject?.workspace) { c, workspace -> - jobs[workspace.id]?.cancel() - jobs[workspace.id] = - cs.launch(ModalityState.current().asContextElement()) { - withContext(Dispatchers.IO) { - try { - c.startWorkspace(workspace) - loadWorkspaces() - } catch (e: Exception) { - logger.error("Could not start workspace ${workspace.name}", e) - } - } - } - } - } - } - - private inner class UpdateWorkspaceTemplateAction : - AnActionButton( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.update.text"), - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.update.description"), - CoderIcons.UPDATE, - ) { - override fun actionPerformed(p0: AnActionEvent) { - withoutNull(client, tableOfWorkspaces.selectedObject?.workspace) { c, workspace -> - jobs[workspace.id]?.cancel() - jobs[workspace.id] = - cs.launch(ModalityState.current().asContextElement()) { - withContext(Dispatchers.IO) { - try { - // Stop the workspace first if it is running. - if (workspace.latestBuild.status == WorkspaceStatus.RUNNING) { - logger.info("Waiting for ${workspace.name} to stop before updating") - c.stopWorkspace(workspace) - loadWorkspaces() - var elapsed = Duration.ofSeconds(0) - val timeout = Duration.ofSeconds(5) - val maxWait = Duration.ofMinutes(10) - while (isActive) { // Wait for the workspace to fully stop. - delay(timeout.toMillis()) - val found = tableOfWorkspaces.items.firstOrNull { it.workspace.id == workspace.id } - when (val status = found?.workspace?.latestBuild?.status) { - WorkspaceStatus.PENDING, WorkspaceStatus.STOPPING, WorkspaceStatus.RUNNING -> { - logger.info("Still waiting for ${workspace.name} to stop before updating") - } - WorkspaceStatus.STARTING, WorkspaceStatus.FAILED, - WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED, - WorkspaceStatus.DELETING, WorkspaceStatus.DELETED, - -> { - logger.warn("Canceled ${workspace.name} update due to status change to $status") - break - } - null -> { - logger.warn("Canceled ${workspace.name} update because it no longer exists") - break - } - WorkspaceStatus.STOPPED -> { - logger.info("${workspace.name} has stopped; updating now") - c.updateWorkspace(workspace) - break - } - } - elapsed += timeout - if (elapsed > maxWait) { - logger.error( - "Canceled ${workspace.name} update because it took took longer than ${maxWait.toMinutes()} minutes to stop", - ) - break - } - } - } else { - c.updateWorkspace(workspace) - loadWorkspaces() - } - } catch (e: Exception) { - logger.error("Could not update workspace ${workspace.name}", e) - } - } - } - } - } - } - - private inner class StopWorkspaceAction : - AnActionButton( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.stop.text"), - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.stop.description"), - CoderIcons.STOP, - ) { - override fun actionPerformed(p0: AnActionEvent) { - withoutNull(client, tableOfWorkspaces.selectedObject?.workspace) { c, workspace -> - jobs[workspace.id]?.cancel() - jobs[workspace.id] = - cs.launch(ModalityState.current().asContextElement()) { - withContext(Dispatchers.IO) { - try { - c.stopWorkspace(workspace) - loadWorkspaces() - } catch (e: Exception) { - logger.error("Could not stop workspace ${workspace.name}", e) - } - } - } - } - } - } - - private inner class CreateWorkspaceAction : - AnActionButton( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.create.text"), - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.create.description"), - CoderIcons.CREATE, - ) { - override fun actionPerformed(p0: AnActionEvent) { - withoutNull(client) { BrowserUtil.browse(it.url.toURI().resolve("/templates")) } - } - } - - init { - updateWorkspaceActions() - addToCenter(component) - } - - /** - * Authorize the client and start polling for workspaces if we can. - */ - fun init() { - // After each poll, the workspace list will be updated. - triggerWorkspacePolling() - // If we already have a client, we are done. Otherwise try to set one - // up from storage or config and automatically connect. Place the - // values in the fields, so they can be seen and edited if necessary. - if (client == null || cliManager == null) { - // Try finding a URL and matching token to use. - val lastUrl = appPropertiesService.getValue(CODER_URL_KEY) - val lastToken = appPropertiesService.getValue(SESSION_TOKEN_KEY) - val url = - if (!lastUrl.isNullOrBlank()) { - lastUrl to Source.LAST_USED - } else { - settings.defaultURL() - } - val token = - if (settings.requireTokenAuth && !lastUrl.isNullOrBlank() && !lastToken.isNullOrBlank()) { - lastToken to Source.LAST_USED - } else if (url != null) { - try { - settings.token(URL(url.first)) - } catch (ex: Exception) { - null - } - } else { - null - } - // Set them into the fields. - if (url != null) { - fields.coderURL = url.first - tfUrl?.text = url.first - logger.info("Using deployment found in ${url.second}") - } - if (token != null) { - fields.token = token - logger.info("Using token found in ${token.second}") - } - // Maybe connect. - if (url != null && (!settings.requireTokenAuth || token != null)) { - connect(url.first.toURL(), token?.first) - } - } - } - - /** - * Enable/disable action buttons based on whether we have a client and the - * status of the selected workspace (if any). - */ - private fun updateWorkspaceActions() { - goToDashboardAction.isEnabled = client != null - createWorkspaceAction.isEnabled = client != null - goToTemplateAction.isEnabled = tableOfWorkspaces.selectedObject != null - when (tableOfWorkspaces.selectedObject?.workspace?.latestBuild?.status) { - WorkspaceStatus.RUNNING -> { - startWorkspaceAction.isEnabled = false - stopWorkspaceAction.isEnabled = true - updateWorkspaceTemplateAction.isEnabled = tableOfWorkspaces.selectedObject?.workspace?.outdated == true - } - - WorkspaceStatus.STOPPED, WorkspaceStatus.FAILED -> { - startWorkspaceAction.isEnabled = true - stopWorkspaceAction.isEnabled = false - updateWorkspaceTemplateAction.isEnabled = tableOfWorkspaces.selectedObject?.workspace?.outdated == true - } - - else -> { - startWorkspaceAction.isEnabled = false - stopWorkspaceAction.isEnabled = false - updateWorkspaceTemplateAction.isEnabled = false - } - } - ActivityTracker.getInstance().inc() - } - - /** - * Ask for a new token if token auth is required (regardless of whether we - * already have a token), place it in the local fields model, then connect. - * - * If the token is invalid try again until the user aborts or we get a valid - * token. Any other error will not be retried. - */ - private fun maybeAskTokenThenConnect(error: String? = null) { - val oldURL = fields.coderURL - component.apply() // Force bindings to be filled. - val newURL = fields.coderURL.toURL() - if (settings.requireTokenAuth) { - val pastedToken = - dialogUi.askToken( - newURL, - // If this is a new URL there is no point in trying to use the same - // token. - if (oldURL == newURL.toString()) fields.token else null, - fields.useExistingToken, - error, - ) ?: return // User aborted. - fields.token = pastedToken - connect(newURL, pastedToken.first) { - maybeAskTokenThenConnect(it) - } - } else { - connect(newURL, null) - } - } - - /** - * Connect to the provided deployment using the provided token (if required) - * and if successful store the deployment's URL and token (if provided) for - * use as the default in subsequent launches then load workspaces into the - * table and keep it updated with a poll. - * - * Existing workspaces will be immediately cleared before attempting to - * connect to the new deployment. - * - * If the token is invalid invoke onAuthFailure. - * - * The main effect of this method is to provide a working `cliManager` and - * `client`. - */ - private fun connect( - deploymentURL: URL, - token: String?, - onAuthFailure: ((error: String) -> Unit)? = null, - ): Job { - tfUrlComment?.foreground = UIUtil.getContextHelpForeground() - tfUrlComment?.text = - CoderGatewayBundle.message( - "gateway.connector.view.coder.workspaces.connect.text.connecting", - deploymentURL.host, - ) - tableOfWorkspaces.setEmptyState( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text.connecting", deploymentURL.host), - ) - - tableOfWorkspaces.listTableModel.items = emptyList() - cliManager = null - client = null - - // Authenticate and load in a background process with progress. - return LifetimeDefinition().launchUnderBackgroundProgress( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.cli.downloader.dialog.title"), - ) { - try { - this.indicator.text = "Authenticating client..." - val authedClient = authenticate(deploymentURL, token) - - // Remember these in order to default to them for future attempts. - appPropertiesService.setValue(CODER_URL_KEY, deploymentURL.toString()) - appPropertiesService.setValue(SESSION_TOKEN_KEY, token ?: "") - - val cli = - ensureCLI( - deploymentURL, - authedClient.buildVersion, - settings, - ) { - this.indicator.text = it - } - - // We only need to log the cli in if we have token-based auth. - // Otherwise, we assume it is set up in the same way the plugin - // is with mTLS. - if (authedClient.token != null) { - this.indicator.text = "Authenticating Coder CLI..." - cli.login(authedClient.token) - } - - cliManager = cli - client = authedClient - - tableOfWorkspaces.setEmptyState( - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text.connected", deploymentURL.host), - ) - tfUrlComment?.text = - CoderGatewayBundle.message( - "gateway.connector.view.coder.workspaces.connect.text.connected", - deploymentURL.host, - ) - - this.indicator.text = "Retrieving workspaces..." - loadWorkspaces() - } catch (e: Exception) { - if (isCancellation(e)) { - tfUrlComment?.text = - CoderGatewayBundle.message( - "gateway.connector.view.coder.workspaces.connect.text.comment", - CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text"), - ) - tableOfWorkspaces.setEmptyState( - CoderGatewayBundle.message( - "gateway.connector.view.workspaces.connect.canceled", - deploymentURL.host, - ), - ) - logger.info("Connection canceled due to ${e.javaClass.simpleName}") - } else { - val msg = humanizeConnectionError(deploymentURL, settings.requireTokenAuth, e) - // It would be nice to place messages directly into the table, - // but it does not support wrapping or markup so place it in the - // comment field of the URL input instead. - tfUrlComment?.foreground = UIUtil.getErrorForeground() - tfUrlComment?.text = msg - tableOfWorkspaces.setEmptyState( - CoderGatewayBundle.message( - "gateway.connector.view.workspaces.connect.failed", - deploymentURL.host, - ), - ) - logger.error(msg, e) - - if (e is APIResponseException && e.isUnauthorized && onAuthFailure != null) { - onAuthFailure.invoke(msg) - } - } - } - } - } - - /** - * Start polling for workspace changes if not already started. - */ - private fun triggerWorkspacePolling() { - if (poller?.isActive == true) { - logger.info("Refusing to start already-started poller") - return - } - poller = - cs.launch(ModalityState.current().asContextElement()) { - while (isActive) { - loadWorkspaces() - delay(5000) - } - } - } - - /** - * Authenticate the Coder client with the provided URL and token (if - * required). On failure throw an error. On success display warning - * banners if versions do not match. Return the authenticated client. - */ - private fun authenticate( - url: URL, - token: String?, - ): CoderRestClient { - logger.info("Authenticating to $url...") - val tryClient = CoderRestClientService(url, token) - tryClient.authenticate() - - try { - logger.info("Checking compatibility with Coder version ${tryClient.buildVersion}...") - val ver = SemVer.parse(tryClient.buildVersion) - if (ver in CoderSupportedVersions.minCompatibleCoderVersion..CoderSupportedVersions.maxCompatibleCoderVersion) { - logger.info("${tryClient.buildVersion} is compatible") - } else { - logger.warn("${tryClient.buildVersion} is not compatible") - notificationBanner.apply { - component.isVisible = true - showWarning( - CoderGatewayBundle.message( - "gateway.connector.view.coder.workspaces.unsupported.coder.version", - tryClient.buildVersion, - ), - ) - } - } - } catch (e: InvalidVersionException) { - logger.warn(e) - notificationBanner.apply { - component.isVisible = true - showWarning( - CoderGatewayBundle.message( - "gateway.connector.view.coder.workspaces.invalid.coder.version", - tryClient.buildVersion, - ), - ) - } - } - - logger.info("Authenticated successfully") - return tryClient - } - - /** - * Request workspaces then update the table. - */ - private suspend fun loadWorkspaces() { - val ws = - withContext(Dispatchers.IO) { - val timeBeforeRequestingWorkspaces = System.currentTimeMillis() - val clientNow = client ?: return@withContext emptySet() - try { - val ws = clientNow.workspaces() - val ams = ws.flatMap { it.toAgentList() } - ams.forEach { - cs.launch(Dispatchers.IO) { - it.icon = clientNow.loadIcon(it.workspace.templateIcon, it.workspace.name) - withContext(Dispatchers.Main) { - tableOfWorkspaces.updateUI() - } - } - } - val timeAfterRequestingWorkspaces = System.currentTimeMillis() - logger.info("Retrieving the workspaces took: ${timeAfterRequestingWorkspaces - timeBeforeRequestingWorkspaces} millis") - return@withContext ams - } catch (e: Exception) { - logger.error("Could not retrieve workspaces for ${clientNow.me.username} on ${clientNow.url}", e) - emptySet() - } - } - withContext(Dispatchers.Main) { - val selectedWorkspace = tableOfWorkspaces.selectedObject - tableOfWorkspaces.listTableModel.items = ws.toList() - tableOfWorkspaces.selectItem(selectedWorkspace) - } - } - - /** - * Return the selected agent. Throw if not configured. - */ - override fun data(): CoderWorkspacesStepSelection { - val selected = tableOfWorkspaces.selectedObject - return withoutNull(client, cliManager, selected?.agent, selected?.workspace) { client, cli, agent, workspace -> - val name = "${workspace.name}.${agent.name}" - logger.info("Returning data for $name") - CoderWorkspacesStepSelection( - agent = agent, - workspace = workspace, - cliManager = cli, - client = client, - workspaces = tableOfWorkspaces.items.map { it.workspace }, - ) - } - } - - override fun stop() { - poller?.cancel() - jobs.forEach { it.value.cancel() } - jobs.clear() - } - - override fun dispose() { - stop() - cs.cancel() - } - - companion object { - val logger = Logger.getInstance(CoderWorkspacesStepView::class.java.simpleName) - } -} - -class WorkspacesTableModel : - ListTableModel<WorkspaceAgentListModel>( - WorkspaceIconColumnInfo(""), - WorkspaceNameColumnInfo("Name"), - WorkspaceTemplateNameColumnInfo("Template"), - WorkspaceVersionColumnInfo("Version"), - WorkspaceStatusColumnInfo("Status"), - ) { - private class WorkspaceIconColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentListModel, String>(columnName) { - override fun valueOf(item: WorkspaceAgentListModel?): String? = item?.workspace?.templateName - - override fun getRenderer(item: WorkspaceAgentListModel?): TableCellRenderer { - return object : IconTableCellRenderer<String>() { - override fun getText(): String = "" - - override fun getIcon( - value: String, - table: JTable?, - row: Int, - ): Icon = item?.icon ?: CoderIcons.UNKNOWN - - override fun isCenterAlignment() = true - - override fun getTableCellRendererComponent( - table: JTable?, - value: Any?, - selected: Boolean, - focus: Boolean, - row: Int, - column: Int, - ): Component { - super.getTableCellRendererComponent(table, value, selected, focus, row, column).apply { - border = JBUI.Borders.empty(8) - } - return this - } - } - } - } - - private class WorkspaceNameColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentListModel, String>(columnName) { - override fun valueOf(item: WorkspaceAgentListModel?): String? = item?.name - - override fun getComparator(): Comparator<WorkspaceAgentListModel> = Comparator { a, b -> - a.name.compareTo(b.name, ignoreCase = true) - } - - override fun getRenderer(item: WorkspaceAgentListModel?): TableCellRenderer { - return object : DefaultTableCellRenderer() { - override fun getTableCellRendererComponent( - table: JTable, - value: Any, - isSelected: Boolean, - hasFocus: Boolean, - row: Int, - column: Int, - ): Component { - super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) - if (value is String) { - text = value - } - - font = RelativeFont.BOLD.derive(table.tableHeader.font) - border = JBUI.Borders.empty(0, 8) - return this - } - } - } - } - - private class WorkspaceTemplateNameColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentListModel, String>(columnName) { - override fun valueOf(item: WorkspaceAgentListModel?): String? = item?.workspace?.templateName - - override fun getComparator(): java.util.Comparator<WorkspaceAgentListModel> = Comparator { a, b -> - a.workspace.templateName.compareTo(b.workspace.templateName, ignoreCase = true) - } - - override fun getRenderer(item: WorkspaceAgentListModel?): TableCellRenderer { - return object : DefaultTableCellRenderer() { - override fun getTableCellRendererComponent( - table: JTable, - value: Any, - isSelected: Boolean, - hasFocus: Boolean, - row: Int, - column: Int, - ): Component { - super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) - if (value is String) { - text = value - } - font = table.tableHeader.font - border = JBUI.Borders.empty(0, 8) - return this - } - } - } - } - - private class WorkspaceVersionColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentListModel, String>(columnName) { - override fun valueOf(workspace: WorkspaceAgentListModel?): String? = if (workspace == null) { - "Unknown" - } else if (workspace.workspace.outdated) { - "Outdated" - } else { - "Up to date" - } - - override fun getRenderer(item: WorkspaceAgentListModel?): TableCellRenderer { - return object : DefaultTableCellRenderer() { - override fun getTableCellRendererComponent( - table: JTable, - value: Any, - isSelected: Boolean, - hasFocus: Boolean, - row: Int, - column: Int, - ): Component { - super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) - if (value is String) { - text = value - } - font = table.tableHeader.font - border = JBUI.Borders.empty(0, 8) - return this - } - } - } - } - - private class WorkspaceStatusColumnInfo(columnName: String) : ColumnInfo<WorkspaceAgentListModel, String>(columnName) { - override fun valueOf(item: WorkspaceAgentListModel?): String? = item?.status?.label - - override fun getComparator(): java.util.Comparator<WorkspaceAgentListModel> = Comparator { a, b -> - a.status.label.compareTo(b.status.label, ignoreCase = true) - } - - override fun getRenderer(item: WorkspaceAgentListModel?): TableCellRenderer { - return object : DefaultTableCellRenderer() { - private val item = item - - override fun getTableCellRendererComponent( - table: JTable, - value: Any, - isSelected: Boolean, - hasFocus: Boolean, - row: Int, - column: Int, - ): Component { - super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) - if (value is String) { - text = value - foreground = this.item?.status?.statusColor() - toolTipText = this.item?.status?.description - } - font = table.tableHeader.font - border = JBUI.Borders.empty(0, 8) - return this - } - } - } - } -} - -class WorkspacesTable : TableView<WorkspaceAgentListModel>(WorkspacesTableModel()) { - /** - * Given either a workspace or an agent select in order of preference: - * 1. That same agent or workspace. - * 2. The first match for the workspace (workspace itself or first agent). - */ - fun selectItem(workspace: WorkspaceAgentListModel?) { - val index = getNewSelection(workspace) - if (index > -1) { - selectionModel.addSelectionInterval(convertRowIndexToView(index), convertRowIndexToView(index)) - // Fix cell selection case. - columnModel.selectionModel.addSelectionInterval(0, columnCount - 1) - } - } - - fun getNewSelection(oldSelection: WorkspaceAgentListModel?): Int { - if (oldSelection == null) { - return -1 - } - val index = listTableModel.items.indexOfFirst { it.name == oldSelection.name } - if (index > -1) { - return index - } - // If there is no matching agent, try matching on just the workspace. - // It is possible it turned off so it no longer has agents displaying; - // in this case we want to keep it highlighted. - return listTableModel.items.indexOfFirst { it.workspace.name == oldSelection.workspace.name } - } -} diff --git a/src/main/kotlin/com/coder/gateway/views/steps/NotificationBanner.kt b/src/main/kotlin/com/coder/gateway/views/steps/NotificationBanner.kt deleted file mode 100644 index 2e8489b37..000000000 --- a/src/main/kotlin/com/coder/gateway/views/steps/NotificationBanner.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.coder.gateway.views.steps - -import com.intellij.icons.AllIcons -import com.intellij.openapi.ui.DialogPanel -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.panel -import com.intellij.util.ui.JBUI -import javax.swing.JEditorPane -import javax.swing.JLabel - -class NotificationBanner { - var component: DialogPanel - private lateinit var icon: JLabel - private lateinit var txt: JEditorPane - - init { - component = - panel { - row { - icon = - icon(AllIcons.General.Warning).applyToComponent { - border = JBUI.Borders.empty(0, 5) - }.component - txt = - text("").resizableColumn().align(AlignX.FILL).applyToComponent { - foreground = JBUI.CurrentTheme.NotificationWarning.foregroundColor() - }.component - } - }.apply { - background = JBUI.CurrentTheme.NotificationWarning.backgroundColor() - } - } - - fun showWarning(warning: String) { - icon.icon = AllIcons.General.Warning - txt.apply { - text = warning - foreground = JBUI.CurrentTheme.NotificationWarning.foregroundColor() - } - - component.background = JBUI.CurrentTheme.NotificationWarning.backgroundColor() - } - - fun showInfo(info: String) { - icon.icon = AllIcons.General.Information - txt.apply { - text = info - foreground = JBUI.CurrentTheme.NotificationInfo.foregroundColor() - } - - component.background = JBUI.CurrentTheme.NotificationInfo.backgroundColor() - } -} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml deleted file mode 100644 index c620a8a9a..000000000 --- a/src/main/resources/META-INF/plugin.xml +++ /dev/null @@ -1,28 +0,0 @@ -<!-- Plugin Configuration File. Read more: https://plugins.jetbrains.com/docs/intellij/plugin-configuration-file.html --> -<idea-plugin> - <id>com.coder.gateway</id> - <name>Coder</name> - <vendor>Coder</vendor> - - <!-- Product and plugin compatibility requirements --> - <!-- https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html --> - <depends>com.intellij.modules.platform</depends> - - <!-- TODO: enable this when https://youtrack.jetbrains.com/issue/GTW-1528/Plugin-depends-on-unknown-plugin-comjetbrainsgateway is fixed--> - <!-- <depends>com.jetbrains.gateway</depends>--> - - <!-- we trick Gateway into no longer rasing the unknown module error by marking the dependency optional--> - <depends optional="true">com.jetbrains.gateway</depends> - - <extensions defaultExtensionNs="com.intellij"> - <applicationService serviceImplementation="com.coder.gateway.services.CoderRecentWorkspaceConnectionsService"/> - <applicationService serviceImplementation="com.coder.gateway.services.CoderSettingsStateService"/> - <applicationService serviceImplementation="com.coder.gateway.services.CoderSettingsService"/> - <applicationConfigurable parentId="tools" instance="com.coder.gateway.CoderSettingsConfigurable"/> - <webHelpProvider implementation="com.coder.gateway.help.CoderWebHelp"/> - </extensions> - <extensions defaultExtensionNs="com.jetbrains"> - <gatewayConnector implementation="com.coder.gateway.CoderGatewayMainView"/> - <gatewayConnectionProvider implementation="com.coder.gateway.CoderGatewayConnectionProvider"/> - </extensions> -</idea-plugin> diff --git a/src/main/resources/META-INF/pluginIcon_dark.svg b/src/main/resources/META-INF/pluginIcon_dark.svg deleted file mode 100644 index 64d036ad8..000000000 --- a/src/main/resources/META-INF/pluginIcon_dark.svg +++ /dev/null @@ -1,15 +0,0 @@ -<svg width="52" height="37" viewBox="0 0 52 37" fill="none" xmlns="http://www.w3.org/2000/svg"> -<g clip-path="url(#clip0_4116_5238)"> -<path d="M50.4353 15.8152C49.4054 15.8152 48.7188 15.219 48.7188 13.9952V6.96621C48.7188 2.47896 46.8462 0 42.0086 0H39.7615V4.73827H40.4482C42.3519 4.73827 43.257 5.77379 43.257 7.62517V13.8383C43.257 16.5369 44.0684 17.6352 45.8474 18.2C44.0684 18.7335 43.257 19.8631 43.257 22.5617C43.257 24.0993 43.257 25.6369 43.257 27.1745C43.257 28.461 43.257 29.7162 42.9137 31.0027C42.5704 32.1952 42.0086 33.3248 41.2284 34.2975C40.7914 34.8625 40.2921 35.3331 39.7304 35.7725V36.4H41.9774C46.815 36.4 48.6876 33.921 48.6876 29.4338V22.4048C48.6876 21.1496 49.343 20.5848 50.4042 20.5848H51.6838V15.8465H50.4353V15.8152Z" fill="#AFB1B3"/> -<path d="M35.1425 7.15546H28.2139C28.0578 7.15546 27.9331 7.02994 27.9331 6.87305V6.33961C27.9331 6.18271 28.0578 6.05719 28.2139 6.05719H35.1738C35.3297 6.05719 35.4546 6.18271 35.4546 6.33961V6.87305C35.4546 7.02994 35.2985 7.15546 35.1425 7.15546Z" fill="#AFB1B3"/> -<path d="M36.3282 13.9331H31.2723C31.1162 13.9331 30.9913 13.8075 30.9913 13.6506V13.1172C30.9913 12.9603 31.1162 12.8348 31.2723 12.8348H36.3282C36.4843 12.8348 36.6091 12.9603 36.6091 13.1172V13.6506C36.6091 13.7762 36.4843 13.9331 36.3282 13.9331Z" fill="#AFB1B3"/> -<path d="M38.3259 10.5443H28.2139C28.0578 10.5443 27.9331 10.4187 27.9331 10.2619V9.7284C27.9331 9.5715 28.0578 9.44599 28.2139 9.44599H38.2947C38.4508 9.44599 38.5756 9.5715 38.5756 9.7284V10.2619C38.5756 10.3874 38.482 10.5443 38.3259 10.5443Z" fill="#AFB1B3"/> -<path d="M20.193 8.69207C20.8796 8.69207 21.5662 8.75483 22.2216 8.91173V7.62517C22.2216 5.80517 23.1579 4.73827 25.0306 4.73827H25.7171V0H23.47C18.6324 0 16.7599 2.47896 16.7599 6.96621V9.28827C17.8522 8.91173 19.007 8.69207 20.193 8.69207Z" fill="#AFB1B3"/> -<path d="M40.4482 25.7617C39.9488 21.7765 36.8902 18.4503 32.9577 17.6972C31.8654 17.4776 30.773 17.4462 29.7119 17.6345C29.6807 17.6345 29.6807 17.603 29.6495 17.603C27.9329 13.9945 24.2502 11.6096 20.2553 11.6096C16.2604 11.6096 12.6089 13.9317 10.8611 17.5403C10.8299 17.5403 10.8299 17.5717 10.7986 17.5717C9.6751 17.4462 8.55154 17.5089 7.42797 17.7913C3.55794 18.7327 0.6242 21.9962 0.0936299 25.9499C0.0312099 26.3578 0 26.7658 0 27.1424C0 28.3347 0.81146 29.433 1.99743 29.5899C3.4643 29.8097 4.74391 28.6799 4.7127 27.2365C4.7127 27.0168 4.7127 26.7658 4.74391 26.5462C4.9936 24.5378 6.52288 22.8434 8.52032 22.3727C9.14452 22.2158 9.76872 22.1845 10.3617 22.2786C12.2655 22.5297 14.1381 21.5568 14.9496 19.8624C15.5426 18.6072 16.4789 17.5089 17.7273 16.9127C19.1004 16.2537 20.661 16.1597 22.0967 16.6617C23.5947 17.1951 24.7183 18.3247 25.4049 19.7368C26.1227 21.1176 26.466 22.0903 27.9954 22.2786C28.6195 22.3727 30.3673 22.3413 31.0228 22.3099C32.3024 22.3099 33.582 22.7493 34.4871 23.6593C35.08 24.2868 35.5169 25.0713 35.7042 25.9499C35.9851 27.362 35.6418 28.7741 34.7991 29.841C34.2061 30.5941 33.3946 31.1589 32.4895 31.4099C32.0526 31.5355 31.6157 31.5668 31.1787 31.5668C30.9291 31.5668 30.5858 31.5668 30.1801 31.5668C28.9317 31.5668 26.2788 31.5668 24.2813 31.5668C22.9082 31.5668 21.8158 30.4686 21.8158 29.0878V24.4437V19.8937C21.8158 19.5172 21.5037 19.2034 21.1292 19.2034H20.1616C18.2578 19.2347 16.7285 21.3686 16.7285 23.6278C16.7285 25.8872 16.7285 31.8807 16.7285 31.8807C16.7285 34.3282 18.6947 36.3051 21.1292 36.3051C21.1292 36.3051 31.9591 36.2737 32.115 36.2737C34.6118 36.0227 36.9214 34.7361 38.4819 32.7593C40.0424 30.8451 40.7602 28.3347 40.4482 25.7617Z" fill="#AFB1B3"/> -</g> -<defs> -<clipPath id="clip0_4116_5238"> -<rect width="52" height="36.4" fill="white"/> -</clipPath> -</defs> -</svg> diff --git a/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension b/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension new file mode 100644 index 000000000..f4aec9029 --- /dev/null +++ b/src/main/resources/META-INF/services/com.jetbrains.toolbox.gateway.GatewayExtension @@ -0,0 +1 @@ +com.coder.gateway.CoderGatewayExtension diff --git a/src/main/resources/dependencies.json b/src/main/resources/dependencies.json new file mode 100644 index 000000000..53898ff2f --- /dev/null +++ b/src/main/resources/dependencies.json @@ -0,0 +1,79 @@ +[ + { + "name": "com.jetbrains.toolbox.gateway:gateway-api", + "version": "2.5.0.32871", + "url": "https://jetbrains.com/toolbox-app/", + "license": "The Apache Software License, Version 2.0", + "licenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "name": "com.squareup.okhttp3:okhttp", + "version": "4.12.0", + "url": "https://square.github.io/okhttp/", + "license": "The Apache Software License, Version 2.0", + "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "name": "com.squareup.retrofit2:converter-moshi", + "version": "2.8.2", + "url": "https://github.com/square/retrofit", + "license": "The Apache Software License, Version 2.0", + "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "name": "com.squareup.retrofit2:retrofit", + "version": "2.8.2", + "url": "https://github.com/square/retrofit", + "license": "The Apache Software License, Version 2.0", + "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "name": "org.jetbrains.kotlin:kotlin-stdlib-jdk8", + "version": "1.9.10", + "url": "https://kotlinlang.org/", + "license": "The Apache License, Version 2.0", + "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "name": "org.jetbrains.kotlinx:kotlinx-coroutines-core", + "version": "1.7.3", + "url": null, + "license": null, + "licenseUrl": null + }, + { + "name": "org.jetbrains.kotlinx:kotlinx-serialization-core", + "version": "1.5.0", + "url": null, + "license": null, + "licenseUrl": null + }, + { + "name": "org.jetbrains.kotlinx:kotlinx-serialization-json", + "version": "1.5.0", + "url": null, + "license": null, + "licenseUrl": null + }, + { + "name": "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", + "version": "1.5.0", + "url": null, + "license": null, + "licenseUrl": null + }, + { + "name": "org.slf4j:slf4j-api", + "version": "2.0.3", + "url": "http://www.slf4j.org", + "license": "MIT License", + "licenseUrl": "http://www.opensource.org/licenses/mit-license.php" + }, + { + "name": "org.zeroturnaround:zt-exec", + "version": "1.12", + "url": "https://github.com/zeroturnaround/zt-exec", + "license": "The Apache Software License, Version 2.0", + "licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } +] diff --git a/src/main/resources/extension.json b/src/main/resources/extension.json new file mode 100644 index 000000000..7ff974c24 --- /dev/null +++ b/src/main/resources/extension.json @@ -0,0 +1,20 @@ +{ + "id": "com.coder.gateway", + "version": "0.0.1", + "meta": { + "readableName": "Coder Gateway", + "description": "This plugin connects your JetBrains IDE to Coder workspaces.", + "vendor": "Coder", + "url": "https://github.com/coder/jetbrains-coder", + "backgroundColors": { + "start": { "hex": "#fdb60d", "opacity": 0.6 }, + "top": { "hex": "#ff318c", "opacity": 0.6 }, + "end": { "hex": "#6b57ff", "opacity": 0.6 } + } + }, + "apiVersion": "0.1.0", + "compatibleVersionRange": { + "from": "2.1.0", + "to": "2.2.0" + } +} diff --git a/src/main/resources/META-INF/pluginIcon.svg b/src/main/resources/icon.svg similarity index 100% rename from src/main/resources/META-INF/pluginIcon.svg rename to src/main/resources/icon.svg diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties deleted file mode 100644 index 73b055c1b..000000000 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ /dev/null @@ -1,131 +0,0 @@ -gateway.connector.title=Coder -gateway.connector.description=Connects to a Coder Workspace dev environment so that you can develop from anywhere -gateway.connector.action.text=Connect to Coder -gateway.connector.view.login.documentation.action=Learn more about Coder -gateway.connector.view.login.url.label=URL: -gateway.connector.view.login.existing-token.label=Use existing token -gateway.connector.view.login.existing-token.tooltip=Checking "{0}" will prevent the browser from being launched for generating a new token after pressing "{1}". Additionally, if a token is already configured for this URL via the CLI it will automatically be used. -gateway.connector.view.coder.workspaces.header.text=Coder workspaces -gateway.connector.view.coder.workspaces.comment=Self-hosted developer workspaces in the cloud or on-premises. Coder empowers developers with secure, consistent, and fast developer workspaces. -gateway.connector.view.coder.workspaces.connect.text=Connect -gateway.connector.view.coder.workspaces.connect.text.comment=Please enter your deployment URL and press "{0}". -gateway.connector.view.coder.workspaces.connect.text.disconnected=Disconnected -gateway.connector.view.coder.workspaces.connect.text.connected=Connected to {0} -gateway.connector.view.coder.workspaces.connect.text.connecting=Connecting to {0}... -gateway.connector.view.coder.workspaces.cli.downloader.dialog.title=Authenticate and setup Coder -gateway.connector.view.coder.workspaces.next.text=Select IDE and project -gateway.connector.view.coder.workspaces.dashboard.text=Open Dashboard -gateway.connector.view.coder.workspaces.dashboard.description=Open dashboard -gateway.connector.view.coder.workspaces.template.text=View Template -gateway.connector.view.coder.workspaces.template.description=View template -gateway.connector.view.coder.workspaces.start.text=Start Workspace -gateway.connector.view.coder.workspaces.start.description=Start workspace -gateway.connector.view.coder.workspaces.stop.text=Stop Workspace -gateway.connector.view.coder.workspaces.stop.description=Stop workspace -gateway.connector.view.coder.workspaces.update.text=Update Workspace -gateway.connector.view.coder.workspaces.update.description=Update workspace -gateway.connector.view.coder.workspaces.create.text=Create Workspace -gateway.connector.view.coder.workspaces.create.description=Create workspace -gateway.connector.view.coder.workspaces.unsupported.os.info=Gateway supports only Linux machines. Support for macOS and Windows is planned. -gateway.connector.view.coder.workspaces.invalid.coder.version=Could not parse Coder version {0}. Coder Gateway plugin might not be compatible with this version. <a href='https://coder.com/docs/v2/latest/ides/gateway#creating-a-new-jetbrains-gateway-connection'>Connect to a Coder workspace manually</a> -gateway.connector.view.coder.workspaces.unsupported.coder.version=Coder version {0} might not be compatible with this plugin version. <a href='https://coder.com/docs/v2/latest/ides/gateway#creating-a-new-jetbrains-gateway-connection'>Connect to a Coder workspace manually</a> -gateway.connector.view.workspaces.connect.failed=Connection to {0} failed. See above for details. -gateway.connector.view.workspaces.connect.canceled=Connection to {0} canceled. -gateway.connector.view.coder.connect-ssh=Establishing SSH connection to remote worker... -gateway.connector.view.coder.connect-ssh.retry=Establishing SSH connection to remote worker (attempt {0})... -gateway.connector.view.coder.retrieve-ides=Retrieving IDEs... -gateway.connector.view.coder.retrieve-ides.retry=Retrieving IDEs (attempt {0})... -gateway.connector.view.coder.retrieve-ides.failed=Failed to retrieve IDEs -gateway.connector.view.coder.retrieve-ides.failed.retry=Failed to retrieve IDEs...retrying {0} -gateway.connector.view.coder.remoteproject.next.text=Start IDE and connect -gateway.connector.view.coder.remoteproject.choose.text=Choose IDE and project for workspace {0} -gateway.connector.view.coder.remoteproject.ide.download.comment=This IDE will be downloaded and installed to the default path on the remote host. -gateway.connector.view.coder.remoteproject.ide.installed.comment=This IDE is already installed and will be used as-is. -gateway.connector.view.coder.remoteproject.ide.none.comment=No IDE selected. -gateway.connector.recent-connections.title=Recent projects -gateway.connector.recent-connections.new.wizard.button.tooltip=Open a new Coder workspace -gateway.connector.recent-connections.remove.button.tooltip=Remove from recent connections -gateway.connector.coder.connection.provider.title=Connecting to Coder workspace... -gateway.connector.coder.connecting=Connecting... -gateway.connector.coder.connecting.retry=Connecting (attempt {0})... -gateway.connector.coder.connection.failed=Failed to connect -gateway.connector.coder.connecting.failed.retry=Failed to connect...retrying {0} -gateway.connector.settings.data-directory.title=Data directory: -gateway.connector.settings.data-directory.comment=Directories are created \ - here that store the credentials for each domain to which the plugin \ - connects. \ - Defaults to {0}. -gateway.connector.settings.binary-source.title=CLI source: -gateway.connector.settings.binary-source.comment=Used to download the Coder \ - CLI which is necessary to make SSH connections. The If-None-Match header \ - will be set to the SHA1 of the CLI and can be used for caching. Absolute \ - URLs will be used as-is; otherwise this value will be resolved against the \ - deployment domain. \ - Defaults to {0}. -gateway.connector.settings.enable-downloads.title=Enable CLI downloads -gateway.connector.settings.enable-downloads.comment=Checking this box will \ - allow the plugin to download the CLI if the current one is out of date or \ - does not exist. -gateway.connector.settings.binary-destination.title=CLI directory: -gateway.connector.settings.binary-destination.comment=Directories are created \ - here that store the CLI for each domain to which the plugin connects. \ - Defaults to the data directory. -gateway.connector.settings.enable-binary-directory-fallback.title=Fall back to data directory -gateway.connector.settings.enable-binary-directory-fallback.comment=Checking this \ - box will allow the plugin to fall back to the data directory when the CLI \ - directory is not writable. -gateway.connector.settings.header-command.title=Header command: -gateway.connector.settings.header-command.comment=An external command that \ - outputs additional HTTP headers added to all requests. The command must \ - output each header as `key=value` on its own line. The following \ - environment variables will be available to the process: CODER_URL. -gateway.connector.settings.tls-cert-path.title=Cert path: -gateway.connector.settings.tls-cert-path.comment=Optionally set this to \ - the path of a certificate to use for TLS connections. The certificate \ - should be in X.509 PEM format. If a certificate and key are set, token \ - authentication will be disabled. -gateway.connector.settings.tls-key-path.title=Key path: -gateway.connector.settings.tls-key-path.comment=Optionally set this to \ - the path of the private key that corresponds to the above cert path to use \ - for TLS connections. The key should be in X.509 PEM format. If a certificate \ - and key are set, token authentication will be disabled. -gateway.connector.settings.tls-ca-path.title=CA path: -gateway.connector.settings.tls-ca-path.comment=Optionally set this to \ - the path of a file containing certificates for an alternate certificate \ - authority used to verify TLS certs returned by the Coder service. \ - The file should be in X.509 PEM format. -gateway.connector.settings.tls-alt-name.title=Alt hostname: -gateway.connector.settings.tls-alt-name.comment=Optionally set this to \ - an alternate hostname used for verifying TLS connections. This is useful \ - when the hostname used to connect to the Coder service does not match the \ - hostname in the TLS certificate. -gateway.connector.settings.disable-autostart.heading=Autostart: -gateway.connector.settings.disable-autostart.title=Disable autostart -gateway.connector.settings.disable-autostart.comment=Checking this box will \ - cause the plugin to configure the CLI with --disable-autostart. You must go \ - through the IDE selection again for the plugin to reconfigure the CLI with \ - this setting. -gateway.connector.settings.ssh-config-options.title=SSH config options -gateway.connector.settings.ssh-config-options.comment=Extra SSH config options \ - to use when connecting to a workspace. This text will be appended as-is to \ - the SSH configuration block for each workspace. If left blank the \ - environment variable {0} will be used, if set. -gateway.connector.settings.setup-command.title=Setup command: -gateway.connector.settings.setup-command.comment=An external command that \ - will be executed on the remote in the bin directory of the IDE before \ - connecting to it. If the command exits with non-zero, the exit code, stdout, \ - and stderr will be displayed to the user and the connection will be aborted \ - unless configured to be ignored below. -gateway.connector.settings.ignore-setup-failure.title=Ignore setup command failure -gateway.connector.settings.ignore-setup-failure.comment=Checking this box will \ - cause the plugin to ignore failures (any non-zero exit code) from the setup \ - command and continue connecting. -gateway.connector.settings.default-url.title=Default URL: -gateway.connector.settings.default-url.comment=The default URL to set in the \ - URL field in the connection window when there is no last used URL. If this \ - is not set, it will try CODER_URL then the URL in the Coder CLI config \ - directory. -gateway.connector.settings.ssh-log-directory.title=SSH log directory: -gateway.connector.settings.ssh-log-directory.comment=If set, the Coder CLI will \ - output extra SSH information into this directory, which can be helpful for \ - debugging connectivity issues. diff --git a/src/main/resources/version/CoderSupportedVersions.properties b/src/main/resources/version/CoderSupportedVersions.properties deleted file mode 100644 index 03c98dd1a..000000000 --- a/src/main/resources/version/CoderSupportedVersions.properties +++ /dev/null @@ -1,2 +0,0 @@ -minCompatibleCoderVersion=0.12.9 -maxCompatibleCoderVersion=3.0.0 diff --git a/src/test/kotlin/com/coder/gateway/models/WorkspaceProjectIDETest.kt b/src/test/kotlin/com/coder/gateway/models/WorkspaceProjectIDETest.kt deleted file mode 100644 index 3a64f6e0c..000000000 --- a/src/test/kotlin/com/coder/gateway/models/WorkspaceProjectIDETest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.coder.gateway.models - -import java.net.URL -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -internal class WorkspaceProjectIDETest { - @Test - fun testNameFallback() { - // Name already exists. - assertEquals( - "workspace-name", - RecentWorkspaceConnection( - name = "workspace-name", - coderWorkspaceHostname = "coder-jetbrains--hostname--bar.coder.com", - projectPath = "/foo/bar", - ideProductCode = "IU", - ideBuildNumber = "number", - idePathOnHost = "/foo/bar", - ).toWorkspaceProjectIDE().name, - ) - - // Pull from host name. - assertEquals( - "hostname", - RecentWorkspaceConnection( - coderWorkspaceHostname = "coder-jetbrains--hostname--baz.coder.com", - projectPath = "/foo/bar", - ideProductCode = "IU", - ideBuildNumber = "number", - idePathOnHost = "/foo/bar", - ).toWorkspaceProjectIDE().name, - ) - - // Nothing to fall back to. - val ex = - assertFailsWith( - exceptionClass = Exception::class, - block = { - RecentWorkspaceConnection( - projectPath = "/foo/bar", - ideProductCode = "IU", - ideBuildNumber = "number", - idePathOnHost = "/foo/bar", - ).toWorkspaceProjectIDE() - }, - ) - assertContains(ex.message.toString(), "Workspace name is missing") - } - - @Test - fun testURLFallback() { - // Deployment URL already exists. - assertEquals( - URL("https://foo.coder.com"), - RecentWorkspaceConnection( - name = "workspace.agent", - deploymentURL = "https://foo.coder.com", - coderWorkspaceHostname = "coder-jetbrains--hostname--bar.coder.com", - projectPath = "/foo/bar", - ideProductCode = "IU", - ideBuildNumber = "number", - idePathOnHost = "/foo/bar", - ).toWorkspaceProjectIDE().deploymentURL, - ) - - // Pull from config directory. - assertEquals( - URL("https://baz.coder.com"), - RecentWorkspaceConnection( - name = "workspace.agent", - configDirectory = "/foo/bar/baz.coder.com/qux", - coderWorkspaceHostname = "coder-jetbrains--hostname--bar.coder.com", - projectPath = "/foo/bar", - ideProductCode = "IU", - ideBuildNumber = "number", - idePathOnHost = "/foo/bar", - ).toWorkspaceProjectIDE().deploymentURL, - ) - - // Pull from host name. - assertEquals( - URL("https://bar.coder.com"), - RecentWorkspaceConnection( - name = "workspace.agent", - coderWorkspaceHostname = "coder-jetbrains--hostname--bar.coder.com", - projectPath = "/foo/bar", - ideProductCode = "IU", - ideBuildNumber = "number", - idePathOnHost = "/foo/bar", - ).toWorkspaceProjectIDE().deploymentURL, - ) - - // Nothing to fall back to. - val ex = - assertFailsWith( - exceptionClass = Exception::class, - block = { - RecentWorkspaceConnection( - name = "workspace.agent", - projectPath = "/foo/bar", - ideProductCode = "IU", - ideBuildNumber = "number", - idePathOnHost = "/foo/bar", - ).toWorkspaceProjectIDE() - }, - ) - assertContains(ex.message.toString(), "Deployment URL is missing") - - // Invalid URL. - assertFailsWith( - exceptionClass = Exception::class, - block = { - RecentWorkspaceConnection( - name = "workspace.agent", - deploymentURL = "foo.coder.com", // Missing protocol. - coderWorkspaceHostname = "coder-jetbrains--hostname--bar.coder.com", - projectPath = "/foo/bar", - ideProductCode = "IU", - ideBuildNumber = "number", - idePathOnHost = "/foo/bar", - ).toWorkspaceProjectIDE() - }, - ) - } -} diff --git a/src/test/kotlin/com/coder/gateway/sdk/DataGen.kt b/src/test/kotlin/com/coder/gateway/sdk/DataGen.kt index c2c7fb3d4..fda2d4181 100644 --- a/src/test/kotlin/com/coder/gateway/sdk/DataGen.kt +++ b/src/test/kotlin/com/coder/gateway/sdk/DataGen.kt @@ -1,6 +1,5 @@ package com.coder.gateway.sdk -import com.coder.gateway.models.WorkspaceAgentListModel import com.coder.gateway.sdk.v2.models.Template import com.coder.gateway.sdk.v2.models.User import com.coder.gateway.sdk.v2.models.Workspace @@ -10,22 +9,12 @@ import com.coder.gateway.sdk.v2.models.WorkspaceAgentStatus import com.coder.gateway.sdk.v2.models.WorkspaceBuild import com.coder.gateway.sdk.v2.models.WorkspaceResource import com.coder.gateway.sdk.v2.models.WorkspaceStatus -import com.coder.gateway.sdk.v2.models.toAgentList import com.coder.gateway.util.Arch import com.coder.gateway.util.OS import java.util.UUID class DataGen { companion object { - // Create a list of random agents for a random workspace. - fun agentList( - workspaceName: String, - vararg agentName: String, - ): List<WorkspaceAgentListModel> { - val workspace = workspace(workspaceName, agents = agentName.associateWith { UUID.randomUUID().toString() }) - return workspace.toAgentList() - } - fun resource( agentName: String, agentId: String, @@ -64,6 +53,7 @@ class DataGen { ), outdated = false, name = name, + ownerName = "owner", ) } diff --git a/src/test/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepViewTest.kt b/src/test/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepViewTest.kt deleted file mode 100644 index 6d5cc559d..000000000 --- a/src/test/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepViewTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.coder.gateway.views.steps - -import com.coder.gateway.sdk.DataGen -import kotlin.test.Test -import kotlin.test.assertEquals - -internal class CoderWorkspacesStepViewTest { - @Test - fun getsNewSelection() { - val table = WorkspacesTable() - table.listTableModel.items = - listOf( - // An off workspace. - DataGen.agentList("ws1"), - // On workspaces. - DataGen.agentList("ws2", "agent1"), - DataGen.agentList("ws2", "agent2"), - DataGen.agentList("ws3", "agent3"), - // Another off workspace. - DataGen.agentList("ws4"), - // In practice we do not list both agents and workspaces - // together but here test that anyway with an agent first and - // then with a workspace first. - DataGen.agentList("ws5", "agent2"), - DataGen.agentList("ws5"), - DataGen.agentList("ws6"), - DataGen.agentList("ws6", "agent3"), - ).flatten() - - val tests = - listOf( - Pair(null, -1), // No selection. - Pair(DataGen.agentList("gone", "gone"), -1), // No workspace that matches. - Pair(DataGen.agentList("ws1"), 0), // Workspace exact match. - Pair(DataGen.agentList("ws1", "gone"), 0), // Agent gone, select workspace. - Pair(DataGen.agentList("ws2"), 1), // Workspace gone, select first agent. - Pair(DataGen.agentList("ws2", "agent1"), 1), // Agent exact match. - Pair(DataGen.agentList("ws2", "agent2"), 2), // Agent exact match. - Pair(DataGen.agentList("ws3"), 3), // Workspace gone, select first agent. - Pair(DataGen.agentList("ws3", "agent3"), 3), // Agent exact match. - Pair(DataGen.agentList("ws4", "gone"), 4), // Agent gone, select workspace. - Pair(DataGen.agentList("ws4"), 4), // Workspace exact match. - Pair(DataGen.agentList("ws5", "agent2"), 5), // Agent exact match. - Pair(DataGen.agentList("ws5", "gone"), 5), // Agent gone, another agent comes first. - Pair(DataGen.agentList("ws5"), 6), // Workspace exact match. - Pair(DataGen.agentList("ws6"), 7), // Workspace exact match. - Pair(DataGen.agentList("ws6", "gone"), 7), // Agent gone, workspace comes first. - Pair(DataGen.agentList("ws6", "agent3"), 8), // Agent exact match. - ) - - tests.forEach { - assertEquals(it.second, table.getNewSelection(it.first?.first())) - } - } -}