├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── dependencies.yml │ ├── main.yml │ ├── release.yml │ └── tag.yml ├── .gitignore ├── .nova ├── Configuration.json └── Tasks │ └── Development.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── rollup.config.main.js ├── src ├── applyLSPEdits.test.ts ├── applyLSPEdits.ts ├── applyWorkspaceEdit.test.ts ├── applyWorkspaceEdit.ts ├── commands │ ├── findReferences.test.ts │ ├── findReferences.ts │ ├── findSymbol.test.ts │ ├── findSymbol.ts │ ├── formatDocument.test.ts │ ├── formatDocument.ts │ ├── organizeImports.test.ts │ ├── organizeImports.ts │ ├── rename.test.ts │ ├── rename.ts │ ├── signatureHelp.test.ts │ └── signatureHelp.ts ├── informationView.test.ts ├── informationView.ts ├── isEnabledForJavascript.test.ts ├── isEnabledForJavascript.ts ├── lspNovaConversions.ts ├── main.test.ts ├── main.ts ├── novaUtils.ts ├── searchResults.test.ts ├── searchResults.ts ├── showLocation.test.ts ├── showLocation.ts ├── skipDestructiveOrganizeImports.test.ts ├── skipDestructiveOrganizeImports.ts ├── test.setup.ts ├── tsLibPath.test.ts ├── tsLibPath.ts ├── tsUserPreferences.test.ts └── tsUserPreferences.ts ├── test-workspaces ├── .nova │ └── Configuration.json ├── README.md ├── bar │ ├── README.md │ └── package-lock.json ├── baz │ └── README.md ├── example.js └── foo │ ├── README.md │ └── package-lock.json ├── tsconfig.json ├── typescript.novaextension ├── CHANGELOG.md ├── Images │ ├── README │ │ ├── example-code-actions.png │ │ ├── example-error.png │ │ ├── example-findsymbol.png │ │ ├── example-sidebar.png │ │ └── example-typeinfo.png │ ├── Search │ │ ├── Search.png │ │ ├── Search@2x.png │ │ └── metadata.json │ ├── SidebarLarge │ │ ├── SidebarLarge.png │ │ ├── SidebarLarge@2x.png │ │ └── metadata.json │ └── SidebarSmall │ │ ├── SidebarSmall.png │ │ ├── SidebarSmall@2x.png │ │ └── metadata.json ├── README.md ├── Syntaxes │ ├── cts.xml │ └── mts.xml ├── extension.json ├── extension.png ├── extension@2x.png ├── npm-shrinkwrap.json ├── package.json └── run.sh └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | "nova/nova": true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "prettier", 11 | ], 12 | parser: "@typescript-eslint/parser", 13 | parserOptions: { 14 | ecmaVersion: 2018, 15 | sourceType: "module", 16 | }, 17 | plugins: ["@typescript-eslint", "nova"], 18 | rules: {}, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [apexskier] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem with the extension's functionality 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | A clear and concise description of what the bug is. 10 | 11 | Any hard crashes of the editor should also be filed with Panic (Help > Report a Problem or Feature Request...). Feel free to cross-file them here as well. 12 | 13 | **To reproduce** 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Environment** 31 | 32 | - Nova version: 33 | - Extension version: 34 | - TypeScript version: 35 | - Sidebar information: 36 | - macOS version: 37 | - NodeJS information: 38 | - node version: 39 | - npm version: 40 | - installation method: 41 | 42 | **Extension console output** 43 | 44 | Turn on extension development in Nova in Preferences > General > Extension Development. Then open the extension console with Extensions > Show Extension Console and copy anything coming from the Source "TypeScript" or "TypeScript Language Server". 45 | Copying text is preferred over screenshots, for accessibility and searching. 46 | 47 | **Additional context** 48 | 49 | Add any other context about the problem here. 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Report to Panic 4 | url: "https://nova.app/help/?ext=TypeScript%20(apexskier.typescript)%20(reported%20from%20https%3A%2F%2Fgithub.com%2Fapexskier%2Fnova-typescript%2Fissues%2Fnew)" 5 | about: Please report any hard crashes directly to Panic 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request new functionality or improvements 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "01:00" 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: npm 10 | directory: "/typescript.novaextension" 11 | schedule: 12 | interval: monthly 13 | time: "01:00" 14 | open-pull-requests-limit: 10 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: "36 5 * * 6" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [javascript] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v2 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v1 31 | with: 32 | languages: ${{ matrix.language }} 33 | 34 | - name: Perform CodeQL Analysis 35 | uses: github/codeql-action/analyze@v1 36 | -------------------------------------------------------------------------------- /.github/workflows/dependencies.yml: -------------------------------------------------------------------------------- 1 | name: Dependency management 2 | # see https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 3 | # for more details on why and how 4 | 5 | on: 6 | workflow_run: 7 | workflows: [CI] 8 | branches-ignore: [main] 9 | types: [completed] 10 | 11 | jobs: 12 | auto-merge: 13 | runs-on: ubuntu-latest 14 | if: > 15 | ${{ 16 | github.actor == 'dependabot[bot]' && 17 | github.event.workflow_run.event == 'pull_request' && 18 | github.event.workflow_run.conclusion == 'success' 19 | }} 20 | 21 | steps: 22 | - name: Auto merge (dependency updates) 23 | uses: pascalgn/automerge-action@v0.14.2 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | MERGE_METHOD: squash 27 | MERGE_LABELS: "" 28 | MERGE_FILTER_AUTHOR: dependabot[bot] 29 | MERGE_FORKS: false 30 | MERGE_DELETE_BRANCH: true 31 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Setup Node.js environment 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "17" 22 | 23 | - name: Install dependencies 24 | run: yarn 25 | 26 | - name: Lint 27 | run: yarn lint 28 | 29 | - name: Build 30 | run: yarn build 31 | 32 | - name: Test 33 | run: yarn test 34 | 35 | - name: Ensure clean 36 | run: | 37 | git status --porcelain 38 | test -z "$(git status --porcelain)" 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release management 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: apexskier/github-release-commenter@v1 13 | with: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Tag creation 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Get the version 14 | id: version 15 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 16 | shell: bash 17 | 18 | - uses: apexskier/github-semver-parse@v1 19 | id: semver 20 | with: 21 | version: ${{ steps.version.outputs.VERSION }} 22 | 23 | - name: Release 24 | if: ${{ steps.semver.outputs.version }} 25 | uses: actions/create-release@v1 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.RELEASE_GH_TOKEN }} 28 | with: 29 | tag_name: ${{ steps.version.outputs.VERSION }} 30 | release_name: ${{ steps.version.outputs.VERSION }} 31 | prerelease: ${{ !!steps.semver.outputs.prerelease }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /typescript.novaextension/Scripts 4 | logs/ 5 | coverage/ 6 | -------------------------------------------------------------------------------- /.nova/Configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "apexskier.typescript.config.organizeImportsOnSave": "null", 3 | "editor.default_syntax": "typescript", 4 | "workspace.name": "TypeScript Extension" 5 | } 6 | -------------------------------------------------------------------------------- /.nova/Tasks/Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": { 3 | "build": { 4 | "enabled": true, 5 | "script": "npm run build" 6 | }, 7 | "run": { 8 | "enabled": true, 9 | "script": "npm run watch" 10 | } 11 | }, 12 | "environment": { 13 | "NO_UPDATE_NOTIFIER": "true" 14 | }, 15 | "identifier": "6FA1209E-7DBA-4707-8FE3-FDACFA6DEFB9", 16 | "openLogOnRun": "fail", 17 | "persistent": true 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | typescript.novaextension/CHANGELOG.md -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Development 4 | 5 | ### Running locally 6 | 7 | Clone this project, and open it in Nova. 8 | 9 | Run `yarn` in a terminal to install dependencies. 10 | 11 | Run the Development task to build scripts and auto-rebuild on file changes. 12 | 13 | Turn on extension development in Nova in Preferences > General > Extension Development. If you've installed the TypeScript extension from the Extension Library, disable it, then activate the local one with Extensions > Activate Project as Extension. 14 | 15 | ### Debugging 16 | 17 | To debug the underlying language server, modify the `run.sh` file to use the [`--inspect` flag](https://nodejs.org/en/docs/guides/debugging-getting-started/) and use [your preferred inspector to debug](https://nodejs.org/en/docs/guides/debugging-getting-started/#inspector-clients). 18 | 19 | To debug the underlying `tsserver` modify the language server code to exec it through `node` with [`--inspect`](https://nodejs.org/en/docs/guides/debugging-getting-started/). The file to be modified is `~/Library/Application Support/Nova/Extensions/apexskier.typescript/dependencyManagement/node_modules/typescript-language-server/lib/tsp-client.js`. (Set `args = ['node', '--inspect-brk', tsserverPath]` and replace `tsserverPath` with `'/usr/bin/env'` in `cp.fork`/`cp.spawn`.) You can increase server shutdown timeouts in the file `~/Library/Application Support/Nova/Extensions/apexskier.typescript/dependencyManagement/node_modules/typescript-language-server/lib/utils.js` 20 | 21 | Use the Extension Console in Nova to debug the extension. I haven't found a way to get a debugger attached to the JavaScriptCore context. 22 | 23 | ### Extension dependencies 24 | 25 | The extension relies on local copies of both `typescript-language-server` and `typescript` itself. To avoid a large bundled extension size (Panic has a limit, you'll get 500 errors when submitting above ~50mb) these are locked with a [`shrinkwrap file`](https://docs.npmjs.com/configuring-npm/shrinkwrap-json.html). 26 | 27 | To update, run `npm install ...` locally in the `typescript.novaextension` directory. The shrinkwrap file should be updated automatically. 28 | 29 | ## Pull Requests 30 | 31 | ### Changelog 32 | 33 | All user-facing changes should be documented in [CHANGELOG.md](./CHANGELOG.md). 34 | 35 | - If not present, add a `## future` section above the latest release 36 | - If not present, add a `###` heading for the category of your changes. Categories can include 37 | - Breaking - backwards incompatible changes (semver major version bump) 38 | - Added - new features (semver minor version bump) 39 | - Fixed - bugfixes (semver patch version bump) 40 | - Changed - tweaks or changes that don't significantly change how the extension is used 41 | - Add a single line for each change you've made 42 | 43 | ## Publishing notes 44 | 45 | Run `yarn build` first. 46 | 47 | Replace `future` in the changelog with a new version, following semver. Update the version in the [bug report template](./.github/ISSUE_TEMPLATE/bug_report.md) and [extension manifest](./typescript.novaextension/extension.json). 48 | 49 | Publish to the extension library (Extensions > Submit to the Extension Library…). 50 | 51 | **On the same commit** create and push a tag for the version `git tag $VERSION && git push --tags`. A github action will create a release and perform some automated housekeeping. 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cameron Little 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️ **Archived**: Although it was fun to build my nova extensions, I haven't ended up using Nova regularly and can't devote the time to maintenance. Feel free to reach out if you want to fork and start maintaining an alternative. 2 | 3 | # TypeScript support for Nova 4 | 5 | This is a plugin providing TypeScript and advanced JavaScript language support for the new [Nova editor from Panic](https://panic.com/nova/). 6 | 7 | [**Install now**](https://camlittle.com/typescript.novaextension) 8 | 9 | [Extension README](./typescript.novaextension/README.md) 10 | 11 | ## Writing Nova extensions in TypeScript 12 | 13 | This extension is written in TypeScript. To support this I've contributed Nova extension type declarations to DefinitelyTyped. To use them, add `@types/nova-editor` (or `@types/nova-editor-node`, see [why you might need this](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/nova-editor/README.md)) to your development dependencies. 14 | 15 | ## Notes 16 | 17 | Nova's language server support conforms to the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/). Unfortunately, [TypeScript's server](https://github.com/Microsoft/TypeScript/wiki/Standalone-Server-%28tsserver%29) doesn't ([but might in the future - follow this ticket](https://github.com/microsoft/TypeScript/issues/39459)). This extension uses [`typescript-language-server`](https://github.com/theia-ide/typescript-language-server/) to translate between the Language Server Protocol and `tsserver`. 18 | 19 | ## Images 20 | 21 | Custom icons have been created in Figma. View or contribute at https://www.figma.com/file/po3JE7AsJcpr0XyhAsfGH3/. 22 | 23 | The main logo comes from https://github.com/remojansen/logo.ts/. 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | setupFiles: ["./src/test.setup.ts"], 4 | collectCoverageFrom: ["./src/**"], 5 | coverageReporters: ["html"], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nova-typescript", 3 | "version": "0.0.0", 4 | "description": "TypeScript language support for Nova.", 5 | "main": "", 6 | "private": true, 7 | "scripts": { 8 | "build": "rollup -c rollup.config.main.js && rm -rf typescript.novaextension/node_modules", 9 | "test": "jest", 10 | "lint": "concurrently 'yarn:lint:*'", 11 | "lint:eslint": "eslint --ignore-path .gitignore \"**/*.{ts,js}\"", 12 | "lint:prettier": "prettier --ignore-path .gitignore --check \"**/*.{ts,js,json,md,yml}\"", 13 | "lint:json": "find . -name node_modules -prune -false -o -type f -name '*.json' -exec node -e 'require(\"{}\")' \\;", 14 | "fix": "concurrently 'yarn:fix:*'", 15 | "fix:eslint": "eslint --fix --ignore-path .gitignore \"**/*.{ts,js}\"", 16 | "fix:prettier": "prettier --ignore-path .gitignore --write \"**/*.{ts,js,json,md,yml}\"", 17 | "watch": "onchange -i \"src/**\" \"rollup.*.js\" -- npm run build" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/apexskier/nova-typescript.git" 22 | }, 23 | "author": "Cameron Little", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/apexskier/nova-typescript/issues" 27 | }, 28 | "homepage": "https://github.com/apexskier/nova-typescript", 29 | "devDependencies": { 30 | "@rollup/plugin-commonjs": "^25.0.7", 31 | "@rollup/plugin-node-resolve": "^15.2.3", 32 | "@types/jest": "^27.4.1", 33 | "@types/nova-editor-node": "^5.1.4", 34 | "@typescript-eslint/eslint-plugin": "^4.33.0", 35 | "@typescript-eslint/parser": "^4.33.0", 36 | "concurrently": "^8.2.2", 37 | "eslint": "^7.32.0", 38 | "eslint-config-prettier": "^9.0.0", 39 | "eslint-plugin-nova": "^1.7.0", 40 | "jest": "^26.6.3", 41 | "nova-extension-utils": "^1.4.0", 42 | "onchange": "^7.1.0", 43 | "prettier": "^2.8.8", 44 | "rollup": "^2.79.1", 45 | "rollup-plugin-typescript2": "^0.35.0", 46 | "ts-jest": "^26.5.6", 47 | "typescript": "^4.9.5", 48 | "vscode-languageserver-protocol": "^3.17.5", 49 | "vscode-languageserver-types": "^3.17.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /rollup.config.main.js: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import typescript from "rollup-plugin-typescript2"; 4 | 5 | export default { 6 | input: "src/main.ts", 7 | plugins: [typescript(), commonjs(), resolve()], 8 | output: { 9 | file: "typescript.novaextension/Scripts/main.dist.js", 10 | sourcemap: true, 11 | format: "cjs", 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/applyLSPEdits.test.ts: -------------------------------------------------------------------------------- 1 | import { applyLSPEdits } from "./applyLSPEdits"; 2 | 3 | jest.mock("./novaUtils"); 4 | jest.mock("./lspNovaConversions", () => ({ 5 | lspRangeToRange(_: unknown, r: unknown) { 6 | return r; 7 | }, 8 | })); 9 | 10 | describe("Apply lsp edits", () => { 11 | it("applies changes to files", async () => { 12 | const mockEditor = { edit: jest.fn() }; 13 | 14 | const edit1 = { 15 | range: { 16 | start: { line: 1, character: 2 }, 17 | end: { line: 1, character: 5 }, 18 | }, 19 | newText: "newText1", 20 | }; 21 | const edit2 = { 22 | range: { 23 | start: { line: 4, character: 0 }, 24 | end: { line: 5, character: 0 }, 25 | }, 26 | newText: "newText2", 27 | }; 28 | await applyLSPEdits(mockEditor as unknown as TextEditor, [edit1, edit2]); 29 | 30 | // file edit callbacks should apply changes 31 | const editCB = mockEditor.edit.mock.calls[0][0]; 32 | const replaceMock = jest.fn(); 33 | editCB({ replace: replaceMock }); 34 | expect(replaceMock).toBeCalledTimes(2); 35 | // in reverse order 36 | expect(replaceMock).toHaveBeenNthCalledWith(1, edit2.range, edit2.newText); 37 | expect(replaceMock).toHaveBeenNthCalledWith(2, edit1.range, edit1.newText); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/applyLSPEdits.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import { lspRangeToRange } from "./lspNovaConversions"; 3 | 4 | export async function applyLSPEdits( 5 | editor: TextEditor, 6 | edits: Array 7 | ) { 8 | editor.edit((textEditorEdit) => { 9 | for (const change of edits.reverse()) { 10 | const range = lspRangeToRange(editor.document, change.range); 11 | textEditorEdit.replace(range, change.newText); 12 | } 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/applyWorkspaceEdit.test.ts: -------------------------------------------------------------------------------- 1 | import { applyWorkspaceEdit } from "./applyWorkspaceEdit"; 2 | import * as novaUtils from "./novaUtils"; 3 | 4 | jest.mock("./novaUtils"); 5 | jest.mock("./lspNovaConversions", () => ({ 6 | lspRangeToRange(_: unknown, r: unknown) { 7 | return r; 8 | }, 9 | })); 10 | 11 | describe("Apply workspace edit", () => { 12 | it("can be a noop", async () => { 13 | await applyWorkspaceEdit({}); 14 | }); 15 | 16 | it("applies changes to files", async () => { 17 | class Editor { 18 | // eslint-disable-next-line no-unused-vars 19 | constructor(readonly fileURI: string) {} 20 | edit = jest.fn(); 21 | } 22 | (novaUtils as any).openFile = jest.fn((uri) => new Editor(uri)); 23 | 24 | const edit1 = { 25 | range: { 26 | start: { line: 1, character: 2 }, 27 | end: { line: 1, character: 5 }, 28 | }, 29 | newText: "newText1", 30 | }; 31 | const edit2 = { 32 | range: { 33 | start: { line: 4, character: 0 }, 34 | end: { line: 5, character: 0 }, 35 | }, 36 | newText: "newText2", 37 | }; 38 | await applyWorkspaceEdit({ 39 | changes: { 40 | fileURI1: [edit1], 41 | fileURI2: [edit1, edit2], 42 | fileURI3: [], 43 | }, 44 | }); 45 | 46 | // each file with changes should be opened 47 | expect(novaUtils.openFile).toBeCalledTimes(2); 48 | const openFileMock = (novaUtils.openFile as jest.Mock).mock; 49 | expect(novaUtils.openFile).toHaveBeenNthCalledWith(1, "fileURI1"); 50 | expect(novaUtils.openFile).toHaveBeenNthCalledWith(2, "fileURI2"); 51 | // each file should be edited 52 | const file1: TextEditor = openFileMock.results[0].value; 53 | expect(file1.edit).toBeCalledTimes(1); 54 | const file2: TextEditor = openFileMock.results[1].value; 55 | expect(file2.edit).toBeCalledTimes(1); 56 | 57 | // file edit callbacks should apply changes 58 | const file1EditCB = (file1.edit as jest.Mock).mock.calls[0][0]; 59 | const replaceMock1 = jest.fn(); 60 | file1EditCB({ replace: replaceMock1 }); 61 | const file2EditCB = (file2.edit as jest.Mock).mock.calls[0][0]; 62 | const replaceMock2 = jest.fn(); 63 | file2EditCB({ replace: replaceMock2 }); 64 | expect(replaceMock1).toBeCalledTimes(1); 65 | expect(replaceMock1).toBeCalledWith(edit1.range, edit1.newText); 66 | expect(replaceMock2).toBeCalledTimes(2); 67 | // reverse order 68 | expect(replaceMock2).toHaveBeenNthCalledWith(1, edit2.range, edit2.newText); 69 | expect(replaceMock2).toHaveBeenNthCalledWith(2, edit1.range, edit1.newText); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/applyWorkspaceEdit.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import { applyLSPEdits } from "./applyLSPEdits"; 3 | import { openFile } from "./novaUtils"; 4 | 5 | // @Deprecated I want to replace this with a call to Nova's client with workspace/applyEdit, but that's currently not possible. 6 | // I've requested this feature. 7 | export async function applyWorkspaceEdit( 8 | workspaceEdit: lspTypes.WorkspaceEdit 9 | ) { 10 | // TODO: support .documentChanges in applyWorkspaceEdit 11 | if (!workspaceEdit.changes) { 12 | return; 13 | } 14 | // this could be parallelized 15 | for (const uri in workspaceEdit.changes) { 16 | const changes = workspaceEdit.changes[uri]; 17 | if (!changes.length) { 18 | continue; 19 | } 20 | const editor = await openFile(uri); 21 | if (!editor) { 22 | nova.workspace.showWarningMessage(`Failed to open ${uri}`); 23 | continue; 24 | } 25 | 26 | applyLSPEdits(editor, changes); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/findReferences.test.ts: -------------------------------------------------------------------------------- 1 | import * as searchResultsModule from "../searchResults"; 2 | import { registerFindReferences } from "./findReferences"; 3 | 4 | jest.mock("../searchResults"); 5 | 6 | describe("findReferences command", () => { 7 | beforeEach(() => { 8 | (global as any).nova = Object.assign(nova, { 9 | commands: { 10 | register: jest.fn(), 11 | }, 12 | workspace: { 13 | showErrorMessage(err: Error) { 14 | throw err; 15 | }, 16 | showInformativeMessage: jest.fn(), 17 | }, 18 | }); 19 | }); 20 | 21 | const mockEditor = { 22 | selectedRange: { 23 | start: 0, 24 | end: 0, 25 | }, 26 | document: { 27 | getTextInRange() { 28 | return ""; 29 | }, 30 | eol: "\n", 31 | }, 32 | }; 33 | 34 | function getCommand( 35 | languageClient: LanguageClient, 36 | // eslint-disable-next-line no-unused-vars 37 | register: (client: LanguageClient) => Disposable 38 | // eslint-disable-next-line no-unused-vars 39 | ): (...args: Array) => Promise { 40 | register(languageClient); 41 | expect(nova.commands.register).toHaveBeenCalledWith( 42 | "apexskier.typescript.findReferences", 43 | expect.any(Function) 44 | ); 45 | const command = (nova.commands.register as jest.Mock).mock.calls[0][1]; 46 | (nova.commands.register as jest.Mock).mockClear(); 47 | return command; 48 | } 49 | 50 | it("warns if no references are available", async () => { 51 | const mockLanguageClient = { 52 | sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve(null)), 53 | }; 54 | const command = getCommand( 55 | mockLanguageClient as any as LanguageClient, 56 | registerFindReferences 57 | ); 58 | await command(mockEditor); 59 | 60 | expect(nova.workspace.showInformativeMessage).toBeCalledTimes(1); 61 | expect(nova.workspace.showInformativeMessage).toHaveBeenCalledWith( 62 | "Couldn't find references." 63 | ); 64 | }); 65 | 66 | it("finds references", async () => { 67 | (global as any).console.info = jest.fn(); 68 | const mockLanguageClient = { 69 | sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve([])), 70 | }; 71 | const command = getCommand( 72 | mockLanguageClient as any as LanguageClient, 73 | registerFindReferences 74 | ); 75 | await command(mockEditor); 76 | 77 | expect(mockLanguageClient.sendRequest).toHaveBeenNthCalledWith( 78 | 1, 79 | "textDocument/references", 80 | expect.anything() 81 | ); 82 | 83 | expect(searchResultsModule.createLocationSearchResultsTree).toBeCalledTimes( 84 | 1 85 | ); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/commands/findReferences.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import { wrapCommand } from "../novaUtils"; 3 | import { rangeToLspRange } from "../lspNovaConversions"; 4 | import { createLocationSearchResultsTree } from "../searchResults"; 5 | 6 | // @Panic: this is totally decoupled from typescript, so it could totally be native to Nova 7 | 8 | export function registerFindReferences(client: LanguageClient) { 9 | return nova.commands.register( 10 | "apexskier.typescript.findReferences", 11 | wrapCommand(findReferences) 12 | ); 13 | 14 | async function findReferences(editor: TextEditor) { 15 | const selectedRange = editor.selectedRange; 16 | const selectedText = editor.selectedText; 17 | const selectedPosition = rangeToLspRange( 18 | editor.document, 19 | selectedRange 20 | )?.start; 21 | if (!selectedPosition) { 22 | nova.workspace.showWarningMessage( 23 | "Couldn't figure out what you've selected." 24 | ); 25 | return; 26 | } 27 | const params: lspTypes.ReferenceParams = { 28 | textDocument: { uri: editor.document.uri }, 29 | position: selectedPosition, 30 | context: { 31 | includeDeclaration: true, 32 | }, 33 | }; 34 | const response = (await client.sendRequest( 35 | "textDocument/references", 36 | params 37 | )) as lspTypes.Location[] | null; 38 | if (response == null) { 39 | nova.workspace.showInformativeMessage("Couldn't find references."); 40 | return; 41 | } 42 | 43 | createLocationSearchResultsTree(selectedText, response); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/findSymbol.test.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import * as searchResultsModule from "../searchResults"; 3 | import { registerFindSymbol } from "./findSymbol"; 4 | 5 | jest.mock("../searchResults"); 6 | 7 | describe("findSymbol command", () => { 8 | beforeEach(() => { 9 | (global as any).nova = Object.assign(nova, { 10 | commands: { 11 | register: jest.fn(), 12 | }, 13 | workspace: { 14 | showErrorMessage(err: Error) { 15 | throw err; 16 | }, 17 | }, 18 | }); 19 | }); 20 | 21 | function getCommand( 22 | languageClient: LanguageClient, 23 | // eslint-disable-next-line no-unused-vars 24 | register: (client: LanguageClient) => Disposable 25 | // eslint-disable-next-line no-unused-vars 26 | ): (...args: Array) => Promise { 27 | register(languageClient); 28 | expect(nova.commands.register).toHaveBeenCalledWith( 29 | "apexskier.typescript.findSymbol", 30 | expect.any(Function) 31 | ); 32 | const command = (nova.commands.register as jest.Mock).mock.calls[0][1]; 33 | (nova.commands.register as jest.Mock).mockClear(); 34 | return command; 35 | } 36 | 37 | it("noop if no symbol searched for", async () => { 38 | const mockLanguageClient = {}; 39 | const mockWorkspace = { 40 | showInputPalette: jest.fn((prompt, options, callback) => callback("")), 41 | }; 42 | const command = getCommand( 43 | mockLanguageClient as any as LanguageClient, 44 | registerFindSymbol 45 | ); 46 | await command(mockWorkspace); 47 | 48 | expect(mockWorkspace.showInputPalette).toHaveBeenCalledTimes(1); 49 | }); 50 | 51 | it("warns if symbol can't be found", async () => { 52 | const mockWorkspace = { 53 | showInputPalette: jest.fn((prompt, options, callback) => 54 | callback("symbol") 55 | ), 56 | showWarningMessage: jest.fn(), 57 | }; 58 | const mockLanguageClient = { 59 | sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve(null)), 60 | }; 61 | const command = getCommand( 62 | mockLanguageClient as any as LanguageClient, 63 | registerFindSymbol 64 | ); 65 | await command(mockWorkspace); 66 | 67 | expect(mockWorkspace.showWarningMessage).toBeCalledTimes(1); 68 | expect(mockWorkspace.showWarningMessage).toHaveBeenCalledWith( 69 | "Couldn't find symbol." 70 | ); 71 | }); 72 | 73 | it("finds symbol", async () => { 74 | const mockWorkspace = { 75 | showInputPalette: jest.fn((prompt, options, callback) => 76 | callback("symbol") 77 | ), 78 | }; 79 | const results: lspTypes.SymbolInformation[] = [ 80 | { 81 | name: "result", 82 | kind: 1, 83 | location: { 84 | uri: "fileURI", 85 | range: { 86 | start: { line: 0, character: 0 }, 87 | end: { line: 0, character: 0 }, 88 | }, 89 | }, 90 | }, 91 | ]; 92 | const mockLanguageClient = { 93 | sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve(results)), 94 | }; 95 | const command = getCommand( 96 | mockLanguageClient as any as LanguageClient, 97 | registerFindSymbol 98 | ); 99 | await command(mockWorkspace); 100 | 101 | expect(mockLanguageClient.sendRequest).toHaveBeenNthCalledWith( 102 | 1, 103 | "workspace/symbol", 104 | { query: "symbol" } 105 | ); 106 | expect(searchResultsModule.createSymbolSearchResultsTree).toBeCalledWith( 107 | results 108 | ); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/commands/findSymbol.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import { wrapCommand } from "../novaUtils"; 3 | import { createSymbolSearchResultsTree } from "../searchResults"; 4 | 5 | // @Panic: this is totally decoupled from typescript, so it could totally be native to Nova 6 | 7 | export function registerFindSymbol(client: LanguageClient) { 8 | let query: string | null = null; 9 | 10 | return nova.commands.register( 11 | "apexskier.typescript.findSymbol", 12 | wrapCommand(findSymbol) 13 | ); 14 | 15 | async function findSymbol(workspace: Workspace) { 16 | query = await new Promise((resolve) => { 17 | const options = query != null ? { placeholder: query } : {}; 18 | workspace.showInputPalette("Search for a symbol name", options, resolve); 19 | }); 20 | 21 | if (!query) { 22 | return; 23 | } 24 | 25 | const params: lspTypes.WorkspaceSymbolParams = { 26 | query, 27 | }; 28 | const response = (await client.sendRequest("workspace/symbol", params)) as 29 | | lspTypes.SymbolInformation[] 30 | | null; 31 | if (response == null || !response.length) { 32 | workspace.showWarningMessage("Couldn't find symbol."); 33 | return; 34 | } 35 | 36 | createSymbolSearchResultsTree(response); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/formatDocument.test.ts: -------------------------------------------------------------------------------- 1 | import { registerFormatDocument } from "./formatDocument"; 2 | 3 | class MockRange { 4 | // eslint-disable-next-line no-unused-vars 5 | constructor(readonly start: number, readonly end: number) {} 6 | } 7 | (global as any).Range = MockRange; 8 | 9 | describe("formatDocument command", () => { 10 | beforeEach(() => { 11 | (global as any).nova = Object.assign(nova, { 12 | commands: { 13 | register: jest.fn(), 14 | }, 15 | workspace: { 16 | showErrorMessage(err: Error) { 17 | throw err; 18 | }, 19 | showWarningMessage: jest.fn(), 20 | }, 21 | }); 22 | 23 | mockEditor.edit.mockClear(); 24 | mockEditor.scrollToCursorPosition.mockClear(); 25 | mockEditor.selectWordsContainingCursors.mockClear(); 26 | }); 27 | 28 | const mockEditor = { 29 | selectedRanges: [new Range(2, 3)], 30 | selectedText: "selectedText", 31 | document: { 32 | length: 10, 33 | uri: "currentDocURI", 34 | path: "/path", 35 | getTextInRange() { 36 | return ""; 37 | }, 38 | eol: "\n", 39 | }, 40 | softTabs: true, 41 | tabLength: 2, 42 | edit: jest.fn(), 43 | selectWordsContainingCursors: jest.fn(), 44 | scrollToCursorPosition: jest.fn(), 45 | }; 46 | 47 | function getCommand( 48 | languageClient: LanguageClient, 49 | // eslint-disable-next-line no-unused-vars 50 | register: (client: LanguageClient) => Disposable 51 | // eslint-disable-next-line no-unused-vars 52 | ): (...args: Array) => Promise { 53 | register(languageClient); 54 | expect(nova.commands.register).toHaveBeenCalledWith( 55 | "apexskier.typescript.commands.formatDocument", 56 | expect.any(Function) 57 | ); 58 | const command = (nova.commands.register as jest.Mock).mock.calls[0][1]; 59 | (nova.commands.register as jest.Mock).mockClear(); 60 | return command; 61 | } 62 | 63 | it("applies changes from server to format document", async () => { 64 | const mockLanguageClient = { 65 | sendRequest: jest.fn().mockImplementationOnce(() => []), 66 | }; 67 | const command = getCommand( 68 | mockLanguageClient as any as LanguageClient, 69 | registerFormatDocument 70 | ); 71 | await command(mockEditor); 72 | 73 | expect(mockLanguageClient.sendRequest).toBeCalledTimes(1); 74 | expect(mockLanguageClient.sendRequest).toHaveBeenCalledWith( 75 | "textDocument/formatting", 76 | { 77 | textDocument: { uri: "currentDocURI" }, 78 | options: { 79 | insertSpaces: true, 80 | tabSize: 2, 81 | }, 82 | } 83 | ); 84 | expect(mockEditor.edit).toBeCalledTimes(1); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/commands/formatDocument.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import { applyLSPEdits } from "../applyLSPEdits"; 3 | import { wrapCommand } from "../novaUtils"; 4 | 5 | export function registerFormatDocument(client: LanguageClient) { 6 | return nova.commands.register( 7 | "apexskier.typescript.commands.formatDocument", 8 | wrapCommand(formatDocument) 9 | ); 10 | 11 | // eslint-disable-next-line no-unused-vars 12 | async function formatDocument(editor: TextEditor): Promise; 13 | async function formatDocument( 14 | // eslint-disable-next-line no-unused-vars 15 | workspace: Workspace, 16 | // eslint-disable-next-line no-unused-vars 17 | editor: TextEditor 18 | ): Promise; 19 | async function formatDocument( 20 | editorOrWorkspace: TextEditor | Workspace, 21 | maybeEditor?: TextEditor 22 | ) { 23 | const editor: TextEditor = maybeEditor ?? (editorOrWorkspace as TextEditor); 24 | 25 | const documentFormatting: lspTypes.DocumentFormattingParams = { 26 | textDocument: { uri: editor.document.uri }, 27 | options: { 28 | insertSpaces: editor.softTabs, 29 | tabSize: editor.tabLength, 30 | }, 31 | }; 32 | const changes = (await client.sendRequest( 33 | "textDocument/formatting", 34 | documentFormatting 35 | )) as null | Array; 36 | 37 | if (!changes) { 38 | return; 39 | } 40 | 41 | applyLSPEdits(editor, changes); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/organizeImports.test.ts: -------------------------------------------------------------------------------- 1 | import { registerOrganizeImports } from "./organizeImports"; 2 | 3 | class MockRange { 4 | // eslint-disable-next-line no-unused-vars 5 | constructor(readonly start: number, readonly end: number) {} 6 | } 7 | (global as any).Range = MockRange; 8 | 9 | jest.mock("../skipDestructiveOrganizeImports", () => ({ 10 | skipDestructiveOrganizeImports: () => false, 11 | })); 12 | 13 | describe("organizeImports command", () => { 14 | beforeEach(() => { 15 | (global as any).nova = Object.assign(nova, { 16 | commands: { 17 | register: jest.fn(), 18 | }, 19 | workspace: { 20 | showErrorMessage(err: Error) { 21 | throw err; 22 | }, 23 | showWarningMessage: jest.fn(), 24 | }, 25 | }); 26 | 27 | mockEditor.edit.mockClear(); 28 | mockEditor.scrollToCursorPosition.mockClear(); 29 | mockEditor.selectWordsContainingCursors.mockClear(); 30 | }); 31 | 32 | const mockEditor = { 33 | selectedRanges: [new Range(2, 3)], 34 | selectedText: "selectedText", 35 | document: { 36 | length: 10, 37 | uri: "currentDocURI", 38 | path: "/path", 39 | getTextInRange() { 40 | return ""; 41 | }, 42 | eol: "\n", 43 | }, 44 | softTabs: true, 45 | tabLength: 2, 46 | edit: jest.fn(), 47 | selectWordsContainingCursors: jest.fn(), 48 | scrollToCursorPosition: jest.fn(), 49 | }; 50 | 51 | function getCommand( 52 | languageClient: LanguageClient, 53 | // eslint-disable-next-line no-unused-vars 54 | register: (client: LanguageClient) => Disposable 55 | // eslint-disable-next-line no-unused-vars 56 | ): (...args: Array) => Promise { 57 | register(languageClient); 58 | expect(nova.commands.register).toHaveBeenCalledWith( 59 | "apexskier.typescript.commands.organizeImports", 60 | expect.any(Function) 61 | ); 62 | const command = (nova.commands.register as jest.Mock).mock.calls[0][1]; 63 | (nova.commands.register as jest.Mock).mockClear(); 64 | return command; 65 | } 66 | 67 | it("configures formatting with the server, asks the server to organize imports, then resets your selection", async () => { 68 | const mockLanguageClient = { 69 | sendRequest: jest.fn().mockImplementationOnce(() => { 70 | mockEditor.document.length = 14; 71 | }), 72 | }; 73 | const command = getCommand( 74 | mockLanguageClient as any as LanguageClient, 75 | registerOrganizeImports 76 | ); 77 | expect(mockEditor.selectedRanges).toEqual([new Range(2, 3)]); 78 | await command(mockEditor); 79 | 80 | expect(mockLanguageClient.sendRequest).toBeCalledTimes(2); 81 | expect(mockLanguageClient.sendRequest).toHaveBeenNthCalledWith( 82 | 1, 83 | "textDocument/formatting", 84 | { 85 | textDocument: { uri: "currentDocURI" }, 86 | options: { 87 | insertSpaces: true, 88 | tabSize: 2, 89 | }, 90 | } 91 | ); 92 | expect(mockLanguageClient.sendRequest).toHaveBeenNthCalledWith( 93 | 2, 94 | "workspace/executeCommand", 95 | { 96 | arguments: ["/path", { skipDestructiveCodeActions: false }], 97 | command: "_typescript.organizeImports", 98 | } 99 | ); 100 | expect(mockEditor.selectedRanges).toEqual([new Range(6, 7)]); 101 | expect(mockEditor.scrollToCursorPosition).toBeCalledTimes(1); 102 | }); 103 | 104 | it("doesn't reset scroll to a negative value", async () => { 105 | const mockLanguageClient = { 106 | sendRequest: jest.fn().mockImplementationOnce(() => { 107 | mockEditor.document.length = 0; 108 | }), 109 | }; 110 | const command = getCommand( 111 | mockLanguageClient as any as LanguageClient, 112 | registerOrganizeImports 113 | ); 114 | await command(mockEditor); 115 | expect(mockEditor.selectedRanges).toEqual([new Range(0, 0)]); 116 | expect(mockEditor.scrollToCursorPosition).toBeCalledTimes(1); 117 | }); 118 | 119 | it("warns if the document isn't saved", async () => { 120 | const mockLanguageClient = { 121 | sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve(null)), 122 | }; 123 | mockEditor.document.path = ""; 124 | const command = getCommand( 125 | mockLanguageClient as any as LanguageClient, 126 | registerOrganizeImports 127 | ); 128 | await command(mockEditor); 129 | 130 | expect(nova.workspace.showWarningMessage).toBeCalledTimes(1); 131 | expect(nova.workspace.showWarningMessage).toHaveBeenCalledWith( 132 | "Please save this document before organizing imports." 133 | ); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/commands/organizeImports.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import { wrapCommand } from "../novaUtils"; 3 | import { skipDestructiveOrganizeImports } from "../skipDestructiveOrganizeImports"; 4 | 5 | // NOTE: this is explicitly built for the typescript-language-server; it directly invokes the specific command it uses. 6 | // In order to decouple and become LSP generic, we'd need to first send a code action request for only 7 | // lspTypes.CodeActionKind.SourceOrganizeImports, then process the response's code action or commands. 8 | // That would mean reimplementing that processing in the extension, which I don't like. 9 | // Related conversation at https://devforum.nova.app/t/ability-to-send-lsp-messages-to-nova/466 10 | 11 | export function registerOrganizeImports(client: LanguageClient) { 12 | return nova.commands.register( 13 | "apexskier.typescript.commands.organizeImports", 14 | wrapCommand(organizeImports) 15 | ); 16 | 17 | // eslint-disable-next-line no-unused-vars 18 | async function organizeImports(editor: TextEditor): Promise; 19 | async function organizeImports( 20 | // eslint-disable-next-line no-unused-vars 21 | workspace: Workspace, 22 | // eslint-disable-next-line no-unused-vars 23 | editor: TextEditor 24 | ): Promise; 25 | async function organizeImports( 26 | editorOrWorkspace: TextEditor | Workspace, 27 | maybeEditor?: TextEditor 28 | ) { 29 | const editor: TextEditor = maybeEditor ?? (editorOrWorkspace as TextEditor); 30 | 31 | const originalSelections = editor.selectedRanges; 32 | const originalLength = editor.document.length; 33 | 34 | if (!editor.document.path) { 35 | nova.workspace.showWarningMessage( 36 | "Please save this document before organizing imports." 37 | ); 38 | return; 39 | } 40 | 41 | // Ensure the language server is aware of the formatting settings for this editor 42 | // Normally this command is used to apply formatting, but we just skip applying 43 | // the response and rely on the server caching the formatting settings. 44 | const documentFormatting: lspTypes.DocumentFormattingParams = { 45 | textDocument: { uri: editor.document.uri }, 46 | options: { 47 | insertSpaces: editor.softTabs, 48 | tabSize: editor.tabLength, 49 | }, 50 | }; 51 | await client.sendRequest("textDocument/formatting", documentFormatting); 52 | 53 | const organizeImportsCommand: lspTypes.ExecuteCommandParams = { 54 | command: "_typescript.organizeImports", 55 | arguments: [ 56 | editor.document.path, 57 | { skipDestructiveCodeActions: skipDestructiveOrganizeImports() }, 58 | ], 59 | }; 60 | await client.sendRequest( 61 | "workspace/executeCommand", 62 | organizeImportsCommand 63 | ); 64 | 65 | // Move selection/cursor back to where it was 66 | // NOTE: this isn't fully perfect, since it doesn't know where the changes were made. 67 | // If your cursor is above the imports it won't be returned properly. 68 | // I'm okay with this for now. To fully fix it we'd need to do the import organization manually 69 | // based on an explicit code action, which isn't worth it. 70 | const newLength = editor.document.length; 71 | const lengthChange = originalLength - newLength; 72 | editor.selectedRanges = originalSelections.map( 73 | (r) => 74 | new Range( 75 | Math.max(0, r.start - lengthChange), 76 | Math.max(0, r.end - lengthChange) 77 | ) 78 | ); 79 | editor.scrollToCursorPosition(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/commands/rename.test.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import * as applyWorkspaceEditModule from "../applyWorkspaceEdit"; 3 | import { registerRename } from "./rename"; 4 | 5 | jest.mock("../applyWorkspaceEdit"); 6 | 7 | describe("rename command", () => { 8 | beforeEach(() => { 9 | (global as any).nova = Object.assign(nova, { 10 | commands: { 11 | register: jest.fn(), 12 | }, 13 | workspace: { 14 | showErrorMessage(err: Error) { 15 | throw err; 16 | }, 17 | showWarningMessage: jest.fn(), 18 | showInputPalette: jest.fn((prompt, options, callback) => 19 | callback("newName") 20 | ), 21 | openFile: jest.fn().mockReturnValue(Promise.resolve(mockEditor)), 22 | }, 23 | }); 24 | 25 | mockEditor.edit.mockClear(); 26 | mockEditor.scrollToCursorPosition.mockClear(); 27 | mockEditor.selectWordsContainingCursors.mockClear(); 28 | }); 29 | 30 | const mockEditor = { 31 | selectedRange: { 32 | start: 0, 33 | end: 0, 34 | }, 35 | selectedText: "selectedText", 36 | document: { 37 | uri: "currentDocURI", 38 | getTextInRange() { 39 | return ""; 40 | }, 41 | eol: "\n", 42 | }, 43 | edit: jest.fn(), 44 | selectWordsContainingCursors: jest.fn(), 45 | scrollToCursorPosition: jest.fn(), 46 | }; 47 | 48 | function getCommand( 49 | languageClient: LanguageClient, 50 | // eslint-disable-next-line no-unused-vars 51 | register: (client: LanguageClient) => Disposable 52 | // eslint-disable-next-line no-unused-vars 53 | ): (...args: Array) => Promise { 54 | register(languageClient); 55 | expect(nova.commands.register).toHaveBeenCalledWith( 56 | "apexskier.typescript.rename", 57 | expect.any(Function) 58 | ); 59 | const command = (nova.commands.register as jest.Mock).mock.calls[0][1]; 60 | (nova.commands.register as jest.Mock).mockClear(); 61 | return command; 62 | } 63 | 64 | it("selects the full symbol selected, then asks for a new name, then applies edits, then returns to original place", async () => { 65 | const response: lspTypes.WorkspaceEdit = {}; 66 | const mockLanguageClient = { 67 | sendRequest: jest.fn().mockReturnValueOnce(response), 68 | }; 69 | nova.workspace.showInputPalette = jest.fn( 70 | // eslint-disable-next-line no-unused-vars 71 | (prompt, options, callback: (value: string) => void) => { 72 | expect(options?.placeholder).toBe("selectedText"); 73 | callback("newName"); 74 | } 75 | ) as typeof nova.workspace.showInputPalette; 76 | const command = getCommand( 77 | mockLanguageClient as any as LanguageClient, 78 | registerRename 79 | ); 80 | await command(mockEditor); 81 | 82 | expect(nova.workspace.showInputPalette).toHaveBeenCalledTimes(1); 83 | expect(mockLanguageClient.sendRequest).toHaveBeenNthCalledWith( 84 | 1, 85 | "textDocument/rename", 86 | { 87 | newName: "newName", 88 | position: { 89 | character: 0, 90 | line: 0, 91 | }, 92 | textDocument: { 93 | uri: "currentDocURI", 94 | }, 95 | } 96 | ); 97 | expect(applyWorkspaceEditModule.applyWorkspaceEdit).toBeCalledTimes(1); 98 | expect(nova.workspace.openFile).toBeCalledWith(mockEditor.document.uri); 99 | expect(mockEditor.scrollToCursorPosition).toBeCalledTimes(1); 100 | }); 101 | 102 | it("warns if the symbol can't be renamed", async () => { 103 | const mockLanguageClient = { 104 | sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve(null)), 105 | }; 106 | const command = getCommand( 107 | mockLanguageClient as any as LanguageClient, 108 | registerRename 109 | ); 110 | await command(mockEditor); 111 | 112 | expect(nova.workspace.showWarningMessage).toBeCalledTimes(1); 113 | expect(nova.workspace.showWarningMessage).toHaveBeenCalledWith( 114 | "Couldn't rename symbol." 115 | ); 116 | }); 117 | 118 | describe("bails if", () => { 119 | const mockLanguageClient = { 120 | sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve(null)), 121 | }; 122 | 123 | afterEach(() => { 124 | expect(mockLanguageClient.sendRequest).toBeCalledTimes(0); 125 | }); 126 | 127 | it("no new name is provided", async () => { 128 | nova.workspace.showInputPalette = jest.fn( 129 | // eslint-disable-next-line no-unused-vars 130 | (prompt, options, callback: (value: string) => void) => { 131 | expect(options?.placeholder).toBe("selectedText"); 132 | callback(""); 133 | } 134 | ) as typeof nova.workspace.showInputPalette; 135 | const command = getCommand( 136 | mockLanguageClient as any as LanguageClient, 137 | registerRename 138 | ); 139 | await command(mockEditor); 140 | }); 141 | 142 | it("the same name is provided", async () => { 143 | nova.workspace.showInputPalette = jest.fn( 144 | // eslint-disable-next-line no-unused-vars 145 | (prompt, options, callback: (value: string) => void) => { 146 | expect(options?.placeholder).toBe("selectedText"); 147 | callback("selectedText"); 148 | } 149 | ) as typeof nova.workspace.showInputPalette; 150 | const command = getCommand( 151 | mockLanguageClient as any as LanguageClient, 152 | registerRename 153 | ); 154 | await command(mockEditor); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/commands/rename.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import { applyWorkspaceEdit } from "../applyWorkspaceEdit"; 3 | import { wrapCommand } from "../novaUtils"; 4 | import { rangeToLspRange } from "../lspNovaConversions"; 5 | 6 | // @Panic: this is totally decoupled from typescript, so it could totally be native to Nova 7 | 8 | export function registerRename(client: LanguageClient) { 9 | return nova.commands.register( 10 | "apexskier.typescript.rename", 11 | wrapCommand(rename) 12 | ); 13 | 14 | async function rename(editor: TextEditor) { 15 | // Select full word. It will be shown in a palette so the user can review it 16 | editor.selectWordsContainingCursors(); 17 | 18 | const selectedRange = editor.selectedRange; 19 | const selectedPosition = rangeToLspRange( 20 | editor.document, 21 | selectedRange 22 | )?.start; 23 | if (!selectedPosition) { 24 | nova.workspace.showErrorMessage( 25 | "Couldn't figure out what you've selected." 26 | ); 27 | return; 28 | } 29 | 30 | const newName = await new Promise((resolve) => { 31 | nova.workspace.showInputPalette( 32 | "New name for symbol", 33 | { placeholder: editor.selectedText, value: editor.selectedText }, 34 | resolve 35 | ); 36 | }); 37 | if (!newName || newName == editor.selectedText) { 38 | return; 39 | } 40 | 41 | const params: lspTypes.RenameParams = { 42 | textDocument: { uri: editor.document.uri }, 43 | position: selectedPosition, 44 | newName, 45 | }; 46 | const response = (await client.sendRequest( 47 | "textDocument/rename", 48 | params 49 | )) as lspTypes.WorkspaceEdit | null; 50 | if (response == null) { 51 | nova.workspace.showWarningMessage("Couldn't rename symbol."); 52 | return; 53 | } 54 | await applyWorkspaceEdit(response); 55 | 56 | // go back to original document 57 | await nova.workspace.openFile(editor.document.uri); 58 | editor.scrollToCursorPosition(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/signatureHelp.test.ts: -------------------------------------------------------------------------------- 1 | import { registerSignatureHelp } from "./signatureHelp"; 2 | 3 | describe("signatureHelp command", () => { 4 | beforeEach(() => { 5 | (global as any).nova = Object.assign(nova, { 6 | commands: { 7 | register: jest.fn(), 8 | }, 9 | workspace: { 10 | showErrorMessage(err: Error) { 11 | throw err; 12 | }, 13 | showInformativeMessage: jest.fn(), 14 | }, 15 | }); 16 | }); 17 | 18 | const mockEditor = { 19 | selectedRange: { 20 | start: 0, 21 | end: 0, 22 | }, 23 | document: { 24 | getTextInRange() { 25 | return ""; 26 | }, 27 | eol: "\n", 28 | }, 29 | }; 30 | 31 | function getCommand( 32 | languageClient: LanguageClient, 33 | // eslint-disable-next-line no-unused-vars 34 | register: (client: LanguageClient) => Disposable 35 | // eslint-disable-next-line no-unused-vars 36 | ): (...args: Array) => Promise { 37 | register(languageClient); 38 | expect(nova.commands.register).toHaveBeenCalledWith( 39 | "apexskier.typescript.signatureHelp", 40 | expect.any(Function) 41 | ); 42 | const command = (nova.commands.register as jest.Mock).mock.calls[0][1]; 43 | (nova.commands.register as jest.Mock).mockClear(); 44 | return command; 45 | } 46 | 47 | it("warns if no signature help is available", async () => { 48 | const mockLanguageClient = { 49 | sendRequest: jest.fn().mockReturnValueOnce(Promise.resolve(null)), 50 | }; 51 | const command = getCommand( 52 | mockLanguageClient as any as LanguageClient, 53 | registerSignatureHelp 54 | ); 55 | await command(mockEditor); 56 | 57 | expect(nova.workspace.showInformativeMessage).toBeCalledTimes(1); 58 | expect(nova.workspace.showInformativeMessage).toHaveBeenCalledWith( 59 | "Couldn't find documentation." 60 | ); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/commands/signatureHelp.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import { rangeToLspRange } from "../lspNovaConversions"; 3 | import { wrapCommand } from "../novaUtils"; 4 | 5 | // @Panic: this is totally decoupled from typescript, so it could totally be native to Nova 6 | 7 | function render(content: string | lspTypes.MarkupContent) { 8 | if (typeof content === "string") { 9 | return content; 10 | } 11 | return content.value; 12 | } 13 | 14 | export function registerSignatureHelp(client: LanguageClient) { 15 | return nova.commands.register( 16 | "apexskier.typescript.signatureHelp", 17 | wrapCommand(signatureHelp) 18 | ); 19 | 20 | async function signatureHelp(editor: TextEditor) { 21 | const selectedRange = editor.selectedRange; 22 | const selectedPosition = rangeToLspRange( 23 | editor.document, 24 | selectedRange 25 | )?.start; 26 | if (!selectedPosition) { 27 | nova.workspace.showWarningMessage( 28 | "Couldn't figure out what you've selected." 29 | ); 30 | return; 31 | } 32 | const params: lspTypes.SignatureHelpParams = { 33 | textDocument: { uri: editor.document.uri }, 34 | position: selectedPosition, 35 | context: { 36 | triggerKind: 1, // Invoked 37 | isRetrigger: false, 38 | }, 39 | }; 40 | const response = (await client.sendRequest( 41 | "textDocument/signatureHelp", 42 | params 43 | )) as lspTypes.SignatureHelp | null; 44 | 45 | if (nova.inDevMode()) { 46 | console.log(JSON.stringify(response)); 47 | } 48 | 49 | // This resolves, but doesn't work often. 50 | // it seemed to be working at one point... 51 | 52 | if (response == null || response.activeSignature == null) { 53 | nova.workspace.showInformativeMessage("Couldn't find documentation."); 54 | return; 55 | } 56 | 57 | const signature = response.signatures[response.activeSignature]; 58 | 59 | let message = signature.label; 60 | if (signature.documentation) { 61 | message += ` 62 | 63 | ${render(signature.documentation)}`; 64 | } 65 | 66 | nova.workspace.showInformativeMessage(message); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/informationView.test.ts: -------------------------------------------------------------------------------- 1 | import { InformationView } from "./informationView"; 2 | 3 | const reload = jest.fn(); 4 | 5 | describe("InformationView", () => { 6 | beforeEach(() => { 7 | reload.mockClear(); 8 | 9 | class MockTreeView { 10 | reload = reload; 11 | } 12 | (global as any).TreeView = MockTreeView; 13 | }); 14 | 15 | it("has no subchildren", () => { 16 | const iv = new InformationView(); 17 | expect( 18 | iv.getChildren({ 19 | title: "title", 20 | value: "value", 21 | identifier: "identifier", 22 | }) 23 | ).toEqual([]); 24 | }); 25 | 26 | it("renders tree items", () => { 27 | class MockTreeItem { 28 | // eslint-disable-next-line no-unused-vars 29 | constructor(readonly name: unknown, readonly state: unknown) {} 30 | } 31 | (global as any).TreeItem = MockTreeItem; 32 | (global as any).TreeItemCollapsibleState = { 33 | None: Symbol("TreeItemCollapsibleState.None"), 34 | }; 35 | 36 | const iv = new InformationView(); 37 | const item = iv.getTreeItem({ 38 | title: "title", 39 | value: "value", 40 | identifier: "identifier", 41 | }); 42 | expect(item).toMatchInlineSnapshot(` 43 | MockTreeItem { 44 | "descriptiveText": "value", 45 | "identifier": "identifier", 46 | "name": "title", 47 | "state": Symbol(TreeItemCollapsibleState.None), 48 | } 49 | `); 50 | }); 51 | 52 | it("displays the current status and typescript version", () => { 53 | const iv = new InformationView(); 54 | expect(iv.getChildren(null)).toMatchInlineSnapshot(` 55 | Array [ 56 | Object { 57 | "identifier": "status", 58 | "title": "Status", 59 | "value": "Inactive", 60 | }, 61 | Object { 62 | "identifier": "tsversion", 63 | "title": "TypeScript Version", 64 | "value": "", 65 | }, 66 | ] 67 | `); 68 | 69 | iv.status = "Testing"; 70 | 71 | expect(reload).toHaveBeenLastCalledWith({ 72 | identifier: "status", 73 | title: "Status", 74 | value: "Testing", 75 | }); 76 | expect(iv.getChildren(null)).toMatchInlineSnapshot(` 77 | Array [ 78 | Object { 79 | "identifier": "status", 80 | "title": "Status", 81 | "value": "Testing", 82 | }, 83 | Object { 84 | "identifier": "tsversion", 85 | "title": "TypeScript Version", 86 | "value": "", 87 | }, 88 | ] 89 | `); 90 | 91 | iv.tsVersion = "1.2.3"; 92 | 93 | expect(reload).toHaveBeenLastCalledWith({ 94 | identifier: "tsversion", 95 | title: "TypeScript Version", 96 | value: "1.2.3", 97 | }); 98 | expect(iv.getChildren(null)).toMatchInlineSnapshot(` 99 | Array [ 100 | Object { 101 | "identifier": "status", 102 | "title": "Status", 103 | "value": "Testing", 104 | }, 105 | Object { 106 | "identifier": "tsversion", 107 | "title": "TypeScript Version", 108 | "value": "1.2.3", 109 | }, 110 | ] 111 | `); 112 | 113 | expect(reload).toHaveBeenCalledTimes(2); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/informationView.ts: -------------------------------------------------------------------------------- 1 | type Element = { 2 | title: string; 3 | value: string; 4 | readonly identifier: string; 5 | }; 6 | 7 | export class InformationView implements TreeDataProvider, Disposable { 8 | constructor() { 9 | this._treeView = new TreeView("apexskier.typescript.sidebar.info", { 10 | dataProvider: this, 11 | }); 12 | 13 | this.getChildren = this.getChildren.bind(this); 14 | this.getTreeItem = this.getTreeItem.bind(this); 15 | } 16 | 17 | private _treeView: TreeView<{ title: string; value: string }>; 18 | 19 | private readonly _statusElement: Element = { 20 | title: "Status", 21 | value: "Inactive", 22 | identifier: "status", 23 | }; 24 | public set status(value: string) { 25 | this._statusElement.value = value; 26 | this._treeView.reload(this._statusElement); 27 | } 28 | 29 | private readonly _tsVersionElement: Element = { 30 | title: "TypeScript Version", 31 | value: "", 32 | identifier: "tsversion", 33 | }; 34 | public set tsVersion(value: string) { 35 | this._tsVersionElement.value = value; 36 | this._treeView.reload(this._tsVersionElement); 37 | } 38 | 39 | reload() { 40 | this._treeView.reload(); 41 | } 42 | 43 | getChildren(element: Element | null): Array { 44 | if (element == null) { 45 | return [this._statusElement, this._tsVersionElement]; 46 | } 47 | return []; 48 | } 49 | 50 | getTreeItem(element: Element) { 51 | const item = new TreeItem(element.title, TreeItemCollapsibleState.None); 52 | item.descriptiveText = element.value; 53 | item.identifier = element.identifier; 54 | return item; 55 | } 56 | 57 | dispose() { 58 | this.status = "Disposed"; 59 | this._treeView.dispose(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/isEnabledForJavascript.test.ts: -------------------------------------------------------------------------------- 1 | (global as any).nova = Object.assign(nova, { 2 | commands: { 3 | invoke: jest.fn(), 4 | }, 5 | config: { 6 | onDidChange: jest.fn(), 7 | ["get"]: jest.fn(), 8 | }, 9 | workspace: { 10 | config: { onDidChange: jest.fn(), ["get"]: jest.fn() }, 11 | }, 12 | }); 13 | 14 | describe("isEnabledForJavascript", () => { 15 | beforeEach(() => { 16 | (nova.workspace.config.get as jest.Mock).mockReset(); 17 | (nova.config.get as jest.Mock).mockReset(); 18 | }); 19 | 20 | const { isEnabledForJavascript } = require("./isEnabledForJavascript"); 21 | 22 | describe("reloads extension when it changes", () => { 23 | it("globally and for the workspace", () => { 24 | expect(nova.config.onDidChange).toBeCalledTimes(1); 25 | expect(nova.config.onDidChange).toBeCalledWith( 26 | "apexskier.typescript.config.isEnabledForJavascript", 27 | expect.any(Function) 28 | ); 29 | expect(nova.workspace.config.onDidChange).toBeCalledTimes(1); 30 | expect(nova.workspace.config.onDidChange).toBeCalledWith( 31 | "apexskier.typescript.config.isEnabledForJavascript", 32 | expect.any(Function) 33 | ); 34 | // same function 35 | const onWorkspaceChange = (nova.workspace.config.onDidChange as jest.Mock) 36 | .mock.calls[0][1]; 37 | const onGlobalChange = (nova.config.onDidChange as jest.Mock).mock 38 | .calls[0][1]; 39 | expect(onWorkspaceChange).toBe(onGlobalChange); 40 | }); 41 | 42 | it("by calling the reload command", () => { 43 | const reload = (nova.config.onDidChange as jest.Mock).mock.calls[0][1]; 44 | reload(); 45 | expect(nova.commands.invoke).toBeCalledTimes(1); 46 | expect(nova.commands.invoke).toBeCalledWith( 47 | "apexskier.typescript.reload" 48 | ); 49 | }); 50 | }); 51 | 52 | describe("is true by default", () => { 53 | expect(isEnabledForJavascript()).toBe(true); 54 | }); 55 | 56 | describe("can be disabled globally", () => { 57 | (nova.workspace.config.get as jest.Mock).mockReturnValueOnce(null); 58 | (nova.config.get as jest.Mock).mockReturnValueOnce(false); 59 | expect(isEnabledForJavascript()).toBe(false); 60 | }); 61 | 62 | describe("can be enabled globally", () => { 63 | (nova.workspace.config.get as jest.Mock).mockReturnValueOnce(null); 64 | (nova.config.get as jest.Mock).mockReturnValueOnce(true); 65 | expect(isEnabledForJavascript()).toBe(true); 66 | }); 67 | 68 | describe("can be disabled in the workspace", () => { 69 | (nova.workspace.config.get as jest.Mock).mockReturnValueOnce("Disable"); 70 | (nova.config.get as jest.Mock).mockReturnValueOnce(true); 71 | expect(isEnabledForJavascript()).toBe(false); 72 | }); 73 | 74 | describe("can be enabled in the workspace", () => { 75 | (nova.workspace.config.get as jest.Mock).mockReturnValueOnce("Enable"); 76 | (nova.config.get as jest.Mock).mockReturnValueOnce(false); 77 | expect(isEnabledForJavascript()).toBe(true); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/isEnabledForJavascript.ts: -------------------------------------------------------------------------------- 1 | function reload() { 2 | nova.commands.invoke("apexskier.typescript.reload"); 3 | } 4 | nova.config.onDidChange( 5 | "apexskier.typescript.config.isEnabledForJavascript", 6 | reload 7 | ); 8 | nova.workspace.config.onDidChange( 9 | "apexskier.typescript.config.isEnabledForJavascript", 10 | reload 11 | ); 12 | 13 | function getWorkspaceSetting(): boolean | null { 14 | const str = nova.workspace.config.get( 15 | "apexskier.typescript.config.isEnabledForJavascript", 16 | "string" 17 | ); 18 | switch (str) { 19 | case "Disable": 20 | return false; 21 | case "Enable": 22 | return true; 23 | default: 24 | return null; 25 | } 26 | } 27 | 28 | export function isEnabledForJavascript(): boolean { 29 | return ( 30 | getWorkspaceSetting() ?? 31 | nova.config.get( 32 | "apexskier.typescript.config.isEnabledForJavascript", 33 | "boolean" 34 | ) ?? 35 | true 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/lspNovaConversions.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import * as lsp from "vscode-languageserver-types"; 3 | 4 | // this could really use some tests 5 | export function rangeToLspRange( 6 | document: TextDocument, 7 | range: Range 8 | ): lspTypes.Range | null { 9 | const fullContents = document.getTextInRange(new Range(0, document.length)); 10 | let chars = 0; 11 | let startLspRange: lspTypes.Position | undefined; 12 | const lines = fullContents.split(document.eol); 13 | for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { 14 | const lineLength = lines[lineIndex].length + document.eol.length; 15 | if (!startLspRange && chars + lineLength >= range.start) { 16 | const character = range.start - chars; 17 | startLspRange = { line: lineIndex, character }; 18 | } 19 | if (startLspRange && chars + lineLength >= range.end) { 20 | const character = range.end - chars; 21 | return { start: startLspRange, end: { line: lineIndex, character } }; 22 | } 23 | chars += lineLength; 24 | } 25 | return null; 26 | } 27 | 28 | // this could really use some tests 29 | export function lspRangeToRange( 30 | document: TextDocument, 31 | range: lspTypes.Range 32 | ): Range { 33 | const fullContents = document.getTextInRange(new Range(0, document.length)); 34 | let rangeStart = 0; 35 | let rangeEnd = 0; 36 | let chars = 0; 37 | const lines = fullContents.split(document.eol); 38 | for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { 39 | const lineLength = lines[lineIndex].length + document.eol.length; 40 | if (range.start.line === lineIndex) { 41 | rangeStart = chars + range.start.character; 42 | } 43 | if (range.end.line === lineIndex) { 44 | rangeEnd = chars + range.end.character; 45 | break; 46 | } 47 | chars += lineLength; 48 | } 49 | return new Range(rangeStart, rangeEnd); 50 | } 51 | 52 | export function isLspLocationArray( 53 | x: Array | Array 54 | ): x is Array { 55 | return lsp.Location.is(x[0]); 56 | } 57 | -------------------------------------------------------------------------------- /src/main.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("./informationView"); 2 | jest.mock("./tsLibPath", () => ({ 3 | getTsLibPath: () => "/tsLibPath", 4 | })); 5 | jest.mock("./tsUserPreferences", () => ({ 6 | setupUserPreferences: jest.fn(), 7 | getUserPreferences: () => "user preferences", 8 | })); 9 | jest.mock("./isEnabledForJavascript", () => ({ 10 | isEnabledForJavascript: () => true, 11 | })); 12 | jest.mock("./skipDestructiveOrganizeImports", () => ({ 13 | skipDestructiveOrganizeImports: () => false, 14 | })); 15 | jest.mock("nova-extension-utils"); 16 | 17 | jest.useFakeTimers(); 18 | 19 | (global as any).nova = Object.assign(nova, { 20 | commands: { 21 | register: jest.fn(), 22 | invoke: jest.fn(), 23 | }, 24 | config: { 25 | ["get"]: jest.fn(), 26 | }, 27 | workspace: { 28 | path: "/workspace", 29 | onDidAddTextEditor: jest.fn(), 30 | }, 31 | extension: { 32 | path: "/extension", 33 | }, 34 | fs: { 35 | access: jest.fn(), 36 | }, 37 | path: { 38 | join(...args: string[]) { 39 | return args.join("/"); 40 | }, 41 | }, 42 | }); 43 | 44 | const originalLog = global.console.log; 45 | global.console.log = jest.fn((...args) => { 46 | if ( 47 | args[0] === "activating..." || 48 | args[0] === "activated" || 49 | args[0] === "reloading..." || 50 | args[0] === "deactivate" 51 | ) { 52 | return; 53 | } 54 | originalLog(...args); 55 | }); 56 | global.console.info = jest.fn(); 57 | 58 | const CompositeDisposableMock: jest.Mock> = 59 | jest.fn(); 60 | (global as any).CompositeDisposable = CompositeDisposableMock; 61 | const ProcessMock: jest.Mock> = jest.fn(); 62 | (global as any).Process = ProcessMock; 63 | const LanguageClientMock: jest.Mock> = jest.fn(); 64 | (global as any).LanguageClient = LanguageClientMock; 65 | 66 | describe("test suite", () => { 67 | beforeEach(() => { 68 | jest.resetModules(); 69 | 70 | const { 71 | dependencyManagement: { installWrappedDependencies }, 72 | } = require("nova-extension-utils"); 73 | installWrappedDependencies 74 | .mockReset() 75 | .mockImplementation(() => Promise.resolve()); 76 | nova.fs.access = jest.fn().mockReturnValue(true); 77 | (nova.commands.register as jest.Mock).mockReset(); 78 | (nova.commands.invoke as jest.Mock).mockReset(); 79 | (nova.config.get as jest.Mock).mockReset(); 80 | LanguageClientMock.mockReset().mockImplementation(() => ({ 81 | onRequest: jest.fn(), 82 | onNotification: jest.fn(), 83 | onDidStop: jest.fn(), 84 | start: jest.fn(), 85 | stop: jest.fn(), 86 | })); 87 | ProcessMock.mockReset().mockImplementation(() => ({ 88 | onStdout: jest.fn(), 89 | onStderr: jest.fn(), 90 | onDidExit: jest.fn((cb) => { 91 | cb(0); 92 | return { dispose: jest.fn() }; 93 | }), 94 | start: jest.fn(), 95 | })); 96 | const { InformationView } = require("./informationView"); 97 | (InformationView as jest.Mock).mockReset(); 98 | (nova.workspace.onDidAddTextEditor as jest.Mock).mockReset(); 99 | CompositeDisposableMock.mockReset().mockImplementation(() => ({ 100 | add: jest.fn(), 101 | dispose: jest.fn(), 102 | })); 103 | }); 104 | 105 | test("global behavior", () => { 106 | require("./main"); 107 | 108 | expect(nova.commands.register).toBeCalledTimes(2); 109 | expect(nova.commands.register).toBeCalledWith( 110 | "apexskier.typescript.openWorkspaceConfig", 111 | expect.any(Function) 112 | ); 113 | expect(nova.commands.register).toBeCalledWith( 114 | "apexskier.typescript.reload", 115 | expect.any(Function) 116 | ); 117 | 118 | expect(CompositeDisposable).toBeCalledTimes(1); 119 | 120 | const { registerDependencyUnlockCommand } = 121 | require("nova-extension-utils").dependencyManagement; 122 | expect(registerDependencyUnlockCommand).toBeCalledTimes(1); 123 | expect(registerDependencyUnlockCommand).toBeCalledWith( 124 | "apexskier.typescript.forceClearLock" 125 | ); 126 | }); 127 | 128 | function assertActivationBehavior() { 129 | expect(nova.commands.register).toBeCalledTimes(7); 130 | expect(nova.commands.register).nthCalledWith( 131 | 1, 132 | "apexskier.typescript.openWorkspaceConfig", 133 | expect.any(Function) 134 | ); 135 | expect(nova.commands.register).nthCalledWith( 136 | 2, 137 | "apexskier.typescript.reload", 138 | expect.any(Function) 139 | ); 140 | expect(nova.commands.register).nthCalledWith( 141 | 3, 142 | "apexskier.typescript.findReferences", 143 | expect.any(Function) 144 | ); 145 | expect(nova.commands.register).nthCalledWith( 146 | 4, 147 | "apexskier.typescript.findSymbol", 148 | expect.any(Function) 149 | ); 150 | expect(nova.commands.register).nthCalledWith( 151 | 5, 152 | "apexskier.typescript.rename", 153 | expect.any(Function) 154 | ); 155 | expect(nova.commands.register).nthCalledWith( 156 | 6, 157 | "apexskier.typescript.commands.organizeImports", 158 | expect.any(Function) 159 | ); 160 | expect(nova.commands.register).nthCalledWith( 161 | 7, 162 | "apexskier.typescript.commands.formatDocument", 163 | expect.any(Function) 164 | ); 165 | 166 | const tsUserPreferencesModule = require("./tsUserPreferences"); 167 | expect(tsUserPreferencesModule.setupUserPreferences).toBeCalled(); 168 | 169 | // installs dependencies 170 | 171 | const { 172 | dependencyManagement: { installWrappedDependencies }, 173 | } = require("nova-extension-utils"); 174 | expect(installWrappedDependencies).toBeCalledTimes(1); 175 | 176 | expect(Process).toBeCalledTimes(2); 177 | // makes the run script executable 178 | expect(Process).toHaveBeenNthCalledWith(1, "/usr/bin/env", { 179 | args: ["chmod", "u+x", "/extension/run.sh"], 180 | }); 181 | // gets the typescript version 182 | expect(Process).toHaveBeenNthCalledWith(2, "/usr/bin/env", { 183 | args: ["node", "/tsLibPath/tsc.js", "--version"], 184 | stdio: ["ignore", "pipe", "ignore"], 185 | }); 186 | 187 | expect(LanguageClientMock).toBeCalledTimes(1); 188 | expect(LanguageClientMock).toBeCalledWith( 189 | "apexskier.typescript", 190 | "TypeScript Language Server", 191 | { 192 | env: { 193 | DEBUG: "FALSE", 194 | DEBUG_BREAK: "FALSE", 195 | DEBUG_PORT: "undefined", 196 | INSTALL_DIR: undefined, 197 | WORKSPACE_DIR: "/workspace", 198 | }, 199 | path: "/extension/run.sh", 200 | type: "stdio", 201 | }, 202 | { 203 | initializationOptions: { 204 | preferences: "user preferences", 205 | tsserver: { 206 | path: "/tsLibPath/tsserver.js", 207 | }, 208 | }, 209 | syntaxes: ["typescript", "tsx", "cts", "mts", "javascript", "jsx"], 210 | } 211 | ); 212 | const languageClient: LanguageClient = 213 | LanguageClientMock.mock.results[0].value; 214 | expect(languageClient.start).toBeCalledTimes(1); 215 | 216 | expect(languageClient.onRequest).not.toBeCalled(); 217 | 218 | const { InformationView } = require("./informationView"); 219 | expect(InformationView).toBeCalledTimes(1); 220 | const informationView = ( 221 | InformationView as jest.Mock 222 | ).mock.instances[0]; 223 | expect(informationView.status).toBe("Running"); 224 | expect(informationView.reload).toBeCalledTimes(1); 225 | } 226 | 227 | describe("activate and deactivate", () => { 228 | it("installs dependencies, runs the server, gets the ts version", async () => { 229 | // dynamically require so global mocks are setup before top level code execution 230 | const { activate, deactivate } = require("./main"); 231 | 232 | (ProcessMock as jest.Mock>) 233 | .mockImplementationOnce(() => ({ 234 | onStdout: jest.fn(), 235 | onStderr: jest.fn(), 236 | onDidExit: jest.fn((cb) => { 237 | cb(0); 238 | return { dispose: jest.fn() }; 239 | }), 240 | start: jest.fn(), 241 | })) 242 | .mockImplementationOnce(() => ({ 243 | onStdout: jest.fn((cb) => { 244 | cb("ts v1.2.3\n"); 245 | return { dispose: jest.fn() }; 246 | }), 247 | onStderr: jest.fn(), 248 | onDidExit: jest.fn(), 249 | start: jest.fn(), 250 | })); 251 | 252 | await activate(); 253 | 254 | assertActivationBehavior(); 255 | 256 | // typescript version is reported in the information view 257 | const { InformationView } = require("./informationView"); 258 | const informationView = ( 259 | InformationView as jest.Mock 260 | ).mock.instances[0]; 261 | expect(informationView.tsVersion).toBeUndefined(); 262 | const tsVersionProcess: Process = ProcessMock.mock.results[1].value; 263 | const exitCB = (tsVersionProcess.onDidExit as jest.Mock).mock.calls[0][0]; 264 | exitCB(0); 265 | // allow promise to execute 266 | await new Promise(setImmediate); 267 | expect(informationView.tsVersion).toBe("ts v1.2.3"); 268 | 269 | deactivate(); 270 | 271 | const languageClient: LanguageClient = 272 | LanguageClientMock.mock.results[0].value; 273 | expect(languageClient.stop).toBeCalledTimes(1); 274 | const compositeDisposable: CompositeDisposable = 275 | CompositeDisposableMock.mock.results[0].value; 276 | expect(compositeDisposable.dispose).toBeCalledTimes(1); 277 | }); 278 | 279 | it("shows an error if activation fails", async () => { 280 | // dynamically require so global mocks are setup before top level code execution 281 | const { activate } = require("./main"); 282 | 283 | global.console.error = jest.fn(); 284 | global.console.warn = jest.fn(); 285 | nova.workspace.showErrorMessage = jest.fn(); 286 | 287 | const { 288 | dependencyManagement: { installWrappedDependencies }, 289 | } = require("nova-extension-utils"); 290 | installWrappedDependencies.mockImplementation(() => 291 | Promise.reject(new Error("Failed to install:\n\nsome output on stderr")) 292 | ); 293 | 294 | await activate(); 295 | 296 | expect(nova.workspace.showErrorMessage).toBeCalledWith( 297 | new Error("Failed to install:\n\nsome output on stderr") 298 | ); 299 | }); 300 | 301 | it("handles unexpected crashes", async () => { 302 | // dynamically require so global mocks are setup before top level code execution 303 | const { activate } = require("./main"); 304 | 305 | nova.workspace.showActionPanel = jest.fn(); 306 | 307 | await activate(); 308 | 309 | const languageClient: LanguageClient = 310 | LanguageClientMock.mock.results[0].value; 311 | const stopCallback = (languageClient.onDidStop as jest.Mock).mock 312 | .calls[0][0]; 313 | 314 | stopCallback(new Error("Mock language server crash")); 315 | 316 | expect(nova.workspace.showActionPanel).toBeCalledTimes(1); 317 | const actionPanelCall = (nova.workspace.showActionPanel as jest.Mock).mock 318 | .calls[0]; 319 | expect(actionPanelCall[0]).toMatchInlineSnapshot(` 320 | "TypeScript Language Server stopped unexpectedly: 321 | 322 | Error: Mock language server crash 323 | 324 | Please report this, along with any output in the Extension Console." 325 | `); 326 | expect(actionPanelCall[1].buttons).toHaveLength(2); 327 | 328 | const { InformationView } = require("./informationView"); 329 | const informationView = ( 330 | InformationView as jest.Mock 331 | ).mock.instances[0]; 332 | expect(informationView.status).toBe("Stopped"); 333 | 334 | const actionCallback = actionPanelCall[2]; 335 | 336 | // reload 337 | expect(nova.commands.invoke).not.toBeCalled(); 338 | actionCallback(0); 339 | expect(nova.commands.invoke).toBeCalledTimes(1); 340 | expect(nova.commands.invoke).toBeCalledWith( 341 | "apexskier.typescript.reload" 342 | ); 343 | 344 | // ignore 345 | actionCallback(1); 346 | }); 347 | 348 | test("reload", async () => { 349 | // dynamically require so global mocks are setup before top level code execution 350 | require("./main"); 351 | 352 | const reload = (nova.commands.register as jest.Mock).mock.calls.find( 353 | ([command]) => command == "apexskier.typescript.reload" 354 | )[1]; 355 | 356 | expect(CompositeDisposableMock).toBeCalledTimes(1); 357 | 358 | await reload(); 359 | 360 | expect(CompositeDisposableMock).toBeCalledTimes(2); 361 | 362 | const compositeDisposable1: CompositeDisposable = 363 | CompositeDisposableMock.mock.results[0].value; 364 | expect(compositeDisposable1.dispose).toBeCalledTimes(1); 365 | const compositeDisposable2: CompositeDisposable = 366 | CompositeDisposableMock.mock.results[1].value; 367 | expect(compositeDisposable2.dispose).not.toBeCalled(); 368 | 369 | assertActivationBehavior(); 370 | }); 371 | 372 | test("watches files to apply post-save actions", async () => { 373 | // dynamically require so global mocks are setup before top level code execution 374 | const { activate } = require("./main"); 375 | 376 | await activate(); 377 | 378 | (nova as any).config = { onDidChange: jest.fn() }; 379 | (nova as any).workspace.config = { onDidChange: jest.fn() }; 380 | 381 | expect(nova.workspace.onDidAddTextEditor).toBeCalledTimes(1); 382 | const setupWatcher = (nova.workspace.onDidAddTextEditor as jest.Mock).mock 383 | .calls[0][0]; 384 | const mockEditor = { 385 | onWillSave: jest.fn(), 386 | onDidDestroy: jest.fn(), 387 | document: { 388 | syntax: "typescript", 389 | onDidChangeSyntax: jest.fn(), 390 | }, 391 | }; 392 | setupWatcher(mockEditor); 393 | 394 | expect(mockEditor.onWillSave).toBeCalledTimes(0); 395 | const refreshListener = (nova.config.onDidChange as jest.Mock).mock 396 | .calls[0][1]; 397 | 398 | const getBoolMock: jest.Mock = require("nova-extension-utils").preferences 399 | .getOverridableBoolean; 400 | 401 | getBoolMock.mockReturnValue(false); 402 | refreshListener(); 403 | 404 | // eslint-disable-next-line no-unused-vars 405 | let saveHandler: (editor: unknown) => Promise; 406 | 407 | getBoolMock.mockReset().mockReturnValue(true); 408 | refreshListener(); 409 | saveHandler = mockEditor.onWillSave.mock.calls[0][0]; 410 | await saveHandler(mockEditor); 411 | expect(nova.commands.invoke).toBeCalledTimes(2); 412 | expect(nova.commands.invoke).toHaveBeenNthCalledWith( 413 | 1, 414 | "apexskier.typescript.commands.organizeImports", 415 | mockEditor 416 | ); 417 | expect(nova.commands.invoke).toHaveBeenNthCalledWith( 418 | 2, 419 | "apexskier.typescript.commands.formatDocument", 420 | mockEditor 421 | ); 422 | 423 | mockEditor.onWillSave.mockReset(); 424 | (nova.commands.invoke as jest.Mock).mockReset(); 425 | getBoolMock 426 | .mockReset() 427 | .mockImplementation( 428 | (test: string) => 429 | test == "apexskier.typescript.config.organizeImportsOnSave" 430 | ); 431 | refreshListener(); 432 | saveHandler = mockEditor.onWillSave.mock.calls[0][0]; 433 | await saveHandler(mockEditor); 434 | expect(nova.commands.invoke).toBeCalledTimes(1); 435 | expect(nova.commands.invoke).toHaveBeenNthCalledWith( 436 | 1, 437 | "apexskier.typescript.commands.organizeImports", 438 | mockEditor 439 | ); 440 | 441 | mockEditor.onWillSave.mockReset(); 442 | (nova.commands.invoke as jest.Mock).mockReset(); 443 | getBoolMock 444 | .mockReset() 445 | .mockImplementation( 446 | (test: string) => 447 | test == "apexskier.typescript.config.formatDocumentOnSave" 448 | ); 449 | refreshListener(); 450 | saveHandler = mockEditor.onWillSave.mock.calls[0][0]; 451 | await saveHandler(mockEditor); 452 | expect(nova.commands.invoke).toBeCalledTimes(1); 453 | expect(nova.commands.invoke).toHaveBeenNthCalledWith( 454 | 1, 455 | "apexskier.typescript.commands.formatDocument", 456 | mockEditor 457 | ); 458 | }); 459 | }); 460 | }); 461 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { dependencyManagement, preferences } from "nova-extension-utils"; 2 | import { registerFindReferences } from "./commands/findReferences"; 3 | import { registerFindSymbol } from "./commands/findSymbol"; 4 | import { registerFormatDocument } from "./commands/formatDocument"; 5 | import { registerOrganizeImports } from "./commands/organizeImports"; 6 | import { registerRename } from "./commands/rename"; 7 | import { registerSignatureHelp } from "./commands/signatureHelp"; 8 | import { InformationView } from "./informationView"; 9 | import { isEnabledForJavascript } from "./isEnabledForJavascript"; 10 | import { wrapCommand } from "./novaUtils"; 11 | import { getTsLibPath } from "./tsLibPath"; 12 | import { getUserPreferences, setupUserPreferences } from "./tsUserPreferences"; 13 | 14 | const organizeImportsOnSaveKey = 15 | "apexskier.typescript.config.organizeImportsOnSave"; 16 | const formatOnSaveKey = "apexskier.typescript.config.formatDocumentOnSave"; 17 | 18 | nova.commands.register( 19 | "apexskier.typescript.openWorkspaceConfig", 20 | wrapCommand(function openWorkspaceConfig(workspace: Workspace) { 21 | workspace.openConfig(); 22 | }) 23 | ); 24 | 25 | nova.commands.register("apexskier.typescript.reload", reload); 26 | 27 | dependencyManagement.registerDependencyUnlockCommand( 28 | "apexskier.typescript.forceClearLock" 29 | ); 30 | 31 | let client: LanguageClient | null = null; 32 | let compositeDisposable = new CompositeDisposable(); 33 | 34 | async function makeFileExecutable(file: string) { 35 | return new Promise((resolve, reject) => { 36 | const process = new Process("/usr/bin/env", { 37 | args: ["chmod", "u+x", file], 38 | }); 39 | process.onDidExit((status) => { 40 | if (status === 0) { 41 | resolve(); 42 | } else { 43 | reject(status); 44 | } 45 | }); 46 | process.start(); 47 | }); 48 | } 49 | 50 | async function getTsVersion(tslibPath: string) { 51 | return new Promise((resolve, reject) => { 52 | const process = new Process("/usr/bin/env", { 53 | args: ["node", nova.path.join(tslibPath, "tsc.js"), "--version"], 54 | stdio: ["ignore", "pipe", "ignore"], 55 | }); 56 | let str = ""; 57 | process.onStdout((versionString) => { 58 | str += versionString.trim(); 59 | }); 60 | process.onDidExit((status) => { 61 | if (status === 0) { 62 | resolve(str); 63 | } else { 64 | reject(status); 65 | } 66 | }); 67 | process.start(); 68 | }); 69 | } 70 | 71 | async function reload() { 72 | deactivate(); 73 | console.log("reloading..."); 74 | await asyncActivate(); 75 | } 76 | 77 | async function asyncActivate() { 78 | const informationView = new InformationView(); 79 | compositeDisposable.add(informationView); 80 | compositeDisposable.add(setupUserPreferences()); 81 | 82 | informationView.status = "Activating..."; 83 | 84 | if ( 85 | !nova.config.get( 86 | "apexskier.typescript.config.debug.disableDependencyManagement", 87 | "boolean" 88 | ) 89 | ) { 90 | try { 91 | await dependencyManagement.installWrappedDependencies( 92 | compositeDisposable, 93 | { 94 | console: { 95 | log: (...args: Array) => { 96 | console.log("dependencyManagement:", ...args); 97 | }, 98 | info: (...args: Array) => { 99 | console.info("dependencyManagement:", ...args); 100 | }, 101 | warn: (...args: Array) => { 102 | console.warn("dependencyManagement:", ...args); 103 | }, 104 | }, 105 | } 106 | ); 107 | } catch (err) { 108 | informationView.status = "Failed to install"; 109 | throw err; 110 | } 111 | } 112 | 113 | const tslibPath = getTsLibPath(); 114 | if (!tslibPath) { 115 | informationView.status = "No tslib"; 116 | return; 117 | } 118 | console.info("using tslib at:", tslibPath); 119 | 120 | const runFile = nova.path.join(nova.extension.path, "run.sh"); 121 | 122 | // Uploading to the extension library makes this file not executable, so fix that 123 | await makeFileExecutable(runFile); 124 | 125 | let serviceArgs; 126 | if (nova.inDevMode() && nova.workspace.path) { 127 | const logDir = nova.path.join(nova.workspace.path, "logs"); 128 | await new Promise((resolve, reject) => { 129 | const p = new Process("/usr/bin/env", { 130 | args: ["mkdir", "-p", logDir], 131 | }); 132 | p.onDidExit((status) => (status === 0 ? resolve() : reject())); 133 | p.start(); 134 | }); 135 | console.log("logging to", logDir); 136 | // passing inLog breaks some requests for an unknown reason 137 | // const inLog = nova.path.join(logDir, "languageServer-in.log"); 138 | const outLog = nova.path.join(logDir, "languageServer-out.log"); 139 | serviceArgs = { 140 | path: "/usr/bin/env", 141 | // args: ["bash", "-c", `tee "${inLog}" | "${runFile}" | tee "${outLog}"`], 142 | args: ["bash", "-c", `"${runFile}" | tee "${outLog}"`], 143 | }; 144 | } else { 145 | serviceArgs = { 146 | path: runFile, 147 | }; 148 | } 149 | 150 | const syntaxes = ["typescript", "tsx", "cts", "mts"]; 151 | if (isEnabledForJavascript()) { 152 | syntaxes.push("javascript", "jsx"); 153 | } 154 | const env = { 155 | WORKSPACE_DIR: nova.workspace.path ?? "", 156 | INSTALL_DIR: dependencyManagement.getDependencyDirectory(), 157 | DEBUG: nova.config.get( 158 | "apexskier.typescript.config.debug.debugLanguageServer", 159 | "boolean" 160 | ) 161 | ? "TRUE" 162 | : "FALSE", 163 | DEBUG_BREAK: nova.config.get( 164 | "apexskier.typescript.config.debug.debugLanguageServer.break", 165 | "boolean" 166 | ) 167 | ? "TRUE" 168 | : "FALSE", 169 | DEBUG_PORT: `${nova.config.get( 170 | "apexskier.typescript.config.debug.debugLanguageServer.port", 171 | "number" 172 | )}`, 173 | }; 174 | client = new LanguageClient( 175 | "apexskier.typescript", 176 | "TypeScript Language Server", 177 | { 178 | type: "stdio", 179 | ...serviceArgs, 180 | env, 181 | }, 182 | { 183 | syntaxes, 184 | initializationOptions: { 185 | tsserver: { 186 | path: nova.path.join(tslibPath, "tsserver.js"), 187 | }, 188 | preferences: getUserPreferences(), 189 | }, 190 | } 191 | ); 192 | 193 | // register nova commands 194 | compositeDisposable.add(registerFindReferences(client)); 195 | compositeDisposable.add(registerFindSymbol(client)); 196 | compositeDisposable.add(registerRename(client)); 197 | compositeDisposable.add(registerOrganizeImports(client)); 198 | compositeDisposable.add(registerFormatDocument(client)); 199 | if (nova.inDevMode()) { 200 | compositeDisposable.add(registerSignatureHelp(client)); 201 | } 202 | 203 | // I think there's a in onDidStop's disposable, which is why this logic is necessary 204 | let disposed = false; 205 | 206 | compositeDisposable.add( 207 | client?.onDidStop((err) => { 208 | if (disposed && !err) { 209 | return; 210 | } 211 | informationView.status = "Stopped"; 212 | 213 | let message = "TypeScript Language Server stopped unexpectedly"; 214 | if (err) { 215 | message += `:\n\n${err.toString()}`; 216 | } else { 217 | message += "."; 218 | } 219 | message += 220 | "\n\nPlease report this, along with any output in the Extension Console."; 221 | nova.workspace.showActionPanel( 222 | message, 223 | { 224 | buttons: ["Restart", "Ignore"], 225 | }, 226 | (index) => { 227 | if (index == 0) { 228 | nova.commands.invoke("apexskier.typescript.reload"); 229 | } 230 | } 231 | ); 232 | }) 233 | ); 234 | 235 | compositeDisposable.add({ 236 | dispose() { 237 | disposed = true; 238 | }, 239 | }); 240 | 241 | client.start(); 242 | 243 | // auto-organize imports on save 244 | compositeDisposable.add( 245 | nova.workspace.onDidAddTextEditor((editor) => { 246 | const editorDisposable = new CompositeDisposable(); 247 | compositeDisposable.add(editorDisposable); 248 | compositeDisposable.add( 249 | editor.onDidDestroy(() => editorDisposable.dispose()) 250 | ); 251 | 252 | // watch things that might change if this needs to happen or not 253 | editorDisposable.add(editor.document.onDidChangeSyntax(refreshListener)); 254 | editorDisposable.add( 255 | nova.config.onDidChange(organizeImportsOnSaveKey, refreshListener) 256 | ); 257 | editorDisposable.add( 258 | nova.workspace.config.onDidChange( 259 | organizeImportsOnSaveKey, 260 | refreshListener 261 | ) 262 | ); 263 | editorDisposable.add( 264 | nova.config.onDidChange(formatOnSaveKey, refreshListener) 265 | ); 266 | editorDisposable.add( 267 | nova.workspace.config.onDidChange(formatOnSaveKey, refreshListener) 268 | ); 269 | 270 | let willSaveListener = setupListener(); 271 | compositeDisposable.add({ 272 | dispose() { 273 | willSaveListener?.dispose(); 274 | }, 275 | }); 276 | 277 | function refreshListener() { 278 | willSaveListener?.dispose(); 279 | willSaveListener = setupListener(); 280 | } 281 | 282 | function setupListener() { 283 | if ( 284 | !(syntaxes as Array).includes(editor.document.syntax) 285 | ) { 286 | return; 287 | } 288 | const organizeImportsOnSave = preferences.getOverridableBoolean( 289 | organizeImportsOnSaveKey 290 | ); 291 | const formatDocumentOnSave = 292 | preferences.getOverridableBoolean(formatOnSaveKey); 293 | if (!organizeImportsOnSave && !formatDocumentOnSave) { 294 | return; 295 | } 296 | return editor.onWillSave(async (editor) => { 297 | if (organizeImportsOnSave) { 298 | await nova.commands.invoke( 299 | "apexskier.typescript.commands.organizeImports", 300 | editor 301 | ); 302 | } 303 | if (formatDocumentOnSave) { 304 | await nova.commands.invoke( 305 | "apexskier.typescript.commands.formatDocument", 306 | editor 307 | ); 308 | } 309 | }); 310 | } 311 | }) 312 | ); 313 | 314 | getTsVersion(tslibPath).then((version) => { 315 | informationView.tsVersion = version; 316 | }); 317 | 318 | informationView.status = "Running"; 319 | 320 | informationView.reload(); // this is needed, otherwise the view won't show up properly, possibly a Nova bug 321 | } 322 | 323 | export function activate() { 324 | console.log("activating..."); 325 | if (nova.inDevMode()) { 326 | const notification = new NotificationRequest("activated"); 327 | notification.body = "TypeScript extension is loading"; 328 | nova.notifications.add(notification); 329 | } 330 | return asyncActivate() 331 | .catch((err) => { 332 | console.error("Failed to activate"); 333 | console.error(err); 334 | nova.workspace.showErrorMessage(err); 335 | }) 336 | .then(() => { 337 | console.log("activated"); 338 | }); 339 | } 340 | 341 | export function deactivate() { 342 | console.log("deactivate"); 343 | compositeDisposable.dispose(); 344 | compositeDisposable = new CompositeDisposable(); 345 | client?.stop(); 346 | } 347 | -------------------------------------------------------------------------------- /src/novaUtils.ts: -------------------------------------------------------------------------------- 1 | export function wrapCommand( 2 | // eslint-disable-next-line no-unused-vars 3 | command: (...args: any[]) => void | Promise 4 | // eslint-disable-next-line no-unused-vars 5 | ): (...args: any[]) => void { 6 | return async function wrapped(...args: any[]) { 7 | try { 8 | await command(...args); 9 | } catch (err) { 10 | nova.workspace.showErrorMessage((err as Error).message); 11 | } 12 | }; 13 | } 14 | 15 | export async function openFile(uri: string) { 16 | const newEditor = await nova.workspace.openFile(uri); 17 | if (newEditor) { 18 | return newEditor; 19 | } 20 | console.warn("failed first open attempt, retrying once", uri); 21 | // try one more time, this doesn't resolve if the file isn't already open. Need to file a bug 22 | return await nova.workspace.openFile(uri); 23 | } 24 | -------------------------------------------------------------------------------- /src/searchResults.test.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import * as lsp from "vscode-languageserver-types"; 3 | import { 4 | createLocationSearchResultsTree, 5 | createSymbolSearchResultsTree, 6 | } from "./searchResults"; 7 | 8 | (global as any).nova = Object.assign(nova, { 9 | commands: { 10 | register: jest.fn(), 11 | }, 12 | workspace: { 13 | showErrorMessage(err: unknown) { 14 | throw err; 15 | }, 16 | openFile: jest.fn(), 17 | }, 18 | notifications: { 19 | add: jest.fn(), 20 | }, 21 | }); 22 | 23 | class NotificationRequestMock {} 24 | (global as any).NotificationRequest = NotificationRequestMock; 25 | 26 | class CompositeDisposableMock implements Disposable { 27 | private _disposables: Array = []; 28 | add(disposable: Disposable) { 29 | this._disposables.push(disposable); 30 | } 31 | dispose() { 32 | this._disposables.forEach((d) => d.dispose()); 33 | } 34 | } 35 | (global as any).CompositeDisposable = CompositeDisposableMock; 36 | 37 | function mockTreeViewImplementation() { 38 | return { 39 | reload: jest.fn(), 40 | onDidChangeSelection: jest.fn(), 41 | dispose: jest.fn(), 42 | visible: true, 43 | }; 44 | } 45 | const TreeViewTypedMock: jest.Mock> = jest.fn(); 46 | const TreeViewMock: jest.Mock>> = TreeViewTypedMock; 47 | (global as any).TreeView = TreeViewMock; 48 | 49 | class MockTreeItem { 50 | // eslint-disable-next-line no-unused-vars 51 | constructor(readonly name: unknown, readonly state: unknown) {} 52 | } 53 | (global as any).TreeItem = MockTreeItem; 54 | (global as any).TreeItemCollapsibleState = { 55 | None: Symbol("TreeItemCollapsibleState.None"), 56 | }; 57 | 58 | beforeEach(() => { 59 | TreeViewMock.mockReset(); 60 | TreeViewMock.mockImplementation(mockTreeViewImplementation); 61 | 62 | (nova.commands.register as jest.Mock) 63 | .mockReset() 64 | .mockReturnValue({ dispose: jest.fn() }); 65 | (nova.notifications.add as jest.Mock).mockReset(); 66 | (nova.workspace.openFile as jest.Mock).mockReset(); 67 | }); 68 | 69 | describe("Symbol search results tree", () => { 70 | const symbols: lspTypes.SymbolInformation[] = [ 71 | { 72 | name: "symbol1", 73 | kind: lsp.SymbolKind.String, 74 | location: { 75 | uri: "fileURI1", 76 | range: { 77 | start: { line: 1, character: 2 }, 78 | end: { line: 3, character: 4 }, 79 | }, 80 | }, 81 | }, 82 | { 83 | name: "symbol2", 84 | kind: lsp.SymbolKind.Enum, 85 | location: { 86 | uri: "fileURI2", 87 | range: { 88 | start: { line: 5, character: 6 }, 89 | end: { line: 7, character: 8 }, 90 | }, 91 | }, 92 | }, 93 | { 94 | name: "symbol3", 95 | kind: lsp.SymbolKind.Property, 96 | location: { 97 | uri: "fileURI2", 98 | range: { 99 | start: { line: 9, character: 10 }, 100 | end: { line: 11, character: 12 }, 101 | }, 102 | }, 103 | }, 104 | ]; 105 | 106 | it("prompts if the tree view isn't visible", () => { 107 | TreeViewMock.mockImplementation(() => ({ 108 | ...mockTreeViewImplementation(), 109 | visible: false, 110 | })); 111 | createSymbolSearchResultsTree(symbols); 112 | expect(nova.notifications.add).toHaveBeenCalledTimes(1); 113 | }); 114 | 115 | it("registers a double click command to open each search result", async () => { 116 | TreeViewMock.mockImplementation(() => ({ 117 | ...mockTreeViewImplementation(), 118 | selection: [symbols[0]], 119 | })); 120 | const mockEditor = { 121 | document: { 122 | getTextInRange() { 123 | return ""; 124 | }, 125 | eol: "\n", 126 | }, 127 | addSelectionForRange: jest.fn(), 128 | scrollToPosition: jest.fn(), 129 | }; 130 | nova.workspace.openFile = jest 131 | .fn() 132 | .mockReturnValueOnce(Promise.resolve(mockEditor)); 133 | 134 | createSymbolSearchResultsTree(symbols); 135 | expect(nova.commands.register).toBeCalledWith( 136 | "apexskier.typescript.showSearchResult", 137 | expect.any(Function) 138 | ); 139 | const command = (nova.commands.register as jest.Mock).mock.calls[0][1]; 140 | await command(); 141 | 142 | expect(nova.workspace.openFile).toBeCalledTimes(1); 143 | expect(nova.workspace.openFile).toBeCalledWith("fileURI1"); 144 | expect(mockEditor.addSelectionForRange).toBeCalledTimes(1); 145 | expect(mockEditor.scrollToPosition).toBeCalledTimes(1); 146 | }); 147 | 148 | it("renders locations as TreeItems by files", async () => { 149 | createSymbolSearchResultsTree(symbols); 150 | const provider: TreeDataProvider = 151 | TreeViewTypedMock.mock.calls[0][1].dataProvider; 152 | 153 | expect(provider.getChildren(null)).toEqual(["fileURI1", "fileURI2"]); 154 | expect(provider.getChildren(1 as any)).toEqual([]); 155 | expect(provider.getChildren("fileURI1")).toEqual([symbols[0]]); 156 | expect(provider.getChildren("fileURI2")).toEqual(symbols.slice(1)); 157 | expect(provider.getTreeItem("fileURI1")).toMatchInlineSnapshot(` 158 | MockTreeItem { 159 | "name": "fileURI1", 160 | "path": "fileURI1", 161 | "state": undefined, 162 | } 163 | `); 164 | expect(provider.getTreeItem(symbols[0])).toMatchInlineSnapshot(` 165 | MockTreeItem { 166 | "command": "apexskier.typescript.showSearchResult", 167 | "descriptiveText": "String", 168 | "image": "__symbol.variable", 169 | "name": "symbol1", 170 | "state": Symbol(TreeItemCollapsibleState.None), 171 | "tooltip": "fileURI1:1:2", 172 | } 173 | `); 174 | }); 175 | }); 176 | 177 | describe("Location search results tree", () => { 178 | const locations: lspTypes.Location[] = [ 179 | { 180 | uri: "fileURI1", 181 | range: { 182 | start: { line: 1, character: 2 }, 183 | end: { line: 3, character: 4 }, 184 | }, 185 | }, 186 | { 187 | uri: "fileURI2", 188 | range: { 189 | start: { line: 5, character: 6 }, 190 | end: { line: 7, character: 8 }, 191 | }, 192 | }, 193 | { 194 | uri: "fileURI2", 195 | range: { 196 | start: { line: 9, character: 10 }, 197 | end: { line: 11, character: 12 }, 198 | }, 199 | }, 200 | ]; 201 | 202 | it("opens and selects the source when a result is focused", async () => { 203 | TreeViewMock.mockImplementation(() => ({ 204 | ...mockTreeViewImplementation(), 205 | selection: [locations[0]], 206 | })); 207 | const mockEditor = { 208 | document: { 209 | getTextInRange() { 210 | return ""; 211 | }, 212 | eol: "\n", 213 | }, 214 | addSelectionForRange: jest.fn(), 215 | scrollToPosition: jest.fn(), 216 | }; 217 | nova.workspace.openFile = jest 218 | .fn() 219 | .mockReturnValueOnce(Promise.resolve(mockEditor)); 220 | 221 | createLocationSearchResultsTree("name", locations); 222 | expect(nova.commands.register).toBeCalledWith( 223 | "apexskier.typescript.showSearchResult", 224 | expect.any(Function) 225 | ); 226 | const command = (nova.commands.register as jest.Mock).mock.calls[0][1]; 227 | await command(); 228 | 229 | expect(nova.workspace.openFile).toBeCalledTimes(1); 230 | expect(nova.workspace.openFile).toBeCalledWith("fileURI1"); 231 | expect(mockEditor.addSelectionForRange).toBeCalledTimes(1); 232 | expect(mockEditor.scrollToPosition).toBeCalledTimes(1); 233 | }); 234 | 235 | it("renders locations as TreeItems by files", async () => { 236 | createLocationSearchResultsTree("name", locations); 237 | const provider: TreeDataProvider = 238 | TreeViewTypedMock.mock.calls[0][1].dataProvider; 239 | 240 | expect(provider.getChildren(null)).toEqual(["fileURI1", "fileURI2"]); 241 | expect(provider.getChildren(1 as any)).toEqual([]); 242 | expect(provider.getChildren("fileURI1")).toEqual([locations[0]]); 243 | expect(provider.getChildren("fileURI2")).toEqual(locations.slice(1)); 244 | expect(provider.getTreeItem("fileURI1")).toMatchInlineSnapshot(` 245 | MockTreeItem { 246 | "command": "apexskier.typescript.showSearchResult", 247 | "name": "fileURI1", 248 | "path": "fileURI1", 249 | "state": undefined, 250 | } 251 | `); 252 | expect(provider.getTreeItem(locations[0])).toMatchInlineSnapshot(` 253 | MockTreeItem { 254 | "command": "apexskier.typescript.showSearchResult", 255 | "descriptiveText": ":2:3", 256 | "name": "name", 257 | "state": Symbol(TreeItemCollapsibleState.None), 258 | } 259 | `); 260 | }); 261 | 262 | it("cleans filepaths before rendering them", () => { 263 | (nova.workspace as any).path = "/workspace"; 264 | createLocationSearchResultsTree("name", locations); 265 | const provider: TreeDataProvider = 266 | TreeViewTypedMock.mock.calls[0][1].dataProvider; 267 | expect(provider.getTreeItem("file:///workspace/path").name).toBe("./path"); 268 | expect(provider.getTreeItem("file:///home/path").name).toBe("~/path"); 269 | expect(provider.getTreeItem("file:///path").name).toBe("/path"); 270 | 271 | (nova.workspace as any).path = null; 272 | expect( 273 | provider.getTreeItem("file:///Volumes/Macintosh%20HD/home/path").name 274 | ).toBe("~/path"); 275 | }); 276 | }); 277 | 278 | it.each([ 279 | [() => createSymbolSearchResultsTree([])], 280 | [() => createLocationSearchResultsTree("name", [])], 281 | ])("prompts if the tree view isn't visible", (create) => { 282 | TreeViewMock.mockImplementation(() => ({ 283 | ...mockTreeViewImplementation(), 284 | visible: false, 285 | })); 286 | create(); 287 | expect(nova.notifications.add).toHaveBeenCalledTimes(1); 288 | }); 289 | 290 | it.each([ 291 | [ 292 | () => createSymbolSearchResultsTree([]), 293 | () => createSymbolSearchResultsTree([]), 294 | ], 295 | [ 296 | () => createSymbolSearchResultsTree([]), 297 | () => createLocationSearchResultsTree("name", []), 298 | ], 299 | [ 300 | () => createLocationSearchResultsTree("name", []), 301 | () => createSymbolSearchResultsTree([]), 302 | ], 303 | [ 304 | () => createLocationSearchResultsTree("name", []), 305 | () => createSymbolSearchResultsTree([]), 306 | ], 307 | ])("disposes of subsequently created objects", (a, b) => { 308 | a(); 309 | expect(TreeView).toHaveBeenCalledTimes(1); 310 | expect(TreeView).toHaveBeenCalledWith( 311 | "apexskier.typescript.sidebar.symbols", 312 | expect.anything() 313 | ); 314 | expect(nova.notifications.add).not.toBeCalled(); 315 | const treeMock1 = TreeViewTypedMock.mock.results[0].value; 316 | expect(treeMock1.dispose).not.toBeCalled(); 317 | expect(nova.commands.register).toBeCalledWith( 318 | "apexskier.typescript.showSearchResult", 319 | expect.any(Function) 320 | ); 321 | (nova.commands.register as jest.Mock).mock.results.forEach(({ value }) => { 322 | expect(value.dispose).not.toBeCalled(); 323 | }); 324 | 325 | b(); 326 | expect(TreeView).toHaveBeenCalledTimes(2); 327 | const treeMock2 = TreeViewTypedMock.mock.results[1].value; 328 | expect(treeMock1.dispose).toBeCalled(); 329 | expect(treeMock2.dispose).not.toBeCalled(); 330 | const command1 = (nova.commands.register as jest.Mock).mock.results[0].value; 331 | expect(command1.dispose).toBeCalled(); 332 | const command2 = (nova.commands.register as jest.Mock).mock.results[1].value; 333 | expect(command2.dispose).toBeCalled(); 334 | }); 335 | -------------------------------------------------------------------------------- /src/searchResults.ts: -------------------------------------------------------------------------------- 1 | import { cleanPath } from "nova-extension-utils"; 2 | import type * as lspTypes from "vscode-languageserver-protocol"; 3 | import { wrapCommand } from "./novaUtils"; 4 | import { showLocation } from "./showLocation"; 5 | 6 | type MyTreeProvider = TreeDataProvider & { 7 | // eslint-disable-next-line no-unused-vars 8 | onSelect(element: T): Promise; 9 | }; 10 | 11 | let lastDisposable: Disposable | null = null; 12 | 13 | export function createSymbolSearchResultsTree( 14 | response: Array 15 | ) { 16 | showTreeView(symbolInformationSearchResultsTreeProvider(response)); 17 | } 18 | 19 | export function createLocationSearchResultsTree( 20 | name: string, 21 | locations: Array 22 | ) { 23 | showTreeView(locationSearchResultsTreeProvider(name, locations)); 24 | } 25 | 26 | function symbolInformationSearchResultsTreeProvider( 27 | response: Array 28 | ): MyTreeProvider { 29 | // group results by file 30 | const files = new Map>(); 31 | response.forEach((r) => { 32 | if (!files.has(r.location.uri)) { 33 | files.set(r.location.uri, []); 34 | } 35 | files.get(r.location.uri)?.push(r); 36 | }); 37 | 38 | return { 39 | getChildren(element) { 40 | if (element == null) { 41 | return Array.from(files.keys()); 42 | } else if (typeof element === "string") { 43 | return files.get(element) ?? []; 44 | } 45 | return []; 46 | }, 47 | getTreeItem(element) { 48 | if (typeof element === "string") { 49 | const item = new TreeItem( 50 | cleanPath(element), 51 | TreeItemCollapsibleState.Expanded 52 | ); 53 | item.path = element; 54 | return item; 55 | } 56 | const item = new TreeItem(element.name, TreeItemCollapsibleState.None); 57 | item.descriptiveText = `${ 58 | element.containerName ? `${element.containerName} > ` : "" 59 | }${symbolKindToText[element.kind]}${ 60 | element.deprecated ? " (deprecated)" : "" 61 | }`; 62 | const position = element.location.range.start; 63 | item.image = `__symbol.${symbolKindToNovaSymbol[element.kind]}`; 64 | item.tooltip = `${element.location.uri}:${position.line}:${position.character}`; 65 | item.command = "apexskier.typescript.showSearchResult"; 66 | return item; 67 | }, 68 | async onSelect(element) { 69 | if (typeof element !== "string") { 70 | await showLocation(element.location); 71 | } 72 | }, 73 | }; 74 | } 75 | 76 | function locationSearchResultsTreeProvider( 77 | name: string, 78 | locations: Array 79 | ): MyTreeProvider { 80 | // group results by file 81 | const files = new Map>(); 82 | locations.forEach((r) => { 83 | if (!files.has(r.uri)) { 84 | files.set(r.uri, []); 85 | } 86 | files.get(r.uri)?.push(r); 87 | }); 88 | 89 | return { 90 | getChildren(element) { 91 | if (element == null) { 92 | return Array.from(files.keys()); 93 | } else if (typeof element === "string") { 94 | return files.get(element) ?? []; 95 | } 96 | return []; 97 | }, 98 | getTreeItem(element) { 99 | let item: TreeItem; 100 | if (typeof element === "string") { 101 | item = new TreeItem( 102 | cleanPath(element), 103 | TreeItemCollapsibleState.Expanded 104 | ); 105 | item.path = element; 106 | } else { 107 | item = new TreeItem(name, TreeItemCollapsibleState.None); 108 | item.descriptiveText = `:${element.range.start.line + 1}:${ 109 | element.range.start.character + 1 110 | }`; 111 | } 112 | item.command = "apexskier.typescript.showSearchResult"; 113 | return item; 114 | }, 115 | async onSelect(element) { 116 | if (typeof element !== "string") { 117 | await showLocation(element); 118 | } 119 | }, 120 | }; 121 | } 122 | 123 | function showTreeView(dataProvider: MyTreeProvider) { 124 | lastDisposable?.dispose(); 125 | 126 | const compositeDisposable = new CompositeDisposable(); 127 | 128 | const treeView = new TreeView("apexskier.typescript.sidebar.symbols", { 129 | dataProvider, 130 | }); 131 | compositeDisposable.add(treeView); 132 | 133 | // can't figure out how to force open the view, but if most usage is from the sidebar directly it's okay? 134 | if (!treeView.visible) { 135 | const notification = new NotificationRequest("search-results-done"); 136 | notification.title = "Find References"; 137 | notification.body = "View the TS/JS sidebar to see results."; 138 | nova.notifications.add(notification); 139 | setTimeout(() => { 140 | nova.notifications.cancel(notification.identifier); 141 | }, 4000); 142 | } 143 | 144 | const command = nova.commands.register( 145 | "apexskier.typescript.showSearchResult", 146 | wrapCommand(async () => { 147 | await Promise.all(treeView.selection.map(dataProvider.onSelect)); 148 | }) 149 | ); 150 | compositeDisposable.add(command); 151 | 152 | lastDisposable = compositeDisposable; 153 | } 154 | 155 | // eslint-disable-next-line no-unused-vars 156 | const symbolKindToText: { [key in lspTypes.SymbolKind]: string } = { 157 | 1: "File", 158 | 2: "Module", 159 | 3: "Namespace", 160 | 4: "Package", 161 | 5: "Class", 162 | 6: "Method", 163 | 7: "Property", 164 | 8: "Field", 165 | 9: "Constructor", 166 | 10: "Enum", 167 | 11: "Interface", 168 | 12: "Function", 169 | 13: "Variable", 170 | 14: "Constant", 171 | 15: "String", 172 | 16: "Number", 173 | 17: "Boolean", 174 | 18: "Array", 175 | 19: "Object", 176 | 20: "Key", 177 | 21: "Null", 178 | 22: "EnumMember", 179 | 23: "Struct", 180 | 24: "Event", 181 | 25: "Operator", 182 | 26: "TypeParameter", 183 | }; 184 | 185 | const symbolKindToNovaSymbol: { 186 | // eslint-disable-next-line no-unused-vars 187 | [key in lspTypes.SymbolKind]: NovaSymbolType; 188 | } = { 189 | 1: "file", 190 | 2: "package", // Module 191 | 3: "package", // Namespace 192 | 4: "package", 193 | 5: "class", 194 | 6: "method", 195 | 7: "property", 196 | 8: "property", // Field 197 | 9: "constructor", 198 | 10: "enum", 199 | 11: "interface", 200 | 12: "function", 201 | 13: "variable", 202 | 14: "constant", 203 | 15: "variable", // String 204 | 16: "variable", // Number 205 | 17: "variable", // Boolean 206 | 18: "variable", // Array 207 | 19: "variable", // Object 208 | 20: "keyword", // Key 209 | 21: "variable", // Null 210 | 22: "enum-member", 211 | 23: "struct", 212 | 24: "variable", // Event 213 | 25: "expression", // Operator 214 | 26: "type", // TypeParameter 215 | }; 216 | -------------------------------------------------------------------------------- /src/showLocation.test.ts: -------------------------------------------------------------------------------- 1 | import { showLocation } from "./showLocation"; 2 | 3 | class MockRange { 4 | // eslint-disable-next-line no-unused-vars 5 | constructor(readonly start: number, readonly end: number) {} 6 | } 7 | (global as any).Range = MockRange; 8 | 9 | beforeEach(() => { 10 | (global as any).nova = Object.assign(nova, { 11 | workspace: { 12 | openFile: jest.fn(), 13 | }, 14 | }); 15 | }); 16 | 17 | const table = [ 18 | // Location 19 | { 20 | uri: "fileURI", 21 | range: { 22 | start: { line: 1, character: 2 }, 23 | end: { line: 3, character: 4 }, 24 | }, 25 | }, 26 | // LocationLink 27 | { 28 | targetUri: "fileURI", 29 | targetRange: { 30 | start: { line: 1, character: 2 }, 31 | end: { line: 3, character: 4 }, 32 | }, 33 | targetSelectionRange: { 34 | start: { line: 1, character: 2 }, 35 | end: { line: 3, character: 4 }, 36 | }, 37 | }, 38 | ]; 39 | describe.each(table)("showLocation", (location) => { 40 | it("opens file, selects text, scrolls to selection", async () => { 41 | const mockEditor = { 42 | document: { 43 | getTextInRange() { 44 | return `This is some 45 | fun text 46 | in the editor. 47 | cool beans 48 | coooooool beeeaaans`; 49 | }, 50 | eol: "\n", 51 | }, 52 | addSelectionForRange: jest.fn(), 53 | scrollToPosition: jest.fn(), 54 | }; 55 | nova.workspace.openFile = jest 56 | .fn() 57 | .mockReturnValueOnce(Promise.resolve(mockEditor)); 58 | 59 | await showLocation(location); 60 | 61 | expect(nova.workspace.openFile).toBeCalledTimes(1); 62 | expect(nova.workspace.openFile).toBeCalledWith("fileURI"); 63 | expect(mockEditor.addSelectionForRange).toBeCalledTimes(1); 64 | expect(mockEditor.addSelectionForRange).toBeCalledWith(new Range(15, 41)); 65 | expect(mockEditor.scrollToPosition).toBeCalledTimes(1); 66 | }); 67 | 68 | it("handles failures to open editor", async () => { 69 | global.console.warn = jest.fn(); 70 | nova.workspace.showWarningMessage = jest.fn(); 71 | await showLocation(location); 72 | 73 | expect(nova.workspace.openFile).toBeCalledWith("fileURI"); 74 | expect(nova.workspace.showWarningMessage).toBeCalledTimes(1); 75 | expect(nova.workspace.showWarningMessage).toBeCalledWith( 76 | "Failed to open fileURI" 77 | ); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/showLocation.ts: -------------------------------------------------------------------------------- 1 | import type * as lspTypes from "vscode-languageserver-protocol"; 2 | import * as lsp from "vscode-languageserver-types"; 3 | import { openFile } from "./novaUtils"; 4 | import { lspRangeToRange } from "./lspNovaConversions"; 5 | 6 | function showRangeInEditor(editor: TextEditor, range: lspTypes.Range) { 7 | const novaRange = lspRangeToRange(editor.document, range); 8 | editor.addSelectionForRange(novaRange); 9 | editor.scrollToPosition(novaRange.start); 10 | } 11 | 12 | async function showRangeInUri(uri: string, range: lspTypes.Range) { 13 | const newEditor = await openFile(uri); 14 | if (!newEditor) { 15 | nova.workspace.showWarningMessage(`Failed to open ${uri}`); 16 | return; 17 | } 18 | showRangeInEditor(newEditor, range); 19 | } 20 | 21 | export async function showLocation( 22 | location: lspTypes.Location | lspTypes.LocationLink 23 | ) { 24 | if (lsp.Location.is(location)) { 25 | await showRangeInUri(location.uri, location.range); 26 | } else { 27 | await showRangeInUri(location.targetUri, location.targetSelectionRange); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/skipDestructiveOrganizeImports.test.ts: -------------------------------------------------------------------------------- 1 | (global as any).nova = Object.assign(nova, { 2 | commands: { 3 | invoke: jest.fn(), 4 | }, 5 | config: { 6 | onDidChange: jest.fn(), 7 | ["get"]: jest.fn(), 8 | }, 9 | workspace: { 10 | config: { onDidChange: jest.fn(), ["get"]: jest.fn() }, 11 | }, 12 | }); 13 | 14 | describe("skipDestructiveOrganizeImports", () => { 15 | beforeEach(() => { 16 | (nova.workspace.config.get as jest.Mock).mockReset(); 17 | (nova.config.get as jest.Mock).mockReset(); 18 | }); 19 | 20 | const { 21 | skipDestructiveOrganizeImports, 22 | } = require("./skipDestructiveOrganizeImports"); 23 | 24 | describe("reloads extension when it changes", () => { 25 | it("globally and for the workspace", () => { 26 | expect(nova.config.onDidChange).toBeCalledTimes(1); 27 | expect(nova.config.onDidChange).toBeCalledWith( 28 | "apexskier.typescript.config.skipDestructiveOrganizeImports", 29 | expect.any(Function) 30 | ); 31 | expect(nova.workspace.config.onDidChange).toBeCalledTimes(1); 32 | expect(nova.workspace.config.onDidChange).toBeCalledWith( 33 | "apexskier.typescript.config.skipDestructiveOrganizeImports", 34 | expect.any(Function) 35 | ); 36 | // same function 37 | const onWorkspaceChange = (nova.workspace.config.onDidChange as jest.Mock) 38 | .mock.calls[0][1]; 39 | const onGlobalChange = (nova.config.onDidChange as jest.Mock).mock 40 | .calls[0][1]; 41 | expect(onWorkspaceChange).toBe(onGlobalChange); 42 | }); 43 | 44 | it("by calling the reload command", () => { 45 | const reload = (nova.config.onDidChange as jest.Mock).mock.calls[0][1]; 46 | reload(); 47 | expect(nova.commands.invoke).toBeCalledTimes(1); 48 | expect(nova.commands.invoke).toBeCalledWith( 49 | "apexskier.typescript.reload" 50 | ); 51 | }); 52 | }); 53 | 54 | describe("is true by default", () => { 55 | expect(skipDestructiveOrganizeImports()).toBe(true); 56 | }); 57 | 58 | describe("can be disabled globally", () => { 59 | (nova.workspace.config.get as jest.Mock).mockReturnValueOnce(null); 60 | (nova.config.get as jest.Mock).mockReturnValueOnce(false); 61 | expect(skipDestructiveOrganizeImports()).toBe(false); 62 | }); 63 | 64 | describe("can be enabled globally", () => { 65 | (nova.workspace.config.get as jest.Mock).mockReturnValueOnce(null); 66 | (nova.config.get as jest.Mock).mockReturnValueOnce(true); 67 | expect(skipDestructiveOrganizeImports()).toBe(true); 68 | }); 69 | 70 | describe("can be disabled in the workspace", () => { 71 | (nova.workspace.config.get as jest.Mock).mockReturnValueOnce("False"); 72 | (nova.config.get as jest.Mock).mockReturnValueOnce(true); 73 | expect(skipDestructiveOrganizeImports()).toBe(false); 74 | }); 75 | 76 | describe("can be enabled in the workspace", () => { 77 | (nova.workspace.config.get as jest.Mock).mockReturnValueOnce("True"); 78 | (nova.config.get as jest.Mock).mockReturnValueOnce(false); 79 | expect(skipDestructiveOrganizeImports()).toBe(true); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/skipDestructiveOrganizeImports.ts: -------------------------------------------------------------------------------- 1 | function reload() { 2 | nova.commands.invoke("apexskier.typescript.reload"); 3 | } 4 | nova.config.onDidChange( 5 | "apexskier.typescript.config.skipDestructiveOrganizeImports", 6 | reload 7 | ); 8 | nova.workspace.config.onDidChange( 9 | "apexskier.typescript.config.skipDestructiveOrganizeImports", 10 | reload 11 | ); 12 | 13 | function getWorkspaceSetting(): boolean | null { 14 | const str = nova.workspace.config.get( 15 | "apexskier.typescript.config.skipDestructiveOrganizeImports", 16 | "string" 17 | ); 18 | switch (str) { 19 | case "False": 20 | return false; 21 | case "True": 22 | return true; 23 | default: 24 | return null; 25 | } 26 | } 27 | 28 | export function skipDestructiveOrganizeImports(): boolean { 29 | return ( 30 | getWorkspaceSetting() ?? 31 | nova.config.get( 32 | "apexskier.typescript.config.skipDestructiveOrganizeImports", 33 | "boolean" 34 | ) ?? 35 | true 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/test.setup.ts: -------------------------------------------------------------------------------- 1 | (global as any).nova = { 2 | environment: { 3 | HOME: "/home", 4 | }, 5 | inDevMode() { 6 | return false; 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/tsLibPath.test.ts: -------------------------------------------------------------------------------- 1 | (global as any).nova = Object.assign(nova, { 2 | commands: { 3 | register: jest.fn(), 4 | invoke: jest.fn(), 5 | }, 6 | config: { 7 | onDidChange: jest.fn(), 8 | ["get"]: jest.fn(), 9 | }, 10 | extension: { 11 | path: "/extension", 12 | }, 13 | fs: { 14 | access: jest.fn(), 15 | }, 16 | path: { 17 | join(...args: string[]) { 18 | return args.join("/"); 19 | }, 20 | isAbsolute: jest.fn((path) => path.startsWith("/")), 21 | }, 22 | workspace: { 23 | config: { onDidChange: jest.fn(), ["get"]: jest.fn() }, 24 | }, 25 | }); 26 | 27 | describe("tsLibPath", () => { 28 | beforeEach(() => { 29 | (nova.workspace.config.get as jest.Mock).mockReset(); 30 | (nova.workspace as any).path = "/workspace"; 31 | (nova.config.get as jest.Mock).mockReset(); 32 | }); 33 | 34 | const { getTsLibPath } = require("./tsLibPath"); 35 | 36 | describe("reloads extension when it changes", () => { 37 | it("globally and for the workspace", () => { 38 | expect(nova.config.onDidChange).toBeCalledTimes(1); 39 | expect(nova.config.onDidChange).toBeCalledWith( 40 | "apexskier.typescript.config.tslibPath", 41 | expect.any(Function) 42 | ); 43 | expect(nova.workspace.config.onDidChange).toBeCalledTimes(1); 44 | expect(nova.workspace.config.onDidChange).toBeCalledWith( 45 | "apexskier.typescript.config.tslibPath", 46 | expect.any(Function) 47 | ); 48 | // same function 49 | const onWorkspaceChange = (nova.workspace.config.onDidChange as jest.Mock) 50 | .mock.calls[0][1]; 51 | const onGlobalChange = (nova.config.onDidChange as jest.Mock).mock 52 | .calls[0][1]; 53 | expect(onWorkspaceChange).toBe(onGlobalChange); 54 | }); 55 | 56 | it("by calling the reload command", () => { 57 | const reload = (nova.config.onDidChange as jest.Mock).mock.calls[0][1]; 58 | reload(); 59 | expect(nova.commands.invoke).toBeCalledTimes(1); 60 | expect(nova.commands.invoke).toBeCalledWith( 61 | "apexskier.typescript.reload" 62 | ); 63 | }); 64 | }); 65 | 66 | describe("returns extension path", () => { 67 | beforeAll(() => { 68 | nova.fs.access = jest.fn().mockReturnValue(true); 69 | }); 70 | 71 | it("defaults to the workspace's installation", () => { 72 | expect(getTsLibPath()).toBe("/workspace/node_modules/typescript/lib"); 73 | }); 74 | 75 | it("uses the extension installation if workspace hasn't been saved", () => { 76 | (nova.workspace.path as any) = ""; 77 | expect(getTsLibPath()).toBe( 78 | "/dependencyManagement/node_modules/typescript/lib" 79 | ); 80 | }); 81 | 82 | it("uses the workspace config", () => { 83 | (nova.workspace.config.get as any) = jest.fn(() => "/workspaceconfig"); 84 | expect(getTsLibPath()).toBe("/workspaceconfig"); 85 | }); 86 | 87 | it("uses the global config", () => { 88 | (nova.config.get as any) = jest.fn(() => "/globalconfig"); 89 | expect(getTsLibPath()).toBe("/globalconfig"); 90 | }); 91 | 92 | it("uses the workspace config over the global config", () => { 93 | (nova.workspace.config.get as any) = jest.fn(() => "/workspaceconfig"); 94 | (nova.config.get as any) = jest.fn(() => "/globalconfig"); 95 | expect(getTsLibPath()).toBe("/workspaceconfig"); 96 | }); 97 | 98 | it("resolved relatively to the workspace", () => { 99 | (nova.workspace.config.get as any) = jest.fn(() => "../workspaceconfig"); 100 | expect(getTsLibPath()).toBe("/workspace/../workspaceconfig"); 101 | }); 102 | }); 103 | 104 | describe("if tslib is missing returns null and", () => { 105 | beforeEach(() => { 106 | nova.fs.access = jest.fn().mockReturnValue(false); 107 | nova.workspace.showErrorMessage = jest.fn(); 108 | }); 109 | 110 | describe("warns if the config is wrong", () => { 111 | afterEach(() => { 112 | expect(getTsLibPath()).toBeNull(); 113 | expect(nova.workspace.showErrorMessage as jest.Mock).toBeCalledTimes(1); 114 | expect(nova.workspace.showErrorMessage as jest.Mock).toBeCalledWith( 115 | "Your TypeScript library couldn't be found, please check your settings." 116 | ); 117 | }); 118 | 119 | it("for the workspace", () => { 120 | (nova.workspace.config.get as any) = jest.fn(() => "/workspaceconfig"); 121 | }); 122 | 123 | it("globally", () => { 124 | (nova.config.get as any) = jest.fn(() => "/globalconfig"); 125 | }); 126 | }); 127 | 128 | it("errors if it's the extension's false", () => { 129 | global.console.error = jest.fn(); 130 | expect(getTsLibPath()).toBe(null); 131 | expect(nova.workspace.showErrorMessage as jest.Mock).toBeCalledTimes(0); 132 | expect(global.console.error).toBeCalledTimes(1); 133 | }); 134 | }); 135 | 136 | it("warns if the workspace isn't saved, but is configured relatively", () => { 137 | (nova.workspace.path as any) = ""; 138 | (nova.workspace.config.get as any) = jest.fn(() => "../workspaceconfig"); 139 | expect(getTsLibPath()).toBeNull(); 140 | expect(nova.workspace.showErrorMessage as jest.Mock).toBeCalledTimes(1); 141 | expect(nova.workspace.showErrorMessage as jest.Mock).toBeCalledWith( 142 | "Save your workspace before using a relative TypeScript library path." 143 | ); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /src/tsLibPath.ts: -------------------------------------------------------------------------------- 1 | import { dependencyManagement } from "nova-extension-utils"; 2 | 3 | function reload() { 4 | nova.commands.invoke("apexskier.typescript.reload"); 5 | } 6 | nova.config.onDidChange("apexskier.typescript.config.tslibPath", reload); 7 | nova.workspace.config.onDidChange( 8 | "apexskier.typescript.config.tslibPath", 9 | reload 10 | ); 11 | 12 | // this determines which version of typescript is being run 13 | // it should be project specific, so find the best option in this order: 14 | // - explicitly configured 15 | // - best guess (installed in the main node_modules) 16 | // - within plugin (no choice of version) 17 | export function getTsLibPath(): string | null { 18 | let tslibPath: string; 19 | const configTslib = 20 | nova.workspace.config.get( 21 | "apexskier.typescript.config.tslibPath", 22 | "string" 23 | ) ?? nova.config.get("apexskier.typescript.config.tslibPath", "string"); 24 | if (configTslib) { 25 | if (nova.path.isAbsolute(configTslib)) { 26 | tslibPath = configTslib; 27 | } else if (nova.workspace.path) { 28 | tslibPath = nova.path.join(nova.workspace.path, configTslib); 29 | } else { 30 | nova.workspace.showErrorMessage( 31 | "Save your workspace before using a relative TypeScript library path." 32 | ); 33 | return null; 34 | } 35 | } else if ( 36 | nova.workspace.path && 37 | nova.fs.access( 38 | nova.path.join(nova.workspace.path, "node_modules/typescript/lib"), 39 | nova.fs.F_OK 40 | ) 41 | ) { 42 | tslibPath = nova.path.join( 43 | nova.workspace.path, 44 | "node_modules/typescript/lib" 45 | ); 46 | } else { 47 | tslibPath = nova.path.join( 48 | dependencyManagement.getDependencyDirectory(), 49 | "node_modules/typescript/lib" 50 | ); 51 | } 52 | if (!nova.fs.access(tslibPath, nova.fs.F_OK)) { 53 | if (configTslib) { 54 | nova.workspace.showErrorMessage( 55 | "Your TypeScript library couldn't be found, please check your settings." 56 | ); 57 | } else { 58 | console.error("typescript lib not found at", tslibPath); 59 | } 60 | return null; 61 | } 62 | 63 | return tslibPath; 64 | } 65 | -------------------------------------------------------------------------------- /src/tsUserPreferences.test.ts: -------------------------------------------------------------------------------- 1 | import { setupUserPreferences, getUserPreferences } from "./tsUserPreferences"; 2 | 3 | (global as any).nova = Object.assign(nova, { 4 | commands: { 5 | register: jest.fn(), 6 | invoke: jest.fn(), 7 | }, 8 | config: { 9 | onDidChange: jest.fn(), 10 | ["get"]: jest.fn(), 11 | }, 12 | workspace: { 13 | config: { onDidChange: jest.fn(), ["get"]: jest.fn() }, 14 | }, 15 | }); 16 | 17 | describe("tsUserPreferences", () => { 18 | beforeEach(() => { 19 | (nova.workspace.config.get as jest.Mock).mockReset(); 20 | (nova.config.get as jest.Mock).mockReset(); 21 | }); 22 | 23 | describe("reloads extension when any preferences change", () => { 24 | it("globally and for the workspace", () => { 25 | (global as any).CompositeDisposable = jest.fn(() => ({ 26 | add: jest.fn(), 27 | })); 28 | 29 | setupUserPreferences(); 30 | 31 | expect(nova.config.onDidChange).toBeCalledTimes(32); 32 | expect(nova.workspace.config.onDidChange).toBeCalledTimes(32); 33 | 34 | const globalConfigKeys = ( 35 | nova.config.onDidChange as jest.Mock 36 | ).mock.calls.map(([key]) => key); 37 | const workspaceConfigKeys = ( 38 | nova.workspace.config.onDidChange as jest.Mock 39 | ).mock.calls.map(([key]) => key); 40 | expect(globalConfigKeys).toEqual(workspaceConfigKeys); 41 | expect(globalConfigKeys).toMatchInlineSnapshot(` 42 | Array [ 43 | "apexskier.typescript.config.userPreferences.allowIncompleteCompletions", 44 | "apexskier.typescript.config.userPreferences.allowRenameOfImportPath", 45 | "apexskier.typescript.config.userPreferences.allowTextChangesInNewFiles", 46 | "apexskier.typescript.config.userPreferences.autoImportFileExcludePatterns", 47 | "apexskier.typescript.config.userPreferences.disableLineTextInReferences", 48 | "apexskier.typescript.config.userPreferences.disableSuggestions", 49 | "apexskier.typescript.config.userPreferences.displayPartsForJSDoc", 50 | "apexskier.typescript.config.userPreferences.generateReturnInDocTemplate", 51 | "apexskier.typescript.config.userPreferences.importModuleSpecifierEnding", 52 | "apexskier.typescript.config.userPreferences.importModuleSpecifierPreference", 53 | "apexskier.typescript.config.userPreferences.includeAutomaticOptionalChainCompletions", 54 | "apexskier.typescript.config.userPreferences.includeCompletionsForImportStatements", 55 | "apexskier.typescript.config.userPreferences.includeCompletionsForModuleExports", 56 | "apexskier.typescript.config.userPreferences.includeCompletionsWithClassMemberSnippets", 57 | "apexskier.typescript.config.userPreferences.includeCompletionsWithInsertText", 58 | "apexskier.typescript.config.userPreferences.includeCompletionsWithObjectLiteralMethodSnippets", 59 | "apexskier.typescript.config.userPreferences.includeCompletionsWithSnippetText", 60 | "apexskier.typescript.config.userPreferences.includeInlayEnumMemberValueHints", 61 | "apexskier.typescript.config.userPreferences.includeInlayFunctionLikeReturnTypeHints", 62 | "apexskier.typescript.config.userPreferences.includeInlayFunctionParameterTypeHints", 63 | "apexskier.typescript.config.userPreferences.includeInlayParameterNameHints", 64 | "apexskier.typescript.config.userPreferences.includeInlayParameterNameHintsWhenArgumentMatchesName", 65 | "apexskier.typescript.config.userPreferences.includeInlayPropertyDeclarationTypeHints", 66 | "apexskier.typescript.config.userPreferences.includeInlayVariableTypeHints", 67 | "apexskier.typescript.config.userPreferences.includeInlayVariableTypeHintsWhenTypeMatchesName", 68 | "apexskier.typescript.config.userPreferences.includePackageJsonAutoImports", 69 | "apexskier.typescript.config.userPreferences.jsxAttributeCompletionStyle", 70 | "apexskier.typescript.config.userPreferences.lazyConfiguredProjectsFromExternalProject", 71 | "apexskier.typescript.config.userPreferences.providePrefixAndSuffixTextForRename", 72 | "apexskier.typescript.config.userPreferences.provideRefactorNotApplicableReason", 73 | "apexskier.typescript.config.userPreferences.quotePreference", 74 | "apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries", 75 | ] 76 | `); 77 | 78 | // same function 79 | const onChange = (nova.workspace.config.onDidChange as jest.Mock).mock 80 | .calls[0][1]; 81 | for (const [, cb] of [ 82 | ...(nova.config.onDidChange as jest.Mock).mock.calls, 83 | ...(nova.workspace.config.onDidChange as jest.Mock).mock.calls, 84 | ]) { 85 | expect(onChange).toBe(cb); 86 | } 87 | }); 88 | 89 | it("by calling the reload command", () => { 90 | const reload = (nova.config.onDidChange as jest.Mock).mock.calls[0][1]; 91 | reload(); 92 | expect(nova.commands.invoke).toBeCalledTimes(1); 93 | expect(nova.commands.invoke).toBeCalledWith( 94 | "apexskier.typescript.reload" 95 | ); 96 | }); 97 | }); 98 | 99 | describe("gets user preferences", () => { 100 | test("returns an empty object by default", () => { 101 | expect(getUserPreferences()).toEqual({}); 102 | }); 103 | 104 | test("returns global preferences", () => { 105 | (nova.config.get as jest.Mock).mockImplementation( 106 | (key, type) => `global ${key} ${type}` 107 | ); 108 | expect(getUserPreferences()).toMatchInlineSnapshot(` 109 | Object { 110 | "allowIncompleteCompletions": "global apexskier.typescript.config.userPreferences.allowIncompleteCompletions boolean", 111 | "allowRenameOfImportPath": "global apexskier.typescript.config.userPreferences.allowRenameOfImportPath boolean", 112 | "allowTextChangesInNewFiles": "global apexskier.typescript.config.userPreferences.allowTextChangesInNewFiles boolean", 113 | "autoImportFileExcludePatterns": "global apexskier.typescript.config.userPreferences.autoImportFileExcludePatterns stringArray", 114 | "disableLineTextInReferences": "global apexskier.typescript.config.userPreferences.disableLineTextInReferences boolean", 115 | "disableSuggestions": "global apexskier.typescript.config.userPreferences.disableSuggestions boolean", 116 | "displayPartsForJSDoc": "global apexskier.typescript.config.userPreferences.displayPartsForJSDoc boolean", 117 | "generateReturnInDocTemplate": "global apexskier.typescript.config.userPreferences.generateReturnInDocTemplate boolean", 118 | "importModuleSpecifierEnding": "global apexskier.typescript.config.userPreferences.importModuleSpecifierEnding string", 119 | "importModuleSpecifierPreference": "global apexskier.typescript.config.userPreferences.importModuleSpecifierPreference string", 120 | "includeAutomaticOptionalChainCompletions": "global apexskier.typescript.config.userPreferences.includeAutomaticOptionalChainCompletions boolean", 121 | "includeCompletionsForImportStatements": "global apexskier.typescript.config.userPreferences.includeCompletionsForImportStatements boolean", 122 | "includeCompletionsForModuleExports": "global apexskier.typescript.config.userPreferences.includeCompletionsForModuleExports boolean", 123 | "includeCompletionsWithClassMemberSnippets": "global apexskier.typescript.config.userPreferences.includeCompletionsWithClassMemberSnippets boolean", 124 | "includeCompletionsWithInsertText": "global apexskier.typescript.config.userPreferences.includeCompletionsWithInsertText boolean", 125 | "includeCompletionsWithObjectLiteralMethodSnippets": "global apexskier.typescript.config.userPreferences.includeCompletionsWithObjectLiteralMethodSnippets boolean", 126 | "includeCompletionsWithSnippetText": "global apexskier.typescript.config.userPreferences.includeCompletionsWithSnippetText boolean", 127 | "includeInlayEnumMemberValueHints": "global apexskier.typescript.config.userPreferences.includeInlayEnumMemberValueHints boolean", 128 | "includeInlayFunctionLikeReturnTypeHints": "global apexskier.typescript.config.userPreferences.includeInlayFunctionLikeReturnTypeHints boolean", 129 | "includeInlayFunctionParameterTypeHints": "global apexskier.typescript.config.userPreferences.includeInlayFunctionParameterTypeHints boolean", 130 | "includeInlayParameterNameHints": "global apexskier.typescript.config.userPreferences.includeInlayParameterNameHints string", 131 | "includeInlayParameterNameHintsWhenArgumentMatchesName": "global apexskier.typescript.config.userPreferences.includeInlayParameterNameHintsWhenArgumentMatchesName boolean", 132 | "includeInlayPropertyDeclarationTypeHints": "global apexskier.typescript.config.userPreferences.includeInlayPropertyDeclarationTypeHints boolean", 133 | "includeInlayVariableTypeHints": "global apexskier.typescript.config.userPreferences.includeInlayVariableTypeHints boolean", 134 | "includeInlayVariableTypeHintsWhenTypeMatchesName": "global apexskier.typescript.config.userPreferences.includeInlayVariableTypeHintsWhenTypeMatchesName boolean", 135 | "includePackageJsonAutoImports": "global apexskier.typescript.config.userPreferences.includePackageJsonAutoImports string", 136 | "jsxAttributeCompletionStyle": "global apexskier.typescript.config.userPreferences.jsxAttributeCompletionStyle string", 137 | "lazyConfiguredProjectsFromExternalProject": "global apexskier.typescript.config.userPreferences.lazyConfiguredProjectsFromExternalProject boolean", 138 | "providePrefixAndSuffixTextForRename": "global apexskier.typescript.config.userPreferences.providePrefixAndSuffixTextForRename boolean", 139 | "provideRefactorNotApplicableReason": "global apexskier.typescript.config.userPreferences.provideRefactorNotApplicableReason boolean", 140 | "quotePreference": "global apexskier.typescript.config.userPreferences.quotePreference string", 141 | "useLabelDetailsInCompletionEntries": "global apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries boolean", 142 | } 143 | `); 144 | }); 145 | 146 | test("returns workspace preferences", () => { 147 | (nova.config.get as jest.Mock).mockImplementation( 148 | (key, type) => `global ${key} ${type}` 149 | ); 150 | (nova.workspace.config.get as jest.Mock).mockImplementation( 151 | (key, type) => `workspace ${key} ${type}` 152 | ); 153 | expect(getUserPreferences()).toMatchInlineSnapshot(` 154 | Object { 155 | "allowIncompleteCompletions": "workspace apexskier.typescript.config.userPreferences.allowIncompleteCompletions boolean", 156 | "allowRenameOfImportPath": "workspace apexskier.typescript.config.userPreferences.allowRenameOfImportPath boolean", 157 | "allowTextChangesInNewFiles": "workspace apexskier.typescript.config.userPreferences.allowTextChangesInNewFiles boolean", 158 | "autoImportFileExcludePatterns": "workspace apexskier.typescript.config.userPreferences.autoImportFileExcludePatterns stringArray", 159 | "disableLineTextInReferences": "workspace apexskier.typescript.config.userPreferences.disableLineTextInReferences boolean", 160 | "disableSuggestions": "workspace apexskier.typescript.config.userPreferences.disableSuggestions boolean", 161 | "displayPartsForJSDoc": "workspace apexskier.typescript.config.userPreferences.displayPartsForJSDoc boolean", 162 | "generateReturnInDocTemplate": "workspace apexskier.typescript.config.userPreferences.generateReturnInDocTemplate boolean", 163 | "importModuleSpecifierEnding": "workspace apexskier.typescript.config.userPreferences.importModuleSpecifierEnding string", 164 | "importModuleSpecifierPreference": "workspace apexskier.typescript.config.userPreferences.importModuleSpecifierPreference string", 165 | "includeAutomaticOptionalChainCompletions": "workspace apexskier.typescript.config.userPreferences.includeAutomaticOptionalChainCompletions boolean", 166 | "includeCompletionsForImportStatements": "workspace apexskier.typescript.config.userPreferences.includeCompletionsForImportStatements boolean", 167 | "includeCompletionsForModuleExports": "workspace apexskier.typescript.config.userPreferences.includeCompletionsForModuleExports boolean", 168 | "includeCompletionsWithClassMemberSnippets": "workspace apexskier.typescript.config.userPreferences.includeCompletionsWithClassMemberSnippets boolean", 169 | "includeCompletionsWithInsertText": "workspace apexskier.typescript.config.userPreferences.includeCompletionsWithInsertText boolean", 170 | "includeCompletionsWithObjectLiteralMethodSnippets": "workspace apexskier.typescript.config.userPreferences.includeCompletionsWithObjectLiteralMethodSnippets boolean", 171 | "includeCompletionsWithSnippetText": "workspace apexskier.typescript.config.userPreferences.includeCompletionsWithSnippetText boolean", 172 | "includeInlayEnumMemberValueHints": "workspace apexskier.typescript.config.userPreferences.includeInlayEnumMemberValueHints boolean", 173 | "includeInlayFunctionLikeReturnTypeHints": "workspace apexskier.typescript.config.userPreferences.includeInlayFunctionLikeReturnTypeHints boolean", 174 | "includeInlayFunctionParameterTypeHints": "workspace apexskier.typescript.config.userPreferences.includeInlayFunctionParameterTypeHints boolean", 175 | "includeInlayParameterNameHints": "workspace apexskier.typescript.config.userPreferences.includeInlayParameterNameHints string", 176 | "includeInlayParameterNameHintsWhenArgumentMatchesName": "workspace apexskier.typescript.config.userPreferences.includeInlayParameterNameHintsWhenArgumentMatchesName boolean", 177 | "includeInlayPropertyDeclarationTypeHints": "workspace apexskier.typescript.config.userPreferences.includeInlayPropertyDeclarationTypeHints boolean", 178 | "includeInlayVariableTypeHints": "workspace apexskier.typescript.config.userPreferences.includeInlayVariableTypeHints boolean", 179 | "includeInlayVariableTypeHintsWhenTypeMatchesName": "workspace apexskier.typescript.config.userPreferences.includeInlayVariableTypeHintsWhenTypeMatchesName boolean", 180 | "includePackageJsonAutoImports": "workspace apexskier.typescript.config.userPreferences.includePackageJsonAutoImports string", 181 | "jsxAttributeCompletionStyle": "workspace apexskier.typescript.config.userPreferences.jsxAttributeCompletionStyle string", 182 | "lazyConfiguredProjectsFromExternalProject": "workspace apexskier.typescript.config.userPreferences.lazyConfiguredProjectsFromExternalProject boolean", 183 | "providePrefixAndSuffixTextForRename": "workspace apexskier.typescript.config.userPreferences.providePrefixAndSuffixTextForRename boolean", 184 | "provideRefactorNotApplicableReason": "workspace apexskier.typescript.config.userPreferences.provideRefactorNotApplicableReason boolean", 185 | "quotePreference": "workspace apexskier.typescript.config.userPreferences.quotePreference string", 186 | "useLabelDetailsInCompletionEntries": "workspace apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries boolean", 187 | } 188 | `); 189 | }); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /src/tsUserPreferences.ts: -------------------------------------------------------------------------------- 1 | import type { UserPreferences } from "typescript/lib/protocol"; 2 | 3 | function reload() { 4 | nova.commands.invoke("apexskier.typescript.reload"); 5 | } 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | const keys: { 9 | [key in keyof UserPreferences]: UserPreferences[key] extends 10 | | boolean 11 | | undefined 12 | ? "boolean" 13 | : UserPreferences[key] extends string | undefined 14 | ? "string" 15 | : UserPreferences[key] extends string[] | undefined 16 | ? "stringArray" 17 | : never; 18 | } & Record = { 19 | allowIncompleteCompletions: "boolean", 20 | allowRenameOfImportPath: "boolean", 21 | allowTextChangesInNewFiles: "boolean", 22 | autoImportFileExcludePatterns: "stringArray", 23 | disableLineTextInReferences: "boolean", 24 | disableSuggestions: "boolean", 25 | displayPartsForJSDoc: "boolean", 26 | generateReturnInDocTemplate: "boolean", 27 | importModuleSpecifierEnding: "string", 28 | importModuleSpecifierPreference: "string", 29 | includeAutomaticOptionalChainCompletions: "boolean", 30 | includeCompletionsForImportStatements: "boolean", 31 | includeCompletionsForModuleExports: "boolean", 32 | includeCompletionsWithClassMemberSnippets: "boolean", 33 | includeCompletionsWithInsertText: "boolean", 34 | includeCompletionsWithObjectLiteralMethodSnippets: "boolean", 35 | includeCompletionsWithSnippetText: "boolean", 36 | includeInlayEnumMemberValueHints: "boolean", 37 | includeInlayFunctionLikeReturnTypeHints: "boolean", 38 | includeInlayFunctionParameterTypeHints: "boolean", 39 | includeInlayParameterNameHints: "string", 40 | includeInlayParameterNameHintsWhenArgumentMatchesName: "boolean", 41 | includeInlayPropertyDeclarationTypeHints: "boolean", 42 | includeInlayVariableTypeHints: "boolean", 43 | includeInlayVariableTypeHintsWhenTypeMatchesName: "boolean", 44 | includePackageJsonAutoImports: "string", 45 | jsxAttributeCompletionStyle: "string", 46 | lazyConfiguredProjectsFromExternalProject: "boolean", 47 | providePrefixAndSuffixTextForRename: "boolean", 48 | provideRefactorNotApplicableReason: "boolean", 49 | quotePreference: "string", 50 | useLabelDetailsInCompletionEntries: "boolean", 51 | }; 52 | 53 | export function setupUserPreferences(): Disposable { 54 | const disposable = new CompositeDisposable(); 55 | for (const key in keys) { 56 | const configKey = `apexskier.typescript.config.userPreferences.${key}`; 57 | disposable.add(nova.config.onDidChange(configKey, reload)); 58 | disposable.add(nova.workspace.config.onDidChange(configKey, reload)); 59 | } 60 | return disposable; 61 | } 62 | 63 | type Mutable = { 64 | -readonly [P in keyof T]: T[P]; 65 | }; 66 | 67 | export function getUserPreferences(): UserPreferences { 68 | const preferences: Mutable = {}; 69 | Object.entries; 70 | for (const _key in keys) { 71 | const key = _key as keyof typeof keys; 72 | const configKey = `apexskier.typescript.config.userPreferences.${key}`; 73 | const configType = keys[key]; 74 | const value = 75 | nova.workspace.config.get(configKey, configType as any) ?? 76 | nova.config.get(configKey, configType as any) ?? 77 | (undefined as UserPreferences[typeof key]); 78 | preferences[key] = value as any; 79 | } 80 | return preferences; 81 | } 82 | -------------------------------------------------------------------------------- /test-workspaces/.nova/Configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "apexskier.typescript.config.tslibPath": "foo/node_modules/typescript/lib/" 3 | } 4 | -------------------------------------------------------------------------------- /test-workspaces/README.md: -------------------------------------------------------------------------------- 1 | Opening Nova to this directory should result in TypeScript 3.6.5 from `foo` being used. 2 | -------------------------------------------------------------------------------- /test-workspaces/bar/README.md: -------------------------------------------------------------------------------- 1 | Opening Nova to this directory should result in TypeScript 3.2.4 being used. 2 | -------------------------------------------------------------------------------- /test-workspaces/bar/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "typescript": { 6 | "version": "3.2.4", 7 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.2.4.tgz", 8 | "integrity": "sha512-0RNDbSdEokBeEAkgNbxJ+BLwSManFy9TeXz8uW+48j/xhEXv1ePME60olyzw2XzUqUBNAYFeJadIqAgNqIACwg==" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test-workspaces/baz/README.md: -------------------------------------------------------------------------------- 1 | Opening Nova to this directory should result in the bundled TypeScript version being used. 2 | -------------------------------------------------------------------------------- /test-workspaces/example.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-unused-vars 2 | type TestType = { 3 | foobar: Object, 4 | }; 5 | -------------------------------------------------------------------------------- /test-workspaces/foo/README.md: -------------------------------------------------------------------------------- 1 | Opening Nova to this directory should result in TypeScript 3.6.5 being used. 2 | -------------------------------------------------------------------------------- /test-workspaces/foo/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "typescript": { 6 | "version": "3.6.5", 7 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.5.tgz", 8 | "integrity": "sha512-BEjlc0Z06ORZKbtcxGrIvvwYs5hAnuo6TKdNFL55frVDlB+na3z5bsLhFaIxmT+dPWgBIjMo6aNnTOgHHmHgiQ==" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "sourceMap": true, 5 | "noEmit": true, 6 | "strict": true, 7 | "noImplicitReturns": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "experimentalDecorators": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /typescript.novaextension/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.6.0 4 | 5 | ### Added 6 | 7 | - Upgrade to TypeScript 4.9 8 | - Add additional configuration option support 9 | - Upgrade underlying `typescript-language-server` from v0.11.0 to v3.0.2 ([changelog](https://github.com/typescript-language-server/typescript-language-server/blob/v3.0.2/CHANGELOG.md)) 10 | - Config option to prevent deleting unused imports when auto-organizing imports 11 | 12 | ### Changed 13 | 14 | - When search results are ready, display a notification instead of an alert. 15 | 16 | ## v2.5.0 17 | 18 | - Upgrade to TypeScript 4.7 19 | - Add additional configuration option support 20 | - Add partial support for new file types: `.cts` and `.d.cts` (CommonJS modules) and `.mts` and `.d.mts` (ES modules) 21 | - Upgrade underlying `typescript-language-server` from v0.5.4 to v0.11.0 ([changelog](https://github.com/typescript-language-server/typescript-language-server/blob/v0.11.0/CHANGELOG.md)) 22 | 23 | ## v2.4.0 24 | 25 | ### Added 26 | 27 | - `tsserver` user preferences 28 | - Extension development debug preferences 29 | 30 | ### Fixed 31 | 32 | - Fix failure to dispose handlers on subsequent extension reloads 33 | - Fix false-positive detection of server crashes 34 | - Language server timeout crashes (via https://github.com/theia-ide/typescript-language-server/pull/189) 35 | 36 | ## v2.3.0 37 | 38 | ### Added 39 | 40 | - Pre-populate Rename Symbol palette with current value ([#133](https://github.com/apexskier/nova-typescript/issues/133)) 41 | 42 | ## v2.2.0 43 | 44 | ### Added 45 | 46 | - "Format Document" command 47 | 48 | ### Fixed 49 | 50 | - Configure correct formatting before organizing imports 51 | 52 | ### Changed 53 | 54 | - Upgrade bundled TypeScript 55 | 56 | ## v2.1.1 57 | 58 | ### Changed 59 | 60 | - Update bundled language server with crash fix 61 | 62 | ## v2.1.0 63 | 64 | ### Added 65 | 66 | - "Organize Imports" command 67 | - Global/workspace preference to automatically organize imports on save 68 | 69 | ### Changed 70 | 71 | - Upgrade bundled TypeScript 72 | 73 | ## v2.0.1 74 | 75 | ### Fixed 76 | 77 | - Handle language server crashes more gracefully 78 | 79 | ## v2.0.0 80 | 81 | ### Fixed 82 | 83 | - Fix paths cleaning in search results sidebar 84 | 85 | ### Breaking 86 | 87 | - Remove functionality now supplied by Nova directly (Go to Definition, Code Action, Offer Suggestions) 88 | 89 | ## v1.8.2 90 | 91 | ### Fixed 92 | 93 | - Fix bad bundled dependency location 94 | 95 | ## v1.8.1 96 | 97 | ### Fixed 98 | 99 | - Fix issue with dependency installation locking during failures 100 | 101 | ## v1.8.0 102 | 103 | ### Changed 104 | 105 | - Major behind the scene changes to dependency management 106 | - Performance improvements 107 | - More reliability with multiple open workspaces 108 | 109 | ### Fixed 110 | 111 | - Use full completion result in manual auto-suggest command 112 | 113 | ## v1.7.0 114 | 115 | ### Added 116 | 117 | - Support disabling processing on javascript files 118 | 119 | ### Fixed 120 | 121 | - Display global tsserver path configuration in UI 122 | 123 | ### Changed 124 | 125 | - Display error message with more details when activation fails 126 | - Documentation updates 127 | 128 | ## v1.6.2 129 | 130 | ### Changed 131 | 132 | - Removed stub language syntax 133 | - When using bundled TypeScript, don't emit npm update checks 134 | 135 | ## v1.6.1 136 | 137 | ### Changed 138 | 139 | - Remove confirmation/documentation message from "Offer Suggestions" 140 | 141 | ## v1.6.0 142 | 143 | ### Added 144 | 145 | - Add "Find References" command 146 | - Add experimental "Offer Suggestions" command 147 | 148 | ### Changed 149 | 150 | - Auto activates in javascript workspaces 151 | - Search results now opened by double clicking instead of selecting 152 | - Find Symbol now uses builtin nova images for types of symbols 153 | - Search results UI cleanup 154 | 155 | ### Fixed 156 | 157 | - Properly dispose of more resources when extension reloads 158 | 159 | ## v1.5.3 160 | 161 | ### Changed 162 | 163 | - Add extension to "Completions" category 164 | 165 | ### Fixed 166 | 167 | - Fix error message when symbol can't be found 168 | - Fix async startup failures not being logged 169 | 170 | ## v1.5.2 171 | 172 | Mistakenly released from dev branch. 173 | 174 | ## v1.5.1 175 | 176 | ### Changed 177 | 178 | - Emit install errors in dev console 179 | - Upgrade bundled typescript 180 | - Add mock typescript syntax (https://dev.panic.com/panic/nova-issues/-/issues/1454) 181 | 182 | ## v1.5.0 183 | 184 | ### Changed 185 | 186 | - Improve documentation in readme and throughout extension 187 | - Sidebar "refresh" button now restarts language server 188 | 189 | ### Added 190 | 191 | - Custom TypeScript library location support (now actually works!) 192 | - Preference type changed to string 193 | - Relative path support 194 | - Auto-restart on preference change 195 | 196 | ## v1.4.2 197 | 198 | ### Changed 199 | 200 | - Upgrade bundled version of TypeScript to 3.9 201 | 202 | ## v1.4.1 203 | 204 | ### Changed 205 | 206 | - New sidebar icons from [Sam Gwilym](http://gwil.co) 207 | 208 | ## v1.4.0 209 | 210 | ### Added 211 | 212 | - Add images to sidebar 213 | 214 | ### Changed 215 | 216 | - Dev functionality and error handling 217 | 218 | ## v1.3.0 219 | 220 | ### Added 221 | 222 | - Show "Go to Definition" results in sidebar for multiple results 223 | 224 | ### Fixed 225 | 226 | - Allow "Code Actions" and "Go to Definition" when editor doesn't have focus. 227 | 228 | ## v1.2.1 229 | 230 | ### Fixed 231 | 232 | - Fix tsx/jsx language support 233 | 234 | ## v1.2.0 235 | 236 | ### Added 237 | 238 | - Display active TypeScript version to sidebar 239 | - Improved "Find Symbol" sidebar UI 240 | - Extension falls back to it's own installation of TypeScript 241 | 242 | ### Fixed 243 | 244 | - Fewer warnings about misconfigured TypeScript 245 | 246 | ### Changed 247 | 248 | - Configuration for custom TypeScript installation has changed 249 | - Language server isn't bundled with published extension 250 | 251 | ## v1.1.0 252 | 253 | ### Added 254 | 255 | - "Find Symbol" command and sidebar 256 | - "Code Actions" editor command 257 | - Support for language server driven edits 258 | 259 | ### Fixed 260 | 261 | - Cleaner deactivation logic 262 | 263 | ## v1.0.1 264 | 265 | ### Fixed 266 | 267 | - Fix issue preventing language server startup when installed from extension library 268 | 269 | ## v1.0.0 270 | 271 | Initial release 272 | 273 | - Language Server support 274 | - Custom TypeScript installation support 275 | - "Go to Definition" editor command 276 | - "Rename" editor command 277 | -------------------------------------------------------------------------------- /typescript.novaextension/Images/README/example-code-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/README/example-code-actions.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/README/example-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/README/example-error.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/README/example-findsymbol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/README/example-findsymbol.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/README/example-sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/README/example-sidebar.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/README/example-typeinfo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/README/example-typeinfo.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/Search/Search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/Search/Search.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/Search/Search@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/Search/Search@2x.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/Search/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": true 3 | } 4 | -------------------------------------------------------------------------------- /typescript.novaextension/Images/SidebarLarge/SidebarLarge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/SidebarLarge/SidebarLarge.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/SidebarLarge/SidebarLarge@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/SidebarLarge/SidebarLarge@2x.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/SidebarLarge/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": true 3 | } 4 | -------------------------------------------------------------------------------- /typescript.novaextension/Images/SidebarSmall/SidebarSmall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/SidebarSmall/SidebarSmall.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/SidebarSmall/SidebarSmall@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/Images/SidebarSmall/SidebarSmall@2x.png -------------------------------------------------------------------------------- /typescript.novaextension/Images/SidebarSmall/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": true 3 | } 4 | -------------------------------------------------------------------------------- /typescript.novaextension/README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Extension 2 | 3 | This extension provides rich TypeScript integration through a dedicated language server for both TypeScript and javascript code. Hover over symbols to see type information, receive better autocomplete suggestions, and see type warnings and errors inline. Quickly search for symbols across your project and type dependencies using the "Find Symbol" command. Apply common code refactors such with code actions. Clean your code with import organization and document formatting. 4 | 5 | ## Usage 6 | 7 | ### Editor functionality 8 | 9 | The main functionality is found inline in the editor. 10 | 11 | **Inline errors** 12 | 13 | Inline errors can also be found in the Issues sidebar (View > Sidebars > Show Issues Sidebar). 14 | 15 | Example errors 16 | 17 | **Type info on hover** 18 | 19 | Example type info 20 | 21 | ### Editor commands 22 | 23 | Right click source code and choose the following from the TypeScript menu. 24 | 25 | - Find References 26 | - Rename Symbol 27 | - Organize Imports 28 | - Format Document 29 | - Offer Suggestions (experimental) 30 | 31 | ### Workspace commands 32 | 33 | From the menu, select Extensions > TypeScript. 34 | 35 | - Find Symbol 36 | 37 | ### Code actions 38 | 39 | From the menu, select Editor > Show Code Actions, or click the "Lightbulb" icon in your editor. 40 | 41 | These code actions are specific to your version of typescript and to the code you've selected. [Here's an example to convert to optional chaining](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#convert-to-optional-chaining). 42 | 43 | Example code actions 44 | 45 | ### Sidebar 46 | 47 | The TS/JS sidebar shows status information about the extension including the version of typescript it's using and if it's started successfully. To access the sidebar, click the “All Sidebars” button or View > Sidebars > Show All Sidebars, then the TS/JS item. It can be dragged into the sidebar header for quick access. 48 | 49 | Sidebar information 50 | 51 | ### Find Symbol 52 | 53 | Find Symbol performs a project search for a symbol. Results are shown in the TS/JS sidebar. 54 | 55 | Find symbol sidebar 56 | 57 | ### Find References 58 | 59 | Find References shows all usages of a given variable, function, or other symbol. Results are shown in the TS/JS sidebar. 60 | 61 | Find references sidebar 62 | 63 | ### Using the workspace version of TypeScript 64 | 65 | This extension will automatically find the workspace version of TypeScript installed under `node_modules` in your workspace root. If one isn't installed it will use a recent, bundled version of typescript. 66 | 67 | To customize this you can specify the TypeScript library location in workspace preferences (Extensions > TypeScript > Preferences > TypeScript Library) as an absolute or workspace-relative path. This should point to a directory containing the TypeScript `tsserver.js` file, generally ending with `node_modules/typescript/lib`. If installed globally, you can find the installation location using `npm list -g typescript` (e.g. "/usr/local/lib/node_modules/typescript/lib"). (You should only need this if your workspace doesn't install typescript under the workspace's root `node_modules` directory or you use a global installation of TypeScript) 68 | 69 | ### Enable/Disable for Javascript 70 | 71 | In certain situations, such as when working with Flow types, you may need to disable this in javascript files. You can do this by configuring preferences per-project in Project Settings or globally in the Extension Library. 72 | 73 | ## Troubleshooting 74 | 75 | Many issues are caused by a missing or improperly configured local node/npm installation. 76 | 77 | Check the Extension Console by turning on extension development in Nova in Preferences > General > Extension Development, then Extensions > Show Extension Console, then filter by Source. 78 | 79 | - Check for any warnings or errors. They might indicate a problem with your local environment or a bug with the extension. 80 | - If you see 81 | ``` 82 | activating... 83 | Already locked 84 | ``` 85 | and _do not see_ 86 | ``` 87 | activated 88 | ``` 89 | something may have gone wrong. Try running the "Force Unlock Dependency Installation" command for this extension. 90 | -------------------------------------------------------------------------------- /typescript.novaextension/Syntaxes/cts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TypeScript (CommonJS module) 5 | script 6 | cts 7 | typescript 8 | 9 | cts,d.cts 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /typescript.novaextension/Syntaxes/mts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TypeScript (ES module) 5 | script 6 | mts 7 | typescript 8 | 9 | mts,d.mts 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /typescript.novaextension/extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "apexskier.typescript", 3 | "name": "TypeScript", 4 | "organization": "Cameron Little", 5 | "description": "Advanced TypeScript and JavaScript language support for Nova", 6 | "version": "2.6.0", 7 | "categories": [ 8 | "completions", 9 | "formatters", 10 | "issues", 11 | "languages", 12 | "sidebars" 13 | ], 14 | "license": "MIT", 15 | "repository": "https://github.com/apexskier/nova-typescript", 16 | "bugs": "https://github.com/apexskier/nova-typescript/issues", 17 | 18 | "main": "main.dist.js", 19 | "min_runtime": "2.0", 20 | 21 | "activationEvents": [ 22 | "onLanguage:typescript", 23 | "onLanguage:tsx", 24 | "onLanguage:javascript", 25 | "onLanguage:jsx", 26 | "onWorkspaceContains:tsconfig.json" 27 | ], 28 | 29 | "entitlements": { 30 | "process": true, 31 | "filesystem": "readwrite" 32 | }, 33 | 34 | "config": [ 35 | { 36 | "key": "apexskier.typescript.config.tslibPath", 37 | "title": "TypeScript Library", 38 | "description": "(optional) Path to a custom installed TypeScript library directory. See \"Using the workspace version of TypeScript\" in the README for details.", 39 | "link": "nova://extension/?id=apexskier.typescript", 40 | "type": "string" 41 | }, 42 | { 43 | "key": "apexskier.typescript.config.organizeImportsOnSave", 44 | "title": "Organize imports on save", 45 | "description": "Run Organize Imports command on file save.", 46 | "type": "boolean", 47 | "default": false 48 | }, 49 | { 50 | "key": "apexskier.typescript.config.formatDocumentOnSave", 51 | "title": "Format document on save", 52 | "description": "Run Format Document command on file save.", 53 | "type": "boolean", 54 | "default": false 55 | }, 56 | { 57 | "key": "apexskier.typescript.config.isEnabledForJavascript", 58 | "title": "Enable on Javascript Files", 59 | "description": "Enable TypeScript editor functionality for javascript and jsx files.", 60 | "type": "boolean", 61 | "default": true 62 | }, 63 | { 64 | "key": "apexskier.typescript.config.skipDestructiveOrganizeImports", 65 | "title": "Skip destructive organize imports changes", 66 | "type": "boolean", 67 | "default": false 68 | }, 69 | { 70 | "title": "TypeScript server User Preferences", 71 | "description": "Advanced configuration passed to the underlying typescript server. These may not apply to older versions of TypeScript.", 72 | "type": "section", 73 | "children": [ 74 | { 75 | "key": "apexskier.typescript.config.userPreferences.disableSuggestions", 76 | "title": "Disable suggestions", 77 | "type": "boolean" 78 | }, 79 | { 80 | "key": "apexskier.typescript.config.userPreferences.quotePreference", 81 | "title": "Quote preferences", 82 | "type": "enum", 83 | "values": [ 84 | ["auto", "Automatic"], 85 | ["double", "Double (\")"], 86 | ["single", "Single (')"] 87 | ], 88 | "default": "auto" 89 | }, 90 | { 91 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsForModuleExports", 92 | "title": "Include completions for module exports", 93 | "description": "If enabled, TypeScript will search through all external modules' exports and add them to the completions list. This affects lone identifier completions but not completions on the right hand side of `obj.`.", 94 | "type": "boolean" 95 | }, 96 | { 97 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsForImportStatements", 98 | "title": "Include completions for import statements", 99 | "description": "Enables auto-import-style completions on partially-typed import statements. E.g., allows `import write|` to be completed to `import { writeFile } from \"fs\"`.", 100 | "type": "boolean" 101 | }, 102 | { 103 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsWithSnippetText", 104 | "title": "Include completions with snippet text", 105 | "description": "Allows completions to be formatted with snippet text, indicated by `CompletionItem[\"isSnippet\"]`.", 106 | "type": "boolean" 107 | }, 108 | { 109 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsWithInsertText", 110 | "title": "Include completions with insert text", 111 | "description": "If enabled, the completion list will include completions with invalid identifier names. For those entries, The `insertText` and `replacementSpan` properties will be set to change from `.x` property access to `[\"x\"]`.", 112 | "type": "boolean" 113 | }, 114 | { 115 | "key": "apexskier.typescript.config.userPreferences.includeAutomaticOptionalChainCompletions", 116 | "title": "Include automatic optional chain completions", 117 | "description": "Unless this option is `false`, or `includeCompletionsWithInsertText` is not enabled, member completion lists triggered with `.` will include entries on potentially-null and potentially-undefined values, with insertion text to replace preceding `.` tokens with `?.`.", 118 | "type": "boolean" 119 | }, 120 | { 121 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsWithClassMemberSnippets", 122 | "title": "Include completions with class member snippets", 123 | "description": "If enabled, completions for class members (e.g. methods and properties) will include a whole declaration for the member. E.g., `class A { f| }` could be completed to `class A { foo(): number {} }`, instead of `class A { foo }`.", 124 | "type": "boolean" 125 | }, 126 | { 127 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsWithObjectLiteralMethodSnippets", 128 | "title": "Include completions with class member snippets", 129 | "description": "If enabled, object literal methods will have a method declaration completion entry in addition to the regular completion entry containing just the method name. E.g., `const objectLiteral: T = { f| }` could be completed to `const objectLiteral: T = { foo(): void {} }`, in addition to `const objectLiteral: T = { foo }`.", 130 | "type": "boolean" 131 | }, 132 | { 133 | "key": "apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries", 134 | "title": "Use label details in completion entries", 135 | "type": "boolean" 136 | }, 137 | { 138 | "key": "apexskier.typescript.config.userPreferences.allowIncompleteCompletions", 139 | "title": "Allow incomplete completions", 140 | "type": "boolean" 141 | }, 142 | { 143 | "key": "apexskier.typescript.config.userPreferences.importModuleSpecifierPreference", 144 | "title": "Import module specifier", 145 | "description": "Preferred path ending for auto imports.", 146 | "type": "enum", 147 | "values": [ 148 | ["shortest", "Shortest"], 149 | ["project-relative", "Project-relative"], 150 | ["relative", "Relative"], 151 | ["non-relative", "Non-relative"] 152 | ], 153 | "default": "shortest" 154 | }, 155 | { 156 | "key": "apexskier.typescript.config.userPreferences.importModuleSpecifierEnding", 157 | "title": "Import module specifier", 158 | "description": "Determines whether we import `foo/index.ts` as \"foo\", \"foo/index\", or \"foo/index.js\"", 159 | "type": "enum", 160 | "values": [ 161 | ["auto", "Automatic"], 162 | ["minimal", "Minimal"], 163 | ["index", "Index"], 164 | ["js", ".js"] 165 | ], 166 | "default": "auto" 167 | }, 168 | { 169 | "key": "apexskier.typescript.config.userPreferences.allowTextChangesInNewFiles", 170 | "title": "Allow text changes in new files", 171 | "type": "boolean" 172 | }, 173 | { 174 | "key": "apexskier.typescript.config.userPreferences.lazyConfiguredProjectsFromExternalProject", 175 | "title": "Lazy configured projects from external project", 176 | "type": "boolean" 177 | }, 178 | { 179 | "key": "apexskier.typescript.config.userPreferences.providePrefixAndSuffixTextForRename", 180 | "title": "Provide prefix and suffix text for rename", 181 | "type": "boolean" 182 | }, 183 | { 184 | "key": "apexskier.typescript.config.userPreferences.provideRefactorNotApplicableReason", 185 | "title": "Provide refactor not applicable reason", 186 | "type": "boolean" 187 | }, 188 | { 189 | "key": "apexskier.typescript.config.userPreferences.allowRenameOfImportPath", 190 | "title": "Allow rename of import path", 191 | "type": "boolean" 192 | }, 193 | { 194 | "key": "apexskier.typescript.config.userPreferences.includePackageJsonAutoImports", 195 | "title": "Allow rename of import path", 196 | "type": "enum", 197 | "values": [ 198 | ["auto", "Automatic"], 199 | ["on", "On"], 200 | ["off", "Off"] 201 | ], 202 | "default": "auto" 203 | }, 204 | { 205 | "key": "apexskier.typescript.config.userPreferences.jsxAttributeCompletionStyle", 206 | "title": "jsx attribute completion style", 207 | "type": "enum", 208 | "values": [ 209 | ["auto", "Automatic"], 210 | ["braces", "Braces"], 211 | ["none", "None"] 212 | ], 213 | "default": "auto" 214 | }, 215 | { 216 | "key": "apexskier.typescript.config.userPreferences.displayPartsForJSDoc", 217 | "title": "Display parts for JSDoc", 218 | "type": "boolean" 219 | }, 220 | { 221 | "key": "apexskier.typescript.config.userPreferences.generateReturnInDocTemplate", 222 | "title": "Generate return in documentation templates", 223 | "type": "boolean" 224 | }, 225 | { 226 | "key": "apexskier.typescript.config.userPreferences.includeInlayParameterNameHints", 227 | "title": "Include inlay parameter name hints", 228 | "type": "enum", 229 | "values": [ 230 | ["none", "None"], 231 | ["literals", "Literals"], 232 | ["all", "All"] 233 | ], 234 | "default": "none" 235 | }, 236 | { 237 | "key": "apexskier.typescript.config.userPreferences.includeInlayParameterNameHintsWhenArgumentMatchesName", 238 | "title": "Include inlay parameter name hints when argument matches name", 239 | "type": "boolean" 240 | }, 241 | { 242 | "key": "apexskier.typescript.config.userPreferences.includeInlayFunctionParameterTypeHints", 243 | "title": "Include inlay function parameter type hints", 244 | "type": "boolean" 245 | }, 246 | { 247 | "key": "apexskier.typescript.config.userPreferences.includeInlayVariableTypeHints", 248 | "title": "Include inlay variable type hints", 249 | "type": "boolean" 250 | }, 251 | { 252 | "key": "apexskier.typescript.config.userPreferences.includeInlayVariableTypeHintsWhenTypeMatchesName", 253 | "title": "Include inlay variable type hints when type matches name", 254 | "type": "boolean" 255 | }, 256 | { 257 | "key": "apexskier.typescript.config.userPreferences.includeInlayPropertyDeclarationTypeHints", 258 | "title": "Include inlay property declaration type hints", 259 | "type": "boolean" 260 | }, 261 | { 262 | "key": "apexskier.typescript.config.userPreferences.includeInlayFunctionLikeReturnTypeHints", 263 | "title": "Include inlay function like return type hints", 264 | "type": "boolean" 265 | }, 266 | { 267 | "key": "apexskier.typescript.config.userPreferences.includeInlayEnumMemberValueHints", 268 | "title": "Include inlay enum member value hints", 269 | "type": "boolean" 270 | }, 271 | { 272 | "key": "apexskier.typescript.config.userPreferences.autoImportFileExcludePatterns", 273 | "title": "Auto import file exclude patterns", 274 | "type": "stringArray" 275 | }, 276 | { 277 | "key": "apexskier.typescript.config.userPreferences.disableLineTextInReferences", 278 | "title": "Disable line text in references", 279 | "description": "Indicates whether ReferenceResponseItem.lineText is supported.", 280 | "type": "boolean" 281 | } 282 | ] 283 | }, 284 | { 285 | "title": "Debug", 286 | "description": "Settings intended for extension development", 287 | "type": "section", 288 | "children": [ 289 | { 290 | "key": "apexskier.typescript.config.debug.disableDependencyManagement", 291 | "title": "Disable dependency management", 292 | "description": "Enable this to skip installing dependencies. Use this if you want to link a custom language server.", 293 | "type": "boolean", 294 | "default": false 295 | }, 296 | { 297 | "key": "apexskier.typescript.config.debug.debugLanguageServer", 298 | "title": "Debug Language Server", 299 | "description": "Open a debug session for the language server.", 300 | "type": "boolean", 301 | "default": false 302 | }, 303 | { 304 | "key": "apexskier.typescript.config.debug.debugLanguageServer.break", 305 | "title": "Debug Language Server (break on start)", 306 | "description": "If debugging, break when starting the language server.", 307 | "type": "boolean", 308 | "default": false 309 | }, 310 | { 311 | "key": "apexskier.typescript.config.debug.debugLanguageServer.port", 312 | "title": "Debug Language Server (port)", 313 | "description": "Language server debug port.", 314 | "type": "number", 315 | "default": 9229 316 | } 317 | ] 318 | } 319 | ], 320 | 321 | "configWorkspace": [ 322 | { 323 | "key": "apexskier.typescript.config.tslibPath", 324 | "title": "TypeScript Library", 325 | "description": "(optional) Path to a custom installed TypeScript library directory. See \"Using the workspace version of TypeScript\" in the README for details.", 326 | "link": "nova://extension/?id=apexskier.typescript", 327 | "type": "string" 328 | }, 329 | { 330 | "key": "apexskier.typescript.config.organizeImportsOnSave", 331 | "title": "Organize imports on save", 332 | "description": "Run Organize Imports command on file save.", 333 | "type": "enum", 334 | "values": [ 335 | ["null", "Inherit from Global Settings"], 336 | ["false", "Disable"], 337 | ["true", "Enable"] 338 | ], 339 | "default": "null" 340 | }, 341 | { 342 | "key": "apexskier.typescript.config.formatDocumentOnSave", 343 | "title": "Format document on save", 344 | "description": "Run Format Document command on file save.", 345 | "type": "enum", 346 | "values": [ 347 | ["null", "Inherit from Global Settings"], 348 | ["false", "Disable"], 349 | ["true", "Enable"] 350 | ], 351 | "default": "null" 352 | }, 353 | { 354 | "key": "apexskier.typescript.config.isEnabledForJavascript", 355 | "title": "Enable on Javascript Files", 356 | "description": "Enable TypeScript editor functionality for javascript and jsx files.", 357 | "type": "enum", 358 | "values": ["Inherit from Global Settings", "Disable", "Enable"], 359 | "default": "Inherit from Global Settings" 360 | }, 361 | { 362 | "key": "apexskier.typescript.config.skipDestructiveOrganizeImports", 363 | "title": "Skip destructive organize imports changes", 364 | "type": "enum", 365 | "values": ["Inherit from Global Settings", "False", "True"], 366 | "default": "Inherit from Global Settings" 367 | }, 368 | { 369 | "title": "TypeScript server User Preferences", 370 | "description": "Advanced configuration passed to the underlying typescript server. These may not apply to older versions of TypeScript.", 371 | "type": "section", 372 | "children": [ 373 | { 374 | "key": "apexskier.typescript.config.userPreferences.disableSuggestions", 375 | "title": "Disable suggestions", 376 | "type": "boolean" 377 | }, 378 | { 379 | "key": "apexskier.typescript.config.userPreferences.quotePreference", 380 | "title": "Quote preferences", 381 | "type": "enum", 382 | "values": [ 383 | ["auto", "Automatic"], 384 | ["double", "Double (\")"], 385 | ["single", "Single (')"] 386 | ], 387 | "default": "auto" 388 | }, 389 | { 390 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsForModuleExports", 391 | "title": "Include completions for module exports", 392 | "description": "If enabled, TypeScript will search through all external modules' exports and add them to the completions list. This affects lone identifier completions but not completions on the right hand side of `obj.`.", 393 | "type": "boolean" 394 | }, 395 | { 396 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsForImportStatements", 397 | "title": "Include completions for import statements", 398 | "description": "Enables auto-import-style completions on partially-typed import statements. E.g., allows `import write|` to be completed to `import { writeFile } from \"fs\"`.", 399 | "type": "boolean" 400 | }, 401 | { 402 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsWithSnippetText", 403 | "title": "Include completions with snippet text", 404 | "description": "Allows completions to be formatted with snippet text, indicated by `CompletionItem[\"isSnippet\"]`.", 405 | "type": "boolean" 406 | }, 407 | { 408 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsWithInsertText", 409 | "title": "Include completions with insert text", 410 | "description": "If enabled, the completion list will include completions with invalid identifier names. For those entries, The `insertText` and `replacementSpan` properties will be set to change from `.x` property access to `[\"x\"]`.", 411 | "type": "boolean" 412 | }, 413 | { 414 | "key": "apexskier.typescript.config.userPreferences.includeAutomaticOptionalChainCompletions", 415 | "title": "Include automatic optional chain completions", 416 | "description": "Unless this option is `false`, or `includeCompletionsWithInsertText` is not enabled, member completion lists triggered with `.` will include entries on potentially-null and potentially-undefined values, with insertion text to replace preceding `.` tokens with `?.`.", 417 | "type": "boolean" 418 | }, 419 | { 420 | "key": "apexskier.typescript.config.userPreferences.includeCompletionsWithObjectLiteralMethodSnippets", 421 | "title": "Include completions with class member snippets", 422 | "description": "If enabled, object literal methods will have a method declaration completion entry in addition to the regular completion entry containing just the method name. E.g., `const objectLiteral: T = { f| }` could be completed to `const objectLiteral: T = { foo(): void {} }`, in addition to `const objectLiteral: T = { foo }`.", 423 | "type": "boolean" 424 | }, 425 | { 426 | "key": "apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries", 427 | "title": "Use label details in completion entries", 428 | "type": "boolean" 429 | }, 430 | { 431 | "key": "apexskier.typescript.config.userPreferences.allowIncompleteCompletions", 432 | "title": "Allow incomplete completions", 433 | "type": "boolean" 434 | }, 435 | { 436 | "key": "apexskier.typescript.config.userPreferences.importModuleSpecifierPreference", 437 | "title": "Import module specifier", 438 | "description": "Preferred path ending for auto imports.", 439 | "type": "enum", 440 | "values": [ 441 | ["shortest", "Shortest"], 442 | ["project-relative", "Project-relative"], 443 | ["relative", "Relative"], 444 | ["non-relative", "Non-relative"] 445 | ], 446 | "default": "shortest" 447 | }, 448 | { 449 | "key": "apexskier.typescript.config.userPreferences.importModuleSpecifierEnding", 450 | "title": "Import module specifier", 451 | "description": "Determines whether we import `foo/index.ts` as \"foo\", \"foo/index\", or \"foo/index.js\"", 452 | "type": "enum", 453 | "values": [ 454 | ["auto", "Automatic"], 455 | ["minimal", "Minimal"], 456 | ["index", "Index"], 457 | ["js", ".js"] 458 | ], 459 | "default": "auto" 460 | }, 461 | { 462 | "key": "apexskier.typescript.config.userPreferences.allowTextChangesInNewFiles", 463 | "title": "Allow text changes in new files", 464 | "type": "boolean" 465 | }, 466 | { 467 | "key": "apexskier.typescript.config.userPreferences.lazyConfiguredProjectsFromExternalProject", 468 | "title": "Lazy configured projects from external project", 469 | "type": "boolean" 470 | }, 471 | { 472 | "key": "apexskier.typescript.config.userPreferences.providePrefixAndSuffixTextForRename", 473 | "title": "Provide prefix and suffix text for rename", 474 | "type": "boolean" 475 | }, 476 | { 477 | "key": "apexskier.typescript.config.userPreferences.provideRefactorNotApplicableReason", 478 | "title": "Provide refactor not applicable reason", 479 | "type": "boolean" 480 | }, 481 | { 482 | "key": "apexskier.typescript.config.userPreferences.allowRenameOfImportPath", 483 | "title": "Allow rename of import path", 484 | "type": "boolean" 485 | }, 486 | { 487 | "key": "apexskier.typescript.config.userPreferences.includePackageJsonAutoImports", 488 | "title": "Allow rename of import path", 489 | "type": "enum", 490 | "values": [ 491 | ["auto", "Automatic"], 492 | ["on", "On"], 493 | ["off", "Off"] 494 | ], 495 | "default": "auto" 496 | }, 497 | { 498 | "key": "apexskier.typescript.config.userPreferences.jsxAttributeCompletionStyle", 499 | "title": "jsx attribute completion style", 500 | "type": "enum", 501 | "values": [ 502 | ["auto", "Automatic"], 503 | ["braces", "Braces"], 504 | ["none", "None"] 505 | ], 506 | "default": "auto" 507 | }, 508 | { 509 | "key": "apexskier.typescript.config.userPreferences.displayPartsForJSDoc", 510 | "title": "Display parts for JSDoc", 511 | "type": "boolean" 512 | }, 513 | { 514 | "key": "apexskier.typescript.config.userPreferences.generateReturnInDocTemplate", 515 | "title": "Generate return in documentation templates", 516 | "type": "boolean" 517 | }, 518 | { 519 | "key": "apexskier.typescript.config.userPreferences.includeInlayParameterNameHints", 520 | "title": "Include inlay parameter name hints", 521 | "type": "enum", 522 | "values": [ 523 | ["none", "None"], 524 | ["literals", "Literals"], 525 | ["all", "All"] 526 | ], 527 | "default": "none" 528 | }, 529 | { 530 | "key": "apexskier.typescript.config.userPreferences.includeInlayParameterNameHintsWhenArgumentMatchesName", 531 | "title": "Include inlay parameter name hints when argument matches name", 532 | "type": "boolean" 533 | }, 534 | { 535 | "key": "apexskier.typescript.config.userPreferences.includeInlayFunctionParameterTypeHints", 536 | "title": "Include inlay function parameter type hints", 537 | "type": "boolean" 538 | }, 539 | { 540 | "key": "apexskier.typescript.config.userPreferences.includeInlayVariableTypeHints", 541 | "title": "Include inlay variable type hints", 542 | "type": "boolean" 543 | }, 544 | { 545 | "key": "apexskier.typescript.config.userPreferences.includeInlayVariableTypeHintsWhenTypeMatchesName", 546 | "title": "Include inlay variable type hints when type matches name", 547 | "type": "boolean" 548 | }, 549 | { 550 | "key": "apexskier.typescript.config.userPreferences.includeInlayPropertyDeclarationTypeHints", 551 | "title": "Include inlay property declaration type hints", 552 | "type": "boolean" 553 | }, 554 | { 555 | "key": "apexskier.typescript.config.userPreferences.includeInlayFunctionLikeReturnTypeHints", 556 | "title": "Include inlay function like return type hints", 557 | "type": "boolean" 558 | }, 559 | { 560 | "key": "apexskier.typescript.config.userPreferences.includeInlayEnumMemberValueHints", 561 | "title": "Include inlay enum member value hints", 562 | "type": "boolean" 563 | }, 564 | { 565 | "key": "apexskier.typescript.config.userPreferences.autoImportFileExcludePatterns", 566 | "title": "Auto import file exclude patterns", 567 | "type": "stringArray" 568 | }, 569 | { 570 | "key": "apexskier.typescript.config.userPreferences.disableLineTextInReferences", 571 | "title": "Disable line text in references", 572 | "description": "Indicates whether ReferenceResponseItem.lineText is supported.", 573 | "type": "boolean" 574 | } 575 | ] 576 | } 577 | ], 578 | 579 | "sidebars": [ 580 | { 581 | "id": "apexskier.typescript.sidebar", 582 | "name": "TS/JS", 583 | "smallImage": "SidebarSmall", 584 | "largeImage": "SidebarLarge", 585 | "sections": [ 586 | { 587 | "id": "apexskier.typescript.sidebar.info", 588 | "name": "Information", 589 | "placeholderText": "TypeScript Extension Information", 590 | "headerCommands": [ 591 | { 592 | "title": "Refresh", 593 | "image": "__builtin.refresh", 594 | "command": "apexskier.typescript.reload" 595 | } 596 | ] 597 | }, 598 | { 599 | "id": "apexskier.typescript.sidebar.symbols", 600 | "name": "Results", 601 | "placeholderText": "Results from “Find Symbol”, “Find Reference” and “Go to Definition” will show here.", 602 | "headerCommands": [ 603 | { 604 | "title": "Find Symbol", 605 | "image": "Search", 606 | "tooltip": "Open the Find Symbol palette", 607 | "command": "apexskier.typescript.findSymbol" 608 | } 609 | ] 610 | } 611 | ] 612 | } 613 | ], 614 | 615 | "commands": { 616 | "extensions": [ 617 | { 618 | "title": "Preferences", 619 | "command": "apexskier.typescript.openWorkspaceConfig" 620 | }, 621 | { 622 | "title": "Restart Server", 623 | "command": "apexskier.typescript.reload" 624 | }, 625 | { 626 | "title": "Find Symbol", 627 | "command": "apexskier.typescript.findSymbol" 628 | }, 629 | { 630 | "title": "Force Unlock Dependency Installation", 631 | "command": "apexskier.typescript.forceClearLock" 632 | } 633 | ], 634 | "editor": [ 635 | { 636 | "title": "Find References", 637 | "command": "apexskier.typescript.findReferences", 638 | "filters": { 639 | "syntaxes": ["typescript", "tsx", "javascript", "jsx", "cts", "mts"] 640 | } 641 | }, 642 | { 643 | "title": "Rename Symbol", 644 | "command": "apexskier.typescript.rename", 645 | "filters": { 646 | "syntaxes": ["typescript", "tsx", "javascript", "jsx", "cts", "mts"] 647 | } 648 | }, 649 | { 650 | "title": "Organize Imports", 651 | "command": "apexskier.typescript.commands.organizeImports", 652 | "filters": { 653 | "syntaxes": ["typescript", "tsx", "javascript", "jsx", "cts", "mts"] 654 | } 655 | }, 656 | { 657 | "title": "Format Document", 658 | "command": "apexskier.typescript.commands.formatDocument", 659 | "filters": { 660 | "syntaxes": ["typescript", "tsx", "javascript", "jsx", "cts", "mts"] 661 | } 662 | }, 663 | { 664 | "title": "Show Documentation (experimental)", 665 | "command": "apexskier.typescript.signatureHelp", 666 | "filters": { 667 | "syntaxes": ["typescript", "tsx", "javascript", "jsx", "cts", "mts"] 668 | } 669 | } 670 | ] 671 | } 672 | } 673 | -------------------------------------------------------------------------------- /typescript.novaextension/extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/extension.png -------------------------------------------------------------------------------- /typescript.novaextension/extension@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexskier/nova-typescript/fb6b22d528742a0c688cb83b23b683800b5bb887/typescript.novaextension/extension@2x.png -------------------------------------------------------------------------------- /typescript.novaextension/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript.novaextension", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "typescript.novaextension", 8 | "dependencies": { 9 | "typescript": "^5.2.2", 10 | "typescript-language-server": "^4.0.0" 11 | } 12 | }, 13 | "node_modules/typescript": { 14 | "version": "5.2.2", 15 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", 16 | "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", 17 | "bin": { 18 | "tsc": "bin/tsc", 19 | "tsserver": "bin/tsserver" 20 | }, 21 | "engines": { 22 | "node": ">=14.17" 23 | } 24 | }, 25 | "node_modules/typescript-language-server": { 26 | "version": "4.0.0", 27 | "resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-4.0.0.tgz", 28 | "integrity": "sha512-u6GLfWtHzOfGNpn0XuUYFg8Jv3oXWKzY6o5/Lt6LbWE6Ux965z2lP+vM0AN8Z2EobnlrDzzdcKusUx46j2eP3A==", 29 | "bin": { 30 | "typescript-language-server": "lib/cli.mjs" 31 | }, 32 | "engines": { 33 | "node": ">=18" 34 | } 35 | } 36 | }, 37 | "dependencies": { 38 | "typescript": { 39 | "version": "5.2.2", 40 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", 41 | "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==" 42 | }, 43 | "typescript-language-server": { 44 | "version": "4.0.0", 45 | "resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-4.0.0.tgz", 46 | "integrity": "sha512-u6GLfWtHzOfGNpn0XuUYFg8Jv3oXWKzY6o5/Lt6LbWE6Ux965z2lP+vM0AN8Z2EobnlrDzzdcKusUx46j2eP3A==" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /typescript.novaextension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript.novaextension", 3 | "private": true, 4 | "dependencies": { 5 | "typescript": "^5.2.2", 6 | "typescript-language-server": "^4.0.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typescript.novaextension/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # note: any output from this script will be used by nova's language server, so it'll break functionality 4 | # to get logging in the extension console, pipe to stderr by prefixing with `>&2 ` 5 | 6 | cd "$WORKSPACE_DIR" 7 | 8 | # symlinks have issues when the extension is submitted to the library, so we don't use node_modules/.bin 9 | 10 | if [ "$DEBUG" != "TRUE" ] 11 | then 12 | node \ 13 | "$INSTALL_DIR/node_modules/.bin/typescript-language-server" \ 14 | --stdio 15 | else 16 | if [ "$DEBUG_BREAK" ] 17 | then 18 | DEBUG_ARG="--inspect-brk" 19 | else 20 | DEBUG_ARG="--inspect" 21 | fi 22 | DEBUG_ARG="$DEBUG_ARG=127.0.0.1:$DEBUG_PORT" 23 | # note: --tsserver-path=".../tsserver.js" doesn't support debugging since it 24 | # tries to fork and bind two processes to the same port 25 | node \ 26 | "$DEBUG_ARG" \ 27 | "$INSTALL_DIR/node_modules/.bin/typescript-language-server" \ 28 | --stdio 29 | fi 30 | --------------------------------------------------------------------------------