├── .gitattributes ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── esbuild.js ├── images ├── chat-usage.gif ├── nextflow-icon-128x128-dark.png ├── nextflow-icon-128x128-light.png ├── nextflow-icon-128x128.png ├── nextflow.svg ├── project_view_list.png ├── project_view_tree.png ├── seqera.svg └── vscode-nextflow.png ├── language-configuration.json ├── package-lock.json ├── package.json ├── settings.gradle ├── src ├── auth │ ├── AuthProvider │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils │ │ │ ├── UriEventHandler.ts │ │ │ ├── jwt.ts │ │ │ └── promiseFromEvent.ts │ ├── getAccessToken.ts │ └── index.ts ├── chatbot │ ├── createHandler │ │ └── index.ts │ ├── index.ts │ ├── prompts.ts │ ├── types.ts │ └── utils │ │ ├── getChatHistory.ts │ │ └── getContext.ts ├── constants.ts ├── extension.ts ├── languageServer │ ├── index.ts │ └── utils │ │ ├── buildMermaid.ts │ │ ├── fetchLanguageServer.ts │ │ └── findJava.ts ├── telemetry │ └── index.ts ├── webview │ ├── ResourcesProvider.ts │ ├── WebviewProvider │ │ ├── index.ts │ │ └── lib │ │ │ ├── index.ts │ │ │ ├── platform │ │ │ ├── fetchHubPipelines.ts │ │ │ ├── fetchPlatformData.ts │ │ │ ├── getAuthState.ts │ │ │ ├── hubTypes.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── addPipeline.ts │ │ │ │ ├── createTest │ │ │ │ ├── fetchContent.ts │ │ │ │ ├── generateTest.ts │ │ │ │ ├── generateValidation.ts │ │ │ │ ├── index.ts │ │ │ │ ├── prompt.ts │ │ │ │ └── utils.ts │ │ │ │ ├── fetchComputeEnvs.ts │ │ │ │ ├── fetchDataLinks.ts │ │ │ │ ├── fetchDatasets.ts │ │ │ │ ├── fetchPipelines.ts │ │ │ │ ├── fetchRuns.ts │ │ │ │ ├── fetchUserInfo.ts │ │ │ │ ├── fetchWorkspaces.ts │ │ │ │ ├── getContainer │ │ │ │ ├── generateRequirements.ts │ │ │ │ ├── index.ts │ │ │ │ ├── prompt.ts │ │ │ │ ├── startBuild.ts │ │ │ │ └── types.ts │ │ │ │ ├── getRepoInfo.ts │ │ │ │ └── index.ts │ │ │ ├── utils │ │ │ ├── debounce.ts │ │ │ ├── index.ts │ │ │ └── sleep.ts │ │ │ └── workspace │ │ │ ├── queryWorkspace.ts │ │ │ └── types.ts │ └── index.ts └── welcomePage │ ├── index.ts │ ├── welcome-cursor.md │ └── welcome-vscode.md ├── syntaxes ├── groovy.tmLanguage.json ├── nextflow-config.tmLanguage.json ├── nextflow-markdown-injection.json └── nextflow.tmLanguage.json ├── tsconfig.json └── webview-ui ├── .gitignore ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.tsx ├── Context │ ├── TowerProvider │ │ ├── index.tsx │ │ └── utils.ts │ ├── WorkspaceProvider │ │ ├── index.tsx │ │ └── types.ts │ ├── index.tsx │ ├── types.ts │ └── utils.ts ├── Layout │ ├── Project │ │ ├── index.tsx │ │ └── styles.module.css │ ├── SeqeraCloud │ │ ├── Login │ │ │ ├── index.tsx │ │ │ └── utils.ts │ │ ├── Toolbar │ │ │ ├── WorkspaceSelector.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Workspace │ │ │ ├── AddPipeline │ │ │ │ ├── Layout │ │ │ │ │ ├── ComputeEnvSelector.tsx │ │ │ │ │ ├── SuccessPage.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── ComputeEnvironments │ │ │ │ └── index.tsx │ │ │ ├── DataLinks │ │ │ │ └── index.tsx │ │ │ ├── Datasets │ │ │ │ └── index.tsx │ │ │ ├── FilterForProject │ │ │ │ └── index.tsx │ │ │ ├── OpenChat │ │ │ │ └── index.tsx │ │ │ ├── Pipelines │ │ │ │ └── index.tsx │ │ │ ├── RunHistory │ │ │ │ ├── ErrorReport.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── styles.module.css │ │ │ │ └── utils.ts │ │ │ └── index.tsx │ │ ├── index.tsx │ │ └── utils.ts │ └── index.tsx ├── components │ ├── Button │ │ ├── index.tsx │ │ ├── styles.module.css │ │ └── utils.ts │ ├── Checkbox │ │ ├── index.tsx │ │ └── styles.module.css │ ├── FileList │ │ ├── FileItem.tsx │ │ ├── ItemActions │ │ │ ├── WaveIcon.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── index.tsx │ │ └── styles.module.css │ ├── FileNode │ │ ├── index.tsx │ │ └── styles.module.css │ ├── Input │ │ ├── index.tsx │ │ └── styles.module.css │ ├── ListItem │ │ ├── index.tsx │ │ └── styles.module.css │ ├── Select │ │ ├── index.tsx │ │ └── styles.module.css │ └── Spinner │ │ ├── index.tsx │ │ └── styles.module.css ├── icons │ ├── AI.tsx │ ├── Nextflow.tsx │ ├── Pipeline.tsx │ ├── Process.tsx │ ├── Seqera.tsx │ ├── Workflow.tsx │ └── index.ts ├── main.tsx ├── styles │ ├── codicon.ttf │ ├── codicons.css │ ├── colors.css │ ├── index.css │ ├── misc.css │ └── spacing.css └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # Linux start script should use lf 5 | /gradlew text eol=lf 6 | 7 | # These are Windows script files and should use crlf 8 | *.bat text eol=crlf 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Extension 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | vscode_marketplace: 7 | description: 'Publish to Visual Studio Marketplace' 8 | type: boolean 9 | default: true 10 | open_vsx: 11 | description: 'Publish to Open VSX Registry' 12 | type: boolean 13 | default: true 14 | 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: "npm" 25 | 26 | - name: npm clean install 27 | run: | 28 | (cd webview-ui ; npm ci) 29 | npm ci 30 | 31 | - name: Build vscode extension 32 | run: npm run package 33 | 34 | - name: Publish to Visual Studio Marketplace 35 | if: ${{ github.event.inputs.vscode_marketplace == 'true' }} 36 | uses: HaaLeo/publish-vscode-extension@v1 37 | with: 38 | pat: ${{ secrets.VSCE_TOKEN }} 39 | registryUrl: https://marketplace.visualstudio.com 40 | extensionFile: build/nextflow.vsix 41 | 42 | - name: Publish to Open VSX Registry 43 | if: ${{ github.event.inputs.open_vsx == 'true' }} 44 | uses: HaaLeo/publish-vscode-extension@v1 45 | with: 46 | pat: ${{ secrets.OPEN_VSX_TOKEN }} 47 | extensionFile: build/nextflow.vsix 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .vsix 4 | build/ 5 | language-server 6 | node_modules -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // See: https://go.microsoft.com/fwlink/?linkid=830387 2 | { 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "Run Extension", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "args": [ 10 | "--extensionDevelopmentPath=${workspaceRoot}/build", 11 | "${input:testWorkspace}", 12 | "--goto", 13 | "${input:testWorkspace}/main.nf" 14 | ], 15 | "preLaunchTask": "npm: compile" 16 | } 17 | ], 18 | "inputs": [ 19 | { 20 | "id": "testWorkspace", 21 | "type": "promptString", 22 | "description": "Path to a Nextflow workspace", 23 | "default": "${workspaceFolder}/../test-workspace" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See: https://go.microsoft.com/fwlink/?LinkId=733558 2 | { 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "label": "npm: compile", 7 | "type": "npm", 8 | "script": "compile", 9 | "presentation": { 10 | "reveal": "never" 11 | }, 12 | "group": { 13 | "kind": "build", 14 | "isDefault": true 15 | } 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | src/** 4 | package-lock.json 5 | tsconfig.json 6 | esbuild.js 7 | **/*.map 8 | webview-ui/**/* 9 | !webview-ui/dist/**/* 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the Nextflow VS Code extension will be documented here. 4 | 5 | See [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 6 | 7 | ## [1.5.2] - 2025-08-21 8 | 9 | - Enable authentication in GitHub API requests 10 | 11 | ## [1.5.1] - 2025-08-06 12 | 13 | - Fix font color in Project view (#149) 14 | - Apply theme colors to control nodes in DAG preview 15 | 16 | ## [1.5.0] - 2025-06-17 17 | 18 | - Launch pipeline in Seqera Cloud (#135) 19 | - Add pipeline to Launchpad (#139) 20 | - Bump default language version to 25.04 21 | 22 | ## [1.4.1] - 2025-05-14 23 | 24 | - Check Java version on language server startup (#128) 25 | - Preview error log in run history (#130) 26 | 27 | ## [1.4.0] - 2025-05-13 28 | 29 | - Add 25.04 to language version options 30 | - Generate process containers with Wave 31 | 32 | ## [1.3.0] - 2025-05-05 33 | 34 | - Run history, Pipelines, Datasets, Data Buckets (#110) 35 | - Replace Processes/Workflows view with Project view (#117) 36 | - Add nf-test generation to Project view (#118) 37 | 38 | ## [1.2.0] - 2025-04-10 39 | 40 | - Add extension settings for code completion and formatting 41 | - Add extension setting for error reporting mode 42 | - Add opt-in telemetry (#82) 43 | - Add Seqera auth and webviews (#92) 44 | 45 | ## [1.1.0] - 2025-02-25 46 | 47 | - Download language server on startup (#68) 48 | - Add Seqera chat participant (#77) 49 | 50 | ## [1.0.4] - 2025-01-21 51 | 52 | - Bump language server to [1.0.4](https://github.com/nextflow-io/language-server/releases/tag/v1.0.4) 53 | - Await language server shutdown on deactivate (#58) 54 | - Cleanup TextMate grammars 55 | 56 | ## [1.0.3] - 2024-12-16 57 | 58 | - Bump language server to [1.0.3](https://github.com/nextflow-io/language-server/releases/tag/v1.0.3) 59 | - Stop language server on extension deactivate (#58) 60 | - Add theme colors, export options to DAG preview (#59) 61 | - Rename suppressFutureWarnings -> paranoidWarnings 62 | - Support offline DAG previews (#64) 63 | - Replace webpack with esbuild, remove gradle (#66) 64 | 65 | ## [1.0.2] - 2024-11-25 66 | 67 | - Bump language server to [1.0.2](https://github.com/nextflow-io/language-server/releases/tag/v1.0.2) 68 | - Support links from DAG preview to source files 69 | 70 | ## [1.0.1] - 2024-11-12 71 | 72 | - Bump language server to [1.0.1](https://github.com/nextflow-io/language-server/releases/tag/v1.0.1) 73 | 74 | ## [1.0.0] - 2024-10-28 75 | 76 | - Language server and client 77 | 78 | ## [0.3.3] - 2024-03-29 79 | 80 | - Update publishing automation to also push to the Open VSX Registry. 81 | 82 | ## [0.3.2] - 2024-03-25 83 | 84 | - Updated Nextflow logo 85 | 86 | ## [0.3.1] - 2022-04-13 87 | 88 | ## [0.3.0] - 2020-09-17 89 | 90 | ## [0.2.0] - 2020-02-22 91 | 92 | ## [0.1.1] - 2018-05-12 93 | 94 | ## [0.1.0] - 2018-01-30 95 | 96 | - Initial release 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome. Fork [this repository](https://github.com/nextflow-io/vscode-language-nextflow) and open a pull request to propose changes. Consider submitting an [issue](https://github.com/nextflow-io/vscode-language-nextflow/issues/new) to discuss any proposed changes with the maintainers before submitting a pull request. 4 | 5 | ## Development 6 | 7 | Clone this repository: 8 | 9 | ```bash 10 | git clone https://github.com/nextflow-io/vscode-language-nextflow 11 | cd vscode-language-nextflow 12 | ``` 13 | 14 | Install dependencies: 15 | 16 | ```bash 17 | (cd webview-ui ; npm install) 18 | npm install 19 | ``` 20 | 21 | If you need to edit the language server, clone the repository and build it: 22 | 23 | ```bash 24 | git clone https://github.com/nextflow-io/language-server 25 | make server 26 | ``` 27 | 28 | Finally, in VS Code or Cursor, press `F5` to build the extension and launch a new workspace with the extension loaded (alternatively you can run `Debug: Start Debugging` from the command palette). 29 | 30 | You will be prompted to enter a path to your Nextflow workspace, which defaults to `../test-workspace` relative to the project directory. 31 | 32 | Alternatively, you can run the Webview UI with live reload: 33 | 34 | ```bash 35 | npm run ui-watch 36 | ``` 37 | 38 | ## Publishing 39 | 40 | 1. Update the extension version number in `package.json`. 41 | 2. Update the changelog in `CHANGELOG.md`. 42 | 3. Make a release commit e.g. "Release 1.0.0". 43 | 4. Create a release in GitHub e.g. "v1.0.0". 44 | 5. Run the "Publish Extension" action to publish the extension to the Visual Studio Marketplace and Open VSX Registry. 45 | 46 | ## Additional resources 47 | 48 | - https://manual.macromates.com/en/language_grammars 49 | - https://code.visualstudio.com/docs/extensions/publish-extension 50 | - https://code.visualstudio.com/docs/extensions/yocode 51 | - https://code.visualstudio.com/docs/extensionAPI/extension-manifest 52 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Paolo Di Tommaso 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: clean server package 2 | 3 | clean: 4 | rm -rf build 5 | 6 | server: 7 | cd language-server ; make 8 | 9 | package: 10 | npm run package 11 | 12 | install: all 13 | code --install-extension build/nextflow.vsix 14 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | const { build } = require("esbuild"); 2 | const { copy } = require("esbuild-plugin-copy"); 3 | 4 | const production = process.argv.includes("--production"); 5 | 6 | async function main() { 7 | const files = { 8 | "images/**": "./images", 9 | "snippets/**": "./snippets", 10 | "syntaxes/**": "./syntaxes", 11 | "CHANGELOG.md": "./CHANGELOG.md", 12 | "LICENSE.md": "./LICENSE.md", 13 | "README.md": "./README.md", 14 | "src/welcomePage/welcome-vscode.md": "./welcome-vscode.md", 15 | "src/welcomePage/welcome-cursor.md": "./welcome-cursor.md", 16 | "language-configuration.json": "./language-configuration.json", 17 | "package.json": "./package.json", 18 | "node_modules/mermaid/dist/mermaid.min.js": "media", 19 | "webview-ui/dist/**": "webview-ui/dist" 20 | }; 21 | if (!production) 22 | files["language-server/build/libs/language-server-all.jar"] = "bin"; 23 | await build({ 24 | entryPoints: ["src/extension.ts"], 25 | bundle: true, 26 | format: "cjs", 27 | minify: production, 28 | sourcemap: !production, 29 | sourcesContent: false, 30 | platform: "node", 31 | outfile: "build/extension.js", 32 | external: ["vscode"], 33 | logLevel: "silent", 34 | plugins: [ 35 | copy({ 36 | assets: Object.entries(files).map(([from, to]) => { 37 | return { from, to }; 38 | }) 39 | }) 40 | ] 41 | }); 42 | } 43 | 44 | main().catch((e) => { 45 | console.error(e); 46 | process.exit(1); 47 | }); 48 | -------------------------------------------------------------------------------- /images/chat-usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextflow-io/vscode-language-nextflow/7acf7a835c123d55ed36eb57495c7ff1e6e1a7c7/images/chat-usage.gif -------------------------------------------------------------------------------- /images/nextflow-icon-128x128-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextflow-io/vscode-language-nextflow/7acf7a835c123d55ed36eb57495c7ff1e6e1a7c7/images/nextflow-icon-128x128-dark.png -------------------------------------------------------------------------------- /images/nextflow-icon-128x128-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextflow-io/vscode-language-nextflow/7acf7a835c123d55ed36eb57495c7ff1e6e1a7c7/images/nextflow-icon-128x128-light.png -------------------------------------------------------------------------------- /images/nextflow-icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextflow-io/vscode-language-nextflow/7acf7a835c123d55ed36eb57495c7ff1e6e1a7c7/images/nextflow-icon-128x128.png -------------------------------------------------------------------------------- /images/nextflow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /images/project_view_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextflow-io/vscode-language-nextflow/7acf7a835c123d55ed36eb57495c7ff1e6e1a7c7/images/project_view_list.png -------------------------------------------------------------------------------- /images/project_view_tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextflow-io/vscode-language-nextflow/7acf7a835c123d55ed36eb57495c7ff1e6e1a7c7/images/project_view_tree.png -------------------------------------------------------------------------------- /images/seqera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 31 | 44 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /images/vscode-nextflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextflow-io/vscode-language-nextflow/7acf7a835c123d55ed36eb57495c7ff1e6e1a7c7/images/vscode-nextflow.png -------------------------------------------------------------------------------- /language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | // symbol used for single line comment. Remove this entry if your language does not support line comments 4 | "lineComment": "//", 5 | // symbols used for start and end a block comment. Remove this entry if your language does not support block comments 6 | "blockComment": ["/*", "*/"] 7 | }, 8 | // symbols used as brackets 9 | "brackets": [ 10 | ["{", "}"], 11 | ["[", "]"], 12 | ["(", ")"] 13 | ], 14 | // symbols that are auto closed when typing 15 | "autoClosingPairs": [ 16 | ["{", "}"], 17 | ["[", "]"], 18 | ["(", ")"], 19 | ["\"", "\""], 20 | ["'", "'"] 21 | ], 22 | // symbols that that can be used to surround a selection 23 | "surroundingPairs": [ 24 | ["{", "}"], 25 | ["[", "]"], 26 | ["(", ")"], 27 | ["\"", "\""], 28 | ["'", "'"] 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'vscode-language-nextflow' -------------------------------------------------------------------------------- /src/auth/AuthProvider/types.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: number; 3 | userName: string; 4 | email: string; 5 | firstName?: string; 6 | lastName?: string; 7 | organization?: string; 8 | description?: string; 9 | avatar?: string; 10 | avatarId?: string; 11 | notification?: string; 12 | termsOfUseConsent: boolean; 13 | marketingConsent: boolean; 14 | lastAccess: string; 15 | dateCreated: string; 16 | lastUpdated: string; 17 | deleted: boolean; 18 | trusted: boolean; 19 | options: { 20 | githubToken?: string; 21 | maxRuns?: number; 22 | hubspotId?: number; 23 | }; 24 | }; 25 | 26 | export type UserInfo = { 27 | user: User; 28 | needConsent: boolean; 29 | defaultWorkspaceId: number; 30 | }; 31 | -------------------------------------------------------------------------------- /src/auth/AuthProvider/utils/UriEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { Uri, EventEmitter } from "vscode"; 2 | 3 | import type { UriHandler } from "vscode"; 4 | 5 | class UriEventHandler extends EventEmitter implements UriHandler { 6 | public handleUri(uri: Uri) { 7 | this.fire(uri); 8 | } 9 | } 10 | 11 | export default UriEventHandler; 12 | -------------------------------------------------------------------------------- /src/auth/AuthProvider/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import { jwtDecode } from "jwt-decode"; 2 | 3 | function jwtExpired(token?: string): boolean { 4 | if (!token) return true; 5 | const decoded = jwtDecode(token); 6 | return expired(decoded.exp); 7 | } 8 | 9 | function expired(timestamp?: number): boolean { 10 | if (!timestamp) return true; 11 | const currentTime = Date.now() / 1000; 12 | return timestamp < currentTime; 13 | } 14 | 15 | function decodeJWT(token?: string): any { 16 | if (!token) return null; 17 | return jwtDecode(token); 18 | } 19 | 20 | export { jwtExpired, decodeJWT, expired }; 21 | -------------------------------------------------------------------------------- /src/auth/AuthProvider/utils/promiseFromEvent.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, Event, EventEmitter } from "vscode"; 2 | 3 | export interface PromiseAdapter { 4 | ( 5 | value: T, 6 | resolve: (value: U | PromiseLike) => void, 7 | reject: (reason: any) => void 8 | ): any; 9 | } 10 | 11 | const passthrough = (value: any, resolve: (value?: any) => void) => 12 | resolve(value); 13 | 14 | export function promiseFromEvent( 15 | event: Event, 16 | adapter: PromiseAdapter = passthrough 17 | ): { promise: Promise; cancel: EventEmitter } { 18 | let subscription: Disposable; 19 | let cancel = new EventEmitter(); 20 | 21 | return { 22 | promise: new Promise((resolve, reject) => { 23 | cancel.event((_) => reject("Cancelled")); 24 | subscription = event((value: T) => { 25 | try { 26 | Promise.resolve(adapter(value, resolve, reject)).catch(reject); 27 | } catch (error) { 28 | reject(error); 29 | } 30 | }); 31 | }).then( 32 | (result: U) => { 33 | subscription.dispose(); 34 | return result; 35 | }, 36 | (error) => { 37 | subscription.dispose(); 38 | throw error; 39 | } 40 | ), 41 | cancel 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/auth/getAccessToken.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationSession, ExtensionContext } from "vscode"; 2 | 3 | import { STORAGE_KEY_NAME } from "./AuthProvider"; 4 | 5 | const getAccessToken = async ( 6 | context: ExtensionContext 7 | ): Promise => { 8 | const sessionsStr = await context.secrets.get(STORAGE_KEY_NAME); 9 | const sessions = sessionsStr ? JSON.parse(sessionsStr) : []; 10 | const session = sessions[0] as AuthenticationSession; 11 | const token = session?.accessToken; 12 | return token; 13 | }; 14 | 15 | export default getAccessToken; 16 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { authentication, commands, Uri, window } from "vscode"; 2 | const { getSession, onDidChangeSessions } = authentication; 3 | 4 | import getAccessToken from "./getAccessToken"; 5 | import AuthProvider from "./AuthProvider"; 6 | import type { ExtensionContext } from "vscode"; 7 | import { SEQERA_PLATFORM_URL } from "../constants"; 8 | 9 | async function activateAuth( 10 | context: ExtensionContext, 11 | authProvider: AuthProvider 12 | ) { 13 | const handleLogin = async () => 14 | await getSession("auth0", [], { 15 | forceNewSession: true 16 | }); 17 | 18 | const handleSessionChange = async () => showWelcomeMessage(); 19 | 20 | const handleLogout = async () => { 21 | const sessions = await authProvider.getSessions(); 22 | const session = sessions[0]; 23 | if (!session) return; 24 | await authProvider.removeSession(session.id); 25 | }; 26 | 27 | const goToCloud = () => { 28 | commands.executeCommand("vscode.open", Uri.parse(SEQERA_PLATFORM_URL)); 29 | }; 30 | 31 | const { registerCommand } = commands; 32 | const loginCommand = registerCommand("nextflow.seqera.login", handleLogin); 33 | const logoutCommand = registerCommand("nextflow.seqera.logout", handleLogout); 34 | const goToCloudCommand = registerCommand( 35 | "nextflow.seqera.goToCloud", 36 | goToCloud 37 | ); 38 | const sessionChange = onDidChangeSessions(handleSessionChange); 39 | 40 | context.subscriptions.push(authProvider); 41 | context.subscriptions.push(loginCommand); 42 | context.subscriptions.push(logoutCommand); 43 | context.subscriptions.push(sessionChange); 44 | context.subscriptions.push(goToCloudCommand); 45 | } 46 | 47 | const showWelcomeMessage = async () => { 48 | const session = await getSession("auth0", []); 49 | let msg = "Logged out from Seqera Cloud"; 50 | if (session) { 51 | msg = `Logged in to Seqera Cloud: ${session.account.label}`; 52 | commands.executeCommand("setContext", "nextflow.isLoggedIn", true); 53 | } else { 54 | commands.executeCommand("setContext", "nextflow.isLoggedIn", false); 55 | } 56 | window.showInformationMessage(msg); 57 | }; 58 | 59 | export { activateAuth, AuthProvider, getAccessToken }; 60 | -------------------------------------------------------------------------------- /src/chatbot/createHandler/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as chatUtils from "@vscode/chat-extension-utils"; 3 | import { BASE_PROMPT, NF_TEST_PROMPT, DSL2_PROMPT } from "../prompts"; 4 | import { getFileContext } from "../utils/getContext"; 5 | import getChatHistory from "../utils/getChatHistory"; 6 | 7 | import type { TrackEvent } from "../../telemetry"; 8 | 9 | type PromptKey = string; 10 | 11 | const prompts: Record = { 12 | "nf-test": NF_TEST_PROMPT, 13 | dsl2: DSL2_PROMPT, 14 | default: BASE_PROMPT 15 | }; 16 | 17 | /** 18 | * Builds the initial messages for the conversation, including: 19 | * 1. System prompt 20 | * 2. File context (references) 21 | * 3. Previous chat history 22 | * @param request - The vscode.ChatRequest 23 | * @param context - The vscode.ChatContext 24 | * @param systemPrompt - Resolved system prompt (BASE_PROMPT or other) 25 | */ 26 | async function buildInitialMessages( 27 | request: vscode.ChatRequest, 28 | context: vscode.ChatContext, 29 | systemPrompt: string 30 | ): Promise { 31 | const messages: vscode.LanguageModelChatMessage[] = []; 32 | 33 | // Add the system prompt as the first user message (depending on your usage, 34 | // you might treat it as System or Assistant message, but here we keep consistent with your existing pattern). 35 | messages.push(vscode.LanguageModelChatMessage.User(systemPrompt)); 36 | 37 | // Add context from any referenced files 38 | if (request.references && request.references.length > 0) { 39 | const fileContents = await getFileContext(request.references); 40 | if (fileContents && fileContents.trim().length > 0) { 41 | const filesPrompt = `Here are the files attached to this message:\n\n${fileContents}\n`; 42 | messages.push(vscode.LanguageModelChatMessage.User(filesPrompt)); 43 | } 44 | } 45 | 46 | // Add previous chat messages 47 | const previousMessages = getChatHistory( 48 | context.history.filter( 49 | (m) => m instanceof vscode.ChatResponseTurn 50 | ) as vscode.ChatResponseTurn[] 51 | ); 52 | messages.push(...previousMessages); 53 | 54 | return messages; 55 | } 56 | 57 | /** 58 | * Creates and returns a chat request handler for VS Code chat interactions. 59 | * It handles building context, managing chat history, and streaming responses. 60 | */ 61 | export function createHandler(trackEvent: TrackEvent): vscode.ChatRequestHandler { 62 | return async ( 63 | request: vscode.ChatRequest, 64 | context: vscode.ChatContext, 65 | stream: vscode.ChatResponseStream, 66 | token: vscode.CancellationToken 67 | ) => { 68 | trackEvent("sentMessage", { 69 | messageLength: request.prompt.length, 70 | command: request.command || "default", 71 | referencesCount: request.references?.length ?? 0 72 | }); 73 | 74 | const command = request.command || "default"; 75 | const prompt = prompts[command] || prompts["default"]; 76 | const messages = await buildInitialMessages(request, context, prompt); 77 | 78 | messages.push(vscode.LanguageModelChatMessage.User(request.prompt)); 79 | 80 | const libResult = chatUtils.sendChatParticipantRequest( 81 | request, 82 | context, 83 | { 84 | prompt, 85 | tools: vscode.lm.tools, 86 | responseStreamOptions: { 87 | stream, 88 | references: true, 89 | responseText: true 90 | } 91 | }, 92 | token 93 | ); 94 | 95 | return libResult.result; 96 | }; 97 | }; 98 | -------------------------------------------------------------------------------- /src/chatbot/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import type { TrackEvent } from "../telemetry"; 4 | import { createHandler } from "./createHandler"; 5 | 6 | export function activateChatbot( 7 | context: vscode.ExtensionContext, 8 | trackEvent: TrackEvent 9 | ) { 10 | // Don't activate chatbot in Cursor 11 | if (vscode.env.appName.includes("Cursor")) { 12 | return; 13 | } 14 | 15 | // Create the chat participant 16 | const chatHandler = createHandler(trackEvent); 17 | const chatParticipant = vscode.chat.createChatParticipant( 18 | "nextflow.chatbot", 19 | chatHandler 20 | ); 21 | context.subscriptions.push(chatParticipant); 22 | 23 | // Commands 24 | 25 | const openChat = vscode.commands.registerCommand( 26 | "nextflow.chatbot.openChat", 27 | async () => { 28 | await vscode.commands.executeCommand("workbench.action.chat.open", { 29 | query: "@Seqera ", 30 | isPartialQuery: true 31 | }); 32 | trackEvent("openChat", { source: "commandPalette" }); 33 | } 34 | ); 35 | context.subscriptions.push(openChat); 36 | 37 | const writeTest = vscode.commands.registerCommand( 38 | "nextflow.chatbot.writeTest", 39 | async () => { 40 | await vscode.commands.executeCommand("workbench.action.chat.open", { 41 | query: "@Seqera /nf-test" 42 | }); 43 | trackEvent("writeTest", { source: "commandPalette" }); 44 | } 45 | ); 46 | context.subscriptions.push(writeTest); 47 | 48 | const convertToDSL2 = vscode.commands.registerCommand( 49 | "nextflow.chatbot.convertToDSL2", 50 | async () => { 51 | await vscode.commands.executeCommand("workbench.action.chat.open", { 52 | query: "@Seqera /dsl2" 53 | }); 54 | trackEvent("convertToDSL2", { source: "commandPalette" }); 55 | } 56 | ); 57 | context.subscriptions.push(convertToDSL2); 58 | } 59 | -------------------------------------------------------------------------------- /src/chatbot/types.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export interface ChatReference { 4 | id: string; 5 | value: unknown; 6 | } 7 | 8 | export interface ChatMessage { 9 | response: readonly ( 10 | | vscode.ChatResponseMarkdownPart 11 | | vscode.ChatResponseFileTreePart 12 | | vscode.ChatResponseAnchorPart 13 | | vscode.ChatResponseCommandButtonPart 14 | )[]; 15 | } 16 | -------------------------------------------------------------------------------- /src/chatbot/utils/getChatHistory.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | /** 4 | * Builds chat history from previous chat response turns 5 | * @param history Array of ChatResponseTurn objects from previous interactions 6 | * @returns Array of LanguageModelChatMessages for the AI model 7 | */ 8 | 9 | function getChatHistory( 10 | history: readonly vscode.ChatResponseTurn[] 11 | ): vscode.LanguageModelChatMessage[] { 12 | return ( 13 | history 14 | // Ensure we only process ChatResponseTurn instances 15 | .filter((h) => h instanceof vscode.ChatResponseTurn) 16 | .map((m) => { 17 | // Combine all markdown parts into a single message 18 | let fullMessage = ""; 19 | m.response.forEach((r) => { 20 | const mdPart = r as vscode.ChatResponseMarkdownPart; 21 | fullMessage += mdPart.value.value; 22 | }); 23 | return vscode.LanguageModelChatMessage.Assistant(fullMessage); 24 | }) 25 | ); 26 | } 27 | 28 | export default getChatHistory; 29 | -------------------------------------------------------------------------------- /src/chatbot/utils/getContext.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ChatReference } from "../types"; 3 | 4 | /** 5 | * Reads the entire contents of a file from a given URI. 6 | * @param uri The vscode.Uri to read. 7 | * @returns The file content in a string, or an empty string if an error occurred. 8 | */ 9 | async function readFileContents(uri: vscode.Uri): Promise { 10 | try { 11 | const document = await vscode.workspace.openTextDocument(uri); 12 | return document.getText(); 13 | } catch (error) { 14 | console.error(`Error reading file ${uri.fsPath}:`, error); 15 | return ""; 16 | } 17 | } 18 | 19 | /** 20 | * Retrieves file paths from the entire workspace. 21 | * In the future, you might add filtering (by file extension, by folder, etc.), 22 | * or limit the number of files for performance reasons. 23 | * 24 | * @returns Promise resolving to an array of vscode.Uri representing all files in the workspace. 25 | */ 26 | async function getAllWorkspaceFiles(): Promise { 27 | const allUris: vscode.Uri[] = []; 28 | const workspaceFolders = vscode.workspace.workspaceFolders; 29 | 30 | if (!workspaceFolders) { 31 | return allUris; 32 | } 33 | 34 | // For each workspace folder, use `vscode.workspace.findFiles` 35 | // to get all file URIs. Adjust the glob pattern if you want 36 | // to filter certain files or directories. 37 | for (const folder of workspaceFolders) { 38 | const uris = await vscode.workspace.findFiles( 39 | new vscode.RelativePattern(folder, "**/*") 40 | ); 41 | allUris.push(...uris); 42 | } 43 | 44 | return allUris; 45 | } 46 | 47 | /** 48 | * Builds context from referenced files in a chat request. 49 | * If no references are provided (or you decide to always include them), 50 | * you can optionally gather all workspace files as a fallback. 51 | * 52 | * @param references Array of ChatReference objects containing file URIs or viewport data 53 | * @returns Promise resolving to a string containing the formatted file contents 54 | */ 55 | export async function getFileContext( 56 | references: readonly ChatReference[] 57 | ): Promise { 58 | // If references are empty, you can either return a message or fallback to retrieving entire workspace: 59 | if (!references || references.length === 0) { 60 | // Uncomment below if you want to gather entire workspace as a fallback: 61 | // return getEntireWorkspaceContext(); 62 | 63 | return "No files attached"; 64 | } 65 | 66 | // Map each reference to a promise that resolves to formatted file contents 67 | const fileContentsPromises = references.map(async (ref) => { 68 | let content = ""; 69 | 70 | try { 71 | if (ref.id === "vscode.implicit.viewport") { 72 | // Visible editor content (viewport references) 73 | const value = ref.value as { uri: vscode.Uri }; 74 | content = await readFileContents(value.uri); 75 | return formatFileContent(value.uri.fsPath, content); 76 | } else { 77 | // Direct file URI references 78 | const uri = ref.value as vscode.Uri; 79 | content = await readFileContents(uri); 80 | return formatFileContent(uri.fsPath, content); 81 | } 82 | } catch (error) { 83 | console.error(`Error processing reference ${ref.id}:`, error); 84 | return ""; 85 | } 86 | }); 87 | 88 | try { 89 | const contents = await Promise.all(fileContentsPromises); 90 | const merged = contents.filter(Boolean).join("\n"); 91 | return merged || "No files attached"; 92 | } catch (error) { 93 | console.error("Error processing files:", error); 94 | return "Error processing files"; 95 | } 96 | } 97 | 98 | /** 99 | * Helper to wrap file path and contents in the desired markup 100 | */ 101 | function formatFileContent(path: string, fileText: string): string { 102 | return `\n${path}\n${fileText}\n`; 103 | } 104 | 105 | /** 106 | * Example: Gathering entire workspace context (all files). 107 | * This is just one approach. You might want to: 108 | * - Use a maximum limit of files or total size (for performance). 109 | * - Filter out certain files by extension or folder. 110 | */ 111 | export async function getEntireWorkspaceContext(): Promise { 112 | try { 113 | const allUris = await getAllWorkspaceFiles(); 114 | if (!allUris.length) { 115 | return "No files found in workspace."; 116 | } 117 | 118 | // Potentially limit files if you don't want to read the entire workspace: 119 | // allUris = allUris.slice(0, SOME_MAX_FILES); 120 | 121 | const contentPromises = allUris.map(async (uri) => { 122 | const fileText = await readFileContents(uri); 123 | if (!fileText) { 124 | return ""; 125 | } 126 | return formatFileContent(uri.fsPath, fileText); 127 | }); 128 | 129 | const allContents = await Promise.all(contentPromises); 130 | const merged = allContents.filter(Boolean).join("\n"); 131 | console.log(`🟢 Has context for ${allContents.length} files`); 132 | return merged || "No file contents found in workspace."; 133 | } catch (error) { 134 | console.error("Error gathering workspace context:", error); 135 | return "Error gathering workspace context."; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SEQERA_PLATFORM_URL = `https://cloud.seqera.io`; 2 | export const SEQERA_API_URL = `${SEQERA_PLATFORM_URL}/api`; 3 | export const SEQERA_HUB_API_URL = `https://hub.seqera.io`; 4 | export const SEQERA_INTERN_API_URL = `https://intern.seqera.io`; 5 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import { activateChatbot } from "./chatbot"; 4 | import { activateLanguageServer, stopLanguageServer } from "./languageServer"; 5 | import { activateWelcomePage } from "./welcomePage"; 6 | import { activateTelemetry, deactivateTelemetry } from "./telemetry"; 7 | import { activateWebview } from "./webview"; 8 | import { activateAuth, AuthProvider } from "./auth"; 9 | 10 | export function activate(context: vscode.ExtensionContext) { 11 | const trackEvent = activateTelemetry(context); 12 | const authProvider = new AuthProvider(context); 13 | activateAuth(context, authProvider); 14 | activateChatbot(context, trackEvent); 15 | activateLanguageServer(context, trackEvent); 16 | activateWebview(context, authProvider); 17 | activateWelcomePage(context); 18 | } 19 | 20 | export function deactivate( 21 | context: vscode.ExtensionContext 22 | ): Promise<[void, void]> { 23 | return Promise.all([deactivateTelemetry(context), stopLanguageServer()]); 24 | } 25 | -------------------------------------------------------------------------------- /src/languageServer/utils/fetchLanguageServer.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as os from "os"; 3 | import * as path from "path"; 4 | import * as vscode from "vscode"; 5 | 6 | async function getLatestRemoteVersion( 7 | versionPrefix: string 8 | ): Promise { 9 | try { 10 | const url = `https://api.github.com/repos/nextflow-io/language-server/releases`; 11 | const headers: Record = { 12 | Accept: "application/vnd.github.v3+json" 13 | }; 14 | const token = await getGitHubToken(); 15 | if (token) { 16 | headers.Authorization = `Bearer ${token}`; 17 | } 18 | const response = await fetch(url, { headers }); 19 | if (!response.ok) { 20 | return null; 21 | } 22 | const releases = await response.json(); 23 | const matchingReleases = (releases as any[]) 24 | .map((release) => release.tag_name) 25 | .filter((tag) => tag.startsWith(versionPrefix)) 26 | .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); 27 | return matchingReleases.length > 0 ? matchingReleases[0] : null; 28 | } catch (error) { 29 | return null; 30 | } 31 | } 32 | 33 | async function getGitHubToken(): Promise { 34 | try { 35 | const session = await vscode.authentication.getSession("github", ["repo"], { silent: true }); 36 | if (session?.accessToken) { 37 | return session.accessToken; 38 | } 39 | } catch (error) { 40 | } 41 | return process.env.GITHUB_TOKEN; 42 | } 43 | 44 | async function getLatestLocalVersion( 45 | versionPrefix: string 46 | ): Promise { 47 | const targetDir = path.join(os.homedir(), ".nextflow", "lsp", versionPrefix); 48 | try { 49 | const files = await vscode.workspace.fs.readDirectory( 50 | vscode.Uri.file(targetDir) 51 | ); 52 | const jarFiles = files 53 | .map(([name]) => name) 54 | .filter((name) => name.endsWith(".jar")) 55 | .map((name) => name.replace(".jar", "")) 56 | .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); 57 | return jarFiles.length > 0 ? jarFiles[0] : null; 58 | } catch (error) { 59 | return null; 60 | } 61 | } 62 | 63 | export async function fetchLanguageServer(context: vscode.ExtensionContext) { 64 | // use development build if present 65 | const devPath = path.resolve( 66 | context.extensionPath, 67 | "bin", 68 | "language-server-all.jar" 69 | ); 70 | if (fs.existsSync(devPath)) { 71 | vscode.window.showInformationMessage( 72 | "Using development build of language server." 73 | ); 74 | return devPath; 75 | } 76 | 77 | // get the latest patch release from GitHub or local cache 78 | const languageVersion = vscode.workspace 79 | .getConfiguration("nextflow") 80 | .get("languageVersion") as string; 81 | const versionPrefix = `v${languageVersion}`; 82 | let resolvedVersion = await getLatestRemoteVersion(versionPrefix); 83 | if (!resolvedVersion) { 84 | resolvedVersion = await getLatestLocalVersion(versionPrefix); 85 | if (resolvedVersion) { 86 | vscode.window.showInformationMessage( 87 | `Failed to query latest version of language server from GitHub -- using version ${resolvedVersion} from local cache.` 88 | ); 89 | } 90 | } 91 | if (!resolvedVersion) { 92 | return null; 93 | } 94 | 95 | // use locally cached version if present 96 | const targetDir = path.join(os.homedir(), ".nextflow", "lsp", versionPrefix); 97 | const cachePath = path.join(targetDir, `${resolvedVersion}.jar`); 98 | if (fs.existsSync(cachePath)) { 99 | return cachePath; 100 | } 101 | 102 | // download latest patch release to local cache 103 | const response = await fetch( 104 | `https://github.com/nextflow-io/language-server/releases/download/${resolvedVersion}/language-server-all.jar` 105 | ); 106 | if (!response.ok) { 107 | return null; 108 | } 109 | const arrayBuffer = await response.arrayBuffer(); 110 | await vscode.workspace.fs.createDirectory(vscode.Uri.file(targetDir)); 111 | const fileUri = vscode.Uri.file(cachePath); 112 | await vscode.workspace.fs.writeFile(fileUri, new Uint8Array(arrayBuffer)); 113 | vscode.window.showInformationMessage( 114 | `Downloaded Nextflow language server ${resolvedVersion}.` 115 | ); 116 | return fileUri.fsPath; 117 | } 118 | -------------------------------------------------------------------------------- /src/languageServer/utils/findJava.ts: -------------------------------------------------------------------------------- 1 | import * as cp from 'child_process'; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import * as semver from 'semver'; 5 | import * as vscode from "vscode"; 6 | 7 | function isFile(javaPath: string): boolean { 8 | return fs.existsSync(javaPath) && fs.statSync(javaPath).isFile(); 9 | } 10 | 11 | export function findJava(): string | null { 12 | const executableFile: string = (process["platform"] === "win32") 13 | ? "java.exe" 14 | : "java"; 15 | 16 | const settingsJavaHome = vscode.workspace 17 | .getConfiguration("nextflow") 18 | .get("java.home"); 19 | if (settingsJavaHome) { 20 | const javaPath = path.join(settingsJavaHome, "bin", executableFile); 21 | if (isFile(javaPath)) { 22 | return javaPath; 23 | } 24 | return null; 25 | } 26 | 27 | if ("JAVA_HOME" in process.env) { 28 | const javaHome = process.env.JAVA_HOME as string; 29 | const javaPath = path.join(javaHome, "bin", executableFile); 30 | if (isFile(javaPath)) { 31 | return javaPath; 32 | } 33 | } 34 | 35 | if ("PATH" in process.env) { 36 | const PATH = process.env.PATH as string; 37 | const paths = PATH.split(path.delimiter); 38 | const pathCount = paths.length; 39 | for (let i = 0; i < pathCount; i++) { 40 | const javaPath = path.join(paths[i], executableFile); 41 | if (isFile(javaPath)) { 42 | return javaPath; 43 | } 44 | } 45 | } 46 | 47 | return null; 48 | } 49 | 50 | export function checkJavaVersion(javaPath: string): boolean { 51 | const output = cp.execSync(`${javaPath} -version 2>&1`, { encoding: 'utf8' }); 52 | const match = output.match(/version "(.*?)"/); 53 | if (!match || match.length < 2) { 54 | throw new Error('Could not parse Java version'); 55 | } 56 | 57 | const versionString = match[1]; 58 | const version = versionString.startsWith('1.') 59 | ? versionString.replace(/^1\./, '') // e.g. "1.8.0" → "8.0" 60 | : versionString; 61 | 62 | const coerced = semver.coerce(version); 63 | if (!coerced) { 64 | throw new Error(`Invalid Java version format: ${coerced}`); 65 | } 66 | 67 | return semver.gte(coerced, '17.0.0'); 68 | } 69 | -------------------------------------------------------------------------------- /src/telemetry/index.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from "crypto"; 2 | import { PostHog } from "posthog-node"; 3 | import * as vscode from "vscode"; 4 | 5 | import { showPage } from "../welcomePage"; 6 | 7 | export type TrackEvent = ( 8 | eventName: string, 9 | properties?: { [key: string]: any } 10 | ) => void; 11 | 12 | const POSTHOG_API_KEY = "phc_pCt2zPQylp5x5dEKMB3TLM2hKBp7aLajUBgAfysPnpd"; 13 | const POSTHOG_API_HOST = "https://eu.i.posthog.com"; 14 | 15 | let posthogClient: PostHog | undefined; 16 | 17 | export function activateTelemetry(context: vscode.ExtensionContext) { 18 | // Prompt for telemetry consent on the first time 19 | const hasPromptedConsent = context.globalState.get("hasPromptedConsent"); 20 | if (!hasPromptedConsent) { 21 | promptTelemetryConsent().then(() => { 22 | context.globalState.update("hasPromptedConsent", true); 23 | }); 24 | } 25 | 26 | // Create event tracker 27 | const trackEvent = createTrackEvent(context); 28 | 29 | // Track consent if accepted 30 | trackEvent("telemetryConsent", { 31 | accepted: true 32 | }); 33 | 34 | // Track environment info 35 | const osPlatform = process.platform; 36 | const vscodeVersion = vscode.version; 37 | const extensionVersion = context.extension.packageJSON.version ?? "unknown"; 38 | 39 | trackEvent("extensionActivated", { 40 | extensionVersion, 41 | vscodeVersion, 42 | osPlatform 43 | }); 44 | 45 | // Track file open events 46 | const trackFileOpens = vscode.workspace.onDidOpenTextDocument((document) => { 47 | const fileName = document.fileName.toLowerCase(); 48 | const fileType = 49 | fileName.endsWith(".nf.test") ? ".nf.test" 50 | : fileName.endsWith(".nf") ? ".nf" 51 | : fileName.endsWith(".config") ? ".config" 52 | : undefined; 53 | if (fileType) { 54 | trackEvent("fileOpened", { fileType }); 55 | } 56 | }); 57 | context.subscriptions.push(trackFileOpens); 58 | 59 | return trackEvent; 60 | } 61 | 62 | async function promptTelemetryConsent(): Promise { 63 | const choice = await vscode.window.showInformationMessage( 64 | "Nextflow: Enable telemetry to help us improve the extension?", 65 | "Yes", 66 | "No", 67 | "More info" 68 | ); 69 | const config = vscode.workspace.getConfiguration("nextflow"); 70 | 71 | if (choice === "Yes") { 72 | await config.update("telemetry.enabled", true); 73 | } else if (choice === "No") { 74 | await config.update("telemetry.enabled", false); 75 | } else if (choice === "More info") { 76 | showPage(); 77 | await sleep(3000); 78 | await promptTelemetryConsent(); 79 | } 80 | } 81 | 82 | function sleep(ms: number) { 83 | return new Promise((resolve) => setTimeout(resolve, ms)); 84 | } 85 | 86 | function createTrackEvent(context: vscode.ExtensionContext) { 87 | return async (eventName: string, properties = {}) => { 88 | // skip if telemetry is disabled 89 | if (!isTelemetryEnabled()) return; 90 | 91 | // create posthog client if needed 92 | if (!posthogClient) { 93 | posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_API_HOST }); 94 | } 95 | 96 | // send telemetry event 97 | try { 98 | posthogClient.capture({ 99 | distinctId: getUserId(context), 100 | event: eventName, 101 | properties: { 102 | ...properties, 103 | time: new Date().toISOString() 104 | } 105 | }); 106 | } catch (err) { 107 | console.error("Failed to send telemetry event", err); 108 | } 109 | }; 110 | } 111 | 112 | function isTelemetryEnabled(): boolean { 113 | const globalTelemetryLevel = vscode.workspace 114 | .getConfiguration("telemetry") 115 | .get("telemetryLevel", "all"); 116 | const enabled = vscode.workspace 117 | .getConfiguration("nextflow") 118 | .get("telemetry.enabled", false); 119 | return globalTelemetryLevel !== "off" && enabled; 120 | } 121 | 122 | function getUserId(context: vscode.ExtensionContext): string { 123 | let anonId = context.globalState.get("anonId"); 124 | if (!anonId) { 125 | anonId = randomBytes(6).toString("hex"); 126 | context.globalState.update("anonId", anonId); 127 | } 128 | return anonId; 129 | } 130 | 131 | export function deactivateTelemetry(context: vscode.ExtensionContext): Thenable { 132 | if (!isTelemetryEnabled() || !posthogClient) { 133 | return Promise.resolve(); 134 | } 135 | posthogClient.capture({ 136 | distinctId: getUserId(context), 137 | event: "extensionDeactivated" 138 | }); 139 | return posthogClient.shutdown(); 140 | } 141 | -------------------------------------------------------------------------------- /src/webview/ResourcesProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export class ResourceItem extends vscode.TreeItem { 4 | constructor( 5 | public readonly label: string, 6 | public readonly url?: string, 7 | public readonly command?: vscode.Command, 8 | public readonly collapsibleState: vscode.TreeItemCollapsibleState = vscode 9 | .TreeItemCollapsibleState.None 10 | ) { 11 | super(label, collapsibleState); 12 | this.tooltip = `${this.label}`; 13 | 14 | if (url) { 15 | this.description = url; 16 | this.command = { 17 | command: "vscode.open", 18 | title: "Open Resource", 19 | arguments: [vscode.Uri.parse(url)] 20 | }; 21 | this.iconPath = new vscode.ThemeIcon("link-external"); 22 | } else if (command) { 23 | this.command = command; 24 | this.iconPath = new vscode.ThemeIcon("comment-discussion"); 25 | } 26 | } 27 | } 28 | 29 | class ResourcesProvider implements vscode.TreeDataProvider { 30 | private _onDidChangeTreeData: vscode.EventEmitter< 31 | ResourceItem | undefined | null | void 32 | > = new vscode.EventEmitter(); 33 | readonly onDidChangeTreeData: vscode.Event< 34 | ResourceItem | undefined | null | void 35 | > = this._onDidChangeTreeData.event; 36 | 37 | private resources = [ 38 | { 39 | label: "Nextflow Training & Getting Started", 40 | url: "https://training.nextflow.io/latest/" 41 | }, 42 | { 43 | label: "Nextflow Documentation", 44 | url: "https://nextflow.io/docs/latest/" 45 | }, 46 | { 47 | label: "Seqera Feedback Forum", 48 | url: "https://feedback.seqera.io/" 49 | }, 50 | { 51 | label: "Seqera Community Forum", 52 | url: "https://community.seqera.io/" 53 | }, 54 | { 55 | label: "Seqera Cloud Documentation", 56 | url: "https://docs.seqera.io/" 57 | }, 58 | { 59 | label: "Seqera AI", 60 | url: "https://ai.seqera.io/" 61 | }, 62 | { 63 | label: "Open Seqera Copilot", 64 | command: "nextflow.chatbot.openChat" 65 | } 66 | ]; 67 | 68 | constructor() {} 69 | 70 | refresh(): void { 71 | this._onDidChangeTreeData.fire(); 72 | } 73 | 74 | getTreeItem(element: ResourceItem): vscode.TreeItem { 75 | return element; 76 | } 77 | 78 | getChildren(element?: ResourceItem): Thenable { 79 | if (element) { 80 | return Promise.resolve([]); 81 | } else { 82 | return Promise.resolve( 83 | this.resources.map((resource) => { 84 | if (resource.url) { 85 | return new ResourceItem(resource.label, resource.url); 86 | } else if (resource.command) { 87 | return new ResourceItem(resource.label, undefined, { 88 | command: resource.command, 89 | title: resource.label, 90 | arguments: [] 91 | }); 92 | } 93 | return new ResourceItem(resource.label); 94 | }) 95 | ); 96 | } 97 | } 98 | } 99 | 100 | export default ResourcesProvider; 101 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as fetchPlatformData } from "./platform/fetchPlatformData"; 2 | export { default as getAuthState } from "./platform/getAuthState"; 3 | export * from "./platform/utils"; 4 | 5 | export { queryWorkspace } from "./workspace/queryWorkspace"; 6 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/fetchHubPipelines.ts: -------------------------------------------------------------------------------- 1 | import type { HubPipeline } from "./hubTypes"; 2 | 3 | import { SEQERA_HUB_API_URL } from "../../../../constants"; 4 | 5 | const fetchPipelines = async (): Promise => { 6 | const response = await fetch(`${SEQERA_HUB_API_URL}/pipelines`, { 7 | credentials: "include", 8 | method: "GET", 9 | headers: new Headers({ "content-type": "application/json" }) 10 | }); 11 | 12 | const data = await response.json(); 13 | const { status } = response; 14 | 15 | if (status !== 200) { 16 | console.error(status, data); 17 | return []; 18 | } 19 | 20 | return data as HubPipeline[]; 21 | }; 22 | 23 | export default fetchPipelines; 24 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/fetchPlatformData.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext, WebviewView } from "vscode"; 2 | import { fetchUserInfo, fetchWorkspaces } from "./utils"; 3 | import { debounce } from "../utils"; 4 | 5 | import { Workspace, UserInfoResponse } from "./types"; 6 | import getAuthState, { AuthState } from "./getAuthState"; 7 | import { expired } from "../../../../auth/AuthProvider/utils/jwt"; 8 | 9 | type PlatformData = { 10 | authState: AuthState; 11 | userInfo?: UserInfoResponse; 12 | workspaces?: Workspace[]; 13 | }; 14 | 15 | function handleUpdate( 16 | data: PlatformData, 17 | context: ExtensionContext, 18 | view?: WebviewView["webview"] 19 | ) { 20 | const vsCodeState = context.workspaceState; 21 | vsCodeState.update("platformData", data); 22 | view?.postMessage(data); 23 | } 24 | 25 | const fetchPlatformData = async ( 26 | accessToken: string, 27 | view: WebviewView["webview"] | undefined, 28 | context: ExtensionContext, 29 | refresh?: boolean 30 | ): Promise => { 31 | const wsState = context.workspaceState; 32 | const savedState = wsState.get("platformData") as PlatformData | undefined; 33 | const savedAuth = savedState?.authState as AuthState | undefined; 34 | let hasExpired = expired(savedAuth?.tokenExpiry); 35 | 36 | if (!hasExpired && !refresh) { 37 | view?.postMessage(savedState); 38 | return savedState as PlatformData; 39 | } 40 | 41 | const authState = await getAuthState(accessToken); 42 | hasExpired = expired(authState?.tokenExpiry); 43 | 44 | let data: PlatformData = { 45 | authState 46 | }; 47 | 48 | if (hasExpired) { 49 | handleUpdate(data, context, view); 50 | return data; 51 | } 52 | 53 | const userInfo = await fetchUserInfo(accessToken); 54 | 55 | if (!userInfo.user) { 56 | if (userInfo.message) data.authState.error = userInfo.message; 57 | handleUpdate(data, context, view); 58 | return data; 59 | } 60 | 61 | const workspaces = await fetchWorkspaces(accessToken, userInfo.user.id); 62 | 63 | data = { 64 | ...data, 65 | userInfo, 66 | workspaces 67 | }; 68 | 69 | handleUpdate(data, context, view); 70 | 71 | return data; 72 | }; 73 | 74 | const debouncedFetchPlatformData = debounce(fetchPlatformData, 100); 75 | 76 | export default debouncedFetchPlatformData; 77 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/getAuthState.ts: -------------------------------------------------------------------------------- 1 | import { jwtExpired, decodeJWT } from "../../../../auth/AuthProvider/utils/jwt"; 2 | 3 | export type AuthState = { 4 | hasToken: boolean; 5 | tokenExpired: boolean; 6 | tokenExpiry: number; 7 | isAuthenticated: boolean; 8 | error: string; 9 | }; 10 | 11 | const getAuthState = async (token?: string): Promise => { 12 | const hasToken = !!token; 13 | let tokenExpired = false; 14 | let tokenExpiry: any = 0; 15 | if (typeof token === "string") { 16 | const decoded = decodeJWT(token); 17 | tokenExpiry = decoded.exp; 18 | tokenExpired = jwtExpired(token); 19 | } 20 | const isAuthenticated = hasToken && !tokenExpired; 21 | const error = ""; 22 | return { hasToken, tokenExpired, tokenExpiry, isAuthenticated, error }; 23 | }; 24 | 25 | export default getAuthState; 26 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/hubTypes.ts: -------------------------------------------------------------------------------- 1 | export type HubPipeline = { 2 | id: number; 3 | latest_release_at: string; 4 | description: string; 5 | watchers: number; 6 | nextflow_main_lang: boolean; 7 | nf_files_in_root: string[]; 8 | contributors: number; 9 | nextflow_code_chars: number; 10 | nf_files_in_subfolders: string[]; 11 | created_at: string; 12 | forks: number; 13 | slugified_name: string; 14 | url: string; 15 | main_nf_file: string; 16 | updated_at: string; 17 | head_fork: null; 18 | readme_name: null; 19 | revision: string; 20 | alive: boolean; 21 | last_commit_at: string; 22 | open_issues: number; 23 | readme_contains_nextflow: null; 24 | highlighted: boolean; 25 | website: string; 26 | open_prs: number; 27 | hidden: boolean; 28 | stars: number; 29 | default_branch: string; 30 | name: string; 31 | languages: { 32 | Nextflow: number; 33 | Groovy: number; 34 | Python: number; 35 | R: number; 36 | Perl: number; 37 | HTML: number; 38 | }; 39 | owner: string; 40 | topics: string[]; 41 | launch_config: LaunchConfig; 42 | }; 43 | 44 | export type LaunchConfig = { 45 | workspaceId: string; 46 | computeEnvId: string; 47 | workDir: string; 48 | pipeline: string; 49 | revision: string; 50 | }; 51 | 52 | export type AddPipelineRequest = { 53 | name: string; 54 | description: string; 55 | launch: LaunchConfig; 56 | }; 57 | 58 | export type AddPipelineResponse = { 59 | pipeline?: { 60 | computeEnv: null; 61 | deleted: boolean; 62 | description: string; 63 | icon: string; 64 | labels: null; 65 | lastUpdated: string; 66 | name: string; 67 | optimizationId: null; 68 | optimizationStatus: null; 69 | optimizationTargets: null; 70 | orgId: number; 71 | orgName: string; 72 | pipelineId: number; 73 | repository: string; 74 | userFirstName: string | null; 75 | userId: number; 76 | userLastName: string | null; 77 | userName: string; 78 | visibility: string; 79 | workspaceId: number; 80 | workspaceName: string; 81 | }; 82 | message?: string; 83 | }; 84 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/addPipeline.ts: -------------------------------------------------------------------------------- 1 | import { SEQERA_API_URL } from "../../../../../constants"; 2 | import { AddPipelineRequest } from "../hubTypes"; 3 | 4 | type Message = { 5 | requestBody: AddPipelineRequest; 6 | }; 7 | 8 | const addPipeline = async ( 9 | accessToken: string, 10 | message: Message 11 | ): Promise => { 12 | const { requestBody } = message; 13 | const { workspaceId } = requestBody.launch; 14 | console.log("🟢 addPipeline", requestBody); 15 | 16 | return await fetch(`${SEQERA_API_URL}/pipelines?workspaceId=${workspaceId}`, { 17 | credentials: "include", 18 | method: "POST", 19 | headers: new Headers({ 20 | "Content-Type": "application/json", 21 | Authorization: `Bearer ${accessToken}` 22 | }), 23 | body: JSON.stringify(requestBody) 24 | }); 25 | }; 26 | 27 | export default addPipeline; 28 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/createTest/fetchContent.ts: -------------------------------------------------------------------------------- 1 | import { SEQERA_INTERN_API_URL } from "../../../../../../constants"; 2 | import { systemPrompt } from "./prompt"; 3 | 4 | async function fetchContent( 5 | prompt: string, 6 | token: string, 7 | onChunk?: (chunk: string) => void, 8 | tags = ["test-generation"] 9 | ): Promise { 10 | try { 11 | const fullPrompt = `:::details\n\n${systemPrompt}\n\n${prompt}\n\n:::\n\n`; 12 | const url = `${SEQERA_INTERN_API_URL}/internal-ai/query`; 13 | 14 | const response = await fetch(url, { 15 | credentials: "include", 16 | method: "POST", 17 | headers: { 18 | "Content-Type": "application/json", 19 | Authorization: `Bearer ${token}` 20 | }, 21 | body: JSON.stringify({ 22 | message: fullPrompt, 23 | stream: true, 24 | tags, 25 | title: tags[0] 26 | }) 27 | }); 28 | 29 | // If it's not a stream response, try to get the error message 30 | if ( 31 | !response.ok && 32 | response.headers.get("content-type")?.includes("application/json") 33 | ) { 34 | const errorData = await response.json(); 35 | console.log("🟢 fetchContent error response:", errorData); 36 | } 37 | 38 | if (!response.ok) { 39 | throw new Error(`HTTP error! status: ${response.status}`); 40 | } 41 | 42 | const reader = response.body?.getReader(); 43 | if (!reader) { 44 | throw new Error("No reader available"); 45 | } 46 | 47 | let fullResponse = ""; 48 | while (true) { 49 | const { done, value } = await reader.read(); 50 | if (done) break; 51 | 52 | // Convert the chunk to text 53 | const chunk = new TextDecoder().decode(value); 54 | 55 | // Parse the SSE data 56 | const lines = chunk.split("\n"); 57 | for (const line of lines) { 58 | if (line.startsWith("data: ")) { 59 | try { 60 | const data = JSON.parse(line.slice(6)); 61 | if (data.content) { 62 | fullResponse += data.content; 63 | // Call the onChunk callback if provided 64 | if (onChunk) { 65 | onChunk(data.content); 66 | } 67 | } 68 | } catch (e) { 69 | console.log("🟢 Error parsing SSE data:", e); 70 | } 71 | } 72 | } 73 | } 74 | 75 | return fullResponse; 76 | } catch (error) { 77 | console.log("🟢 fetchContent error:", error); 78 | throw error; 79 | } 80 | } 81 | 82 | export default fetchContent; 83 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/createTest/generateTest.ts: -------------------------------------------------------------------------------- 1 | import fetchContent from "./fetchContent"; 2 | import { getPrompt } from "./prompt"; 3 | 4 | async function generateTest( 5 | content: string, 6 | token: string, 7 | onChunk?: (chunk: string) => void 8 | ): Promise { 9 | const initialPrompt = getPrompt(content); 10 | 11 | try { 12 | const response = await fetchContent(initialPrompt, token, onChunk); 13 | return response; 14 | } catch (error) { 15 | console.error("🟠 Error generating nf-test:", error); 16 | throw error; 17 | } 18 | } 19 | 20 | export default generateTest; 21 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/createTest/generateValidation.ts: -------------------------------------------------------------------------------- 1 | import { getValidationPrompt } from "./prompt"; 2 | import fetchContent from "./fetchContent"; 3 | 4 | async function generateValidation( 5 | content: string, 6 | token: string, 7 | onChunk?: (chunk: string) => void 8 | ) { 9 | try { 10 | const prompt = getValidationPrompt(content); 11 | const response = await fetchContent(prompt, token, onChunk); 12 | return response; 13 | } catch (error) { 14 | return "Error validating test file. Please check manually."; 15 | } 16 | } 17 | 18 | export default generateValidation; 19 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/createTest/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import generateTest from "./generateTest"; 5 | import generateValidation from "./generateValidation"; 6 | import { appendToFile } from "./utils"; 7 | 8 | function getTestPath(filePath: string): string { 9 | const dirName = path.dirname(filePath); 10 | const testDir = path.join(dirName, "tests"); 11 | if (!fs.existsSync(testDir)) { 12 | fs.mkdirSync(testDir); 13 | } 14 | const baseName = path.basename(filePath); 15 | return path.join(testDir, baseName.replace(".nf", ".nf.test")); 16 | } 17 | 18 | async function createTest(filePath: string, token = ""): Promise { 19 | return vscode.window.withProgress( 20 | { 21 | location: vscode.ProgressLocation.Notification, 22 | title: "Creating nf-test", 23 | cancellable: false 24 | }, 25 | async (progress) => { 26 | try { 27 | progress.report({ message: "Reading file contents" }); 28 | const content = fs.readFileSync(filePath, "utf8"); 29 | const testPath = getTestPath(filePath); 30 | const uri = vscode.Uri.file(testPath); 31 | 32 | // Create new file 33 | progress.report({ message: "Creating test file" }); 34 | const createEdit = new vscode.WorkspaceEdit(); 35 | createEdit.createFile(uri, { ignoreIfExists: true }); 36 | const createSuccess = await vscode.workspace.applyEdit(createEdit); 37 | if (!createSuccess) { 38 | vscode.window.showErrorMessage("Failed to create test file"); 39 | return false; 40 | } 41 | 42 | // Open the file 43 | const document = await vscode.workspace.openTextDocument(uri); 44 | const editor = await vscode.window.showTextDocument(document); 45 | 46 | // Generate test code 47 | progress.report({ message: "Generating code (streaming to file)" }); 48 | let generatedContent = ""; 49 | await generateTest(content, token, async (chunk) => { 50 | generatedContent += chunk; 51 | await appendToFile(uri, document, editor, generatedContent); 52 | }); 53 | 54 | // Save 55 | await document.save(); 56 | 57 | // Validate & fix the test 58 | progress.report({ message: "Validating test file" }); 59 | await generateValidation(generatedContent, token, async (chunk) => { 60 | generatedContent += chunk; 61 | await appendToFile(uri, document, editor, generatedContent); 62 | }); 63 | 64 | // Save 65 | await document.save(); 66 | 67 | vscode.window.showInformationMessage(`nf-test created: ${testPath}`); 68 | 69 | return true; 70 | } catch (error: any) { 71 | const isAuthError = 72 | error?.message?.includes("401") || 73 | error?.message?.includes("403") || 74 | error?.message?.includes("Unauthorized"); 75 | 76 | if (isAuthError) { 77 | return handleAuthError(); 78 | } else { 79 | vscode.window.showErrorMessage( 80 | `Failed to generate nf-test: ${error?.message}` 81 | ); 82 | return false; 83 | } 84 | } 85 | } 86 | ); 87 | } 88 | 89 | async function handleAuthError(): Promise { 90 | const loginAction = "Login to Seqera Cloud"; 91 | const result = await vscode.window.showInformationMessage( 92 | "Authentication required to generate nf-test. Please login to continue.", 93 | loginAction 94 | ); 95 | 96 | if (result === loginAction) { 97 | await vscode.commands.executeCommand("nextflow.seqera.login"); 98 | } 99 | return false; 100 | } 101 | 102 | export default createTest; 103 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/createTest/utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export function scrollToEnd( 4 | editor: vscode.TextEditor, 5 | document: vscode.TextDocument 6 | ) { 7 | if (editor) { 8 | const lastLine = document.lineCount - 1; 9 | const lastPosition = new vscode.Position( 10 | lastLine, 11 | document.lineAt(lastLine).text.length 12 | ); 13 | editor.revealRange( 14 | new vscode.Range(lastPosition, lastPosition), 15 | vscode.TextEditorRevealType.Default 16 | ); 17 | } 18 | } 19 | 20 | export async function appendToFile( 21 | uri: vscode.Uri, 22 | document: vscode.TextDocument, 23 | editor: vscode.TextEditor, 24 | content: string 25 | ) { 26 | const edit = new vscode.WorkspaceEdit(); 27 | edit.replace( 28 | uri, 29 | new vscode.Range( 30 | new vscode.Position(0, 0), 31 | document.lineAt(document.lineCount - 1).range.end 32 | ), 33 | content 34 | ); 35 | await vscode.workspace.applyEdit(edit); 36 | scrollToEnd(editor, document); 37 | } 38 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/fetchComputeEnvs.ts: -------------------------------------------------------------------------------- 1 | import { SEQERA_API_URL } from "../../../../../constants"; 2 | import type { WorkspaceID, ComputeEnv } from "../types"; 3 | 4 | const fetchComputeEnvs = async ( 5 | token: string, 6 | workspaceId: WorkspaceID 7 | ): Promise => { 8 | if (!token) return []; 9 | 10 | try { 11 | const url = `${SEQERA_API_URL}/compute-envs?workspaceId=${workspaceId}`; 12 | const res = await fetch(url, { 13 | credentials: "include", 14 | method: "GET", 15 | headers: new Headers({ 16 | "Content-Type": "application/json", 17 | Authorization: `Bearer ${token}` 18 | }) 19 | }); 20 | const data = (await res.json()) as { computeEnvs: ComputeEnv[] }; 21 | return data?.computeEnvs || ([] as ComputeEnv[]); 22 | } catch (e) { 23 | console.error(e); 24 | return []; 25 | } 26 | }; 27 | 28 | export default fetchComputeEnvs; 29 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/fetchDataLinks.ts: -------------------------------------------------------------------------------- 1 | import type { DataLink, DataLinksResponse } from "../types"; 2 | import { SEQERA_API_URL } from "../../../../../constants"; 3 | 4 | const fetchDataLinks = async ( 5 | token: string, 6 | workspaceId: number 7 | ): Promise => { 8 | if (!token) return []; 9 | try { 10 | const response = await fetch( 11 | `${SEQERA_API_URL}/data-links?workspaceId=${workspaceId}`, 12 | { 13 | credentials: "include", 14 | method: "GET", 15 | headers: new Headers({ 16 | "Content-Type": "application/json", 17 | Authorization: `Bearer ${token}` 18 | }) 19 | } 20 | ); 21 | console.log("🟢 fetchDataLinks", response.status); 22 | const res = (await response.json()) as DataLinksResponse; 23 | return res?.dataLinks || []; 24 | } catch (e) { 25 | console.error(e); 26 | return []; 27 | } 28 | }; 29 | 30 | export default fetchDataLinks; 31 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/fetchDatasets.ts: -------------------------------------------------------------------------------- 1 | import { SEQERA_API_URL } from "../../../../../constants"; 2 | import type { Dataset } from "../types"; 3 | 4 | const fetchDatasets = async ( 5 | token: string, 6 | workspaceId: number 7 | ): Promise => { 8 | if (!token) return []; 9 | try { 10 | const response = await fetch( 11 | `${SEQERA_API_URL}/datasets?workspaceId=${workspaceId}`, 12 | { 13 | credentials: "include", 14 | method: "GET", 15 | headers: new Headers({ 16 | "Content-Type": "application/json", 17 | Authorization: `Bearer ${token}` 18 | }) 19 | } 20 | ); 21 | console.log("🟢 fetchDatasets", response.status); 22 | const res = (await response.json()) as { datasets: Dataset[] }; 23 | return res?.datasets || []; 24 | } catch (e) { 25 | console.error(e); 26 | return []; 27 | } 28 | }; 29 | 30 | export default fetchDatasets; 31 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/fetchPipelines.ts: -------------------------------------------------------------------------------- 1 | import type { PipelinesResponse } from "../types"; 2 | import { SEQERA_API_URL } from "../../../../../constants"; 3 | 4 | const fetchPipelines = async ( 5 | token: string, 6 | workspaceId: number 7 | ): Promise => { 8 | if (!token) return { pipelines: [], totalSize: 0 }; 9 | try { 10 | const response = await fetch( 11 | `${SEQERA_API_URL}/pipelines?workspaceId=${workspaceId}`, 12 | { 13 | credentials: "include", 14 | method: "GET", 15 | headers: new Headers({ 16 | "Content-Type": "application/json", 17 | Authorization: `Bearer ${token}` 18 | }) 19 | } 20 | ); 21 | console.log("🟣 fetchPipelines", response.status); 22 | const res = (await response.json()) as PipelinesResponse; 23 | return res || { pipelines: [], totalSize: 0 }; 24 | } catch (e) { 25 | console.error(e); 26 | return { pipelines: [], totalSize: 0 }; 27 | } 28 | }; 29 | 30 | export default fetchPipelines; 31 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/fetchRuns.ts: -------------------------------------------------------------------------------- 1 | import type { RunsResponse } from "../types"; 2 | import { SEQERA_API_URL } from "../../../../../constants"; 3 | 4 | const fetchRuns = async ( 5 | token: string, 6 | workspaceId: number 7 | ): Promise => { 8 | if (!token) return { workflows: [], totalSize: 0 }; 9 | try { 10 | const response = await fetch( 11 | `${SEQERA_API_URL}/workflow?workspaceId=${workspaceId}`, 12 | { 13 | credentials: "include", 14 | method: "GET", 15 | headers: new Headers({ 16 | "Content-Type": "application/json", 17 | Authorization: `Bearer ${token}` 18 | }) 19 | } 20 | ); 21 | console.log("🟣 fetchRuns", response.status); 22 | const res = (await response.json()) as RunsResponse; 23 | return res || { workflows: [], totalSize: 0 }; 24 | } catch (e) { 25 | console.error(e); 26 | return { workflows: [], totalSize: 0 }; 27 | } 28 | }; 29 | 30 | export default fetchRuns; 31 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/fetchUserInfo.ts: -------------------------------------------------------------------------------- 1 | import { SEQERA_API_URL } from "../../../../../constants"; 2 | import type { UserInfoResponse } from "../types"; 3 | 4 | const fetchUserInfo = async (token: string): Promise => { 5 | if (!token) return { message: "No token found" } as UserInfoResponse; 6 | try { 7 | const response = await fetch(`${SEQERA_API_URL}/user-info`, { 8 | headers: { 9 | Authorization: `Bearer ${token}` 10 | } 11 | }); 12 | console.log("🟣 fetchUserInfo", response.status); 13 | if (response.status === 401) { 14 | throw new Error("Unauthorized"); 15 | } 16 | const res = (await response.json()) as UserInfoResponse; 17 | return res; 18 | } catch (error) { 19 | console.error(error); 20 | return { message: "Could not fetch user info" } as UserInfoResponse; 21 | } 22 | }; 23 | 24 | export default fetchUserInfo; 25 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/fetchWorkspaces.ts: -------------------------------------------------------------------------------- 1 | import type { Workspace } from "../types"; 2 | import { SEQERA_API_URL } from "../../../../../constants"; 3 | 4 | const fetchWorkspaces = async ( 5 | token: string, 6 | userID: number 7 | ): Promise => { 8 | if (!token) return []; 9 | try { 10 | const response = await fetch( 11 | `${SEQERA_API_URL}/user/${userID}/workspaces`, 12 | { 13 | credentials: "include", 14 | method: "GET", 15 | headers: new Headers({ 16 | "Content-Type": "application/json", 17 | Authorization: `Bearer ${token}` 18 | }) 19 | } 20 | ); 21 | console.log("🟣 fetchWorkspaces", response.status); 22 | const res = (await response.json()) as { 23 | orgsAndWorkspaces: Workspace[]; 24 | }; 25 | const workspaces = res.orgsAndWorkspaces || []; 26 | return workspaces.filter((ws: Workspace) => ws.orgName !== "community"); 27 | } catch (e) { 28 | console.error(e); 29 | return []; 30 | } 31 | }; 32 | 33 | export default fetchWorkspaces; 34 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/getContainer/generateRequirements.ts: -------------------------------------------------------------------------------- 1 | import fetchContent from "../createTest/fetchContent"; 2 | import { getPrompt } from "./prompt"; 3 | 4 | async function generateRequirements( 5 | content: string, 6 | token: string, 7 | onChunk?: (chunk: string) => void 8 | ): Promise { 9 | const prompt = getPrompt(content); 10 | 11 | try { 12 | const response = await fetchContent(prompt, token, onChunk, [ 13 | "wave-generation" 14 | ]); 15 | return response; 16 | } catch (error) { 17 | console.error("🟠 Error generating requirements:", error); 18 | throw error; 19 | } 20 | } 21 | 22 | export default generateRequirements; 23 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/getContainer/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as fs from "fs"; 3 | import generateRequirements from "./generateRequirements"; 4 | import { startBuild } from "./startBuild"; 5 | 6 | async function getContainer(filePath: string, token = ""): Promise { 7 | return vscode.window.withProgress( 8 | { 9 | location: vscode.ProgressLocation.Notification, 10 | cancellable: false 11 | }, 12 | async (progress) => { 13 | try { 14 | // Open the original file 15 | const originalFilePath = vscode.Uri.file(filePath); 16 | await vscode.workspace.openTextDocument(originalFilePath); 17 | 18 | const content = fs.readFileSync(filePath, "utf8"); 19 | 20 | // Find required packages 21 | progress.report({ message: "Seqera AI: Finding required packages" }); 22 | const generatedContent = await generateRequirements(content, token); 23 | 24 | // Start container build 25 | progress.report({ message: "Wave: Starting container build" }); 26 | const buildResult = await startBuild(generatedContent); 27 | 28 | if (buildResult.error) { 29 | vscode.window.showErrorMessage( 30 | `Wave: Failed to build container: ${buildResult.error}` 31 | ); 32 | return false; 33 | } 34 | 35 | const { buildId, containerImage } = buildResult; 36 | 37 | if (buildId) { 38 | const buildUrl = `https://wave.seqera.io/view/builds/${buildId}`; 39 | const openBuildAction = "See details"; 40 | 41 | if (containerImage) { 42 | progress.report({ message: "Wave: Container built" }); 43 | 44 | // Show URL 45 | await vscode.window.showInputBox({ 46 | value: containerImage, 47 | ignoreFocusOut: true, 48 | title: "Wave Image URL" 49 | }); 50 | 51 | // Copy to clipboard 52 | await vscode.env.clipboard.writeText(containerImage); 53 | 54 | // Show success message 55 | vscode.window 56 | .showInformationMessage( 57 | `Wave: Copied to clipboard`, 58 | openBuildAction 59 | ) 60 | .then((selection) => { 61 | if (selection === openBuildAction) { 62 | vscode.env.openExternal(vscode.Uri.parse(buildUrl)); 63 | } 64 | }); 65 | } 66 | } else { 67 | vscode.window.showErrorMessage("Wave: Failed to build container"); 68 | return false; 69 | } 70 | 71 | return true; 72 | } catch (error: any) { 73 | const isAuthError = 74 | error?.message?.includes("401") || 75 | error?.message?.includes("403") || 76 | error?.message?.includes("Unauthorized"); 77 | 78 | if (isAuthError) { 79 | return handleAuthError(); 80 | } else { 81 | vscode.window.showErrorMessage( 82 | `Wave: Failed to build container: ${error?.message}` 83 | ); 84 | return false; 85 | } 86 | } 87 | } 88 | ); 89 | } 90 | 91 | async function handleAuthError(): Promise { 92 | const loginAction = "Login to Seqera Cloud"; 93 | const result = await vscode.window.showInformationMessage( 94 | "Authentication required to generate nf-test. Please login to continue.", 95 | loginAction 96 | ); 97 | 98 | if (result === loginAction) { 99 | await vscode.commands.executeCommand("nextflow.seqera.login"); 100 | } 101 | return false; 102 | } 103 | 104 | export default getContainer; 105 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/getContainer/prompt.ts: -------------------------------------------------------------------------------- 1 | export const systemPrompt = `You ONLY output pure CSV, nothing else. Do not include backticks or code blocks. You do not output any other text such as explanations, or anything else. You output pure CSV.`; 2 | 3 | export const getPrompt = (fileContents: string) => { 4 | return ` 5 | Analyze the following code, and determine which packages would be required to make a Wave container for it. 6 | Your response should be in the format of "channel::package=version,channel::package=version". 7 | For example: "bioconda::bcftools=1.2,pip:numpy==2.0.0rc1,bioconda::bioconductor-iranges=2.36.0" 8 | 9 | Here is the code: 10 | ${fileContents} 11 | `; 12 | }; 13 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/getContainer/startBuild.ts: -------------------------------------------------------------------------------- 1 | import { SEQERA_HUB_API_URL } from "../../../../../../constants"; 2 | import type { 3 | ImageType, 4 | PackageResult, 5 | Platform, 6 | WaveBuild, 7 | WaveResponse 8 | } from "./types"; 9 | 10 | type StartBuildPayload = { 11 | packages: { 12 | type: "CONDA"; 13 | channels: string[]; 14 | entries: string[]; 15 | }; 16 | format?: string; 17 | containerPlatform?: string; 18 | }; 19 | 20 | function parsePackagesString(packagesString: string): PackageResult[] { 21 | return packagesString.split(",").map((pkg) => { 22 | const [spec, version] = pkg.split("="); 23 | const [channel, name] = spec.split("::"); 24 | 25 | return { 26 | name, 27 | source: channel === "pip" ? "pip" : "conda", 28 | selected_version: version 29 | }; 30 | }); 31 | } 32 | 33 | /** 34 | * Starts a container build process with the specified packages and configuration 35 | * @param packagesString - String of packages in format "channel::package=version,channel::package=version" 36 | * @param imageType - Type of container image to build (e.g. "singularity") 37 | * @param selectedPlatform - Target platform for the container 38 | * @returns Promise resolving to the build information or error 39 | */ 40 | export async function startBuild( 41 | packagesString: string, 42 | imageType?: ImageType, 43 | selectedPlatform?: Platform 44 | ): Promise { 45 | const packages = parsePackagesString(packagesString); 46 | 47 | if (packages.length === 0) { 48 | throw new Error("No packages added to container"); 49 | } 50 | 51 | console.log("🟢 Starting container build with packages:", packages); 52 | 53 | const url = `${SEQERA_HUB_API_URL}/container`; 54 | 55 | // Collect all required channels, including default ones 56 | const channels = new Set(["conda-forge", "bioconda"]); 57 | packages.forEach((p) => { 58 | if (p.channel && !channels.has(p.channel)) { 59 | channels.add(p.channel); 60 | } 61 | }); 62 | 63 | const payload: StartBuildPayload = { 64 | packages: { 65 | type: "CONDA", 66 | channels: Array.from(channels), 67 | entries: packages.map( 68 | (p) => `${p.name}${p.selected_version ? `=${p.selected_version}` : ""}` 69 | ) 70 | } 71 | }; 72 | 73 | if (imageType === "singularity") { 74 | payload.format = "sif"; 75 | } 76 | 77 | if (selectedPlatform === "linux/arm64") { 78 | payload.containerPlatform = "arm64"; 79 | } 80 | 81 | console.log("🟢 Build payload:", payload); 82 | 83 | try { 84 | const response = await fetch(url, { 85 | method: "POST", 86 | headers: { "content-type": "application/json" }, 87 | body: JSON.stringify(payload) 88 | }); 89 | 90 | const build = (await response.json()) as WaveResponse; 91 | 92 | if (response.ok) { 93 | console.log("🟢 Container built:", build); 94 | return build; 95 | } 96 | 97 | console.log( 98 | "🟠 Build request failed:", 99 | response.status, 100 | response.statusText 101 | ); 102 | return { buildId: build.buildId, error: response.statusText }; 103 | } catch (error) { 104 | console.error("🟠 Error starting build:", error); 105 | throw new Error( 106 | `${error instanceof Error ? error.message : String(error)}` 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/getContainer/types.ts: -------------------------------------------------------------------------------- 1 | export type BuildId = string; 2 | 3 | export type Platform = "linux/amd64" | "linux/arm64"; 4 | 5 | export type ImageType = "docker" | "singularity"; 6 | 7 | export type PackageResult = { 8 | name: string; 9 | source: "conda" | "pip"; 10 | name_similarity?: number; 11 | name_distance?: number; 12 | summary?: string; 13 | latest_version?: string; 14 | selected_version?: string; 15 | versions?: string[]; 16 | channel?: string; 17 | supports_arm?: boolean; 18 | download_count?: number; 19 | favorite_count?: number; 20 | }; 21 | 22 | export type WaveResponse = { 23 | buildId: BuildId; 24 | cached?: boolean; 25 | containerImage?: string; 26 | containerToken?: string; 27 | expiration?: string; 28 | freeze?: boolean; 29 | targetImage?: string; 30 | }; 31 | 32 | export type BuildStatus = { 33 | duration: number; 34 | id: BuildId; 35 | startTime: string; 36 | status: "PENDING" | "COMPLETED"; 37 | succeeded: boolean; 38 | }; 39 | 40 | export type BuildDetails = { 41 | buildId: BuildId; 42 | condaFile: string; 43 | digest: string; 44 | dockerFile: string; 45 | duration: number; 46 | exitStatus: number; 47 | format: string; 48 | offsetId: string; 49 | platform: string; 50 | requestIp: string; 51 | scanId: string; 52 | startTime: string; 53 | targetImage: string; 54 | }; 55 | 56 | export type WaveBuild = WaveResponse & { 57 | status?: BuildStatus; 58 | details?: BuildDetails; 59 | singularityLink?: string; 60 | error?: string; 61 | logs?: string; 62 | }; 63 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/getRepoInfo.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode"; 2 | import * as vscode from "vscode"; 3 | 4 | import { RepoInfo } from "../types"; 5 | 6 | function handleUpdate(context: ExtensionContext, repoInfo: RepoInfo) { 7 | const vsCodeState = context.workspaceState; 8 | vsCodeState.update("repoInfo", repoInfo); 9 | } 10 | 11 | async function getRepoInfo( 12 | context: ExtensionContext 13 | ): Promise { 14 | try { 15 | const wsState = context.workspaceState; 16 | const savedState = wsState.get("repoInfo") as RepoInfo | undefined; 17 | if (savedState) return savedState; 18 | 19 | const extension = vscode.extensions.getExtension("vscode.git"); 20 | if (!extension) { 21 | return undefined; 22 | } 23 | 24 | const gitExtension = extension.isActive 25 | ? extension.exports 26 | : await extension.activate(); 27 | const git = gitExtension.getAPI(1); 28 | 29 | if (!git?.repositories?.length) { 30 | return undefined; 31 | } 32 | 33 | const repository = git.repositories[0]; 34 | if (!repository) return undefined; 35 | 36 | const remotes = repository?.state?.remotes || repository?.remotes; 37 | 38 | if (!remotes) return undefined; 39 | 40 | const origin = remotes.find( 41 | (remote: { name: string }) => remote.name === "origin" 42 | ); 43 | 44 | if (!origin) return undefined; 45 | 46 | let url = origin.fetchUrl || origin.pushUrl; 47 | if (!url) return undefined; 48 | 49 | if (url.startsWith("git@")) { 50 | url = url.replace("git@github.com:", "https://github.com/"); 51 | } 52 | 53 | url = url.replace(/\.git$/, ""); 54 | const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/); 55 | if (!match) return { url, name: "", owner: "" }; 56 | 57 | const [, owner, name] = match; 58 | 59 | const repoInfo = { 60 | url, 61 | name, 62 | owner 63 | }; 64 | 65 | handleUpdate(context, repoInfo); 66 | return repoInfo; 67 | } catch (error) { 68 | console.error("Error getting repo info:", error); 69 | return undefined; 70 | } 71 | } 72 | 73 | export default getRepoInfo; 74 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/platform/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as fetchUserInfo } from "./fetchUserInfo"; 2 | export { default as fetchWorkspaces } from "./fetchWorkspaces"; 3 | export { default as fetchComputeEnvs } from "./fetchComputeEnvs"; 4 | export { default as addPipeline } from "./addPipeline"; 5 | export { default as fetchRuns } from "./fetchRuns"; 6 | export { default as getRepoInfo } from "./getRepoInfo"; 7 | export { default as fetchPipelines } from "./fetchPipelines"; 8 | export { default as fetchDatasets } from "./fetchDatasets"; 9 | export { default as fetchDataLinks } from "./fetchDataLinks"; 10 | export { default as createTest } from "./createTest"; 11 | export { default as getContainer } from "./getContainer"; 12 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export default function debounce Promise>( 2 | func: T, 3 | wait: number 4 | ): (...args: Parameters) => Promise> { 5 | let timeoutId: NodeJS.Timeout; 6 | 7 | return (...args: Parameters): Promise> => { 8 | return new Promise((resolve, reject) => { 9 | if (timeoutId) { 10 | clearTimeout(timeoutId); 11 | } 12 | 13 | timeoutId = setTimeout(() => { 14 | func(...args) 15 | .then(resolve) 16 | .catch(reject); 17 | }, wait); 18 | }); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as debounce } from "./debounce"; 2 | export { default as sleep } from "./sleep"; 3 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export default function sleep(ms: number) { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/workspace/queryWorkspace.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as vscode from "vscode"; 4 | 5 | import { TestNode, TreeNode } from "./types"; 6 | 7 | function findFiles(dir: string, extension: string): string[] { 8 | return fs.readdirSync(dir, { withFileTypes: true }) 9 | .flatMap((entry) => { 10 | const filePath = path.join(dir, entry.name); 11 | 12 | if (entry.isDirectory()) 13 | return findFiles(filePath, extension); 14 | if (entry.isFile() && entry.name.endsWith(extension)) 15 | return [filePath]; 16 | return []; 17 | }); 18 | } 19 | 20 | function getLineNumber(text: string, charIndex: number): number { 21 | return text.slice(0, charIndex).split("\n").length - 1; 22 | }; 23 | 24 | function parseNfTest(filePath: string): TestNode[] { 25 | const text = fs.readFileSync(filePath, "utf8"); 26 | const matches = text.matchAll(/^\s*(process|workflow)\s+"(\w+)"/gm); 27 | return [...matches].map((m) => ( 28 | { 29 | name: m[2], 30 | path: filePath, 31 | line: getLineNumber(text, m.index) 32 | } as TestNode 33 | )); 34 | } 35 | 36 | function findNfTests(dir: string): TestNode[] { 37 | return findFiles(dir, ".nf.test").flatMap(parseNfTest); 38 | } 39 | 40 | async function previewWorkspace(name: string): Promise { 41 | try { 42 | return await vscode.commands.executeCommand("nextflow.server.previewWorkspace", name); 43 | } catch (error) { 44 | return null; 45 | } 46 | } 47 | 48 | export async function queryWorkspace(): Promise { 49 | const folders = vscode.workspace.workspaceFolders; 50 | if (!folders || folders.length == 0) 51 | return []; 52 | 53 | const name = folders[0].name; 54 | const res: any = await previewWorkspace(name); 55 | if (!res || !res.result) { 56 | if (res?.error) 57 | vscode.window.showErrorMessage(res.error); 58 | return []; 59 | } 60 | 61 | const nodes = res.result as TreeNode[]; 62 | const tests = new Map() 63 | 64 | nodes.forEach((node) => { 65 | const filePath = node.path; 66 | if (!tests.has(filePath)) { 67 | tests.set(filePath, findNfTests(path.dirname(filePath))) 68 | } 69 | node.test = tests.get(filePath)?.find((test) => test.name === node.name); 70 | }); 71 | 72 | return nodes; 73 | } 74 | -------------------------------------------------------------------------------- /src/webview/WebviewProvider/lib/workspace/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface TreeNode { 3 | name: string; 4 | type: "process" | "workflow"; 5 | path: string; 6 | line: number; 7 | test?: TestNode; 8 | children?: CallNode[]; 9 | } 10 | 11 | export interface TestNode { 12 | name: string; 13 | path: string; 14 | line: number; 15 | } 16 | 17 | export interface CallNode { 18 | name: string; 19 | path: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/webview/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { AuthProvider } from "../auth"; 3 | import ResourcesProvider from "./ResourcesProvider"; 4 | import WebviewProvider from "./WebviewProvider"; 5 | 6 | function isNextflowFile(filePath: string) { 7 | return filePath.endsWith(".nf") || filePath.endsWith(".nf.test"); 8 | } 9 | 10 | export function activateWebview( 11 | context: vscode.ExtensionContext, 12 | authProvider: AuthProvider 13 | ) { 14 | const projectProvider = new WebviewProvider(context, "project"); 15 | const resourcesProvider = new ResourcesProvider(); 16 | const seqeraCloudProvider = new WebviewProvider( 17 | context, 18 | "seqeraCloud", 19 | authProvider 20 | ); 21 | 22 | const refresh = (uris?: readonly vscode.Uri[]) => { 23 | if ( 24 | uris === undefined || 25 | uris.some((uri) => isNextflowFile(uri.fsPath)) 26 | ) { 27 | projectProvider.initViewData(); 28 | } 29 | }; 30 | 31 | // Register views 32 | const providers = [ 33 | vscode.window.registerWebviewViewProvider("project", projectProvider), 34 | vscode.window.registerWebviewViewProvider("seqeraCloud", seqeraCloudProvider), 35 | vscode.window.registerTreeDataProvider("resources", resourcesProvider) 36 | ]; 37 | 38 | providers.forEach((provider) => { 39 | context.subscriptions.push(provider); 40 | }); 41 | 42 | // Register command 43 | vscode.commands.registerCommand("nextflow.seqera.reloadWebview", () => { 44 | seqeraCloudProvider.initViewData(true); 45 | refresh(); 46 | }); 47 | 48 | // Register events 49 | vscode.workspace.onDidSaveTextDocument((e) => refresh([e.uri])); 50 | vscode.workspace.onDidCreateFiles((e) => refresh(e.files)); 51 | vscode.workspace.onDidDeleteFiles((e) => refresh(e.files)); 52 | vscode.workspace.onDidRenameFiles((e) => refresh(e.files.map((r) => r.newUri))); 53 | vscode.workspace.onDidChangeWorkspaceFolders((_) => refresh()); 54 | 55 | return providers; 56 | } 57 | -------------------------------------------------------------------------------- /src/welcomePage/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | function getWelcomePage(): string { 4 | return vscode.env.appName.includes("Cursor") 5 | ? "welcome-cursor.md" 6 | : "welcome-vscode.md"; 7 | } 8 | 9 | export function showPage(filename?: string) { 10 | const extension = vscode.extensions.getExtension("nextflow.nextflow"); 11 | if (!extension) return; 12 | const path = filename ?? getWelcomePage(); 13 | const docUri = vscode.Uri.joinPath(extension.extensionUri, path); 14 | vscode.commands.executeCommand("markdown.showPreview", docUri); 15 | } 16 | 17 | export function activateWelcomePage(context: vscode.ExtensionContext) { 18 | // Add command to show welcome page 19 | const showWelcomePage = vscode.commands.registerCommand( 20 | "nextflow.showWelcomePage", 21 | showPage 22 | ); 23 | context.subscriptions.push(showWelcomePage); 24 | 25 | // Show welcome page on installation of new version 26 | const versionKey = "nextflow.version"; 27 | const currentVersion = context.extension.packageJSON.version ?? "unknown"; 28 | const storedVersion = context.globalState.get(versionKey, ""); 29 | if (currentVersion !== storedVersion) { 30 | context.globalState.update(versionKey, currentVersion); 31 | showPage(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/welcomePage/welcome-cursor.md: -------------------------------------------------------------------------------- 1 | # Nextflow extension for Cursor 2 | 3 | ## Commands 4 | 5 | Open the command palette and type `Nextflow` to see the list of available commands. 6 | 7 | ## Language server 8 | 9 | The extension will automatically download a language server release based on the `nextflow.languageVersion` extension setting. 10 | 11 | Read the [Nextflow documentation](https://nextflow.io/docs/latest/vscode.html) for more information about the Nextflow language server. 12 | 13 | ## Seqera AI 14 | 15 | Cursor does not currently support custom AI functionality. 16 | 17 | To fully leverage Seqera workspaces and [Seqera AI](https://ai.seqera.io/), use the VS Code extension. 18 | 19 | ### Add the `@Nextflow` command to Cursor Chat 20 | 21 | In order for Cursor to have access to the Nextflow docs, perform the following steps: 22 | 23 | 1. Open the chat window 24 | 2. Type `@Docs` 25 | 3. Click `+ Add new doc` 26 | 4. Paste `https://www.nextflow.io/docs/latest/` 27 | 28 | Now you will be able to use the `@Nextflow` command in Cursor Chat. The same can be done for MultiQC or other tools. 29 | 30 | ## Telemetry 31 | 32 | Telemetry is opt-in and can be enabled or disabled at any time. See our [Telemetry notice](vscode:extension/nextflow.nextflow) on the extension page for more information about what we do and do not collect. 33 | -------------------------------------------------------------------------------- /src/welcomePage/welcome-vscode.md: -------------------------------------------------------------------------------- 1 | # Nextflow extension for VS Code 2 | 3 | ## Commands 4 | 5 | Open the command palette and type `Nextflow` to see the list of available commands. 6 | 7 | ## Language server 8 | 9 | The extension will automatically download a language server release based on the `nextflow.languageVersion` extension setting. 10 | 11 | Read the [Nextflow documentation](https://nextflow.io/docs/latest/vscode.html) for more information about the Nextflow language server. 12 | 13 | ## Telemetry 14 | 15 | Telemetry is opt-in and can be enabled or disabled at any time. See our [Telemetry notice](vscode:extension/nextflow.nextflow) on the extension page for more information about what we do and do not collect. 16 | -------------------------------------------------------------------------------- /syntaxes/nextflow-config.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", 3 | "name": "Nextflow Config", 4 | "scopeName": "source.nextflow-config", 5 | "patterns": [ 6 | { 7 | "include": "#nextflow-config" 8 | } 9 | ], 10 | "repository": { 11 | "nextflow-config": { 12 | "patterns": [ 13 | { 14 | "include": "#include-config" 15 | }, 16 | { 17 | "include": "source.nextflow-groovy" 18 | } 19 | ] 20 | }, 21 | "include-config": { 22 | "name": "keyword.nextflow", 23 | "match": "(?<=(^|{)\\s*)includeConfig(?=[\\s(])" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /syntaxes/nextflow-markdown-injection.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileTypes": [], 3 | "injectionSelector": "L:text.html.markdown", 4 | "patterns": [ 5 | { 6 | "include": "#nextflow-code-block" 7 | } 8 | ], 9 | "repository": { 10 | "nextflow-code-block": { 11 | "begin": "(^|\\G)(\\s*)(\\`{3,}|~{3,})\\s*(?i:(nextflow)(\\s+[^`~]*)?$)", 12 | "name": "markup.fenced_code.block.markdown", 13 | "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", 14 | "beginCaptures": { 15 | "3": { 16 | "name": "punctuation.definition.markdown" 17 | }, 18 | "4": { 19 | "name": "fenced_code.block.language.markdown" 20 | }, 21 | "5": { 22 | "name": "fenced_code.block.language.attributes.markdown" 23 | } 24 | }, 25 | "endCaptures": { 26 | "3": { 27 | "name": "punctuation.definition.markdown" 28 | } 29 | }, 30 | "patterns": [ 31 | { 32 | "begin": "(^|\\G)(\\s*)(.*)", 33 | "while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)", 34 | "contentName": "meta.embedded.block.nextflow", 35 | "patterns": [ 36 | { 37 | "include": "source.nextflow" 38 | } 39 | ] 40 | } 41 | ] 42 | } 43 | }, 44 | "scopeName": "markdown.nextflow.codeblock" 45 | } 46 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "lib": ["es2020"], 6 | "sourceMap": true, 7 | "strict": true, 8 | "rootDir": "src", 9 | "types": [] 10 | }, 11 | "exclude": ["node_modules", ".vscode-test", "webview-ui"] 12 | } 13 | -------------------------------------------------------------------------------- /webview-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /webview-ui/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /webview-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /webview-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webview-ui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "clsx": "^2.1.1", 14 | "react": "^19.0.0", 15 | "react-dom": "^19.0.0" 16 | }, 17 | "devDependencies": { 18 | "@eslint/js": "^9.21.0", 19 | "@types/react": "^19.0.10", 20 | "@types/react-dom": "^19.0.4", 21 | "@vitejs/plugin-react": "^4.3.4", 22 | "eslint": "^9.21.0", 23 | "eslint-plugin-react-hooks": "^5.1.0", 24 | "eslint-plugin-react-refresh": "^0.4.19", 25 | "globals": "^15.15.0", 26 | "typescript": "~5.7.2", 27 | "typescript-eslint": "^8.24.1", 28 | "vite": "^6.3.6" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /webview-ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Layout from "./Layout"; 2 | import Context from "./Context"; 3 | 4 | function App() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /webview-ui/src/Context/TowerProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useMemo, useState } from "react"; 2 | 3 | import { 4 | Workspace, 5 | Organization, 6 | ComputeEnv, 7 | UserInfo, 8 | RunsResponse, 9 | RepoInfo, 10 | PipelinesResponse, 11 | Pipeline, 12 | Workflow, 13 | Dataset, 14 | DataLink, 15 | WorkspaceID, 16 | HubPipeline, 17 | AddPipelineRequest 18 | } from "../types"; 19 | import { AuthState } from ".."; 20 | import { 21 | getOrganizations, 22 | getWorkspaces, 23 | filterPipelines, 24 | filterRuns, 25 | filterComputeEnvs 26 | } from "./utils"; 27 | 28 | const TowerContext = createContext(null as any); 29 | 30 | type Props = { 31 | children: React.ReactNode; 32 | authState?: AuthState; 33 | platformData: PlatformData; 34 | vscode: any; 35 | }; 36 | 37 | type PlatformData = { 38 | userInfo?: UserInfo; 39 | workspaces: Workspace[]; 40 | computeEnvs: ComputeEnv[]; 41 | organizations: Organization[]; 42 | runs?: RunsResponse; 43 | repoInfo?: RepoInfo; 44 | pipelines?: PipelinesResponse; 45 | datasets?: Dataset[]; 46 | dataLinks?: DataLink[]; 47 | hubPipelines?: HubPipeline[]; 48 | }; 49 | 50 | type TowerContextType = { 51 | error?: string | null; 52 | userInfo?: UserInfo; 53 | runs?: Workflow[]; 54 | selectedWorkspace: Workspace | undefined; 55 | workspaceId: WorkspaceID | undefined; 56 | setSelectedWorkspace: (n: Workspace) => void; 57 | selectedComputeEnv: string | null; 58 | setSelectedComputeEnv: (n: string) => void; 59 | computeEnvs: ComputeEnv[]; 60 | fetchComputeEnvs: (workspaceId?: WorkspaceID) => void; 61 | fetchPipelines: (workspaceId?: WorkspaceID) => void; 62 | fetchDatasets: (workspaceId?: WorkspaceID) => void; 63 | fetchDataLinks: (workspaceId?: WorkspaceID) => void; 64 | fetchRuns: (workspaceId?: WorkspaceID) => void; 65 | workspaces: Workspace[]; 66 | organizations?: Organization[]; 67 | getWorkspaces: (orgId: string | number) => Workspace[]; 68 | setSelectedOrg: (n: string) => void; 69 | selectedOrg: string; 70 | isAuthenticated?: boolean; 71 | hasToken?: boolean; 72 | tokenExpired?: boolean; 73 | tokenExpiry?: number; 74 | repoInfo?: RepoInfo; 75 | pipelines?: Pipeline[]; 76 | hubPipelines?: HubPipeline[]; 77 | datasets?: Dataset[]; 78 | dataLinks?: DataLink[]; 79 | useLocalContext: boolean; 80 | setUseLocalContext: (n: boolean) => void; 81 | addPipeline: (requestBody: AddPipelineRequest) => void; 82 | fetchHubPipelines: () => void; 83 | }; 84 | 85 | const TowerProvider: React.FC = ({ 86 | children, 87 | authState, 88 | platformData, 89 | vscode 90 | }) => { 91 | const { 92 | userInfo, 93 | workspaces: orgsAndWorkspaces, 94 | computeEnvs, 95 | runs, 96 | pipelines, 97 | datasets, 98 | dataLinks, 99 | repoInfo, 100 | hubPipelines 101 | } = platformData; 102 | 103 | const organizations: Organization[] = useMemo( 104 | () => getOrganizations(orgsAndWorkspaces), 105 | [orgsAndWorkspaces] 106 | ); 107 | 108 | const workspaces: Workspace[] = useMemo( 109 | () => getWorkspaces(orgsAndWorkspaces), 110 | [orgsAndWorkspaces] 111 | ); 112 | 113 | const [selectedWorkspace, setSelectedWorkspace] = useState< 114 | Workspace | undefined 115 | >(undefined); 116 | const [selectedComputeEnv, setSelectedComputeEnv] = useState(""); 117 | const [selectedOrg, setSelectedOrg] = useState(""); 118 | const [useLocalContext, setUseLocalContext] = useState(false); 119 | const workspaceId = selectedWorkspace?.workspaceId; 120 | 121 | useEffect(() => { 122 | setSelectedWorkspace(workspaces?.[0]); 123 | }, [workspaces]); 124 | 125 | const getOrgWorkspaces = (orgId: string | number) => { 126 | return getWorkspaces(orgsAndWorkspaces, orgId); 127 | }; 128 | 129 | let auth = authState; 130 | if (!auth) { 131 | auth = { 132 | hasToken: false, 133 | tokenExpired: false, 134 | tokenExpiry: 0, 135 | isAuthenticated: false, 136 | error: "" 137 | }; 138 | } 139 | 140 | function fetchRuns(workspaceId?: WorkspaceID) { 141 | vscode.postMessage({ command: "fetchRuns", workspaceId }); 142 | } 143 | 144 | function fetchPipelines(workspaceId?: WorkspaceID) { 145 | vscode.postMessage({ command: "fetchPipelines", workspaceId }); 146 | } 147 | 148 | function fetchDatasets(workspaceId?: WorkspaceID) { 149 | vscode.postMessage({ command: "fetchDatasets", workspaceId }); 150 | } 151 | 152 | function fetchDataLinks(workspaceId?: WorkspaceID) { 153 | vscode.postMessage({ command: "fetchDataLinks", workspaceId }); 154 | } 155 | 156 | function fetchComputeEnvs(workspaceId?: WorkspaceID) { 157 | vscode.postMessage({ command: "fetchComputeEnvs", workspaceId }); 158 | } 159 | 160 | function fetchHubPipelines() { 161 | vscode.postMessage({ command: "fetchHubPipelines" }); 162 | } 163 | 164 | function addPipeline(requestBody: AddPipelineRequest) { 165 | vscode.postMessage({ command: "addPipeline", requestBody }); 166 | } 167 | 168 | return ( 169 | 205 | {children} 206 | 207 | ); 208 | }; 209 | 210 | const useTowerContext = () => { 211 | return useContext(TowerContext); 212 | }; 213 | 214 | export { TowerProvider as default, useTowerContext }; 215 | -------------------------------------------------------------------------------- /webview-ui/src/Context/TowerProvider/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Organization, 3 | Pipeline, 4 | PipelinesResponse, 5 | RepoInfo, 6 | Workflow, 7 | RunsResponse, 8 | Workspace, 9 | ComputeEnv 10 | } from "../types"; 11 | 12 | export function getOrganizations( 13 | orgsAndWorkspaces?: Organization[] 14 | ): Organization[] { 15 | if (!orgsAndWorkspaces) return []; 16 | return orgsAndWorkspaces.filter((w) => !w.workspaceId); 17 | } 18 | 19 | export function filterComputeEnvs( 20 | computeEnvs: ComputeEnv[] | undefined, 21 | workspace: Workspace | undefined 22 | ): ComputeEnv[] { 23 | if (!computeEnvs) return []; 24 | if (!workspace) return computeEnvs; 25 | return ( 26 | computeEnvs?.filter((ce) => ce.workspaceName === workspace.workspaceName) || 27 | [] 28 | ); 29 | } 30 | 31 | export function getWorkspaces( 32 | orgsAndWorkspaces?: Workspace[], 33 | orgId?: string | number 34 | ): Workspace[] { 35 | if (!orgsAndWorkspaces) return []; 36 | const workspaces = orgsAndWorkspaces.filter((w) => !!w.workspaceId); 37 | if (orgId) { 38 | return workspaces.filter((w) => w.orgId === orgId); 39 | } 40 | return workspaces; 41 | } 42 | 43 | export function filterPipelines( 44 | pipelines: PipelinesResponse | undefined, 45 | repoInfo: RepoInfo | undefined, 46 | shouldFilter: boolean 47 | ): Pipeline[] { 48 | let items = pipelines?.pipelines || []; 49 | items = items.sort( 50 | (a, b) => 51 | new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime() 52 | ); 53 | if (!shouldFilter || !repoInfo) return items; 54 | return items.filter((w) => w.repository === repoInfo.url); 55 | } 56 | 57 | export function filterRuns( 58 | runs: RunsResponse | undefined, 59 | repoInfo: RepoInfo | undefined, 60 | shouldFilter: boolean 61 | ): Workflow[] { 62 | let items = runs?.workflows?.map(({ workflow }) => workflow) || []; 63 | items = items.sort((a, b) => { 64 | // Starred first 65 | if (a.starred !== b.starred) { 66 | return a.starred ? -1 : 1; 67 | } 68 | // Ordered by dateCreated 69 | return ( 70 | new Date(b.dateCreated).getTime() - new Date(a.dateCreated).getTime() 71 | ); 72 | }); 73 | 74 | if (!shouldFilter || !repoInfo) { 75 | return items; 76 | } 77 | 78 | // Filter items not related to the repo's repository 79 | return items.filter((w) => w.repository === repoInfo.url); 80 | } 81 | -------------------------------------------------------------------------------- /webview-ui/src/Context/WorkspaceProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | 3 | import { TestCreation, TreeNode } from "./types"; 4 | 5 | const WorkspaceContext = createContext({ 6 | nodes: [], 7 | findChildren: () => [], 8 | openFile: () => {}, 9 | selectedItems: [], 10 | selectItem: () => {}, 11 | isSelected: () => false, 12 | viewID: "", 13 | login: () => {}, 14 | openChat: () => {}, 15 | isCursor: false, 16 | selectedView: "pipelines", 17 | setSelectedView: () => {}, 18 | getRepoInfo: () => {}, 19 | refresh: () => {}, 20 | createTest: () => {}, 21 | testCreation: {}, 22 | getContainer: () => {} 23 | }); 24 | 25 | interface WorkspaceContextType { 26 | nodes: TreeNode[]; 27 | findChildren: (node: TreeNode) => TreeNode[]; 28 | openFile: (filePath: string, line: number) => void; 29 | selectedItems: string[]; 30 | selectItem: (name: string) => void; 31 | isSelected: (name: string) => boolean; 32 | viewID: string; 33 | login: () => void; 34 | openChat: () => void; 35 | isCursor: boolean; 36 | selectedView: string; 37 | setSelectedView: (view: string) => void; 38 | getRepoInfo: () => void; 39 | refresh: () => void; 40 | createTest: (filePath: string) => void; 41 | testCreation: TestCreation; 42 | getContainer: (filePath: string) => void; 43 | } 44 | 45 | type Props = { 46 | children: React.ReactNode; 47 | vscode: any; 48 | viewID: string; 49 | isCursor: boolean; 50 | }; 51 | 52 | const WorkspaceProvider = ({ children, vscode, viewID, isCursor }: Props) => { 53 | const state = vscode.getState(); 54 | 55 | const [nodes, setNodes] = useState([]); 56 | const [testCreation, setCreatingTest] = useState< 57 | WorkspaceContextType["testCreation"] 58 | >({}); 59 | const [selectedItems, setSelectedItems] = useState( 60 | state?.selectedItems || [] 61 | ); 62 | const [selectedView, setSelectedView] = useState("pipelines"); 63 | 64 | useEffect(() => { 65 | vscode.setState({ selectedItems }); 66 | }, [selectedItems]); 67 | 68 | useEffect(() => { 69 | const handleMessage = (event: MessageEvent) => { 70 | const message = event.data; 71 | if (message.nodes) setNodes(message.nodes); 72 | if (message.testCreated) { 73 | const data = message.testCreated; 74 | setCreatingTest({ 75 | filePath: data.filePath, 76 | successful: data.successful, 77 | finished: true 78 | }); 79 | } 80 | }; 81 | window.addEventListener("message", handleMessage); 82 | return () => window.removeEventListener("message", handleMessage); 83 | }, []); 84 | 85 | function selectItem(name: string) { 86 | if (selectedItems.includes(name)) { 87 | setSelectedItems(selectedItems.filter((item) => item !== name)); 88 | } else { 89 | setSelectedItems([...selectedItems, name]); 90 | } 91 | } 92 | 93 | function isSelected(name: string) { 94 | return selectedItems.includes(name); 95 | } 96 | 97 | function findChildren(node: TreeNode): TreeNode[] { 98 | if (!node.children) return []; 99 | return node.children.flatMap((call) => 100 | nodes.filter((n) => n.path === call.path && n.name === call.name) 101 | ); 102 | } 103 | 104 | function openFile(filePath: string, line: number) { 105 | vscode.postMessage({ command: "openFile", path: filePath, line: line }); 106 | } 107 | 108 | function login() { 109 | vscode.postMessage({ command: "login" }); 110 | } 111 | 112 | function openChat() { 113 | vscode.postMessage({ command: "openChat" }); 114 | } 115 | 116 | function getRepoInfo() { 117 | vscode.postMessage({ command: "getRepoInfo" }); 118 | } 119 | 120 | function refresh() { 121 | console.log("🟠 refresh"); 122 | vscode.postMessage({ command: "refresh" }); 123 | } 124 | 125 | function createTest(filePath: string) { 126 | setCreatingTest({ 127 | filePath, 128 | finished: false 129 | }); 130 | vscode.postMessage({ command: "createTest", filePath }); 131 | } 132 | 133 | function getContainer(filePath: string) { 134 | vscode.postMessage({ command: "getContainer", filePath }); 135 | } 136 | 137 | return ( 138 | 159 | {children} 160 | 161 | ); 162 | }; 163 | 164 | const useWorkspaceContext = () => useContext(WorkspaceContext); 165 | 166 | export { useWorkspaceContext }; 167 | 168 | export default WorkspaceProvider; 169 | -------------------------------------------------------------------------------- /webview-ui/src/Context/WorkspaceProvider/types.ts: -------------------------------------------------------------------------------- 1 | export interface TreeNode { 2 | name: string; 3 | type: "process" | "workflow"; 4 | path: string; 5 | line: number; 6 | test?: TestNode; 7 | children?: CallNode[]; 8 | } 9 | 10 | export interface TestNode { 11 | name: string; 12 | path: string; 13 | line: number; 14 | } 15 | 16 | export interface CallNode { 17 | name: string; 18 | path: string; 19 | } 20 | 21 | export interface TestCreation { 22 | filePath?: string; 23 | successful?: boolean; 24 | finished?: boolean; 25 | } 26 | -------------------------------------------------------------------------------- /webview-ui/src/Context/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import WorkspaceProvider, { useWorkspaceContext } from "./WorkspaceProvider"; 3 | import TowerProvider, { useTowerContext } from "./TowerProvider"; 4 | import { getVscode } from "./utils"; 5 | 6 | import { 7 | UserInfo, 8 | Workspace, 9 | ComputeEnv, 10 | Organization, 11 | RunsResponse, 12 | RepoInfo, 13 | PipelinesResponse, 14 | Dataset, 15 | DataLink, 16 | HubPipeline 17 | } from "./types"; 18 | 19 | const vscode = getVscode(); 20 | 21 | type Props = { 22 | children: React.ReactNode; 23 | }; 24 | 25 | export type AuthState = { 26 | hasToken?: boolean; 27 | tokenExpired?: boolean; 28 | tokenExpiry?: number; 29 | isAuthenticated?: boolean; 30 | error?: string; 31 | }; 32 | 33 | type ViewID = "project" | "seqeraCloud" | ""; 34 | 35 | const Context = ({ children }: Props) => { 36 | const viewID = window.initialData?.viewID as ViewID; 37 | const isCursor = window.initialData?.isCursor; 38 | const [authState, setAuthState] = useState(undefined); 39 | const [userInfo, setUserInfo] = useState(undefined); 40 | const [workspaces, setWorkspaces] = useState([]); 41 | const [computeEnvs, setComputeEnvs] = useState([]); 42 | const [organizations, setOrganizations] = useState([]); 43 | const [runs, setRuns] = useState(undefined); 44 | const [pipelines, setPipelines] = useState( 45 | undefined 46 | ); 47 | const [repoInfo, setRepoInfo] = useState(undefined); 48 | const [datasets, setDatasets] = useState([]); 49 | const [dataLinks, setDataLinks] = useState([]); 50 | const [hubPipelines, setHubPipelines] = useState([]); 51 | 52 | useEffect(() => { 53 | const handleMessage = (event: MessageEvent) => { 54 | console.log("🟠 message", event.data); 55 | const { data } = event; 56 | if (data.authState) setAuthState(data.authState); 57 | if (data.userInfo) setUserInfo(data.userInfo); 58 | if (data.workspaces) setWorkspaces(data.workspaces); 59 | if (data.computeEnvs) setComputeEnvs(data.computeEnvs); 60 | if (data.organizations) setOrganizations(data.organizations); 61 | if (data.runs) setRuns(data.runs); 62 | if (data.pipelines) setPipelines(data.pipelines); 63 | if (data.repoInfo) setRepoInfo(data.repoInfo); 64 | if (data.datasets) setDatasets(data.datasets); 65 | if (data.dataLinks) setDataLinks(data.dataLinks); 66 | if (data.hubPipelines) setHubPipelines(data.hubPipelines); 67 | }; 68 | window.addEventListener("message", handleMessage); 69 | return () => window.removeEventListener("message", handleMessage); 70 | }, []); 71 | 72 | return ( 73 | 74 | {viewID === "seqeraCloud" ? ( 75 | 91 | {children} 92 | 93 | ) : ( 94 | <>{children} 95 | )} 96 | 97 | ); 98 | }; 99 | 100 | export { useWorkspaceContext, useTowerContext }; 101 | 102 | export default Context; 103 | -------------------------------------------------------------------------------- /webview-ui/src/Context/types.ts: -------------------------------------------------------------------------------- 1 | export * from "../../../src/webview/WebviewProvider/lib/platform/types"; 2 | -------------------------------------------------------------------------------- /webview-ui/src/Context/utils.ts: -------------------------------------------------------------------------------- 1 | function getVscode() { 2 | if (window.vscode) return window.vscode; 3 | if (window.acquireVsCodeApi) { 4 | const vscode = window.acquireVsCodeApi(); 5 | window.vscode = vscode; 6 | return vscode; 7 | } 8 | console.warn("VS Code API could not be acquired"); 9 | return null; 10 | } 11 | 12 | export { getVscode }; 13 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/Project/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useWorkspaceContext } from "../../Context"; 3 | import FileList from "../../components/FileList"; 4 | import FileNode from "../../components/FileNode"; 5 | import Input from "../../components/Input"; 6 | import Select from "../../components/Select"; 7 | import styles from "./styles.module.css"; 8 | 9 | type ViewMode = "tree" | "list"; 10 | 11 | function round(x: number) { 12 | return Math.round(x * 100) / 100; 13 | } 14 | 15 | const Project = () => { 16 | const { nodes } = useWorkspaceContext(); 17 | const [viewMode, setViewMode] = useState("list"); 18 | const [search, setSearch] = useState(""); 19 | 20 | const entryNodes = nodes.filter((n) => n.name === ""); 21 | 22 | function testCoverage() { 23 | const totalCount = nodes.length - entryNodes.length; 24 | if( totalCount == 0 ) 25 | return <>; 26 | const testCount = nodes.filter((n) => n.test !== undefined).length; 27 | const coverage = round((testCount / totalCount) * 100); 28 | const color = 29 | coverage >= 80 ? "#0dc09d" : 30 | coverage >= 20 ? "orange" : 31 | "red"; 32 | return ( 33 |
34 | Test coverage: {coverage}% 35 |
36 | ); 37 | }; 38 | 39 | function treeView() { 40 | if (entryNodes.length == 0) 41 | return
No entry workflows found
; 42 | return ( 43 |
44 | {entryNodes.map((node) => ( 45 | 46 | ))} 47 |
48 | ); 49 | }; 50 | 51 | function listView() { 52 | const filteredNodes = search 53 | ? nodes.filter((n) => n.name.toLowerCase().includes(search.toLowerCase())) 54 | : nodes.slice(); 55 | filteredNodes.sort((a, b) => a.name.localeCompare(b.name)); 56 | if (filteredNodes.length == 0) 57 | return
No processes or workflows found
; 58 | return ; 59 | }; 60 | 61 | return ( 62 | <> 63 |
64 | setSearch(value)} 67 | placeholder="Search processes and workflows" 68 | /> 69 | ({ 24 | label: ws.orgName + "/" + ws.workspaceName, 25 | value: ws.workspaceId as number 26 | }))} 27 | value={workspace?.workspaceId ?? ""} 28 | icon="seqera" 29 | onChange={(value) => { 30 | const workspace = workspaces.find( 31 | (ws) => ws.workspaceId === value 32 | ); 33 | if (!workspace) return; 34 | setSelectedWorkspace(workspace); 35 | }} 36 | subtle 37 | /> 38 | ) : ( 39 |
No workspaces found
40 | )} 41 | {!!manageURL && ( 42 |
50 | 51 | ); 52 | }; 53 | 54 | export default WorkspaceSelector; 55 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { SEQERA_PLATFORM_URL } from "../../../../../src/constants"; 2 | import Button from "../../../components/Button"; 3 | import { useTowerContext, useWorkspaceContext } from "../../../Context"; 4 | import WorkspaceSelector from "./WorkspaceSelector"; 5 | import Select from "../../../components/Select"; 6 | import { PipelineIcon, SeqeraIcon, AiIcon } from "../../../icons"; 7 | 8 | import styles from "./styles.module.css"; 9 | 10 | const Toolbar = () => { 11 | const { repoInfo, isAuthenticated } = useTowerContext(); 12 | const { selectedView, setSelectedView } = useWorkspaceContext(); 13 | const url = repoInfo?.url; 14 | const isGithub = url?.includes("github.com"); 15 | 16 | return ( 17 | <> 18 |
19 | {!!repoInfo && ( 20 | 28 | )} 29 | {isAuthenticated && ( 30 | 41 | 44 |
51 | {isAuthenticated && ( 52 | <> 53 | 54 |
55 | ({ 22 | label: computeEnv.name, 23 | value: computeEnv.id 24 | }))} 25 | value={selectedComputeEnv?.id ?? ""} 26 | onChange={(value) => { 27 | const computeEnv = computeEnvs?.find( 28 | (computeEnv) => computeEnv.id === value 29 | ); 30 | if (!computeEnv) return; 31 | setSelectedComputeEnv(computeEnv); 32 | }} 33 | /> 34 |
35 | ); 36 | }; 37 | 38 | export default ComputeEnvSelector; 39 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/AddPipeline/Layout/SuccessPage.tsx: -------------------------------------------------------------------------------- 1 | import { AddPipelineResponse } from "../../../../../../../src/webview/WebviewProvider/lib/platform/types"; 2 | import Button from "../../../../../components/Button"; 3 | import { getEditURL } from "../../../utils"; 4 | 5 | type Props = { 6 | responseBody: AddPipelineResponse | null; 7 | }; 8 | 9 | const SuccessPage = ({ responseBody }: Props) => { 10 | console.log(responseBody); 11 | console.log(getEditURL(responseBody as AddPipelineResponse)); 12 | return ( 13 |
14 |
Pipeline added to your launchpad
15 | {responseBody && ( 16 | 19 | )} 20 |
21 | ); 22 | }; 23 | 24 | export default SuccessPage; 25 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/AddPipeline/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import Input from "../../../../../components/Input"; 2 | import ComputeEnvSelector from "./ComputeEnvSelector"; 3 | import Button from "../../../../../components/Button"; 4 | import { 5 | ComputeEnv, 6 | AddPipelineRequest, 7 | AddPipelineResponse 8 | } from "../../../../../Context/types"; 9 | import SuccessPage from "./SuccessPage"; 10 | import Spinner from "../../../../../components/Spinner"; 11 | import { useTowerContext } from "../../../../../Context"; 12 | 13 | type Props = { 14 | requestBody: AddPipelineRequest; 15 | setRequestBody: React.Dispatch>; 16 | isLoading: boolean; 17 | failed: boolean; 18 | pipelineAdded: boolean; 19 | setSelectedComputeEnv: (computeEnv: ComputeEnv | null) => void; 20 | selectedComputeEnv: ComputeEnv | null; 21 | handleAddPipeline: () => void; 22 | responseBody: AddPipelineResponse | null; 23 | message?: string; 24 | }; 25 | 26 | const Layout: React.FC = ({ 27 | isLoading, 28 | failed, 29 | pipelineAdded, 30 | setSelectedComputeEnv, 31 | selectedComputeEnv, 32 | requestBody, 33 | setRequestBody, 34 | handleAddPipeline, 35 | responseBody, 36 | message 37 | }) => { 38 | const { computeEnvs } = useTowerContext(); 39 | if (pipelineAdded && !failed) { 40 | return ; 41 | } 42 | if (!computeEnvs?.length) { 43 | return
No compute environments found on current workspace
; 44 | } 45 | return ( 46 |
47 | 51 | 55 | setRequestBody((prev) => ({ 56 | ...prev, 57 | launch: { ...prev.launch, workDir: value } 58 | })) 59 | } 60 | /> 61 | 66 | setRequestBody((prev) => ({ ...prev, name: value })) 67 | } 68 | /> 69 | 74 | setRequestBody((prev) => ({ ...prev, description: value })) 75 | } 76 | /> 77 | 81 | setRequestBody((prev) => ({ 82 | ...prev, 83 | launch: { ...prev.launch, pipeline: value } 84 | })) 85 | } 86 | /> 87 | 91 | setRequestBody((prev) => ({ 92 | ...prev, 93 | launch: { ...prev.launch, revision: value } 94 | })) 95 | } 96 | /> 97 |
98 | {failed &&
Failed: {!!message && message}
} 99 |
100 | 103 | {isLoading && } 104 |
105 |
106 |
107 | ); 108 | }; 109 | 110 | export default Layout; 111 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/AddPipeline/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useTowerContext } from "../../../../Context"; 3 | 4 | import type { 5 | ComputeEnv, 6 | AddPipelineRequest, 7 | AddPipelineResponse 8 | } from "../../../../Context/types"; 9 | import Layout from "./Layout"; 10 | 11 | const initialState: AddPipelineRequest = { 12 | name: "", 13 | description: "", 14 | launch: { 15 | workspaceId: "", 16 | computeEnvId: "", 17 | workDir: "", 18 | pipeline: "", 19 | revision: "" 20 | } 21 | }; 22 | 23 | const AddPipeline = () => { 24 | const { 25 | fetchHubPipelines, 26 | hubPipelines, 27 | repoInfo, 28 | workspaceId, 29 | addPipeline, 30 | fetchComputeEnvs, 31 | computeEnvs 32 | } = useTowerContext(); 33 | const [selectedComputeEnv, setSelectedComputeEnv] = 34 | useState(null); 35 | const [isLoading, setIsLoading] = useState(false); 36 | const [pipelineAdded, setPipelineAdded] = useState(false); 37 | const [responseBody, setResponseBody] = useState( 38 | null 39 | ); 40 | const [requestBody, setRequestBody] = 41 | useState(initialState); 42 | let failed = pipelineAdded && !responseBody; 43 | const message = responseBody?.message; 44 | if (message) failed = true; 45 | 46 | useEffect(() => { 47 | // Fetch hub pipelines 48 | if (hubPipelines?.length) return; 49 | fetchHubPipelines(); 50 | }, [hubPipelines]); 51 | 52 | useEffect(() => { 53 | // Fetch compute environments 54 | fetchComputeEnvs(workspaceId); 55 | }, [workspaceId]); 56 | 57 | useEffect(() => { 58 | // Set default selectedcompute env 59 | if (!computeEnvs?.length) return; 60 | if (selectedComputeEnv) return; 61 | setSelectedComputeEnv(computeEnvs[0]); 62 | }, [computeEnvs, selectedComputeEnv]); 63 | 64 | useEffect(() => { 65 | // Set compute env id and work dir on request body 66 | if (!selectedComputeEnv) return; 67 | setRequestBody((prev) => ({ 68 | ...prev, 69 | launch: { 70 | ...prev.launch, 71 | computeEnvId: selectedComputeEnv.id, 72 | workDir: selectedComputeEnv.workDir 73 | } 74 | })); 75 | }, [selectedComputeEnv]); 76 | 77 | useEffect(() => { 78 | // Set workspace id on request body 79 | if (!workspaceId) return; 80 | setRequestBody((prev) => ({ 81 | ...prev, 82 | launch: { 83 | ...prev.launch, 84 | workspaceId: `${workspaceId}` 85 | } 86 | })); 87 | }, [workspaceId]); 88 | 89 | useEffect(() => { 90 | // Set repo name and url on request body 91 | if (!repoInfo?.url) return; 92 | setRequestBody((prev) => ({ 93 | ...prev, 94 | name: repoInfo.name, 95 | launch: { 96 | ...prev.launch, 97 | pipeline: repoInfo.url 98 | } 99 | })); 100 | }, [repoInfo]); 101 | 102 | useEffect(() => { 103 | // Set Pipeline info found on Seqera Hub on request body 104 | if (!repoInfo?.url) return; 105 | if (!hubPipelines?.length) return; 106 | const found = hubPipelines?.find( 107 | (pipeline) => pipeline.url === repoInfo.url 108 | ); 109 | if (!found) return; 110 | setRequestBody((prev) => ({ 111 | name: found.name, 112 | description: found.description, 113 | launch: { 114 | ...prev.launch, 115 | revision: found.revision, 116 | pipeline: found.url 117 | } 118 | })); 119 | }, [repoInfo, hubPipelines, workspaceId]); 120 | 121 | useEffect(() => { 122 | // Handle message from webview 123 | const handleMessage = (event: MessageEvent) => { 124 | const { data } = event; 125 | if (data.pipelineAdded) { 126 | setIsLoading(false); 127 | setPipelineAdded(data.pipelineAdded); 128 | setResponseBody(data.responseBody); 129 | } 130 | }; 131 | window.addEventListener("message", handleMessage); 132 | return () => window.removeEventListener("message", handleMessage); 133 | }, []); 134 | 135 | const handleAddPipeline = () => { 136 | setPipelineAdded(false); 137 | setResponseBody(null); 138 | setIsLoading(true); 139 | addPipeline(requestBody); 140 | }; 141 | 142 | return ( 143 | 155 | ); 156 | }; 157 | 158 | export default AddPipeline; 159 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/ComputeEnvironments/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { getComputeEnvURL } from "../../utils"; 3 | import { useTowerContext } from "../../../../Context"; 4 | import ListItem from "../../../../components/ListItem"; 5 | 6 | const ComputeEnvironments = () => { 7 | const { workspaces, computeEnvs, fetchComputeEnvs, workspaceId } = 8 | useTowerContext(); 9 | 10 | useEffect(() => fetchComputeEnvs(workspaceId), [workspaceId]); 11 | 12 | if (!computeEnvs?.length) 13 | return
No compute environments found on current workspace
; 14 | 15 | return ( 16 | <> 17 | {computeEnvs?.map((computeEnv) => ( 18 | 23 | {computeEnv.name} 24 | 25 | ))} 26 | 27 | ); 28 | }; 29 | 30 | export default ComputeEnvironments; 31 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/DataLinks/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useTowerContext } from "../../../../Context"; 3 | import Button from "../../../../components/Button"; 4 | import { getDataLinkURL } from "../../utils"; 5 | import ListItem from "../../../../components/ListItem"; 6 | 7 | const DataLinks = () => { 8 | const { 9 | dataLinks, 10 | selectedWorkspace: workspace, 11 | fetchDataLinks, 12 | workspaceId 13 | } = useTowerContext(); 14 | const [displayCount, setDisplayCount] = useState(5); 15 | 16 | const hasDataLinks = !!dataLinks?.length; 17 | const displayedDataLinks = dataLinks?.slice(0, displayCount) || []; 18 | const hasMore = hasDataLinks && dataLinks.length > displayCount; 19 | 20 | useEffect(() => fetchDataLinks(workspaceId), [workspaceId]); 21 | 22 | return ( 23 |
24 | {hasDataLinks ? ( 25 | <> 26 | {displayedDataLinks?.map((dataLink) => ( 27 | 33 | {dataLink.name} 34 | 35 | ))} 36 | {hasMore && ( 37 | 46 | )} 47 | 48 | ) : ( 49 |
None found for current workspace
50 | )} 51 |
52 | ); 53 | }; 54 | 55 | export default DataLinks; 56 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/Datasets/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useTowerContext } from "../../../../Context"; 3 | import { getDatasetURL } from "../../utils"; 4 | import ListItem from "../../../../components/ListItem"; 5 | 6 | const Datasets = () => { 7 | const { datasets, selectedWorkspace, fetchDatasets, workspaceId } = 8 | useTowerContext(); 9 | 10 | const hasDatasets = !!datasets?.length; 11 | 12 | useEffect(() => fetchDatasets(workspaceId), [workspaceId]); 13 | 14 | if (!hasDatasets) 15 | return
None found for current workspace
; 16 | 17 | return ( 18 | <> 19 | {datasets?.map((dataset) => ( 20 | 26 | {dataset.name} 27 | 28 | ))} 29 | 30 | ); 31 | }; 32 | 33 | export default Datasets; 34 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/FilterForProject/index.tsx: -------------------------------------------------------------------------------- 1 | import Button from "../../../../components/Button"; 2 | import Checkbox from "../../../../components/Checkbox"; 3 | import { useTowerContext, useWorkspaceContext } from "../../../../Context"; 4 | 5 | const FilterForProject = ({ showAddButton = false }) => { 6 | const { repoInfo, useLocalContext, setUseLocalContext } = useTowerContext(); 7 | const { setSelectedView } = useWorkspaceContext(); 8 | 9 | if (!repoInfo) return null; 10 | 11 | return ( 12 |
13 | 14 | Filter for {repoInfo.name} 15 | 16 | {showAddButton && ( 17 | 25 | )} 26 |
27 | ); 28 | }; 29 | 30 | export default FilterForProject; 31 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/OpenChat/index.tsx: -------------------------------------------------------------------------------- 1 | import Button from "../../../../components/Button"; 2 | import { useWorkspaceContext } from "../../../../Context"; 3 | 4 | const OpenChat = () => { 5 | const { openChat, isCursor } = useWorkspaceContext(); 6 | return ( 7 |
8 | 12 | {!isCursor && ( 13 |
14 | 18 |
19 | )} 20 |
21 | ); 22 | }; 23 | 24 | export default OpenChat; 25 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/Pipelines/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { getLaunchURL, getWorkflowURL, relativeTime } from "../../utils"; 3 | import { useTowerContext } from "../../../../Context"; 4 | import FilterForProject from "../FilterForProject"; 5 | import { WorkflowIcon } from "../../../../icons"; 6 | import ListItem from "../../../../components/ListItem"; 7 | 8 | const Pipelines = () => { 9 | const { useLocalContext, pipelines, repoInfo, fetchPipelines, workspaceId } = 10 | useTowerContext(); 11 | const hasPipelines = !!pipelines?.length; 12 | 13 | useEffect(() => fetchPipelines(workspaceId), [workspaceId]); 14 | 15 | return ( 16 |
17 | 18 | {hasPipelines ? ( 19 | <> 20 | {pipelines.map((pipeline) => ( 21 | 26 |
27 |
28 | 32 | 33 |
34 |
35 | Updated: {relativeTime(pipeline.lastUpdated)} 36 |
37 |
38 |
39 | 45 |
46 |
47 | ))} 48 | 49 | ) : ( 50 | <> 51 | {useLocalContext && repoInfo ? ( 52 |
53 | None found for {repoInfo?.name} in current workspace 54 |
55 | ) : ( 56 |
None found in current workspace
57 | )} 58 | 59 | )} 60 |
61 | ); 62 | }; 63 | 64 | export default Pipelines; 65 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/RunHistory/ErrorReport.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import clsx from "clsx"; 3 | 4 | import styles from "./styles.module.css"; 5 | 6 | const ErrorReport = ({ errorReport }: { errorReport: string }) => { 7 | const [isExpanded, setIsExpanded] = useState(false); 8 | const [copied, setCopied] = useState(false); 9 | 10 | if (!errorReport) return null; 11 | 12 | const firstLine = errorReport.split("\n")[0]; 13 | 14 | const handleCopy = (e: React.MouseEvent) => { 15 | e.preventDefault(); 16 | e.stopPropagation(); 17 | navigator.clipboard.writeText(errorReport); 18 | setCopied(true); 19 | setTimeout(() => setCopied(false), 2000); 20 | }; 21 | 22 | return ( 23 |
24 |
{ 27 | e.preventDefault(); 28 | e.stopPropagation(); 29 | setIsExpanded(!isExpanded); 30 | }} 31 | > 32 |
33 | {isExpanded ? errorReport : `${firstLine}...`} 34 |
35 | 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default ErrorReport; 49 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/RunHistory/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import clsx from "clsx"; 3 | 4 | import { useTowerContext } from "../../../../Context"; 5 | import { 6 | getRunURL, 7 | relativeTime, 8 | getRuntimeMinutes, 9 | getStatusIcon 10 | } from "./utils"; 11 | import Button from "../../../../components/Button"; 12 | import ErrorReport from "./ErrorReport"; 13 | import FilterForProject from "../FilterForProject"; 14 | import ListItem from "../../../../components/ListItem"; 15 | import styles from "./styles.module.css"; 16 | 17 | const RunHistory = () => { 18 | const { 19 | selectedWorkspace: workspace, 20 | runs, 21 | useLocalContext, 22 | repoInfo, 23 | fetchRuns, 24 | workspaceId 25 | } = useTowerContext(); 26 | const [displayCount, setDisplayCount] = useState(5); 27 | 28 | const hasRuns = !!runs?.length; 29 | const displayedHistory = runs?.slice(0, displayCount) || []; 30 | const hasMore = hasRuns && runs.length > displayCount; 31 | 32 | useEffect(() => fetchRuns(workspaceId), [workspaceId]); 33 | 34 | return ( 35 |
36 | 37 | {hasRuns ? ( 38 | <> 39 | {displayedHistory.map((workflow) => ( 40 | 45 |
46 | {workflow.runName} 47 |
{workflow.projectName}
48 |
49 |
50 |
51 | 57 | {workflow.status} 58 | {!!workflow.complete && ` (${getRuntimeMinutes(workflow)}m)`} 59 |
60 |
61 | {relativeTime(workflow.dateCreated)} 62 |
63 |
64 | {workflow.status === "FAILED" && ( 65 | 66 | )} 67 |
68 | ))} 69 | {hasMore && ( 70 | 79 | )} 80 | 81 | ) : ( 82 | <> 83 | {useLocalContext && repoInfo ? ( 84 |
85 | No runs found for {repoInfo?.name} in current workspace 86 |
87 | ) : ( 88 |
No runs found in current workspace
89 | )} 90 | 91 | )} 92 |
93 | ); 94 | }; 95 | 96 | export default RunHistory; 97 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/RunHistory/styles.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | width: 100%; 6 | & .name { 7 | flex: 1; 8 | margin-right: 1rem; 9 | font-weight: 500; 10 | white-space: nowrap; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | } 14 | & .metadata { 15 | color: var(--vscode-descriptionForeground); 16 | font-size: 10px; 17 | white-space: nowrap; 18 | overflow: hidden; 19 | text-overflow: ellipsis; 20 | } 21 | } 22 | .footer { 23 | margin-top: 2px; 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: center; 27 | width: 100%; 28 | & .status { 29 | display: flex; 30 | align-items: center; 31 | font-size: 10px; 32 | opacity: 0.8; 33 | white-space: nowrap; 34 | overflow: hidden; 35 | text-overflow: ellipsis; 36 | & i { 37 | margin-right: 2px; 38 | font-size: 11px; 39 | } 40 | &.FAILED { 41 | opacity: 0.6; 42 | } 43 | } 44 | & .date { 45 | color: var(--vscode-descriptionForeground); 46 | font-size: 10px; 47 | white-space: nowrap; 48 | overflow: hidden; 49 | text-overflow: ellipsis; 50 | } 51 | } 52 | 53 | .errorReport { 54 | margin-top: 6px; 55 | font-size: 11px; 56 | } 57 | 58 | .errorPreview { 59 | background: var(--vscode-editor-background); 60 | border: 1px solid var(--vscode-panel-border); 61 | border-radius: 4px; 62 | padding: 6px; 63 | cursor: pointer; 64 | white-space: pre-wrap; 65 | word-break: break-word; 66 | color: var(--vscode-errorForeground); 67 | position: relative; 68 | &:hover { 69 | & .copyButton { 70 | opacity: 0.5; 71 | } 72 | } 73 | } 74 | 75 | .errorContent { 76 | padding-right: 24px; 77 | } 78 | 79 | .copyButton { 80 | position: absolute; 81 | top: 4px; 82 | right: 4px; 83 | background: none; 84 | border: none; 85 | padding: 4px; 86 | cursor: pointer; 87 | color: var(--vscode-button-foreground); 88 | opacity: 0; 89 | transition: opacity 0.2s; 90 | font-size: 12px; 91 | outline: none !important; 92 | &:hover { 93 | opacity: 1; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/RunHistory/utils.ts: -------------------------------------------------------------------------------- 1 | import { SEQERA_PLATFORM_URL } from "../../../../../../src/constants"; 2 | import { 3 | Workflow, 4 | Workspace 5 | } from "../../../../../../src/webview/WebviewProvider/lib/platform/types"; 6 | 7 | export { formatDate, relativeTime } from "../../utils"; 8 | 9 | export function getRunURL( 10 | workspace: Workspace | undefined, 11 | item: Workflow 12 | ) { 13 | if (!workspace) return ""; 14 | 15 | return `${SEQERA_PLATFORM_URL}/orgs/${workspace.orgName}/workspaces/${workspace.workspaceName}/watch/${item.id}`; 16 | } 17 | 18 | export const getRuntimeMinutes = (workflow: any) => { 19 | if (!workflow.complete) return null; 20 | const start = new Date(workflow.dateCreated).getTime(); 21 | const end = new Date(workflow.complete).getTime(); 22 | return Math.round((end - start) / (1000 * 60)); 23 | }; 24 | 25 | export const getStatusIcon = (status: string) => { 26 | switch (status) { 27 | case "SUCCEEDED": 28 | return "check"; 29 | case "FAILED": 30 | return "close"; 31 | case "RUNNING": 32 | return "sync~spin"; 33 | case "CANCELLED": 34 | return "close"; 35 | default: 36 | return "square"; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/Workspace/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTowerContext, useWorkspaceContext } from "../../../Context"; 2 | import RunHistory from "./RunHistory"; 3 | import Pipelines from "./Pipelines"; 4 | import Datasets from "./Datasets"; 5 | import DataLinks from "./DataLinks"; 6 | import ComputeEnvironments from "./ComputeEnvironments"; 7 | import AddPipeline from "./AddPipeline"; 8 | 9 | const Workspace = () => { 10 | const { error } = useTowerContext(); 11 | const { selectedView } = useWorkspaceContext(); 12 | return ( 13 | <> 14 | {error && ( 15 |
16 |

Error:{error}

17 |
18 | )} 19 |
20 | {selectedView === "pipelines" && } 21 | {selectedView === "add-pipeline" && } 22 | {selectedView === "runs" && } 23 | {selectedView === "datasets" && } 24 | {selectedView === "data-links" && } 25 | {selectedView === "compute-environments" && } 26 |
27 | 28 | ); 29 | }; 30 | 31 | export default Workspace; 32 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useTowerContext, useWorkspaceContext } from "../../Context"; 3 | 4 | import Login from "./Login"; 5 | import Workspace from "./Workspace"; 6 | import Toolbar from "./Toolbar"; 7 | 8 | const SeqeraCloud = () => { 9 | const timeoutRef = useRef(null); 10 | const { repoInfo, isAuthenticated } = useTowerContext(); 11 | const { refresh } = useWorkspaceContext(); 12 | 13 | // Note: This effect ensures that we have the needed state after the component 14 | // mounts (it usually does, but not always). Would be good to find a better 15 | // way of doing this. 16 | 17 | useEffect(() => { 18 | if (isAuthenticated && repoInfo?.name) { 19 | const ref = timeoutRef.current; 20 | if (!ref) return; 21 | clearTimeout(ref); 22 | timeoutRef.current = null; 23 | } else { 24 | timeoutRef.current = setTimeout(refresh, 1000); 25 | } 26 | }, [isAuthenticated, repoInfo?.name]); 27 | 28 | return ( 29 | <> 30 | 31 | {!isAuthenticated ? : } 32 | 33 | ); 34 | }; 35 | 36 | export default SeqeraCloud; 37 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/SeqeraCloud/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Workspace, 3 | ComputeEnv, 4 | Pipeline, 5 | Dataset, 6 | DataLink, 7 | AddPipelineResponse 8 | } from "../../Context/types"; 9 | import { SEQERA_PLATFORM_URL } from "../../../../src/constants"; 10 | 11 | // Build URLs to Seqera Cloud 12 | 13 | export function getWorkspaceURL(workspace: Workspace) { 14 | return `${SEQERA_PLATFORM_URL}/orgs/${workspace.orgName}/workspaces/${workspace.workspaceName}/launchpad`; 15 | } 16 | 17 | export function getComputeEnvURL( 18 | workspaces: Workspace[], 19 | computeEnv: ComputeEnv 20 | ) { 21 | const workspace = workspaces.find( 22 | (w) => w.workspaceName === computeEnv.workspaceName 23 | ); 24 | if (!workspace) return ""; 25 | return `${SEQERA_PLATFORM_URL}/orgs/${workspace.orgName}/workspaces/${workspace.workspaceName}/compute-envs/${computeEnv.id}`; 26 | } 27 | 28 | export function getWorkflowURL(pipeline: Pipeline) { 29 | return `${SEQERA_PLATFORM_URL}/orgs/${pipeline.orgName}/workspaces/${pipeline.workspaceName}/launchpad/${pipeline.pipelineId}`; 30 | } 31 | 32 | export function getLaunchURL(pipeline: Pipeline) { 33 | return `${SEQERA_PLATFORM_URL}/orgs/${pipeline.orgName}/workspaces/${pipeline.workspaceName}/launchpad/${pipeline.pipelineId}/form/new-form`; 34 | } 35 | 36 | export function getEditURL(responseBody: AddPipelineResponse) { 37 | const pipeline = responseBody.pipeline; 38 | if (!pipeline) return ""; 39 | return `${SEQERA_PLATFORM_URL}/orgs/${pipeline.orgName}/workspaces/${pipeline.workspaceName}/launchpad/${pipeline.pipelineId}/edit`; 40 | } 41 | 42 | export function getDatasetURL(dataset: Dataset, workspace?: Workspace) { 43 | if (!workspace) return ""; 44 | return `${SEQERA_PLATFORM_URL}/orgs/${workspace.orgName}/workspaces/${workspace.workspaceName}/datasets/${dataset.id}`; 45 | } 46 | 47 | export function getDataLinkURL(dataLink: DataLink, workspace?: Workspace) { 48 | const credID = dataLink.credentials?.[0]?.id; 49 | if (!workspace || !credID) return ""; 50 | return `${SEQERA_PLATFORM_URL}/orgs/${workspace.orgName}/workspaces/${workspace.workspaceName}/data-explorer/${dataLink.id}/browse?credentialsId=${credID}`; 51 | } 52 | 53 | // Date formatting 54 | 55 | export const formatDate = (dateString: string) => { 56 | const date = new Date(dateString); 57 | const now = new Date(); 58 | const isCurrentYear = date.getFullYear() === now.getFullYear(); 59 | 60 | const options: Intl.DateTimeFormatOptions = { 61 | month: "short", 62 | day: "numeric", 63 | hour: "numeric", 64 | minute: "2-digit", 65 | hour12: true 66 | }; 67 | 68 | if (!isCurrentYear) { 69 | options.year = "numeric"; 70 | } 71 | 72 | return date.toLocaleString("en-US", options); 73 | }; 74 | 75 | export const relativeTime = (dateString: string) => { 76 | const date = new Date(dateString); 77 | const now = new Date(); 78 | const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 79 | 80 | if (diffInSeconds < 60) { 81 | return "Just now"; 82 | } 83 | 84 | const diffInMinutes = Math.floor(diffInSeconds / 60); 85 | if (diffInMinutes < 60) { 86 | return `${diffInMinutes} minute${diffInMinutes === 1 ? "" : "s"} ago`; 87 | } 88 | 89 | const diffInHours = Math.floor(diffInMinutes / 60); 90 | if (diffInHours < 24) { 91 | return `${diffInHours} hour${diffInHours === 1 ? "" : "s"} ago`; 92 | } 93 | 94 | const diffInDays = Math.floor(diffInHours / 24); 95 | if (diffInDays < 28) { 96 | return `${diffInDays} day${diffInDays === 1 ? "" : "s"} ago`; 97 | } 98 | 99 | // Fallback to normal date format 100 | return formatDate(dateString); 101 | }; 102 | -------------------------------------------------------------------------------- /webview-ui/src/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { useWorkspaceContext } from "../Context"; 3 | import Project from "./Project"; 4 | import SeqeraCloud from "./SeqeraCloud"; 5 | 6 | const Layout = () => { 7 | const { viewID, nodes, refresh } = useWorkspaceContext(); 8 | const timeoutRef = useRef(null); 9 | const [retryCount, setRetryCount] = useState(0); 10 | 11 | useEffect(() => { 12 | // Hacky fix for empty file state we get sometimes 13 | // TODO: find out why this is happening 14 | if (viewID !== "project") return; 15 | if (!nodes.length && retryCount <= 2) { 16 | timeoutRef.current = setTimeout(() => { 17 | refresh(); 18 | setRetryCount((prev) => prev + 1); 19 | }, 2000); 20 | } else { 21 | const ref = timeoutRef.current; 22 | if (ref) clearTimeout(ref); 23 | timeoutRef.current = null; 24 | } 25 | }, [nodes, retryCount]); 26 | 27 | if (viewID === "project") return ; 28 | if (viewID === "seqeraCloud") return ; 29 | return null; 30 | }; 31 | 32 | export default Layout; 33 | -------------------------------------------------------------------------------- /webview-ui/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import styles from "./styles.module.css"; 3 | import { shouldAdjustIconSize } from "./utils"; 4 | 5 | type Props = { 6 | onClick?: () => void; 7 | href?: string; 8 | children?: React.ReactNode; 9 | alt?: boolean; 10 | subtle?: boolean; 11 | subtle2?: boolean; 12 | small?: boolean; 13 | bare?: boolean; 14 | active?: boolean; 15 | fullWidth?: boolean; 16 | icon?: string; 17 | className?: string; 18 | iconClassName?: string; 19 | description?: string; 20 | disabled?: boolean; 21 | }; 22 | 23 | const Button: React.FC = ({ 24 | onClick, 25 | href, 26 | children = null, 27 | small, 28 | bare, 29 | active, 30 | fullWidth, 31 | icon, 32 | className: classNameProp, 33 | iconClassName = "", 34 | description, 35 | alt, 36 | subtle, 37 | subtle2, 38 | disabled 39 | }) => { 40 | const className = clsx(styles.button, classNameProp, { 41 | [styles.small]: small, 42 | [styles.fullWidth]: fullWidth, 43 | [styles.icon]: !!icon && !children, 44 | [styles.alt]: alt, 45 | [styles.subtle]: subtle, 46 | [styles.subtle2]: subtle2, 47 | [styles.bare]: bare, 48 | [styles.active]: active 49 | }); 50 | 51 | let content = children; 52 | 53 | if (icon) { 54 | content = ( 55 | <> 56 | 62 | {children} 63 | 64 | ); 65 | } 66 | 67 | if (href) { 68 | return ( 69 |
70 | {content} 71 | 72 | ); 73 | } 74 | return ( 75 | 83 | ); 84 | }; 85 | 86 | export default Button; 87 | -------------------------------------------------------------------------------- /webview-ui/src/components/Button/styles.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | height: 29px; 3 | display: inline-flex; 4 | align-items: center; 5 | background-color: var(--vscode-button-background); 6 | border: 1px solid var(--vscode-button-border); 7 | color: var(--vscode-button-foreground); 8 | padding: 0 10px; 9 | border-radius: 3px; 10 | cursor: pointer; 11 | text-decoration: none; 12 | line-height: 1; 13 | white-space: nowrap; 14 | transition: all 0.2s; 15 | min-width: 29px; 16 | white-space: nowrap; 17 | overflow: hidden; 18 | text-overflow: ellipsis; 19 | &:hover { 20 | background-color: var(--vscode-button-hoverBackground); 21 | border-color: var(--vscode-button-border-hover); 22 | color: var(--vscode-button-foreground); 23 | & i { 24 | opacity: 1; 25 | } 26 | } 27 | & i { 28 | font-size: 14px; 29 | opacity: 0.8; 30 | flex: none; 31 | } 32 | &.small { 33 | height: 20px; 34 | padding: 0 6px; 35 | font-size: 11px; 36 | outline: none; 37 | & i { 38 | font-size: 13px !important; 39 | margin-right: 4px; 40 | } 41 | } 42 | &.fullWidth { 43 | width: 100%; 44 | } 45 | & i { 46 | &.iconfix { 47 | font-size: 17px; 48 | } 49 | } 50 | &.icon { 51 | justify-content: center; 52 | } 53 | } 54 | .button.alt { 55 | background-color: var(--vscode-editor-background); 56 | border: 1px solid var(--vscode-button-background); 57 | color: var(--vscode-button-foreground); 58 | font-weight: 400; 59 | &:hover { 60 | border-color: var(--nf-highlight); 61 | } 62 | } 63 | .button.subtle { 64 | opacity: 0.9; 65 | &:hover { 66 | opacity: 1; 67 | } 68 | } 69 | .button.subtle2 { 70 | opacity: 0.7; 71 | &:hover { 72 | opacity: 1; 73 | } 74 | } 75 | .button.bare { 76 | background-color: transparent; 77 | border: none; 78 | color: var(--vscode-button-foreground); 79 | outline: none; 80 | opacity: 0.6; 81 | padding: 0; 82 | &:hover, 83 | &.active { 84 | background-color: transparent; 85 | border-color: transparent; 86 | color: var(--vscode-button-foreground); 87 | opacity: 0.8; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /webview-ui/src/components/Button/utils.ts: -------------------------------------------------------------------------------- 1 | export function shouldAdjustIconSize(icon: string) { 2 | const exceptions = ["account", "github", "gear"]; 3 | for (const exception of exceptions) { 4 | if (icon.includes(exception)) { 5 | return false; 6 | } 7 | } 8 | return true; 9 | } 10 | -------------------------------------------------------------------------------- /webview-ui/src/components/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import styles from "./styles.module.css"; 3 | 4 | type Props = { 5 | checked: boolean; 6 | onChange: (checked: boolean) => void; 7 | children?: React.ReactNode; 8 | small?: boolean; 9 | className?: string; 10 | description?: string; 11 | }; 12 | 13 | const Checkbox: React.FC = ({ 14 | checked, 15 | onChange, 16 | children = null, 17 | small, 18 | className: classNameProp, 19 | description 20 | }) => { 21 | const className = clsx(styles.checkbox, classNameProp, { 22 | [styles.small]: small, 23 | [styles.checked]: checked 24 | }); 25 | 26 | const handleChange = (newValue: boolean) => { 27 | onChange(newValue); 28 | }; 29 | 30 | return ( 31 | 41 | ); 42 | }; 43 | 44 | export default Checkbox; 45 | -------------------------------------------------------------------------------- /webview-ui/src/components/Checkbox/styles.module.css: -------------------------------------------------------------------------------- 1 | .checkbox { 2 | display: flex; 3 | align-items: center; 4 | background: transparent; 5 | border: none; 6 | color: var(--vscode-foreground); 7 | border-radius: 3px; 8 | padding: 0; 9 | margin: 0; 10 | cursor: pointer; 11 | transition: all 0.2s; 12 | white-space: nowrap; 13 | overflow: hidden; 14 | text-overflow: ellipsis; 15 | 16 | & .box { 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | width: 14px; 21 | height: 14px; 22 | border-radius: 2px; 23 | border: 1px solid var(--vscode-descriptionForeground); 24 | margin-right: 6px; 25 | & i { 26 | font-size: 12px; 27 | opacity: 0; 28 | color: var(--nf-highlight); 29 | } 30 | } 31 | &.checked i { 32 | opacity: 1; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /webview-ui/src/components/FileList/FileItem.tsx: -------------------------------------------------------------------------------- 1 | import { useWorkspaceContext } from "../../Context"; 2 | import { TreeNode } from "../../Context/WorkspaceProvider/types"; 3 | import { ProcessIcon } from "../../icons"; 4 | import ItemActions from "./ItemActions"; 5 | 6 | import styles from "./styles.module.css"; 7 | 8 | type Props = { 9 | node: TreeNode; 10 | }; 11 | 12 | const FileItem = ({ node }: Props) => { 13 | const { openFile } = useWorkspaceContext(); 14 | 15 | return ( 16 |
17 | 26 | 27 |
28 | ); 29 | }; 30 | 31 | export default FileItem; 32 | -------------------------------------------------------------------------------- /webview-ui/src/components/FileList/ItemActions/WaveIcon.tsx: -------------------------------------------------------------------------------- 1 | const WaveIcon = (props: React.SVGProps) => { 2 | return ( 3 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default WaveIcon; 19 | -------------------------------------------------------------------------------- /webview-ui/src/components/FileList/ItemActions/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { 3 | TestCreation, 4 | TreeNode 5 | } from "../../../Context/WorkspaceProvider/types"; 6 | import { useWorkspaceContext } from "../../../Context/WorkspaceProvider"; 7 | import WaveIcon from "./WaveIcon"; 8 | 9 | import styles from "./styles.module.css"; 10 | 11 | type Props = { 12 | node: TreeNode; 13 | }; 14 | 15 | const ItemActions: React.FC = ({ node }) => { 16 | const { openFile, testCreation, createTest, getContainer } = 17 | useWorkspaceContext(); 18 | if (node.name === "") return null; 19 | const hasTest = !!node.test; 20 | const { style } = getTestLabel(node, testCreation); 21 | 22 | let inProgress = true; 23 | let currentItemInProgress = false; 24 | if ( 25 | typeof testCreation?.finished === "undefined" || 26 | testCreation.finished === true 27 | ) { 28 | inProgress = false; 29 | } 30 | 31 | if (inProgress && testCreation.filePath === node.path) { 32 | currentItemInProgress = true; 33 | } 34 | 35 | return ( 36 |
37 | 45 | {hasTest ? ( 46 | 55 | ) : ( 56 | 82 | )} 83 |
84 | ); 85 | }; 86 | 87 | function getTestLabel(node: TreeNode, test: TestCreation) { 88 | let label = "Generate"; 89 | let style = ""; 90 | if (test.filePath === node.path) { 91 | if (test.finished) { 92 | if (test.successful) { 93 | label = "nf-test generated"; 94 | style = styles.success; 95 | } else { 96 | label = "Generation failed"; 97 | style = styles.error; 98 | } 99 | } else { 100 | label = "Generating..."; 101 | style = styles.generating; 102 | } 103 | } 104 | return { label, style }; 105 | } 106 | 107 | export default ItemActions; 108 | -------------------------------------------------------------------------------- /webview-ui/src/components/FileList/ItemActions/styles.module.css: -------------------------------------------------------------------------------- 1 | .actions { 2 | display: flex; 3 | align-items: center; 4 | flex: none; 5 | margin-left: 4px; 6 | padding-right: 6px; 7 | } 8 | .action { 9 | background: none !important; 10 | border: none !important; 11 | color: var(--vscode-editor-foreground); 12 | padding: 0; 13 | outline: none !important; 14 | white-space: nowrap; 15 | cursor: pointer; 16 | text-overflow: ellipsis; 17 | overflow: hidden; 18 | width: auto; 19 | flex: none; 20 | opacity: 0.8; 21 | display: flex; 22 | align-items: center; 23 | font-size: 9px; 24 | text-transform: uppercase; 25 | font-family: sans-serif; 26 | letter-spacing: 0.5px; 27 | transition: all 0.2s; 28 | &:hover { 29 | opacity: 1; 30 | color: var(--nf-highlight); 31 | & .actionIcon { 32 | opacity: 1; 33 | } 34 | & .wave { 35 | opacity: 1; 36 | fill: #3D95FD; 37 | } 38 | } 39 | &.success, 40 | &.generating { 41 | color: var(--nf-highlight); 42 | } 43 | &.error { 44 | color: var(--vscode-gitDecoration-modifiedResourceForeground); 45 | } 46 | &.disabled { 47 | color: #666 !important; 48 | cursor: default; 49 | } 50 | &.inProgress { 51 | cursor: default; 52 | } 53 | & .actionIcon { 54 | font-size: 14px; 55 | margin-right: 4px; 56 | opacity: 0.8; 57 | position: relative; 58 | top: -1px; 59 | transition: opacity 0.1s; 60 | } 61 | & .actionGo { 62 | color: #388A34; 63 | } 64 | & .wave { 65 | width: 21px; 66 | height: 21px; 67 | transition: fill 0.2s; 68 | opacity: 0.8; 69 | fill: #77B5FE; 70 | } 71 | } 72 | &:hover { 73 | & .metaIcon { 74 | display: none; 75 | } 76 | & .metaIconHover { 77 | display: block; 78 | } 79 | } 80 | .spin { 81 | animation: spin 1s linear infinite; 82 | } 83 | @keyframes spin { 84 | 0% { 85 | transform: rotate(0deg); 86 | } 87 | 100% { 88 | transform: rotate(360deg); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /webview-ui/src/components/FileList/index.tsx: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "../../Context/WorkspaceProvider/types"; 2 | import FileItem from "./FileItem"; 3 | 4 | type Props = { 5 | nodes: TreeNode[]; 6 | }; 7 | 8 | const FileList = ({ nodes }: Props) => { 9 | return ( 10 |
11 | {nodes.map((node) => ( 12 | 13 | ))} 14 |
15 | ); 16 | }; 17 | 18 | export default FileList; 19 | -------------------------------------------------------------------------------- /webview-ui/src/components/FileList/styles.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | user-select: none; 3 | width: 100%; 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | cursor: pointer; 8 | &:hover { 9 | background-color: var(--vscode-list-hoverBackground); 10 | } 11 | } 12 | .label { 13 | line-height: 1; 14 | display: flex; 15 | align-items: center; 16 | color: var(--vscode-foreground); 17 | overflow: hidden; 18 | flex: 1 1 auto; 19 | cursor: pointer; 20 | & .name { 21 | display: flex; 22 | align-items: center; 23 | padding: 6px 12px; 24 | width: 100%; 25 | flex: 1 1 auto; 26 | text-overflow: ellipsis; 27 | overflow: hidden; 28 | cursor: pointer; 29 | } 30 | & .icon { 31 | width: 15px; 32 | height: 14px; 33 | margin-right: 6px; 34 | cursor: pointer; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /webview-ui/src/components/FileNode/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { useState, useEffect } from "react"; 3 | import { useWorkspaceContext } from "../../Context"; 4 | import { TreeNode } from "../../Context/WorkspaceProvider/types"; 5 | import { ProcessIcon, WorkflowIcon } from "../../icons"; 6 | 7 | import styles from "./styles.module.css"; 8 | 9 | type Props = { 10 | node: TreeNode; 11 | level?: number; 12 | searchTerm?: string; 13 | }; 14 | 15 | const FileNode = ({ node, level = 0, searchTerm }: Props) => { 16 | const { findChildren, openFile } = useWorkspaceContext(); 17 | const [expanded, setExpanded] = useState(level < 1); 18 | const isWorkflow = node.type === "workflow"; 19 | 20 | useEffect(() => { 21 | if (searchTerm) setExpanded(true); 22 | if (!searchTerm) setExpanded(level < 1); 23 | }, [searchTerm]); 24 | 25 | function handleClick() { 26 | if (hasChildren && !searchTerm) setExpanded((prev) => !prev); 27 | openFile(node.path, node.line); 28 | } 29 | 30 | function isMatch(node: TreeNode): boolean { 31 | return ( 32 | !searchTerm || node.name.toLowerCase().includes(searchTerm.toLowerCase()) 33 | ); 34 | } 35 | 36 | function isRecursiveMatch(node: TreeNode): boolean { 37 | if (!node || !searchTerm) return true; 38 | 39 | if (isMatch(node)) return true; 40 | 41 | return findChildren(node).some(isRecursiveMatch); 42 | } 43 | 44 | const children = findChildren(node); 45 | const filteredChildren = searchTerm 46 | ? children.filter(isRecursiveMatch) 47 | : children; 48 | 49 | const hasChildren = filteredChildren.length > 0; 50 | if (!hasChildren && !isMatch(node)) return null; 51 | 52 | const Icon = isWorkflow ? WorkflowIcon : ProcessIcon; 53 | const iconClassName = isWorkflow ? styles.workflowIcon : styles.processIcon; 54 | 55 | return ( 56 |
62 | 69 | {hasChildren && expanded && ( 70 |
71 | {filteredChildren.map((child) => ( 72 | 78 | ))} 79 |
80 | )} 81 |
82 | ); 83 | }; 84 | 85 | export default FileNode; 86 | -------------------------------------------------------------------------------- /webview-ui/src/components/FileNode/styles.module.css: -------------------------------------------------------------------------------- 1 | .row { 2 | user-select: none; 3 | width: 100%; 4 | & .item { 5 | line-height: 1; 6 | display: flex; 7 | align-items: center; 8 | justify-content: space-between; 9 | width: 100%; 10 | cursor: pointer; 11 | color: var(--vscode-foreground); 12 | &:hover { 13 | background-color: var(--vscode-list-hoverBackground); 14 | } 15 | & .name { 16 | display: flex; 17 | align-items: center; 18 | padding: 5px 8px; 19 | width: 100%; 20 | } 21 | & i { 22 | transition: transform 0.2s; 23 | margin-right: 4px; 24 | } 25 | } 26 | & .children { 27 | padding: 0 0 4px 0; 28 | margin-left: 7px; 29 | border-left: 1px solid var(--vscode-tree-inactiveIndentGuidesStroke); 30 | } 31 | &.workflow { 32 | & > .item > .name { 33 | font-weight: bold; 34 | & i { 35 | opacity: 1; 36 | } 37 | } 38 | &.expanded { 39 | & > .item { 40 | & > i { 41 | transform: rotate(90deg); 42 | } 43 | } 44 | } 45 | } 46 | } 47 | .icon { 48 | margin-right: 6px; 49 | height: 13px; 50 | width: 20px; 51 | &.processIcon { 52 | width: 13px; 53 | height: 12px; 54 | } 55 | &.workflowIcon { 56 | opacity: 0.7; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /webview-ui/src/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styles from "./styles.module.css"; 3 | import clsx from "clsx"; 4 | 5 | type Props = { 6 | value: string; 7 | onChange: (value: string) => void; 8 | placeholder?: string; 9 | type?: "text" | "password" | "email" | "number"; 10 | disabled?: boolean; 11 | label?: string; 12 | className?: string; 13 | textarea?: boolean; 14 | lines?: number; 15 | }; 16 | 17 | const Input: React.FC = ({ 18 | value, 19 | onChange, 20 | placeholder = "", 21 | type = "text", 22 | disabled = false, 23 | label = "", 24 | className = "", 25 | textarea = false, 26 | lines = 10 27 | }) => { 28 | return ( 29 |
30 | {!!label && } 31 | {textarea ? ( 32 |