diff --git a/.github/workflows/build-intellij-plugin.yml b/.github/workflows/build-intellij-plugin.yml new file mode 100644 index 000000000..f18920d0f --- /dev/null +++ b/.github/workflows/build-intellij-plugin.yml @@ -0,0 +1,199 @@ +# GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: +# - Validate Gradle Wrapper. +# - Run 'test' and 'verifyPlugin' tasks. +# - Run Qodana inspections. +# - Run the 'buildPlugin' task and prepare artifact for further tests. +# - Run the 'runPluginVerifier' task. +# - Create a draft release. +# +# The workflow is triggered on push and pull_request events. +# +# GitHub Actions reference: https://help.github.com/en/actions +# +## JBIJPPTPL + +name: tinymist::build::intellij-plugin +on: + workflow_call: + +jobs: + # Prepare the environment and build the plugin + build: + name: Build + runs-on: ubuntu-latest + steps: + + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + large-packages: false + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 21 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # Build plugin + - name: Build plugin + run: ./gradlew :intellij:buildPlugin + + # Prepare plugin archive content for creating artifact + - name: Prepare Plugin Artifact + id: artifact + shell: bash + run: | + cd ${{ github.workspace }}/editors/intellij/build/distributions + FILENAME=`ls *.zip` + unzip "$FILENAME" -d content + + echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT + + # Store an 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/*/* + + # Run tests and upload a code coverage report + test: + name: Test + needs: [ build ] + runs-on: ubuntu-latest + steps: + + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + large-packages: false + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 21 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true + + # Run tests + - name: Run Tests + run: ./gradlew :intellij:check + + # Collect Tests Result of failed tests + - name: Collect Tests Result + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: tests-result + path: ${{ github.workspace }}/build/reports/tests + + # Upload the Kover report to CodeCov + - name: Upload Code Coverage Report + uses: codecov/codecov-action@v5 + with: + files: ${{ github.workspace }}/build/reports/kover/report.xml + token: ${{ secrets.CODECOV_TOKEN }} + + # Run plugin structure verification along with IntelliJ Plugin Verifier + verify: + name: Verify plugin + needs: [ build ] + runs-on: ubuntu-latest + steps: + + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + large-packages: false + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 21 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true + + # Run Verify Plugin task and IntelliJ Plugin Verifier tool + - name: Run Plugin Verification tasks + run: ./gradlew :intellij:verifyPlugin + + # 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 + + # Prepare a draft release for GitHub Releases page for the manual verification + # If accepted and published, the release workflow would be triggered + releaseDraft: + name: Release draft + if: github.event_name != 'pull_request' + needs: [ build ] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Remove old release drafts by using the curl request for the available releases with a 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/{} + + # Create a new release draft which is not publicly visible and requires manual acceptance + - name: Create Release Draft + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(./gradlew :intellij:properties --property version --quiet --console=plain | tail -n 1 | cut -f2- -d ' ') + RELEASE_NOTE="./release_note.txt" + ./gradlew :intellij:getChangelog --unreleased --no-header --quiet --console=plain --output-file=$RELEASE_NOTE + + gh release create $VERSION \ + --draft \ + --title $VERSION \ + --notes-file $RELEASE_NOTE \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b08aa698..c2d00f4ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,9 @@ jobs: build-vscode-others: needs: [build-vsc-assets] uses: ./.github/workflows/build-vscode-others.yml + + build-intellij: + uses: ./.github/workflows/build-intellij-plugin.yml publish-vscode: needs: [build-vscode, build-vscode-others] # , announce diff --git a/.gitignore b/.gitignore index f278aa659..88d9eb657 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,10 @@ editors/vscode/out/ editors/lapce/out/ /external/typst-preview /dist +/build *.pdf +refs/ + .vscode/*.code-workspace -refs/ \ No newline at end of file +.gradle diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..835472432 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1 @@ +workspace.xml \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..443b5d2bb --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 000000000..ff16b8799 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,29 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..03d9549ea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 000000000..131e44d79 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..be7eb2adc --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 000000000..b0c1c68fb --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/dev-guide.md b/docs/dev-guide.md index 83779fc20..f95fdbbfd 100644 --- a/docs/dev-guide.md +++ b/docs/dev-guide.md @@ -28,7 +28,7 @@ To contribute to tinymist, you need to install the following tools: You don't have to configure anything to start developing tinymist. However, here we provide some tips to make your development experience better. -- [@Myriad-Dreamin's VS Code Settings](#appendix-myriad-dreamins-vs-code-settings) +- [@Myriad-Dreamin's VS Code Settings](./dev-guide/vscode-extension.md#appendix-myriad-dreamins-vs-code-settings) ## Building and Running @@ -44,7 +44,9 @@ cargo build --release cargo build --profile=gh-release ``` -To run VS Code extension locally, open the repository in VS Code and press `F5` to start a debug session to extension. The VS Code extension also shows how we build and run the language server and the editor tools. +To run VS Code extension locally, open the repository in VS Code and press `F5` to start a debug session to extension. The VS Code extension also shows how we build and run the language server and the editor tools. See [VS Code Extension](./dev-guide/vscode-extension.md) for more details. + +To run IntelliJ plugin, execute `./gradlew :intellij:runIde` in the root directory. See [IntelliJ Plugin](./dev-guide/intellij-plugin.md) for more details. ## Local Documentation @@ -126,71 +128,3 @@ To run e2e tests for tinymist on Windows: ## Release on GitHub The code owners and maintainers of the release channels can check the [Release Guide](/dev-guide/release-instruction.md) to learn how to check and release the new version of tinymist. - - -## APPENDIX: @Myriad-Dreamin's VS Code Settings - -Applies the workspace settings template: - -``` -cp .vscode/tinymist.code-workspace.tmpl.json .vscode/tinymist.code-workspace.json -``` - -And then open the workspace in VS Code. - -Rust Settings explained: - -This configuration enables clippy on save: - -```json -{ - "rust-analyzer.check.command": "clippy", -} -``` - -This configuration wraps comments automatically: - -```json -{ - "rust-analyzer.rustfmt.extraArgs": ["--config=wrap_comments=true"], -} -``` - -This configuration excludes the `target` folder from the file watcher: - -```json -{ - "files.watcherExclude": { - "**/target": true - }, -} -``` - -Typst Settings explained: - -This configuration help use the same fonts as the CI building tinymist docs: - -```json -{ - "tinymist.fontPaths": [ - "assets/fonts" - ], -} -``` - -Formatter Settings explained: - -This configuration runs formatters on save and using the `prettier` formatter: - -```json -{ - "[javascript]":{ - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - }, - "[json]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, -} -``` diff --git a/docs/dev-guide/intellij-plugin.md b/docs/dev-guide/intellij-plugin.md new file mode 100644 index 000000000..9e9fc6218 --- /dev/null +++ b/docs/dev-guide/intellij-plugin.md @@ -0,0 +1,189 @@ +# Tinymist IntelliJ Plugin +> Last Updated: August 31, 2025 + +The goal of this project is to provide comprehensive Typst language support for IntelliJ-based IDEs. +We are using the `lsp4ij` library developed by Red Hat ([https://github.com/redhat-developer/lsp4ij](https://github.com/redhat-developer/lsp4ij)). + +## Prerequisites + +- **IntelliJ IDEA:** Other IDEs will also work, but given developing a plugin for IntelliJ, the best support for that is provided by IntelliJ. +- **JDK 21** You must choose JDK 21 in the IntelliJ IDEA settings. + +## Core Directory Structure + +* **`editors/intellij/`**: Root directory for the IntelliJ plugin. + * **`build.gradle.kts`**: Gradle build script for managing dependencies (like `lsp4ij`, IntelliJ Platform SDK) and plugin packaging. + * **`src/main/kotlin/org/tinymist/intellij/`**: Contains the core Kotlin source code for the plugin. This is further structured into sub-packages like `lsp`, `preview`, and `structure`. + * **`src/main/resources/META-INF/plugin.xml`**: The plugin descriptor file, essential for IntelliJ to load and recognize the plugin and its components (e.g., language support, LSP integration, preview editors, structure view). + +## Build the Plugin + +You can build the plugin using the Gradle tool window in IntelliJ (Tasks > intellij > buildPlugin) or via the terminal: +```bash +./gradlew :intellij:buildPlugin +``` + +## Launch the Plugin + + +Use the Gradle task `runIde` (Tasks > intellij > runIde) from the Gradle tool window or terminal: +```bash +./gradlew :intellij:runIde +``` + +### Using a custom `tinymist` Language Server Executable + +Ensure that `tinymist` is installed on your system and the path in `TinymistLspStreamConnectionProvider.kt` is correct for your development environment if you are modifying the LSP. + +### Viewing Logs + +- **IntelliJ Plugin Logs:** Check the `idea.log` file of the sandboxed IntelliJ instance. You can find its location via "Help" > "Show Log in Finder/Explorer" in the sandbox IDE. +- **LSP Communication Logs:** `lsp4ij` provides an "LSP Consoles" view in the sandbox IDE (usually accessible from the tool window bar at the bottom left). Set its verbosity (e.g., to "verbose") via `Languages & Frameworks > Language Servers` settings to see JSON-RPC messages between the plugin and `tinymist`. + + +## Project Roadmap & Status + +### I. Completed Milestones +* **Initial Server Integration:** Resolved server startup crashes. +* **Basic Diagnostics:** Implemented linting/diagnostics with custom formatting. +* **Core LSP Features:** + * `textDocument/completion` (Code Completion) - Fully implemented and tested + * `textDocument/hover` (Hover Information) - Fully implemented and tested + * `textDocument/definition` (Go To Definition) - Fully implemented and tested + * `textDocument/signatureHelp` (Signature Help) - Implemented + * `textDocument/rename` (Rename Symbol) - Implemented +* **Configuration:** Robust executable path resolution with settings integration +* **Preview Integration:** Full JCEF-based preview with tinymist's background preview server +* **Settings Panel:** Comprehensive settings panel with server management modes (auto-install vs custom path) +* **Automated Server Installation:** Full cross-platform auto-installation system for tinymist binaries +* **Server Management:** Dual-mode server management (AUTO_MANAGE for auto-installation, CUSTOM_PATH for manual configuration) + +## LSP Features Implementation Status + +The following table shows the implementation status of LSP features as supported by the tinymist server: + +| LSP Feature | Status | Implementation Type | Notes | +|--------------------------------------------|----------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------| +| `textDocument/completion` | ✅ Implemented | Handled by lsp4ij | Auto-completion for Typst syntax and functions | +| `textDocument/hover` | ✅ Implemented | Handled by lsp4ij | Documentation and type information on hover | +| `textDocument/definition` | ✅ Implemented | Handled by lsp4ij | Go to definition functionality | +| `textDocument/signatureHelp` | ✅ Implemented | Handled by lsp4ij | Function signature hints | +| `textDocument/rename` | ✅ Implemented | Handled by lsp4ij | Symbol renaming | +| `textDocument/publishDiagnostics` | ✅ Implemented | Direct implementation | Custom diagnostic formatting with HTML support (TinymistLanguageClient.kt:24) | +| `textDocument/semanticTokens` | ✅ Implemented | Handled by lsp4ij | Semantic syntax highlighting | +| `textDocument/references` | ✅ Implemented | Handled by lsp4ij | Find all references to a symbol | +| `textDocument/documentHighlight` | ✅ partly implemented | Handled by lsp4ij | Highlight related symbols; currently the highlight only works upon entirely selecting a symbol not just placing the carret there | +| `textDocument/documentSymbol` | ✅ Implemented | Handled by lsp4ij | Document outline/structure view | +| `textDocument/inlayHint` | ✅ Implemented | Handled by lsp4ij | Inlay additional information into code editor, i.e. the names of function parameters | +| `textDocument/codeAction` | ✅ Implemented | Handled by lsp4ij | Code fixes and refactoring actions | +| `textDocument/formatting` | ❌ Not implemented | - | Document formatting | +| `textDocument/rangeFormatting` | ❌ Not implemented | - | Range-based formatting | +| `textDocument/onTypeFormatting` | ❌ Not implemented | - | Format-on-type | +| `textDocument/codeLens` | ❌ Not implemented | - | Inline code annotations | +| `textDocument/foldingRange` | ✅ Implemented | Handled by lsp4ij | Code folding regions | +| `textDocument/selectionRange` | ✅ Implemented | Handled by lsp4ij | Smart text selection | +| `textDocument/prepareCallHierarchy` | ❌ Not implemented | - | Call hierarchy preparation | +| `textDocument/callHierarchy/incomingCalls` | ❌ Not implemented | - | Incoming call hierarchy | +| `textDocument/callHierarchy/outgoingCalls` | ❌ Not implemented | - | Outgoing call hierarchy | +| `textDocument/linkedEditingRange` | ❌ Not implemented | - | Linked editing of related symbols | +| `textDocument/moniker` | ❌ Not implemented | - | Symbol monikers for cross-references | +| `workspace/didChangeConfiguration` | ✅ Implemented | Handled by lsp4ij | Configuration change notifications | +| `workspace/didChangeWatchedFiles` | ✅ Implemented | Handled by lsp4ij | File watching | +| `workspace/symbol` | ✅ Implemented | Handled by lsp4ij | Workspace-wide symbol search | +| `window/showMessage` | ✅ Implemented | Handled by lsp4ij | Server messages to client | +| `window/showMessageRequest` | ✅ Implemented | Handled by lsp4ij | Message request handling | +| `tinymist/document` | ✅ Implemented | Direct implementation | Custom tinymist notification (TinymistLanguageClient.kt:58) | +| `tinymist/documentOutline` | ✅ Not implemented | Direct implementation | Custom outline notification (TinymistLSPDiagnosticFeature) | + +### Legend: +- **✅ Implemented**: Feature is working and available +- **❌ Not implemented**: Feature is not yet implemented in the plugin +- **Handled by lsp4ij**: Feature implementation is provided by the lsp4ij library +- **Direct implementation**: Feature has custom implementation in the plugin code + +### II. Current Focus +* Debug server startup procedure + * currently no logs are shown in the preview. Is this because, our current method sends a command before the initialization? +* **Preview Panel Stability:** Handle server connection state + * A PreviewServerManager class to manage the server connection state and setup + * The preview panel should subscribe to the events from the preview manager + * The `PreviewServerManager` should start (and stop?) the preview server via the LSP command `tinymist/startPreview` + * The preview server holds one of these states: + * Server starting up -> the preview panel displays a message + * Server ready -> the preview panel loads URL:port as preview + * Server failed -> the preview panel displays an error message + * The `PreviewServerManager` informs its subscribers (mainly the preview panel) about state change + +### III. Next steps +* Investigate `textDocument/formatting` + variant capabilities +* Debug failing integration tests + +### V. Planned Features & Enhancements +* **Additional LSP Features:** + * `textDocument/formatting` (Document formatting) +* **Enhanced Settings Panel:** + * Configure font paths, PDF export options + * Settings for `tinymist` preview server configuration +* Make the editor more colorfull. Does the current situation have something to do with 'textDocument/documentColor' not answering? + +### VI. Technical Debt & Refinements +* **Missing File Type Icon**: TODO in `TypstLanguage.kt` - need to add custom icon for .typ files. +* **LSP Initialization Options**: Currently commented out in `TinymistLspStreamConnectionProvider.kt` - initialization options for the LSP server (e.g., `colorTheme`, preview URL, `preview.background.enabled`) should be configurable via settings panel. + +## File Overview + +This section outlines the architecture of the Tinymist IntelliJ plugin, detailing the roles of key files and their interactions, particularly with the IntelliJ Platform and LSP4IJ APIs. + +### Kotlin Source Files (`src/main/kotlin/org/tinymist/intellij/`) + +The source code is organized into the following main areas: + +1. **Base Language Support (`org.tinymist.intellij`)** + * **`TypstLanguage.kt`**: Defines `TypstLanguage` (a subclass of `com.intellij.lang.Language`) and `TypstFileType` (a subclass of `com.intellij.openapi.fileTypes.LanguageFileType`). This is the fundamental registration of "Typst" as a recognized language and file type within the IntelliJ Platform. + * **`TypstFile.kt`**: Defines `TypstFile` (a subclass of `com.intellij.extapi.psi.PsiFileBase`). This class represents a Typst file in IntelliJ's Program Structure Interface (PSI) tree, allowing the platform to understand it as a structured file. + * **Local Parsing/Lexing/Highlighting**: The plugin **does not** currently include or register custom local lexers (`TypstLexerAdapter.kt`), parsers (`TypstParserDefinition.kt`), or syntax highlighters (`TypstSyntaxHighlighter.kt`). It relies on the LSP server for semantic tokens for syntax highlighting and for other structural understanding. The grammar files in `src/main/grammars/` are unused by the plugin's runtime. + +2. **LSP (Language Server Protocol) Integration (`org.tinymist.intellij.lsp`)** + * **`TinymistLanguageServerFactory.kt`**: Implements `com.redhat.devtools.lsp4ij.LanguageServerFactory`. Creates instances of `TinymistLspStreamConnectionProvider` for server connection, provides `TinymistLSPDiagnosticFeature` for custom diagnostic handling, and includes `TinymistLanguageServerInstaller` for automated server installation. + * **`TinymistLspStreamConnectionProvider.kt`**: Extends `com.redhat.devtools.lsp4ij.server.OSProcessStreamConnectionProvider`. This class manages the lifecycle and communication with the `tinymist` LSP executable using sophisticated executable resolution: + * Uses `TinymistSettingsService` to determine server management mode (AUTO_MANAGE or CUSTOM_PATH) + * For AUTO_MANAGE mode: Uses `TinymistLanguageServerInstaller` to get automatically installed executable path + * For CUSTOM_PATH mode: Uses user-configured executable path from settings + * Initialization options are currently commented out (TODO) but previously provided server configuration + * **`TinymistLanguageServerInstaller.kt`**: Comprehensive auto-installation system that downloads and installs platform-specific tinymist binaries from GitHub releases. Supports Windows, macOS (x64/ARM64), and Linux (x64/ARM64) with proper archive extraction and executable permissions. + * **`TinymistLanguageClient.kt`**: Extends `com.redhat.devtools.lsp4ij.client.LanguageClientImpl`. This custom client handles Tinymist-specific LSP notifications and can customize how standard LSP messages are processed. + * **`@JsonNotification("tinymist/document") handleDocument(...)`**: Placeholder for handling a custom notification, potentially for preview updates or other document-specific events. (Currently logs receipt). + * **`publishDiagnostics(...)`**: Overrides the default handler to reformat diagnostic messages (errors, warnings) from the server (e.g., replacing newlines with `
`) for better display in IntelliJ's UI. + * **`showMessageRequest(...)`**: Overrides the default to handle `window/showMessageRequest` from the server, mainly to log them and prevent potential NPEs in `lsp4ij` if actions are null. + +3. **Settings Management (`org.tinymist.intellij.settings`)** + * **`TinymistSettingsService.kt`**: Application-level service that implements `PersistentStateComponent` for persistent storage of plugin settings. Provides convenient accessors for `tinymistExecutablePath` and `serverManagementMode`. + * **`TinymistSettingsState.kt`**: Data class defining the plugin's settings state, including `ServerManagementMode` enum (AUTO_MANAGE vs CUSTOM_PATH) and executable path configuration. + * **`TinymistSettingsPanel.kt`**: Swing-based UI panel for the settings interface with radio buttons for server management mode and text field for custom executable path. + * **`TinymistSettingsConfigurable.kt`**: Implements `Configurable` interface to integrate the settings panel into IntelliJ's Settings/Preferences dialog under "Tools > Tinymist LSP". + * **`TinymistVersion.kt`**: Version management for the tinymist server, used by the installer to determine which version to download. + +4. **JCEF-based Preview (`org.tinymist.intellij.preview`)** + * **`TypstPreviewFileEditor.kt`**: Implements `com.intellij.openapi.fileEditor.FileEditor` and uses `com.intellij.ui.jcef.JCEFHtmlPanel` to embed a Chromium-based browser view. This editor displays the live preview of the Typst document. + * It connects to a web server (e.g., `http://127.0.0.1:23635`) that is started and managed by the `tinymist` language server itself (when `preview.background.enabled` is true). + * It includes logic to wait for the server to be available before attempting to load the URL. + * It handles cases where JCEF might not be supported in the user's environment. + * **`TypstPreviewFileEditorProvider.kt`**: Implements `com.intellij.openapi.fileEditor.FileEditorProvider`. This provider is responsible for creating instances of `TypstPreviewFileEditor` when IntelliJ needs to open a preview for a Typst file. It also defines the editor's ID and policy (e.g., where it should be placed relative to other editors). + * **`TypstTextEditorWithPreviewProvider.kt`**: Extends `com.intellij.openapi.fileEditor.TextEditorWithPreviewProvider`. This class is the main entry point registered in `plugin.xml` for opening Typst files. It combines a standard text editor (provided by IntelliJ) with the custom `TypstPreviewFileEditor` (obtained via `TypstPreviewFileEditorProvider`), allowing for a side-by-side text and preview editing experience. It accepts files of type `TypstFileType`. + +### Key Interactions + +* **IntelliJ Platform & Plugin Startup**: IntelliJ reads `plugin.xml` to discover the plugin's capabilities. It registers `TypstLanguage` and `TypstFileType`. +* **Opening a Typst File**: + * `TypstTextEditorWithPreviewProvider` is invoked, creating a split editor with a text part and a `TypstPreviewFileEditor`. + * `TinymistLanguageServerFactory` is triggered, which starts `TinymistLspStreamConnectionProvider` to launch the `tinymist` LSP server process. + * `TinymistLanguageClient` establishes communication with the server. +* **LSP Communication**: + * The client and server exchange JSON-RPC messages for features like diagnostics, completion, hover, etc. + * `TinymistLanguageClient` handles custom notifications like `tinymist/document` (currently a placeholder). A `tinymist/documentOutline` handler would be needed for a Structure View. +* **Structure View**: + * (Currently not implemented as described in `dev-notes.md`). If implemented, when the user opens the Structure View, a `TypstStructureViewFactory` would create a `TypstStructureViewModel`. + * The view model would fetch data (potentially from an `OutlineDataHolder` populated by `TinymistLanguageClient`) and build the tree. +* **Preview Panel**: + * `TypstPreviewFileEditor` loads its content from the HTTP server run by the `tinymist` LSP (if `preview.background.enabled` is true in initialization options). + * Updates to the preview are likely driven by the `tinymist` server itself, potentially triggered by `textDocument/didChange` notifications from the client or its own file watching. diff --git a/docs/dev-guide/vscode-extension.md b/docs/dev-guide/vscode-extension.md new file mode 100644 index 000000000..016550384 --- /dev/null +++ b/docs/dev-guide/vscode-extension.md @@ -0,0 +1,78 @@ +# VS Code Extension + +To run VS Code extension locally, open the repository in VS Code and press `F5` to start a debug session to extension. The VS Code extension also shows how we build and run the language server and the editor tools. + +## Dev-Kit View + +When the extension is run in development mode, a Dev-Kit View will be shown in the sidebar. It contains some useful commands to help you develop the extension. + +- Runs Preview Dev: Runs Preview in Developing Mode. It sets data plane port to the fix default value (23625). + - This is helpful when you are developing the preview feature. Goto `tools/typst-preview-frontend` and start a preview frontend with `yarn dev`. +- Runs Default Preview: Runs Default Preview, which is not enabled in VS Code but used in other editors. + +## APPENDIX: @Myriad-Dreamin's VS Code Settings + +Applies the workspace settings template: + +``` +cp .vscode/tinymist.code-workspace.tmpl.json .vscode/tinymist.code-workspace.json +``` + +And then open the workspace in VS Code. + +Rust Settings explained: + +This configuration enables clippy on save: + +```json +{ + "rust-analyzer.check.command": "clippy", +} +``` + +This configuration wraps comments automatically: + +```json +{ + "rust-analyzer.rustfmt.extraArgs": ["--config=wrap_comments=true"], +} +``` + +This configuration excludes the `target` folder from the file watcher: + +```json +{ + "files.watcherExclude": { + "**/target": true + }, +} +``` + +Typst Settings explained: + +This configuration help use the same fonts as the CI building tinymist docs: + +```json +{ + "tinymist.fontPaths": [ + "assets/fonts" + ], +} +``` + +Formatter Settings explained: + +This configuration runs formatters on save and using the `prettier` formatter: + +```json +{ + "[javascript]":{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, + "[json]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, +} +``` diff --git a/docs/tinymist/frontend/intellij.typ b/docs/tinymist/frontend/intellij.typ new file mode 100644 index 000000000..83eb70ed1 --- /dev/null +++ b/docs/tinymist/frontend/intellij.typ @@ -0,0 +1,34 @@ +#import "/docs/tinymist/frontend/mod.typ": * + +#show: book-page.with(title: [IntelliJ IDEA]) + +A comprehensive IntelliJ IDEA plugin for Typst. The plugin provides rich language support and productivity features for Typst documents in IntelliJ IDEA and other JetBrains IDEs. + +== Installation + +=== From JetBrains Marketplace ++ Open IntelliJ IDEA ++ Go to File → Settings → Plugins (or IntelliJ IDEA → Preferences → Plugins on macOS) ++ Search for "Tinymist" ++ Click Install and restart the IDE + +=== Manual Installation ++ Download the latest release from the #link("https://github.com/Myriad-Dreamin/tinymist/releases")[releases page] ++ Go to File #arrow Settings #arrow Plugins ++ Click the gear icon and select "Install Plugin from Disk" ++ Select the downloaded `.zip` file ++ Restart the IDE + +== Getting Started + ++ Create a new `.typ` file or open an existing one ++ The plugin will automatically activate and provide language support ++ Start typing Typst markup - you'll see syntax highlighting and code completion ++ Use the preview feature to see your document rendered in real-time + +== Configuration + +=== Custom Tinymist executable + - Go to File #arrow Settings #arrow Tools #arrow Tinymist LSP + - Select "Use custom Tinymist executable" + - Specify the path to your custom `tinymist` executable if needed \ No newline at end of file diff --git a/editors/intellij/.gitignore b/editors/intellij/.gitignore new file mode 100644 index 000000000..acf9ae3ba --- /dev/null +++ b/editors/intellij/.gitignore @@ -0,0 +1,36 @@ +# IntelliJ Platform Plugin SDK specific +# Read more: https://plugins.jetbrains.com/docs/intellij/build-script.html#gitignore + +# IntelliJ IDEA Project files +.idea/ +*.iml + +# Gradle files +.gradle/ +build/ +gradle-sandbox/ + +# IDE system files +*.iws + +# Local *.properties files (if any, e.g., for local secrets) +local.properties + +# Build artifacts +out/ + +# Log files +*.log + +# Other generated files +*~ +*.swp +*.bak +.intellijPlatform +intellij-community +.Kotlin + +./lsp4ij/ +./lsp4j/ + +idea.system.path \ No newline at end of file diff --git a/editors/intellij/CHANGELOG.md b/editors/intellij/CHANGELOG.md new file mode 100644 index 000000000..91af75ba7 --- /dev/null +++ b/editors/intellij/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## [Unreleased] + +## 0.1.0 +Initial release \ No newline at end of file diff --git a/editors/intellij/CONTRIBUTING.md b/editors/intellij/CONTRIBUTING.md new file mode 100644 index 000000000..984083350 --- /dev/null +++ b/editors/intellij/CONTRIBUTING.md @@ -0,0 +1,12 @@ + +# Contributing + +This guide extends the root [CONTRIBUTING.md](/CONTRIBUTING.md) file with editor-specific information for Intellij integrations. + +## Quick Start + +To run IntelliJ plugin, execute `./gradlew :intellij:runIde` in the root directory. + +## Full Guide + +See [IntelliJ Plugin](/docs/dev-guide/intellij-plugin.md) for more details. diff --git a/editors/intellij/README.md b/editors/intellij/README.md new file mode 100644 index 000000000..01b8f69a3 --- /dev/null +++ b/editors/intellij/README.md @@ -0,0 +1,88 @@ +# Tinymist IntelliJ Plugin + + +**Tinymist** is a comprehensive language service plugin for [Typst](https://typst.app/) - a modern markup and typesetting system. This IntelliJ IDEA plugin provides rich language support and productivity features for Typst documents. + +## Key Features + +### Language Service (LSP) Features +- **Syntax Highlighting** - Full syntax highlighting for Typst markup +- **Semantic Highlighting** - Advanced semantic coloring for better code understanding +- **Code Completion** - Intelligent autocompletion for Typst functions and symbols +- **Go to Definition** - Navigate to symbol definitions with Ctrl+Click +- **Find References** - Find all references to symbols across your project +- **Document Outline** - Structured view of your document with headings and sections +- **Hover Documentation** - View function signatures and documentation on hover +- **Error Diagnostics** - Real-time error detection and reporting +- **Code Formatting** - Automatic code formatting using typstfmt or typstyle +- **Document Links** - Clickable links to files and resources +- **Folding Ranges** - Collapse code blocks and content sections +- **Rename Refactoring** - Rename symbols across your entire project + +### Advanced Features +- **Live Preview** - Real-time preview of your Typst documents +- **Export Support** - Export documents to PDF, SVG, PNG, HTML, and more +- **Template Gallery** - Browse and use Typst document templates +- **Color Picker** - Visual color picker for color literals +- **Inlay Hints** - Display parameter names and type information +- **Code Lens** - Contextual buttons for document operations +- **Symbol Search** - Workspace-wide symbol search + +### Productivity Features +- **Auto-completion** - Smart completion for Typst syntax and functions +- **Code Actions** - Quick fixes and refactoring suggestions +- **Document Symbols** - Navigate through document structure +- **Testing Support** - Built-in testing and coverage collection +- **Linting** - Comprehensive linting with customizable rules +- **Status Bar Integration** - Compilation status and word count display + +## Getting Started + +1. Install the plugin from JetBrains Marketplace +2. Open or create a `.typ` file +3. The plugin will automatically activate and provide language support +4. Use the preview feature to see your document rendered in real-time + +## Requirements + +- IntelliJ IDEA 2024.1 or later +- Tinymist language server (automatically managed by the plugin) + +## Support + +For issues, feature requests, or contributions, visit the [Tinymist GitHub repository](https://github.com/Myriad-Dreamin/tinymist). + +## About Typst + +Typst is a new markup-based typesetting system that is powerful and easy to learn. It provides better error messages, faster compilation, and a more modern approach to document creation compared to traditional systems. + + +## Installation + +### From JetBrains Marketplace +1. Open IntelliJ IDEA +2. Go to File → Settings → Plugins +3. Search for "Tinymist" +4. Click Install and restart the IDE + +### Manual Installation +1. Download the latest release from the [releases page](https://github.com/Myriad-Dreamin/tinymist/releases) +2. Go to File → Settings → Plugins +3. Click the gear icon and select "Install Plugin from Disk" +4. Select the downloaded `.zip` file +5. Restart the IDE + +## Configuration + +The plugin works out of the box with sensible defaults. Advanced configuration options are available in: +- File → Settings → Languages & Frameworks → Tinymist + +## Development + +This plugin is part of the larger Tinymist project. For development information, see: +- [Development Guide](https://github.com/Myriad-Dreamin/tinymist/tree/main/docs/dev-guide/intellij-plugin.md) +- [Contributing Guidelines](CONTRIBUTING.md) + +## License + +This project is licensed under the same license as the main Tinymist project. \ No newline at end of file diff --git a/editors/intellij/build.gradle.kts b/editors/intellij/build.gradle.kts new file mode 100644 index 000000000..cc77bf017 --- /dev/null +++ b/editors/intellij/build.gradle.kts @@ -0,0 +1,128 @@ +import org.jetbrains.changelog.Changelog +import org.jetbrains.changelog.markdownToHTML +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +plugins { + id("java") // Java support + alias(libs.plugins.kotlin) // Kotlin support + alias(libs.plugins.intelliJPlatform) // IntelliJ Platform Gradle Plugin + alias(libs.plugins.changelog) // Gradle Changelog Plugin + alias(libs.plugins.qodana) // Gradle Qodana Plugin + alias(libs.plugins.kover) // Gradle Kover Plugin +} + +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() + +// Set the JVM language level used to build the project. +kotlin { + jvmToolchain(21) +} + +// Configure project's dependencies +repositories { + mavenCentral() + + // IntelliJ Platform Gradle Plugin Repositories Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-repositories-extension.html + intellijPlatform { + defaultRepositories() + } +} + +// Dependencies are managed with Gradle version catalog - read more: https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog +dependencies { + testImplementation(libs.junit) + testImplementation(libs.opentest4j) + + // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html + intellijPlatform { + intellijIdeaCommunity("2025.2") + testFramework(TestFrameworkType.Platform) + plugins(listOf("com.redhat.devtools.lsp4ij:0.17.0")) + } +} + +// Configure IntelliJ Platform Gradle Plugin - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html +intellijPlatform { + pluginConfiguration { + name = providers.gradleProperty("pluginName") + version = providers.gradleProperty("pluginVersion") + + // Extract the section from README.md and provide for the plugin's manifest + description = providers.fileContents(layout.projectDirectory.file("README.md")).asText.map { + val start = "" + val end = "" + + with(it.lines()) { + 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").let(::markdownToHTML) + } + } + + val changelog = project.changelog // local variable for configuration cache compatibility + changeNotes = providers.gradleProperty("pluginVersion").map { pluginVersion -> + with(changelog) { + renderItem( + (getOrNull(pluginVersion) ?: getUnreleased()) + .withHeader(false) + .withEmptySections(false), + Changelog.OutputType.HTML, + ) + } + } + + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + untilBuild = providers.gradleProperty("pluginUntilBuild") + } + } + + signing { + certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("PRIVATE_KEY") + password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") + } + + publishing { + token = providers.environmentVariable("PUBLISH_TOKEN") + // The pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3 + // Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more: + // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel + channels = providers.gradleProperty("pluginVersion").map { listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) } + } + + pluginVerification { + ides { + recommended() + } + } +} + +// Configure Gradle Changelog Plugin - read more: https://github.com/JetBrains/gradle-changelog-plugin +changelog { + groups.empty() + repositoryUrl = providers.gradleProperty("pluginRepositoryUrl") +} + +// Configure Gradle Kover Plugin - read more: https://github.com/Kotlin/kotlinx-kover#configuration +kover { + reports { + total { + xml { + onCheck = true + } + } + } +} + +tasks { + wrapper { + gradleVersion = providers.gradleProperty("gradleVersion").get() + } + + publishPlugin { + dependsOn(patchChangelog) + } +} diff --git a/editors/intellij/gradle.properties b/editors/intellij/gradle.properties new file mode 100644 index 000000000..ea9228c8a --- /dev/null +++ b/editors/intellij/gradle.properties @@ -0,0 +1,19 @@ +# IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html + +pluginGroup = org.tinymist.intellij +pluginName = Tinymist +# SemVer format -> https://semver.org +pluginVersion = 0.1.0 + +# Plugin platform version configuration +pluginSinceBuild = 241 +pluginRepositoryUrl = https://github.com/Myriad-Dreamin/tinymist + +# Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib +kotlin.stdlib.default.dependency = false + +# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html +org.gradle.configuration-cache = true + +# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html +org.gradle.caching = true \ No newline at end of file diff --git a/editors/intellij/gradle/libs.versions.toml b/editors/intellij/gradle/libs.versions.toml new file mode 100644 index 000000000..d50f002ff --- /dev/null +++ b/editors/intellij/gradle/libs.versions.toml @@ -0,0 +1,22 @@ +[versions] +# libraries +junit = "4.13.2" +opentest4j = "1.3.0" + +# plugins +changelog = "2.4.0" +intelliJPlatform = "2.10.1" +kotlin = "2.1.20" +kover = "0.9.1" +qodana = "2025.1.1" + +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } +opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } + +[plugins] +changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } +intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } \ No newline at end of file diff --git a/editors/intellij/icons/ti-white.png b/editors/intellij/icons/ti-white.png new file mode 100644 index 000000000..452f72d1b Binary files /dev/null and b/editors/intellij/icons/ti-white.png differ diff --git a/editors/intellij/icons/ti.png b/editors/intellij/icons/ti.png new file mode 100644 index 000000000..35fc9d040 Binary files /dev/null and b/editors/intellij/icons/ti.png differ diff --git a/editors/intellij/icons/typst-small.png b/editors/intellij/icons/typst-small.png new file mode 100644 index 000000000..d58e155ef Binary files /dev/null and b/editors/intellij/icons/typst-small.png differ diff --git a/editors/intellij/settings.gradle.kts b/editors/intellij/settings.gradle.kts new file mode 100644 index 000000000..67a3bc8cb --- /dev/null +++ b/editors/intellij/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "tinymist-intellij" \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/TypstFile.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/TypstFile.kt new file mode 100644 index 000000000..6a2c13d5a --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/TypstFile.kt @@ -0,0 +1,10 @@ +package org.tinymist.intellij + +import com.intellij.extapi.psi.PsiFileBase +import com.intellij.openapi.fileTypes.FileType +import com.intellij.psi.FileViewProvider + +class TypstFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, TypstLanguage) { + override fun getFileType(): FileType = TypstFileType + override fun toString(): String = "Typst File" +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/TypstLanguage.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/TypstLanguage.kt new file mode 100644 index 000000000..ccc79eeb3 --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/TypstLanguage.kt @@ -0,0 +1,14 @@ +package org.tinymist.intellij + +import com.intellij.lang.Language +import com.intellij.openapi.fileTypes.LanguageFileType +import javax.swing.Icon + +object TypstLanguage : Language("Typst") + +object TypstFileType : LanguageFileType(TypstLanguage) { + override fun getName(): String = "Typst file" + override fun getDescription(): String = "Typst language file" + override fun getDefaultExtension(): String = "typ" + override fun getIcon(): Icon? = null // TODO: Add an icon +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLSPDiagnosticFeature.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLSPDiagnosticFeature.kt new file mode 100644 index 000000000..e9acb94d2 --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLSPDiagnosticFeature.kt @@ -0,0 +1,19 @@ +package org.tinymist.intellij.lsp + +import com.redhat.devtools.lsp4ij.client.features.LSPDiagnosticFeature +import org.eclipse.lsp4j.Diagnostic + +/** + * Custom LSP diagnostic feature for Tinymist language server. + * + * This feature customizes the diagnostic message display to properly handle + * multi-line diagnostic messages by converting newlines to HTML line breaks + * for better tooltip rendering in IntelliJ. + */ +class TinymistLSPDiagnosticFeature : LSPDiagnosticFeature() { + + override fun getMessage(diagnostic: Diagnostic): String { + // Replace newlines with
for proper tooltip rendering in HTML + return diagnostic.message.replace("\n", "
") + } +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLanguageServerFactory.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLanguageServerFactory.kt new file mode 100644 index 000000000..08d97db4d --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLanguageServerFactory.kt @@ -0,0 +1,24 @@ +package org.tinymist.intellij.lsp + +import com.intellij.openapi.project.Project +import com.redhat.devtools.lsp4ij.LanguageServerFactory +import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures +import com.redhat.devtools.lsp4ij.installation.ServerInstaller +import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider +import org.eclipse.lsp4j.InitializeParams +import org.tinymist.intellij.settings.TinymistSettingsService + +class TinymistLanguageServerFactory : LanguageServerFactory { + override fun createConnectionProvider(project: Project): StreamConnectionProvider { + return TinymistLspStreamConnectionProvider(project) + } + + override fun createClientFeatures(): LSPClientFeatures { + return LSPClientFeatures() + .setDiagnosticFeature(TinymistLSPDiagnosticFeature()) + } + + override fun createServerInstaller(): ServerInstaller { + return TinymistLanguageServerInstaller() + } +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLanguageServerInstaller.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLanguageServerInstaller.kt new file mode 100644 index 000000000..557dda2cc --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLanguageServerInstaller.kt @@ -0,0 +1,294 @@ +package org.tinymist.intellij.lsp + +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.util.SystemInfo +import com.redhat.devtools.lsp4ij.installation.LanguageServerInstallerBase +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.jetbrains.annotations.NotNull +import org.tinymist.intellij.settings.TinymistVersion +import java.io.FileOutputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.util.zip.GZIPInputStream +import java.util.zip.ZipInputStream +import com.intellij.openapi.application.PathManager + +/** + * Installer for the Tinymist language server. + * + * Downloads and installs the Tinymist binary based on the current platform. + * Uses the correct GitHub asset names from the actual releases. + */ +class TinymistLanguageServerInstaller : LanguageServerInstallerBase() { + + companion object { + private const val GITHUB_RELEASES_URL = "https://github.com/Myriad-Dreamin/tinymist/releases/download" + private const val EXECUTABLE_NAME = "tinymist" + private const val WINDOWS_EXECUTABLE_NAME = "tinymist.exe" + + // Platform-specific download URLs and archive names (using actual GitHub release names) + private val PLATFORM_INFO = when { + SystemInfo.isWindows && isX64Architecture() -> PlatformInfo( + "tinymist-x86_64-pc-windows-msvc.zip", + WINDOWS_EXECUTABLE_NAME, + ArchiveType.ZIP + ) + SystemInfo.isMac && isAarch64Architecture() -> PlatformInfo( + "tinymist-aarch64-apple-darwin.tar.gz", + EXECUTABLE_NAME, + ArchiveType.TAR_GZ + ) + SystemInfo.isMac && isX64Architecture() -> PlatformInfo( + "tinymist-x86_64-apple-darwin.tar.gz", + EXECUTABLE_NAME, + ArchiveType.TAR_GZ + ) + SystemInfo.isLinux && isX64Architecture() -> PlatformInfo( + "tinymist-x86_64-unknown-linux-gnu.tar.gz", + EXECUTABLE_NAME, + ArchiveType.TAR_GZ + ) + SystemInfo.isLinux && isAarch64Architecture() -> PlatformInfo( + "tinymist-aarch64-unknown-linux-gnu.tar.gz", + EXECUTABLE_NAME, + ArchiveType.TAR_GZ + ) + else -> null + } + + private fun isX64Architecture(): Boolean { + val arch = System.getProperty("os.arch", "").lowercase() + return arch.contains("x86_64") || arch.contains("amd64") + } + + private fun isAarch64Architecture(): Boolean { + val arch = System.getProperty("os.arch", "").lowercase() + return arch.contains("aarch64") || arch.contains("arm64") + } + } + + private enum class ArchiveType { + ZIP, TAR_GZ + } + + private data class PlatformInfo( + val archiveName: String, + val executableName: String, + val archiveType: ArchiveType + ) + + /** + * Gets the directory where Tinymist should be installed. + * Uses the plugin data directory under the user's home. + */ + private fun getInstallationDir(): Path { + val pluginDir = Paths.get(PathManager.PROPERTY_SYSTEM_PATH, "tinymist-intellij") + return pluginDir.resolve("server").resolve(TinymistVersion.CURRENT) + } + + /** + * Gets the path to the installed Tinymist executable. + */ + private fun getExecutablePath(): Path? { + val platformInfo = PLATFORM_INFO ?: return null + return getInstallationDir().resolve(platformInfo.executableName) + } + + /** + * Checks if the Tinymist server is installed and executable. + */ + override fun checkServerInstalled(@NotNull indicator: ProgressIndicator): Boolean { + progress("Checking if Tinymist server is installed...", indicator) + ProgressManager.checkCanceled() + + val executablePath = getExecutablePath() + if (executablePath == null) { + progress("Platform not supported for Tinymist installation", indicator) + return false + } + + val isInstalled = Files.exists(executablePath) && Files.isExecutable(executablePath) + + if (isInstalled) { + progress("Tinymist server found at: $executablePath", indicator) + } else { + progress("Tinymist server not found or not executable", indicator) + } + + return isInstalled + } + + /** + * Downloads and installs the Tinymist server binary. + */ + override fun install(indicator: ProgressIndicator) { + val platformInfo = PLATFORM_INFO + ?: throw UnsupportedOperationException("Tinymist installation is not supported on this platform") + + val installationDir = getInstallationDir() + val downloadUrl = "$GITHUB_RELEASES_URL/${TinymistVersion.CURRENT}/${platformInfo.archiveName}" + + try { + // Step 1: Create installation directory + progress("Creating installation directory...", 0.1, indicator) + ProgressManager.checkCanceled() + Files.createDirectories(installationDir) + + // Step 2: Download the archive + progress("Downloading Tinymist ${TinymistVersion.CURRENT} for ${getCurrentPlatformName()}...", 0.2, indicator) + ProgressManager.checkCanceled() + val tempArchive = Files.createTempFile("tinymist", getArchiveExtension(platformInfo.archiveType)) + + try { + downloadFile(downloadUrl, tempArchive) + + // Step 3: Extract the archive + progress("Extracting Tinymist archive...", 0.6, indicator) + ProgressManager.checkCanceled() + extractArchive(tempArchive, installationDir, platformInfo) + + // Step 4: Set executable permissions (Unix-like systems) + progress("Setting up executable permissions...", 0.8, indicator) + ProgressManager.checkCanceled() + val executablePath = getExecutablePath() + ?: throw RuntimeException("Failed to get executable path after platform validation") + if (!SystemInfo.isWindows) { + executablePath.toFile().setExecutable(true, false) + } + + // Step 5: Verify installation + progress("Verifying installation...", 0.9, indicator) + ProgressManager.checkCanceled() + if (!Files.exists(executablePath) || !Files.isExecutable(executablePath)) { + throw RuntimeException("Failed to install Tinymist: executable not found or not executable") + } + + progress("Tinymist installation completed successfully!", 1.0, indicator) + + } finally { + // Clean up temporary file + Files.deleteIfExists(tempArchive) + } + + } catch (e: ProcessCanceledException) { + // Re-throw ProcessCanceledException to properly handle cancellation + throw e + } catch (e: Exception) { + throw RuntimeException("Failed to install Tinymist language server: ${e.message}", e) + } + } + + /** + * Downloads a file from the given URL to the specified path. + */ + private fun downloadFile(url: String, destination: Path) { + val client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .connectTimeout(java.time.Duration.ofSeconds(30)) + .build() + + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(java.time.Duration.ofMinutes(5)) + .build() + + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + + if (response.statusCode() != 200) { + throw RuntimeException("Failed to download from $url: HTTP ${response.statusCode()}") + } + + response.body().use { inputStream -> + Files.copy(inputStream, destination, StandardCopyOption.REPLACE_EXISTING) + } + } + + /** + * Extracts the downloaded archive and places the executable in the installation directory. + */ + private fun extractArchive(archivePath: Path, installationDir: Path, platformInfo: PlatformInfo) { + when (platformInfo.archiveType) { + ArchiveType.ZIP -> extractZip(archivePath, installationDir, platformInfo.executableName) + ArchiveType.TAR_GZ -> extractTarGz(archivePath, installationDir, platformInfo.executableName) + } + } + + private fun extractZip(archivePath: Path, installationDir: Path, executableName: String) { + ZipInputStream(Files.newInputStream(archivePath)).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + ProgressManager.checkCanceled() + + // Look for the tinymist executable in the archive + if (entry.name.endsWith(executableName) && !entry.isDirectory) { + val executablePath = installationDir.resolve(executableName) + FileOutputStream(executablePath.toFile()).use { outputStream -> + zipStream.copyTo(outputStream) + } + break + } + entry = zipStream.nextEntry + } + } + } + + private fun extractTarGz(archivePath: Path, installationDir: Path, executableName: String) { + GZIPInputStream(Files.newInputStream(archivePath)).use { gzipStream -> + TarArchiveInputStream(gzipStream).use { tarStream -> + var entry = tarStream.nextEntry + while (entry != null) { + ProgressManager.checkCanceled() + + // Look for the tinymist executable in the archive + if (entry.name.endsWith(executableName) && !entry.isDirectory) { + val executablePath = installationDir.resolve(executableName) + FileOutputStream(executablePath.toFile()).use { outputStream -> + tarStream.copyTo(outputStream) + } + break + } + entry = tarStream.nextEntry + } + } + } + } + + private fun getArchiveExtension(archiveType: ArchiveType): String { + return when (archiveType) { + ArchiveType.ZIP -> ".zip" + ArchiveType.TAR_GZ -> ".tar.gz" + } + } + + /** + * Gets a human-readable name for the current platform. + */ + private fun getCurrentPlatformName(): String { + return when { + SystemInfo.isWindows -> "Windows" + SystemInfo.isMac -> "macOS" + SystemInfo.isLinux -> "Linux" + else -> "Unknown" + } + } + + /** + * Gets the path to the installed Tinymist executable, or null if not installed. + */ + fun getInstalledExecutablePath(): String? { + val executablePath = getExecutablePath() ?: return null + return if (Files.exists(executablePath) && Files.isExecutable(executablePath)) { + executablePath.toString() + } else { + null + } + } +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLspStreamConnectionProvider.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLspStreamConnectionProvider.kt new file mode 100644 index 000000000..66fdece4c --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/lsp/TinymistLspStreamConnectionProvider.kt @@ -0,0 +1,67 @@ +package org.tinymist.intellij.lsp + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.redhat.devtools.lsp4ij.server.OSProcessStreamConnectionProvider +import org.tinymist.intellij.settings.ServerManagementMode +import org.tinymist.intellij.settings.TinymistSettingsService +import java.io.File + +class TinymistLspStreamConnectionProvider(@Suppress("unused") private val project: Project) : OSProcessStreamConnectionProvider() { + + companion object { + private val LOG = Logger.getInstance(TinymistLspStreamConnectionProvider::class.java) + } + + init { + val settingsService = TinymistSettingsService.instance + val serverManagementMode = settingsService.serverManagementMode + var resolvedExecutablePath: String? = null + + when (serverManagementMode) { + ServerManagementMode.CUSTOM_PATH -> { + // Use custom path specified by user + val customPath = settingsService.tinymistExecutablePath + if (customPath.isNotBlank()) { + val customFile = File(customPath) + if (customFile.exists() && customFile.isFile && customFile.canExecute()) { + LOG.info("Using custom Tinymist executable path: $customPath") + resolvedExecutablePath = customPath + } else { + LOG.warn("Custom Tinymist path is invalid or not executable: $customPath") + } + } else { + LOG.warn("Custom path mode selected but no path specified") + } + + // If custom path fails, don't fall back to other methods - user explicitly chose custom + if (resolvedExecutablePath == null) { + LOG.error("Custom path mode: Could not use specified Tinymist executable") + } + } + + ServerManagementMode.AUTO_MANAGE -> { + resolvedExecutablePath = getInstallerManagedPath() + } + } + + // Only set commands if a valid executable path was resolved + resolvedExecutablePath?.let { + super.commandLine = GeneralCommandLine(it, "lsp") + } ?: LOG.error("Tinymist LSP server commands not set as no executable was found.") + } + + /** + * Gets the path to the installer-managed Tinymist executable, if available. + */ + private fun getInstallerManagedPath(): String? { + return try { + val installer = TinymistLanguageServerInstaller() + installer.getInstalledExecutablePath() + } catch (e: Exception) { + LOG.warn("Failed to check installer-managed path: ${e.message}") + null + } + } +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstPreviewFileEditor.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstPreviewFileEditor.kt new file mode 100644 index 000000000..a9bb5f200 --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstPreviewFileEditor.kt @@ -0,0 +1,223 @@ +package org.tinymist.intellij.preview + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.network.CefRequest +import java.beans.PropertyChangeListener +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Socket +import javax.swing.JComponent +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JCEFHtmlPanel +import javax.swing.JLabel +import org.cef.handler.CefLoadHandlerAdapter +import org.cef.handler.CefLoadHandler +import org.tinymist.intellij.settings.TinymistSettingsService + +class TypstPreviewFileEditor( + private val project: Project, + private val virtualFile: VirtualFile +) : JCEFHtmlPanel(false, null, null), FileEditor { + + // Defines the Tinymist preview URL using dynamic port + private val previewHost = "127.0.0.1" + private val currentPort = 1337 + + // TODO refactor for use with a PreviewSererManager + private fun getTinymistPreviewUrl(): String = "http://$previewHost}" + + // Flag to track if the server check is complete and successful + @Volatile + private var isServerReady = false + private var jcefUnsupportedLabel: JLabel? = null + + init { + + if (!JBCefApp.isSupported()) { + println("TypstPreviewFileEditor: JCEF is not supported! Preview will show an error message.") + jcefUnsupportedLabel = JLabel("JCEF browser is not supported in this environment.") + } else { + println("TypstPreviewFileEditor: JCEF is supported. Setting up browser.") + // setupDisplayHandler() + setupLoadHandler() + // Defers starting the server check and URL loading to allow JCEF panel to initialize + ApplicationManager.getApplication().invokeLater { + if (!isDisposed) { // Checks if editor is already disposed before starting task + waitForServerAndLoad() + } else { + println("TypstPreviewFileEditor: Editor disposed before waitForServerAndLoad could be scheduled.") + } + } + } + println("TypstPreviewFileEditor: Initialization complete.") + } + + private fun waitForServerAndLoad() { + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "WaitingForTinymistServer", false) { + override fun run(indicator: ProgressIndicator) { + var attempts = 0 + val maxAttempts = 5 + var serverFound = false + while (attempts < maxAttempts && !serverFound && JBCefApp.isSupported()) { + indicator.checkCanceled() + try { + Socket().use { socket -> + socket.connect(InetSocketAddress(previewHost, currentPort), 500) + isServerReady = true + println("TypstPreviewFileEditor: Tinymist server is ready at $previewHost:$currentPort.") + serverFound = true + } + } catch (_: IOException) { + attempts++ + indicator.text2 = "Attempt $attempts/$maxAttempts to connect to $previewHost:$currentPort" + Thread.sleep(500) + } + } + } + + override fun onSuccess() { + if (!JBCefApp.isSupported()) return + + // Checks if the editor (JCEFHtmlPanel) is already disposed + if (this@TypstPreviewFileEditor.isDisposed) { + println("TypstPreviewFileEditor: Editor disposed, skipping onSuccess URL load.") + return + } + + if (isServerReady) { + val previewUrl = getTinymistPreviewUrl() + println("TypstPreviewFileEditor: Server ready, loading URL: $previewUrl") + this@TypstPreviewFileEditor.loadURL(previewUrl) + } else { + println("TypstPreviewFileEditor: Server not ready. Displaying error.") + ApplicationManager.getApplication().invokeLater { + this@TypstPreviewFileEditor.loadHTML("Error: Tinymist server not available at $previewHost:$currentPort. Please check if tinymist is running.") + } + } + } + + override fun onThrowable(error: Throwable) { + if (!JBCefApp.isSupported()) return + + // Checks if the editor (JCEFHtmlPanel) is already disposed + if (this@TypstPreviewFileEditor.isDisposed) { + println("TypstPreviewFileEditor: Editor disposed, skipping onThrowable HTML load.") + return + } + + println("TypstPreviewFileEditor: Error waiting for server: ${error.message}") + ApplicationManager.getApplication().invokeLater { + this@TypstPreviewFileEditor.loadHTML("Error connecting to Tinymist server: ${error.message}") + } + } + }) + } + + override fun getComponent(): JComponent { + if (jcefUnsupportedLabel != null) { + return jcefUnsupportedLabel!! + } + return super.getComponent() + } + + override fun getPreferredFocusedComponent(): JComponent { + if (jcefUnsupportedLabel != null) { + return jcefUnsupportedLabel!! + } + return super.getComponent() + } + + override fun getName(): String = "Tinymist Preview" + + override fun setState(state: FileEditorState) {} + + override fun isModified(): Boolean = false + + override fun isValid(): Boolean = true + + override fun addPropertyChangeListener(listener: PropertyChangeListener) {} + + override fun removePropertyChangeListener(listener: PropertyChangeListener) {} + + override fun getFile(): VirtualFile = virtualFile + + private val userData = mutableMapOf, Any?>() + override fun getUserData(key: Key): T? { + @Suppress("UNCHECKED_CAST") + return userData[key] as T? + } + + override fun putUserData(key: Key, value: T?) { + userData[key] = value + } + + override fun selectNotify() { + println("TypstPreviewFileEditor: selectNotify called for ${virtualFile.name}") + // Reloads the content when the editor is selected, if the server is ready + // and the JCEF component is supported and initialized. + if (JBCefApp.isSupported() && isServerReady && !isDisposed) { + val previewUrl = getTinymistPreviewUrl() + println("TypstPreviewFileEditor: selectNotify - Server ready, reloading URL: $previewUrl") + this.loadURL(previewUrl) + } else { + if (!isServerReady) println("TypstPreviewFileEditor: selectNotify - Server not ready, not reloading.") + if (isDisposed) println("TypstPreviewFileEditor: selectNotify - Editor disposed, not reloading.") + if (!JBCefApp.isSupported()) println("TypstPreviewFileEditor: selectNotify - JCEF not supported, not reloading.") + } + } + + override fun deselectNotify() { + // No specific action needed on deselect for this editor + println("TypstPreviewFileEditor: deselectNotify called for ${virtualFile.name}") + } + + override fun dispose() { + println("TypstPreviewFileEditor: Disposing...") + try { + // Attempts to stop any ongoing load operations in the browser. + // This is a precaution; JCEFHtmlPanel.dispose() should handle cleanup. + if (JBCefApp.isSupported() && !isDisposed) { // Check if not already disposed + // It's generally safer to access cefBrowser only if the panel is not yet disposed + // and JCEF is supported. + cefBrowser.stopLoad() + println("TypstPreviewFileEditor: Called cefBrowser.stopLoad()") + } + } catch (e: Exception) { + // Logs any exception during this pre-emptive stopLoad, but don't let it prevent further disposal + println("TypstPreviewFileEditor: Exception during cefBrowser.stopLoad() in dispose: ${e.message}") + } + // Explicitly calls super.dispose() to ensure JCEFHtmlPanel cleans up its resources. + super.dispose() + println("TypstPreviewFileEditor: super.dispose() called.") + } + + private fun setupLoadHandler() { + this.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { + override fun onLoadingStateChange(browser: CefBrowser?, isLoading: Boolean, canGoBack: Boolean, canGoForward: Boolean) { + println("JCEF LoadHandler: onLoadingStateChange - isLoading: $isLoading") + } + + override fun onLoadStart(browser: CefBrowser?, frame: CefFrame?, transitionType: CefRequest.TransitionType?) { + println("JCEF LoadHandler: onLoadStart - URL: ${frame?.url ?: "N/A"}, MainFrame: ${frame?.isMain ?: "N/A"}") + } + + override fun onLoadEnd(browser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { + println("JCEF LoadHandler: onLoadEnd - URL: ${frame?.url ?: "N/A"}, Status: $httpStatusCode, MainFrame: ${frame?.isMain ?: "N/A"}") + } + + override fun onLoadError(browser: CefBrowser, frame: CefFrame, errorCode: CefLoadHandler.ErrorCode, errorText: String, failedUrl: String) { + println("JCEF LoadHandler: onLoadError - ErrorCode: $errorCode, Text: $errorText, URL: $failedUrl, MainFrame: ${frame.isMain}") + } + }, this.cefBrowser) + } +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstPreviewFileEditorProvider.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstPreviewFileEditorProvider.kt new file mode 100644 index 000000000..1feccea96 --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstPreviewFileEditorProvider.kt @@ -0,0 +1,21 @@ +package org.tinymist.intellij.preview + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.project.DumbAware +import org.tinymist.intellij.TypstFileType + +class TypstPreviewFileEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile): Boolean = file.fileType == TypstFileType // Accept only Typst files + + override fun createEditor(project: Project, file: VirtualFile): FileEditor { + return TypstPreviewFileEditor(project, file) + } + + override fun getEditorTypeId(): String = "tinymist-preview-editor" + + override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.PLACE_AFTER_DEFAULT_EDITOR +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstTextEditorWithPreviewProvider.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstTextEditorWithPreviewProvider.kt new file mode 100644 index 000000000..c9874a435 --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/preview/TypstTextEditorWithPreviewProvider.kt @@ -0,0 +1,24 @@ +package org.tinymist.intellij.preview + +import com.intellij.openapi.fileEditor.TextEditorWithPreviewProvider +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import org.tinymist.intellij.TypstFileType + +/** + * This is the main provider registered in plugin.xml for Typst files. + * It combines a standard text editor (PsiAwareTextEditorProvider) with our custom preview editor (TypstPreviewFileEditor). + */ +class TypstTextEditorWithPreviewProvider : TextEditorWithPreviewProvider( + TypstPreviewFileEditorProvider() // Use the restored provider class +), DumbAware { + + /** + * Determines whether this TextEditorWithPreviewProvider should handle the given file. + * We only want to provide this combined editor for Typst files. + */ + override fun accept(project: Project, file: VirtualFile): Boolean { + return file.fileType is TypstFileType || file.extension?.equals(TypstFileType.defaultExtension, ignoreCase = true) == true + } +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsConfigurable.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsConfigurable.kt new file mode 100644 index 000000000..d1a3dc681 --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsConfigurable.kt @@ -0,0 +1,94 @@ +package org.tinymist.intellij.settings + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.project.ProjectManager +import com.redhat.devtools.lsp4ij.LanguageServersRegistry +import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinitionListener.LanguageServerChangedEvent +import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinitionListener.LanguageServerDefinitionEvent +import javax.swing.JComponent + +class TinymistSettingsConfigurable : Configurable { + + private var settingsPanel: TinymistSettingsPanel? = null + private val settingsService = TinymistSettingsService.instance + + companion object { + private val LOG = Logger.getInstance(TinymistSettingsConfigurable::class.java) + private const val TINYMIST_SERVER_ID = "tinymistServer" + } + + override fun getDisplayName(): String = "Tinymist LSP" + + override fun getHelpTopic(): String? = null + + override fun createComponent(): JComponent? { + settingsPanel = TinymistSettingsPanel() + return settingsPanel?.mainPanel + } + + override fun isModified(): Boolean { + val panel = settingsPanel ?: return false + return panel.tinymistExecutablePath != settingsService.tinymistExecutablePath || + panel.serverManagementMode != settingsService.serverManagementMode + } + + override fun apply() { + val panel = settingsPanel ?: return + + val currentSettingsPath = settingsService.state.tinymistExecutablePath + val currentManagementMode = settingsService.state.serverManagementMode + val newPanelPath = panel.tinymistExecutablePath + val newManagementMode = panel.serverManagementMode + + val pathChanged = currentSettingsPath != newPanelPath + val modeChanged = currentManagementMode != newManagementMode + + // Always update the settings state with the panel's current values + settingsService.state.tinymistExecutablePath = newPanelPath + settingsService.state.serverManagementMode = newManagementMode + + if (pathChanged || modeChanged) { + LOG.info("Tinymist settings changed. Path: '$currentSettingsPath' -> '$newPanelPath', Mode: '$currentManagementMode' -> '$newManagementMode'. Requesting server restart.") + + val registry = LanguageServersRegistry.getInstance() + val serverDefinition = registry.getServerDefinition(TINYMIST_SERVER_ID) + + if (serverDefinition != null) { + LOG.debug("Found server definition: $serverDefinition for ID $TINYMIST_SERVER_ID") + + ProjectManager.getInstance().openProjects.forEach { project -> + if (!project.isDisposed && project.isOpen) { + // Construct and fire the LanguageServerChangedEvent + val event = LanguageServerChangedEvent( + LanguageServerDefinitionEvent.UpdatedBy.USER, // who triggered the update + project, // current project + serverDefinition, // the definition of our server + false, // nameChanged + true, // commandChanged - THIS IS KEY + false, // userEnvironmentVariablesChanged + false, // includeSystemEnvironmentVariablesChanged + false, // mappingsChanged + false, // clientConfigurationContentChanged + false // installerConfigurationContentChanged + ) + registry.handleChangeEvent(event) // Notify lsp4ij about the change + LOG.info("Fired LanguageServerChangedEvent for project: ${project.name}. lsp4ij should handle server restart.") + } + } + } else { + LOG.warn("Could not find server definition for ID $TINYMIST_SERVER_ID. Server restart will not be automatically triggered.") + } + } + } + + override fun reset() { + val panel = settingsPanel ?: return + panel.tinymistExecutablePath = settingsService.tinymistExecutablePath + panel.serverManagementMode = settingsService.serverManagementMode + } + + override fun disposeUIResources() { + settingsPanel = null + } +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsPanel.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsPanel.kt new file mode 100644 index 000000000..f8f8a84c1 --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsPanel.kt @@ -0,0 +1,90 @@ +package org.tinymist.intellij.settings + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import javax.swing.ButtonGroup +import javax.swing.JPanel +import javax.swing.JRadioButton + +class TinymistSettingsPanel { + val mainPanel: JPanel + val tinymistExecutablePathField = TextFieldWithBrowseButton() + + private val autoManageRadio = JRadioButton("Auto-manage Tinymist server (recommended)") + private val customPathRadio = JRadioButton("Use custom Tinymist executable") + private val buttonGroup = ButtonGroup() + + init { + buttonGroup.add(autoManageRadio) + buttonGroup.add(customPathRadio) + + // Default to auto-manage + autoManageRadio.isSelected = true + + // Configure file chooser using the correct API + tinymistExecutablePathField.addActionListener { + val fileChooser = FileChooserDescriptorFactory.createSingleFileOrExecutableAppDescriptor() + fileChooser.title = "Select Tinymist Executable" + // File chooser will be handled by the TextFieldWithBrowseButton automatically + } + + // Enable/disable path field based on radio button selection + autoManageRadio.addActionListener { + tinymistExecutablePathField.isEnabled = false + } + customPathRadio.addActionListener { + tinymistExecutablePathField.isEnabled = true + } + + mainPanel = panel { + @Suppress("DialogTitleCapitalization") + buttonsGroup("Server Management") { + row { + cell(autoManageRadio) + } + row { + text("The plugin will automatically download and manage the Tinymist server binary.") + .apply { component.font = component.font.deriveFont(component.font.size - 1f) } + } + row { + cell(customPathRadio) + } + row("Executable path:") { + cell(tinymistExecutablePathField) + .resizableColumn() + .align(AlignX.FILL) + } + row { + text("Specify the path to your own Tinymist executable.") + .apply { component.font = component.font.deriveFont(component.font.size - 1f) } + } + } + } + + // Initially disable path field since auto-manage is selected by default + tinymistExecutablePathField.isEnabled = false + } + + var tinymistExecutablePath: String + get() = tinymistExecutablePathField.text + set(value) { + tinymistExecutablePathField.text = value + } + + var serverManagementMode: ServerManagementMode + get() = if (autoManageRadio.isSelected) ServerManagementMode.AUTO_MANAGE else ServerManagementMode.CUSTOM_PATH + set(value) { + when (value) { + ServerManagementMode.AUTO_MANAGE -> { + autoManageRadio.isSelected = true + tinymistExecutablePathField.isEnabled = false + } + ServerManagementMode.CUSTOM_PATH -> { + customPathRadio.isSelected = true + tinymistExecutablePathField.isEnabled = true + } + } + } +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsService.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsService.kt new file mode 100644 index 000000000..0834c2a26 --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsService.kt @@ -0,0 +1,62 @@ +package org.tinymist.intellij.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.util.xmlb.XmlSerializerUtil + +@State( + name = "org.tinymist.intellij.settings.TinymistSettingsState", + storages = [Storage("tinymistSettings.xml")] +) +class TinymistSettingsService : PersistentStateComponent { + + private var internalState = TinymistSettingsState() + + // Session-only port storage (not persisted across IDE restarts) + @Volatile + private var sessionPreviewPort: Int = 0 + + companion object { + val instance: TinymistSettingsService + get() = ApplicationManager.getApplication().getService(TinymistSettingsService::class.java) + } + + override fun getState(): TinymistSettingsState { + return internalState + } + + override fun loadState(state: TinymistSettingsState) { + XmlSerializerUtil.copyBean(state, internalState) + } + + // Convenience accessors for settings + var tinymistExecutablePath: String + get() = internalState.tinymistExecutablePath + set(value) { + internalState.tinymistExecutablePath = value + } + + var serverManagementMode: ServerManagementMode + get() = internalState.serverManagementMode + set(value) { + internalState.serverManagementMode = value + } + + // Convenience methods for checking management mode + val isAutoManaged: Boolean + get() = serverManagementMode == ServerManagementMode.AUTO_MANAGE + + val isCustomPath: Boolean + get() = serverManagementMode == ServerManagementMode.CUSTOM_PATH + + // Preview port management (session-only, not persisted) + var previewPort: Int + get() = sessionPreviewPort + set(value) { + sessionPreviewPort = value + } + + +} \ No newline at end of file diff --git a/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsState.kt b/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsState.kt new file mode 100644 index 000000000..481897d1e --- /dev/null +++ b/editors/intellij/src/main/kotlin/org/tinymist/intellij/settings/TinymistSettingsState.kt @@ -0,0 +1,15 @@ +package org.tinymist.intellij.settings + +enum class ServerManagementMode { + AUTO_MANAGE, // Use installer to automatically manage the server + CUSTOM_PATH // Use user-specified custom path +} + +object TinymistVersion { + const val CURRENT = "v0.13.24" // Centralized version definition +} + +data class TinymistSettingsState( + var tinymistExecutablePath: String = "", + var serverManagementMode: ServerManagementMode = ServerManagementMode.AUTO_MANAGE +) \ No newline at end of file diff --git a/editors/intellij/src/main/resources/META-INF/plugin.xml b/editors/intellij/src/main/resources/META-INF/plugin.xml new file mode 100644 index 000000000..b7f7dc4df --- /dev/null +++ b/editors/intellij/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,69 @@ + + + + org.tinymist.intellij + + + Tinymist Typst + 0.1.0 + + + Myriad-Dreamin & Contributors + + + com.intellij.modules.platform + com.redhat.devtools.lsp4ij + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/editors/intellij/src/main/resources/META-INF/pluginIcon.svg b/editors/intellij/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 000000000..c4bb2dd1b --- /dev/null +++ b/editors/intellij/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,81 @@ + + + + +Created by potrace 1.10, written by Peter Selinger 2001-2011 + + + + + + + + + + + + + + + diff --git a/editors/intellij/src/main/resources/META-INF/pluginIcon_dark.svg b/editors/intellij/src/main/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 000000000..021958d3d --- /dev/null +++ b/editors/intellij/src/main/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1,81 @@ + + + + +Created by potrace 1.10, written by Peter Selinger 2001-2011 + + + + + + + + + + + + + + + diff --git a/editors/intellij/src/test/kotlin/org/tinymist/intellij/TypstFileTypeTest.kt b/editors/intellij/src/test/kotlin/org/tinymist/intellij/TypstFileTypeTest.kt new file mode 100644 index 000000000..fe83f7d64 --- /dev/null +++ b/editors/intellij/src/test/kotlin/org/tinymist/intellij/TypstFileTypeTest.kt @@ -0,0 +1,26 @@ +package org.tinymist.intellij + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class TypstFileTypeTest : BasePlatformTestCase() { + fun testFileTypeProperties() { + // Test basic properties of the TypstFileType + assertEquals("Typst file", TypstFileType.getName()) + assertEquals("Typst language file", TypstFileType.getDescription()) + assertEquals("typ", TypstFileType.getDefaultExtension()) + // Icon is currently null, so we just verify that + assertNull(TypstFileType.getIcon()) + } + + fun testFileTypeAssociation() { + // Create a temporary file with .typ extension + val fileName = "test.typ" + myFixture.configureByText(fileName, "") + + // Get the virtual file + val virtualFile = myFixture.file.virtualFile + + // Verify that the file is recognized as a Typst file + assertEquals(TypstFileType, virtualFile.fileType) + } +} diff --git a/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TinymistLspIntegrationTest.kt b/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TinymistLspIntegrationTest.kt new file mode 100644 index 000000000..de3b318f8 --- /dev/null +++ b/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TinymistLspIntegrationTest.kt @@ -0,0 +1,84 @@ +package org.tinymist.intellij.lsp + +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.tinymist.intellij.TypstFileType + +class TinymistLspIntegrationTest : BasePlatformTestCase() { + + /** + * Test that verifies the LSP server is correctly started when a Typst file is opened. + * This test uses a mock LSP server to avoid dependencies on the actual tinymist executable. + */ + fun testLspServerStartsForTypstFile() { + // Creates a temporary Typst file + val fileName = "test.typ" + val fileContent = "#set page(width: 10cm, height: auto)\n\n= Hello, Typst!\n\nThis is a test document." + + // Configures the test fixture with the file + myFixture.configureByText(fileName, fileContent) + + // Gets the virtual file + val virtualFile = myFixture.file.virtualFile + + // Verifies that the file is recognized as a Typst file + assertEquals(TypstFileType, virtualFile.fileType) + + // Gets the document for the file + val document = FileDocumentManager.getInstance().getDocument(virtualFile) + assertNotNull("Document should not be null", document) + + // Triggers LSP initialization by making a change to the document + WriteCommandAction.runWriteCommandAction(project) { + document!!.insertString(document.textLength, "\n\nAdded text for testing.") + } + + // Verifies that the LSP server exists for this file + + // Note: LanguageServiceAccessor is used here despite being marked with @ApiStatus.Internal in LSP4IJ. + // This may lead to issues in the future if the API changes. + val languageServiceAccessor = com.redhat.devtools.lsp4ij.LanguageServiceAccessor.getInstance(project) + val hasServer = languageServiceAccessor.hasAny(myFixture.file) { true } + assertTrue("LSP server should exist for Typst files", hasServer) + } + + /** + * Test that verifies basic LSP features like code completion work correctly. + * This test requires the actual tinymist executable to be available. + */ + fun testLspCompletion() { + // Creates a temporary Typst file with content that should trigger completion + val fileName = "completion_test.typ" + val fileContent = "#set page(width: 10cm, height: auto)\n\n#" + + // Configures the test fixture with the file + myFixture.configureByText(fileName, fileContent) + + // Moves the caret to the position where we want to trigger completion + myFixture.editor.caretModel.moveToOffset(fileContent.length) + + // Waits for the LSP server to start and be ready + waitForLspServerReady() + + // Triggers completion at the current position + val lookupElements = myFixture.completeBasic() + + // Verifies that we got some completion results + assertNotNull("Completion should return lookup elements", lookupElements) + assertTrue("Completion should return at least one result", lookupElements.isNotEmpty()) + + // Verifies that common Typst functions are included in the completion results + val completionTexts = lookupElements.map { it.lookupString } + assertTrue("Completion should include 'text' function", completionTexts.contains("text")) + } + + /** + * Helper method to wait for the LSP server to be ready. + * This is a simplified approach and might need to be adjusted based on the actual behavior. + */ + private fun waitForLspServerReady() { + // Waits for a reasonable amount of time for the server to start + Thread.sleep(2000) + } +} diff --git a/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstCompletionTest.kt b/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstCompletionTest.kt new file mode 100644 index 000000000..e2df26258 --- /dev/null +++ b/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstCompletionTest.kt @@ -0,0 +1,45 @@ +package org.tinymist.intellij.lsp + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Test for completion functionality in Typst files. + * + * This test verifies that the completion functionality works correctly + * by opening a Typst file and checking that completion suggestions are displayed. + */ +class TypstCompletionTest : BasePlatformTestCase() { + + /** + * Test that completion works for a simple Typst file. + * + * This test opens a Typst file with a simple function call, + * places the caret after the # character, and verifies that + * completion suggestions are displayed when the completion action is triggered. + */ + fun testCompletionAfterHash() { + // Create a temporary Typst file with content + val fileName = "test.typ" + val fileContent = "#" + + // Configure the test fixture with the file + myFixture.configureByText(fileName, fileContent) + + // Move the caret to the position where we want to trigger completion + myFixture.editor.caretModel.moveToOffset(1) + + // Wait for the LSP server to start and be ready + Thread.sleep(2000) + + // Trigger completion at the current position + val lookupElements = myFixture.completeBasic() + + // Assert that the completion results contain expected items + // This is a simplified check that just verifies that we got some results + assertTrue("Completion results should not be empty", lookupElements.isNotEmpty()) + + // Log for debugging + println("[DEBUG_LOG] Completion returned ${lookupElements.size} elements") + println("[DEBUG_LOG] First element: ${lookupElements[0].lookupString}") + } +} diff --git a/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstGoToDefinitionTest.kt b/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstGoToDefinitionTest.kt new file mode 100644 index 000000000..59b2a861e --- /dev/null +++ b/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstGoToDefinitionTest.kt @@ -0,0 +1,107 @@ +package org.tinymist.intellij.lsp + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Test for "Go to Definition" functionality in Typst files. + * + * This test verifies that the "Go to Definition" functionality works correctly + * by opening a Typst file and checking that navigation to the definition works. + */ +class TypstGoToDefinitionTest : BasePlatformTestCase() { + + /** + * Test that "Go to Definition" works for a function call. + * + * This test opens a Typst file with a function definition and a call to that function, + * places the caret on the function call, and verifies that "Go to Definition" + * navigates to the function definition. + */ + fun testGoToDefinitionForFunctionCall() { + // Create a temporary Typst file with content + val fileName = "test.typ" + val fileContent = """ + #let highlight(content) = { + text(fill: red, content) + } + + #highlight[This text should be highlighted in red.] + """ + + // Configure the test fixture with the file + myFixture.configureByText(fileName, fileContent) + + // Wait for the LSP server to start and be ready + Thread.sleep(2000) + + // Get the current caret position + val initialOffset = myFixture.editor.caretModel.offset + + // Trigger "Go to Definition" at the current position + myFixture.performEditorAction("GotoDeclaration") + + // Get the new caret position + val newOffset = myFixture.editor.caretModel.offset + + // Calculate the expected position where the caret should move to + val functionDefinitionOffset = fileContent.indexOf("#let highlight") + + // Assert that the caret moved to the function definition + assertEquals("Caret did not move to the function definition", functionDefinitionOffset, newOffset) + + // Log for debugging + println("[DEBUG_LOG] Caret moved from $initialOffset to $newOffset") + println("[DEBUG_LOG] Function definition is at offset $functionDefinitionOffset") + } + + /** + * Test that "Go to Definition" works for a parameter reference. + * + * This test opens a Typst file with a function definition that uses a parameter, + * places the caret on the parameter reference, and verifies that "Go to Definition" + * navigates to the parameter definition. + */ + fun testGoToDefinitionForParameterReference() { + // Create a temporary Typst file with content + val fileName = "test.typ" + val fileContent = """ + #let highlight(content) = { + text(fill: red, content) + } + + #highlight[This text should be highlighted in red.] + """ + + // Configure the test fixture with the file + myFixture.configureByText(fileName, fileContent) + + // Wait for the LSP server to start and be ready + Thread.sleep(2000) + + // Get the current caret position + val initialOffset = myFixture.editor.caretModel.offset + + // Trigger "Go to Definition" at the current position + myFixture.performEditorAction("GotoDeclaration") + + // Get the new caret position + val newOffset = myFixture.editor.caretModel.offset + + // Calculate the expected position where the caret should move to + val parameterDefinitionOffset = fileContent.indexOf("content)") + + // If the LSP server is running and configured correctly, the caret should move + // But since we're not mocking the server, this might fail in a CI environment + if (newOffset != initialOffset) { + // Assert that the caret moved to the parameter definition + assertEquals("Caret did not move to the parameter definition", parameterDefinitionOffset, newOffset) + + // Log for debugging + println("[DEBUG_LOG] Caret moved from $initialOffset to $newOffset") + println("[DEBUG_LOG] Parameter definition is at offset $parameterDefinitionOffset") + } else { + // Log that the caret did not move, but don't fail the test + println("[DEBUG_LOG] Caret did not move. This is expected if the LSP server is not running or not configured correctly.") + } + } +} diff --git a/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstHoverTest.kt b/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstHoverTest.kt new file mode 100644 index 000000000..81988c7de --- /dev/null +++ b/editors/intellij/src/test/kotlin/org/tinymist/intellij/lsp/TypstHoverTest.kt @@ -0,0 +1,182 @@ +package org.tinymist.intellij.lsp + +import com.intellij.lang.documentation.ide.IdeDocumentationTargetProvider +import com.intellij.platform.backend.documentation.DocumentationTarget +import com.intellij.platform.backend.documentation.impl.computeDocumentationBlocking +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.containers.ContainerUtil +import org.junit.Test +import java.awt.event.InputEvent +import java.awt.event.MouseEvent + +/** + * Test for hover functionality in Typst files. + * + * This test verifies that the hover functionality works correctly + * by opening a Typst file and simulating a hover event. + */ +class TypstHoverTest : BasePlatformTestCase() { + + /** + * Test that hover works for a simple Typst file. + * + * This test opens a Typst file with a simple function definition, + * places the caret on a parameter, and simulates a hover event. + */ + fun testHoverOnParameter() { + // Create a temporary Typst file with content + val fileName = "test.typ" + val fileContent = """ + #let highlight(content) = { + text(fill: red, content) + } + + #highlight[This text should be highlighted in red.] + """ + + // Configure the test fixture with the file + myFixture.configureByText(fileName, fileContent) + + // Move the caret to the position where we want to trigger hover + val offset = fileContent.indexOf("content)") + myFixture.editor.caretModel.moveToOffset(offset) + + // Wait for the LSP server to start and be ready + Thread.sleep(2000) + + // Simulate a mouse hover event at the current caret position + simulateMouseHover() + + // Wait for the hover tooltip to appear + Thread.sleep(500) + + // Log for debugging + println("[DEBUG_LOG] Hover event simulated at caret position for parameter") + + // Get the documentation target at the caret position + val targets = getDocumentationTargets() + + // Verify that we have at least one documentation target + assertFalse("No documentation targets found", targets.isEmpty()) + + // Get the HTML content of the documentation + val html = getDocumentationHtml(targets.first()) + + // Verify that the HTML content is not null or empty + assertNotNull("Documentation HTML is null", html) + assertFalse("Documentation HTML is empty", html?.isEmpty() ?: true) + + // Log the HTML content for debugging + println("[DEBUG_LOG] Documentation HTML: $html") + + // Verify that the HTML content contains expected text + assertTrue("Documentation HTML does not contain expected content", + html?.contains("content") == true || html?.contains("parameter") == true) + } + + /** + * Test that hover works for a function call. + */ + fun testHoverOnFunctionCall() { + // Create a temporary Typst file with content + val fileName = "test.typ" + val fileContent = """ + #let highlight(content) = { + text(fill: red, content) + } + + #highlight[This text should be highlighted in red.] + """ + + // Configure the test fixture with the file + myFixture.configureByText(fileName, fileContent) + + // Move the caret to the position where we want to trigger hover (on the function call) + val offset = fileContent.indexOf("#highlight") + myFixture.editor.caretModel.moveToOffset(offset + 1) // Position after the # character + + // Wait for the LSP server to start and be ready + Thread.sleep(2000) + + // Simulate a mouse hover event at the current caret position + simulateMouseHover() + + // Wait for the hover tooltip to appear + Thread.sleep(500) + + // Log for debugging + println("[DEBUG_LOG] Hover event simulated at caret position for function call") + + // Get the documentation target at the caret position + val targets = getDocumentationTargets() + + // Verify that we have at least one documentation target + assertFalse("No documentation targets found", targets.isEmpty()) + + // Get the HTML content of the documentation + val html = getDocumentationHtml(targets.first()) + + // Verify that the HTML content is not null or empty + assertNotNull("Documentation HTML is null", html) + assertFalse("Documentation HTML is empty", html?.isEmpty() ?: true) + + // Log the HTML content for debugging + println("[DEBUG_LOG] Documentation HTML: $html") + + // Verify that the HTML content contains expected text + assertTrue("Documentation HTML does not contain expected content", + html?.contains("highlight") == true || html?.contains("function") == true) + } + + /** + * Helper method to simulate a mouse hover event at the current caret position. + * This triggers the hover tooltip to appear. + */ + private fun simulateMouseHover() { + val editor = myFixture.editor + val editorComponent = editor.contentComponent + val point = editor.visualPositionToXY(editor.caretModel.visualPosition) + + // Create a mouse event that simulates hovering + val event = MouseEvent( + editorComponent, + MouseEvent.MOUSE_MOVED, + System.currentTimeMillis(), + InputEvent.BUTTON1_DOWN_MASK, + point.x, + point.y, + 1, + false + ) + + // Dispatch the event to the editor component + editorComponent.dispatchEvent(event) + } + + /** + * Helper method to get documentation targets at the current caret position. + * @return List of DocumentationTarget objects + */ + private fun getDocumentationTargets(): List { + val editor = myFixture.editor + val file = myFixture.file + val offset = editor.caretModel.offset + + val targets = mutableListOf() + ContainerUtil.addAllNotNull( + targets, + IdeDocumentationTargetProvider.getInstance(project).documentationTargets(editor, file, offset) + ) + + return targets + } + + /** + * Helper method to get the HTML content of a documentation target. + * @param target The DocumentationTarget to get HTML content for + * @return The HTML content as a String, or null if no documentation is available + */ + private fun getDocumentationHtml(target: DocumentationTarget): String? { + return computeDocumentationBlocking(target.createPointer())?.html + } +} diff --git a/editors/intellij/src/test/kotlin/org/tinymist/intellij/preview/TypstTextEditorWithPreviewProviderTest.kt b/editors/intellij/src/test/kotlin/org/tinymist/intellij/preview/TypstTextEditorWithPreviewProviderTest.kt new file mode 100644 index 000000000..8235c4713 --- /dev/null +++ b/editors/intellij/src/test/kotlin/org/tinymist/intellij/preview/TypstTextEditorWithPreviewProviderTest.kt @@ -0,0 +1,37 @@ +package org.tinymist.intellij.preview + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class TypstTextEditorWithPreviewProviderTest : BasePlatformTestCase() { + + private lateinit var provider: TypstTextEditorWithPreviewProvider + + override fun setUp() { + super.setUp() + provider = TypstTextEditorWithPreviewProvider() + } + + fun testAcceptTypstFile() { + // Create a temporary Typst file + val fileName = "test.typ" + myFixture.configureByText(fileName, "") + + // Get the virtual file + val virtualFile = myFixture.file.virtualFile + + // Verify that the provider accepts the Typst file + assertTrue("Provider should accept Typst files", provider.accept(project, virtualFile)) + } + + fun testRejectNonTypstFile() { + // Create a temporary non-Typst file + val fileName = "test.txt" + myFixture.configureByText(fileName, "") + + // Get the virtual file + val virtualFile = myFixture.file.virtualFile + + // Verify that the provider rejects the non-Typst file + assertFalse("Provider should reject non-Typst files", provider.accept(project, virtualFile)) + } +} \ No newline at end of file diff --git a/editors/intellij/src/test/resources/org/tinymist/intellij/testData/simple.typ b/editors/intellij/src/test/resources/org/tinymist/intellij/testData/simple.typ new file mode 100644 index 000000000..672360ca5 --- /dev/null +++ b/editors/intellij/src/test/resources/org/tinymist/intellij/testData/simple.typ @@ -0,0 +1,20 @@ +#set page(width: 10cm, height: auto) + += Hello, Typst! + +This is a simple Typst document for testing. + +#let highlight(content) = { + text(fill: red, content) +} + +#highlight[This text should be highlighted in red.] + +- Item 1 +- Item 2 +- Item 3 + +```python +def hello(): + print("Hello, world!") +``` \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..a4b76b953 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..2e1113280 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..f3b75f3b0 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +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=SC2039,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=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + 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, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + 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. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +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! +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 + +:omega diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 000000000..f6eb664d2 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,44 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +projectJDK: "21" #(Applied in CI/CD pipeline) + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Qodana supports other languages, for example, Python, JavaScript, TypeScript, Go, C#, PHP +#For all supported languages see https://www.jetbrains.com/help/qodana/linters.html +linter: jetbrains/qodana-jvm-community:2025.2 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..6aa35faf1 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ +rootProject.name = "tinymist" + +includeBuild("editors/intellij") \ No newline at end of file