diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c41a53..8f087ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,7 @@ # Change Log -All notable changes to the "typst-companion" extension will be documented in this file. +All notable changes to Typst Companion will be documented in this file. -Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## 0.0.1 -## [Unreleased] - -- Initial release \ No newline at end of file +- Initial release of Typst Companion. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..486ff93 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2023 Caleb Figgers + +With attributed inclusions Copyright (c) 2017 张宇 + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 68b5dd6..69bec17 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,27 @@ -# typst-companion README +# Typst Companion -This is the README for your extension "typst-companion". After writing up a brief description, we recommend including the following sections. +A VS Code extension that adds Markdown-like editing niceties **on top of and in addition to** to Nathan Varner's [Typst LSP](https://github.com/nvarner/typst-lsp). ## Features -Describe specific features of your extension including screenshots of your extension in action. Image paths are relative to this README file. - -For example if there is an image subfolder under your extension project workspace: - -\!\[feature X\]\(images/feature-x.png\) - -> Tip: Many popular extensions utilize animations. This is an excellent way to show off your extension! We recommend short, focused animations that are easy to follow. +- Intuitive handling of Ordered and Unordered lists in `.typ` files. + - `Enter` while in a list context (either ordered or unordered) continues the existing list at the current level of indentation (with correct numbering, if ordered). + - `Tab` and `Shift+Tab` while in a list context (either ordered or unordered) indents and out-dents bullets intuitively (and re-numbers ordered lists if appropriate). + - Reordering lines inside an ordered list automatically updates the list numbers accordingly. ## Requirements -If you have any requirements or dependencies, add a section describing those and how to install and configure them. - -## Extension Settings - -Include if your extension adds any VS Code settings through the `contributes.configuration` extension point. - -For example: - -This extension contributes the following settings: - -* `myExtension.enable`: Enable/disable this extension. -* `myExtension.thing`: Set to `blah` to do something. - -## Known Issues - -Calling out known issues can help limit users opening duplicate issues against your extension. +I *strongly* encourage installing Nathan Varner's [Typst LSP](https://github.com/nvarner/typst-lsp) in addition to this extension for syntax highlighting, error reporting, code completion, and all of Typst LSP's other features. + This extension just adds some small additional features that I missed when using Typst LSP. ## Release Notes -Users appreciate release notes as you update your extension. - -### 1.0.0 - -Initial release of ... - -### 1.0.1 - -Fixed issue #. - -### 1.1.0 - -Added features X, Y, and Z. - ---- - -## Following extension guidelines - -Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. - -* [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) - -## Working with Markdown - -You can author your README using Visual Studio Code. Here are some useful editor keyboard shortcuts: +### 0.0.1 -* Split the editor (`Cmd+\` on macOS or `Ctrl+\` on Windows and Linux). -* Toggle preview (`Shift+Cmd+V` on macOS or `Shift+Ctrl+V` on Windows and Linux). -* Press `Ctrl+Space` (Windows, Linux, macOS) to see a list of Markdown snippets. +Initial release of Typst Companion. -## For more information +## Prior Art -* [Visual Studio Code's Markdown Support](http://code.visualstudio.com/docs/languages/markdown) -* [Markdown Syntax Reference](https://help.github.com/articles/markdown-basics/) +The core logic of this extension is adapted, with attribution and gratitude, from [Markdown All-in-One](https://github.com/yzhang-gh/vscode-markdown/), under the following license: -**Enjoy!** +MIT License, Copyright (c) 2017 张宇 \ No newline at end of file diff --git a/icons/typst-small.png b/icons/typst-small.png new file mode 100644 index 0000000..24edfba Binary files /dev/null and b/icons/typst-small.png differ diff --git a/icons/typst.png b/icons/typst.png new file mode 100644 index 0000000..f1e3ae9 Binary files /dev/null and b/icons/typst.png differ diff --git a/language-configuration.json b/language-configuration.json new file mode 100644 index 0000000..c3db14b --- /dev/null +++ b/language-configuration.json @@ -0,0 +1,46 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["[", "]"], + ["{", "}"], + ["(", ")"] + ], + "autoClosingPairs": [ + { + "open": "[", + "close": "]" + }, + { + "open": "{", + "close": "}" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"", + "notIn": ["string"] + }, + { + "open": "$", + "close": "$", + "notIn": ["string"] + } + ], + "autoCloseBefore": "$ \n\t", + "surroundingPairs": [ + ["[", "]"], + ["{", "}"], + ["(", ")"], + ["\"", "\""], + ["*", "*"], + ["_", "_"], + ["`", "`"], + ["$", "$"] + ] +} diff --git a/package.json b/package.json index 3e3d0cb..e1cce36 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,16 @@ { "name": "typst-companion", "displayName": "Typst Companion", - "description": "Adds editing niceities on top of Typst LSP.", + "description": "Adds Markdown-like editing niceties on top of Typst LSP.", "version": "0.0.1", + "author": "Caleb Figgers", + "publisher": "Caleb Figgers", + "icon": "icons/typst.png", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/CFiggers/typst-companion" + }, "engines": { "vscode": "^1.81.0" }, @@ -10,14 +18,133 @@ "Other" ], "activationEvents": [ - "onCommand:typst-companion.helloWorld" + "onLanguage:typst" ], "main": "./dist/extension.js", "contributes": { - "commands": [ + "configuration": [ + { + "type": "object", + "title": "Typst Markdown", + "properties": { + "typst-companion.extension.orderedList.autoRenumber": { + "type": "boolean", + "default": true, + "description": "Auto fix ordered list markers." + }, + "typst-companion.extension.orderedList.marker": { + "type": "string", + "default": "ordered", + "description": "Ordered list marker.", + "enum": [ + "ordered", + "plus" + ], + "markdownEnumDescriptions": [ + "Use increasing numbers as ordered list marker.", + "Always use `+` as ordered list marker." + ] + }, + "typst-companion.extension.list.indentationSize": { + "type": "string", + "enum": [ + "adaptive", + "inherit" + ], + "markdownEnumDescriptions": [ + "Adaptive indentation size according to the context, trying to left align the sublist with its parent's content.", + "Use the configured tab size of the current document (see the status bar)." + ], + "default": "inherit", + "markdownDescription": "Whether to use different indentation sizes on different list contexts or stick to VS Code's tab size.", + "scope": "resource" + } + } + } + ], + "languages": [ + { + "id": "typst", + "configuration": "./language-configuration.json", + "extensions": [ + ".typ" + ], + "aliases": [ + "Typst" + ], + "icon": { + "light": "./icons/typst-small.png", + "dark": "./icons/typst-small.png" + } + } + ], + "keybindings": [ + { + "command": "typst-companion.extension.onEnterKey", + "key": "enter", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible && !editorHasMultipleSelections && vim.mode != 'Normal' && vim.mode != 'Visual' && vim.mode != 'VisualBlock' && vim.mode != 'VisualLine' && vim.mode != 'SearchInProgressMode' && vim.mode != 'CommandlineInProgress' && vim.mode != 'Replace' && vim.mode != 'EasyMotionMode' && vim.mode != 'EasyMotionInputMode' && vim.mode != 'SurroundInputMode'" + }, + { + "command": "typst-companion.extension.onCtrlEnterKey", + "key": "ctrl+enter", + "mac": "cmd+enter", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible && !editorHasMultipleSelections" + }, + { + "command": "typst-companion.extension.onShiftEnterKey", + "key": "shift+enter", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible && !editorHasMultipleSelections" + }, + { + "command": "typst-companion.extension.onTabKey", + "key": "tab", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible && !inlineSuggestionVisible && !editorHasMultipleSelections && !editorTabMovesFocus && !inSnippetMode && !hasSnippetCompletions && !hasOtherSuggestions && typst-companion.extension.editor.cursor.inList" + }, + { + "command": "typst-companion.extension.onShiftTabKey", + "key": "shift+tab", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible && !editorHasMultipleSelections && !editorTabMovesFocus && !inSnippetMode && !hasSnippetCompletions && !hasOtherSuggestions && typst-companion.extension.editor.cursor.inList" + }, + { + "command": "typst-companion.extension.onBackspaceKey", + "key": "backspace", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible && !editorHasMultipleSelections && vim.mode != 'Normal' && vim.mode != 'Visual' && vim.mode != 'VisualBlock' && vim.mode != 'VisualLine' && vim.mode != 'SearchInProgressMode' && vim.mode != 'CommandlineInProgress' && vim.mode != 'Replace' && vim.mode != 'EasyMotionMode' && vim.mode != 'EasyMotionInputMode' && vim.mode != 'SurroundInputMode'" + }, + { + "command": "typst-companion.extension.onMoveLineUp", + "key": "alt+up", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible" + }, + { + "command": "typst-companion.extension.onMoveLineDown", + "key": "alt+down", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible" + }, + { + "command": "typst-companion.extension.onCopyLineUp", + "win": "shift+alt+up", + "mac": "shift+alt+up", + "linux": "ctrl+shift+alt+up", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible" + }, + { + "command": "typst-companion.extension.onCopyLineDown", + "win": "shift+alt+down", + "mac": "shift+alt+down", + "linux": "ctrl+shift+alt+down", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst && !suggestWidgetVisible" + }, + { + "command": "typst-companion.extension.onIndentLines", + "key": "ctrl+]", + "mac": "cmd+]", + "when": "editorTextFocus && editorLangId == typst && !suggestWidgetVisible" + }, { - "command": "typst-companion.helloWorld", - "title": "Hello World" + "command": "typst-companion.extension.onOutdentLines", + "key": "ctrl+[", + "mac": "cmd+[", + "when": "editorTextFocus && editorLangId == typst && !suggestWidgetVisible" } ] }, diff --git a/src/IDisposable.ts b/src/IDisposable.ts new file mode 100644 index 0000000..2ef2124 --- /dev/null +++ b/src/IDisposable.ts @@ -0,0 +1,39 @@ +// From https://github.com/yzhang-gh/vscode-markdown/ , under the following license: +// +// MIT License + +// Copyright (c) 2017 张宇 + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +"use strict"; + +/** + * @see + * @see + * @see + */ +export default interface IDisposable { + + /** + * Performs application-defined tasks associated with freeing, releasing, or resetting resources. + */ + dispose(): any; +} \ No newline at end of file diff --git a/src/editor-context-service/context-service-in-list.ts b/src/editor-context-service/context-service-in-list.ts new file mode 100644 index 0000000..834bf79 --- /dev/null +++ b/src/editor-context-service/context-service-in-list.ts @@ -0,0 +1,61 @@ +// From https://github.com/yzhang-gh/vscode-markdown/ , under the following license: +// +// MIT License + +// Copyright (c) 2017 张宇 + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +'use strict' + +import { ExtensionContext, Position, TextDocument, window } from 'vscode'; +import { AbsContextService } from "./i-context-service"; + +export class ContextServiceEditorInList extends AbsContextService { + public contextName: string = "typst-companion.extension.editor.cursor.inList"; + + public onActivate(_context: ExtensionContext) { + // set initial state of context + this.setState(false); + } + + public dispose(): void { } + + public onDidChangeActiveTextEditor(document: TextDocument, cursorPos: Position) { + this.updateContextState(document, cursorPos); + } + + public onDidChangeTextEditorSelection(document: TextDocument, cursorPos: Position) { + this.updateContextState(document, cursorPos); + } + + private updateContextState(document: TextDocument, cursorPos: Position) { + let lineText = document.lineAt(cursorPos.line).text; + + let inList = /^\s*([-+*]|[0-9]+[.)]) +(\[[ x]\] +)?/.test(lineText); + if (inList) { + this.setState(true); + } + else { + this.setState(false); + } + return; + } +} \ No newline at end of file diff --git a/src/editor-context-service/i-context-service.ts b/src/editor-context-service/i-context-service.ts new file mode 100644 index 0000000..f8bba83 --- /dev/null +++ b/src/editor-context-service/i-context-service.ts @@ -0,0 +1,74 @@ +// From https://github.com/yzhang-gh/vscode-markdown/ , under the following license: +// +// MIT License + +// Copyright (c) 2017 张宇 + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +'use strict' + +import { commands, ExtensionContext, Position, TextDocument } from 'vscode'; +import type IDisposable from "../IDisposable"; + +interface IContextService extends IDisposable { + onActivate(context: ExtensionContext): void; + + /** + * handler of onDidChangeActiveTextEditor + * implement this method to handle that event to update context state + */ + onDidChangeActiveTextEditor(document: TextDocument, cursorPos: Position): void; + /** + * handler of onDidChangeTextEditorSelection + * implement this method to handle that event to update context state + */ + onDidChangeTextEditorSelection(document: TextDocument, cursorPos: Position): void; +} + +export abstract class AbsContextService implements IContextService { + public abstract readonly contextName: string; + + /** + * activate context service + * @param context ExtensionContext + */ + public abstract onActivate(context: ExtensionContext): void; + public abstract dispose(): void; + + /** + * default handler of onDidChangeActiveTextEditor, do nothing. + * override this method to handle that event to update context state. + */ + public abstract onDidChangeActiveTextEditor(document: TextDocument, cursorPos: Position): void; + + /** + * default handler of onDidChangeTextEditorSelection, do nothing. + * override this method to handle that event to update context state. + */ + public abstract onDidChangeTextEditorSelection(document: TextDocument, cursorPos: Position): void; + + /** + * set state of context + */ + protected setState(state: any) { + commands.executeCommand('setContext', this.contextName, state); + } +} \ No newline at end of file diff --git a/src/editor-context-service/manager.ts b/src/editor-context-service/manager.ts new file mode 100644 index 0000000..f0fb229 --- /dev/null +++ b/src/editor-context-service/manager.ts @@ -0,0 +1,94 @@ +// From https://github.com/yzhang-gh/vscode-markdown/ , under the following license: +// +// MIT License + +// Copyright (c) 2017 张宇 + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +'use strict' + +import type IDisposable from "../IDisposable"; +import { ExtensionContext, window } from 'vscode'; +import { AbsContextService } from "./i-context-service"; +import { ContextServiceEditorInList } from "./context-service-in-list"; +// import { ContextServiceEditorInFencedCodeBlock } from "./context-service-in-fenced-code-block"; +// import { ContextServiceEditorInMathEn } from "./context-service-in-math-env"; + +export class ContextServiceManager implements IDisposable { + private readonly contextServices: Array = []; + + public constructor() { + // push context services + this.contextServices.push(new ContextServiceEditorInList()); + // this.contextServices.push(new ContextServiceEditorInFencedCodeBlock()); + // this.contextServices.push(new ContextServiceEditorInMathEn()); + } + + public activate(context: ExtensionContext) { + for (const service of this.contextServices) { + service.onActivate(context); + } + // subscribe update handler for context + context.subscriptions.push( + window.onDidChangeActiveTextEditor(() => this.onDidChangeActiveTextEditor()), + window.onDidChangeTextEditorSelection(() => this.onDidChangeTextEditorSelection()) + ); + // initialize context state + this.onDidChangeActiveTextEditor(); + } + + public dispose(): void { + while (this.contextServices.length > 0) { + const service = this.contextServices.pop(); + service!.dispose(); + } + } + + private onDidChangeActiveTextEditor() { + const editor = window.activeTextEditor; + if (editor === undefined) { + return; + } + + const cursorPos = editor.selection.start; + const document = editor.document; + + for (const service of this.contextServices) { + service.onDidChangeActiveTextEditor(document, cursorPos); + } + } + + private onDidChangeTextEditorSelection() { + const editor = window.activeTextEditor; + if (editor === undefined) { + return; + } + + const cursorPos = editor.selection.start; + const document = editor.document; + + for (const service of this.contextServices) { + service.onDidChangeTextEditorSelection(document, cursorPos); + } + } +} + +export const contextServiceManager = new ContextServiceManager(); \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index f21f7b4..890e96f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,26 +1,18 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; +import { contextServiceManager } from "./editor-context-service/manager" +import * as listEditing from './listEditing'; -// This method is called when your extension is activated -// Your extension is activated the very first time the command is executed export function activate(context: vscode.ExtensionContext) { + context.subscriptions.push( + contextServiceManager + ); - // Use the console to output diagnostic information (console.log) and errors (console.error) - // This line of code will only be executed once when your extension is activated - console.log('Congratulations, your extension "typst-companion" is now active!'); + // Context services + contextServiceManager.activate(context); + + // Override `Enter`, `Tab` and `Backspace` keys + listEditing.activate(context); - // The command has been defined in the package.json file - // Now provide the implementation of the command with registerCommand - // The commandId parameter must match the command field in package.json - let disposable = vscode.commands.registerCommand('typst-companion.helloWorld', () => { - // The code you place here will be executed every time your command is executed - // Display a message box to the user - vscode.window.showInformationMessage('Hello World from Typst Companion!'); - }); - - context.subscriptions.push(disposable); } -// This method is called when your extension is deactivated export function deactivate() {} diff --git a/src/listEditing.ts b/src/listEditing.ts new file mode 100644 index 0000000..d16b113 --- /dev/null +++ b/src/listEditing.ts @@ -0,0 +1,567 @@ +// From https://github.com/yzhang-gh/vscode-markdown/ , under the following license: +// +// MIT License + +// Copyright (c) 2017 张宇 + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + + +import { commands, ExtensionContext, Position, Range, Selection, TextEditor, window, workspace, WorkspaceEdit } from 'vscode'; +// import { isInFencedCodeBlock, mathEnvCheck } from "./util/contextCheck"; + +type IModifier = "ctrl" | "shift"; + +export function activate(context: ExtensionContext) { + context.subscriptions.push( + commands.registerCommand('typst-companion.extension.onEnterKey', onEnterKey), + commands.registerCommand('typst-companion.extension.onCtrlEnterKey', () => { return onEnterKey('ctrl'); }), + commands.registerCommand('typst-companion.extension.onShiftEnterKey', () => { return onEnterKey('shift'); }), + commands.registerCommand('typst-companion.extension.onTabKey', onTabKey), + commands.registerCommand('typst-companion.extension.onShiftTabKey', () => { return onTabKey('shift'); }), + commands.registerCommand('typst-companion.extension.onBackspaceKey', onBackspaceKey), + commands.registerCommand('typst-companion.extension.checkTaskList', checkTaskList), + commands.registerCommand('typst-companion.extension.onMoveLineDown', onMoveLineDown), + commands.registerCommand('typst-companion.extension.onMoveLineUp', onMoveLineUp), + commands.registerCommand('typst-companion.extension.onCopyLineDown', onCopyLineDown), + commands.registerCommand('typst-companion.extension.onCopyLineUp', onCopyLineUp), + commands.registerCommand('typst-companion.extension.onIndentLines', onIndentLines), + commands.registerCommand('typst-companion.extension.onOutdentLines', onOutdentLines) + ); +} + +// The commands here are only bound to keys with `when` clause containing `editorTextFocus && !editorReadonly`. (package.json) +// So we don't need to check whether `activeTextEditor` returns `undefined` in most cases. + +function onEnterKey(modifiers?: IModifier) { + const editor = window.activeTextEditor!; + let cursorPos: Position = editor.selection.active; + let line = editor.document.lineAt(cursorPos.line); + let textBeforeCursor = line.text.substr(0, cursorPos.character); + let textAfterCursor = line.text.substr(cursorPos.character); + + let lineBreakPos = cursorPos; + if (modifiers == 'ctrl') { + lineBreakPos = line.range.end; + } + + if (modifiers == 'shift') { + return asNormal(editor, 'enter', modifiers); + } + + //// This is a possibility that the current line is a thematic break `
` (GitHub #785) + const lineTextNoSpace = line.text.replace(/\s/g, ''); + if (lineTextNoSpace.length > 2 + && ( + lineTextNoSpace.replace(/\-/g, '').length === 0 + || lineTextNoSpace.replace(/\*/g, '').length === 0 + ) + ) { + return asNormal(editor, 'enter', modifiers); + } + + //// If it's an empty list item, remove it + if (/^([-+*]|[0-9]+[.)])( +\[[ x]\])?$/.test(textBeforeCursor.trim()) && textAfterCursor.trim().length == 0) { + return editor.edit(editBuilder => { + editBuilder.delete(line.range); + editBuilder.insert(line.range.end, '\n'); + }).then(() => { + editor.revealRange(editor.selection); + }).then(() => fixMarker(editor)); + } + + let matches: RegExpExecArray | null; + if (/^> /.test(textBeforeCursor)) { + // Block quotes + + // Case 1: ending a blockquote if: + const isEmptyArrowLine = line.text.replace(/[ \t]+$/, '') === '>'; + if (isEmptyArrowLine) { + if (cursorPos.line === 0) { + // it is an empty '>' line and also the first line of the document + return editor.edit(editorBuilder => { + editorBuilder.replace(new Range(new Position(0, 0), new Position(cursorPos.line, cursorPos.character)), ''); + }).then(() => { editor.revealRange(editor.selection) }); + } else { + // there have been 2 consecutive empty `>` lines + const prevLineText = editor.document.lineAt(cursorPos.line - 1).text; + if (prevLineText.replace(/[ \t]+$/, '') === '>') { + return editor.edit(editorBuilder => { + editorBuilder.replace(new Range(new Position(cursorPos.line - 1, 0), new Position(cursorPos.line, cursorPos.character)), '\n'); + }).then(() => { editor.revealRange(editor.selection) }); + } + } + } + + // Case 2: `>` continuation + return editor.edit(editBuilder => { + if (isEmptyArrowLine) { + const startPos = new Position(cursorPos.line, line.text.trim().length); + editBuilder.delete(new Range(startPos, line.range.end)); + lineBreakPos = startPos; + } + editBuilder.insert(lineBreakPos, `\n> `); + }).then(() => { + // Fix cursor position + if (modifiers == 'ctrl' && !cursorPos.isEqual(lineBreakPos)) { + let newCursorPos = cursorPos.with(line.lineNumber + 1, 2); + editor.selection = new Selection(newCursorPos, newCursorPos); + } + }).then(() => { editor.revealRange(editor.selection) }); + } else if ((matches = /^((\s*[-+*] +)(\[[ x]\] +)?)/.exec(textBeforeCursor)) !== null) { + // satisfy compiler's null check + const match0 = matches[0]; + const match1 = matches[1]; + const match2 = matches[2]; + const match3 = matches[3]; + + // Unordered list + return editor.edit(editBuilder => { + if ( + match3 && // If it is a task list item and + match0 === textBeforeCursor && // the cursor is right after the checkbox "- [x] |item1" + modifiers !== 'ctrl' + ) { + // Move the task list item to the next line + // - [x] |item1 + // ↓ + // - [ ] + // - [x] |item1 + editBuilder.replace(new Range(cursorPos.line, match2.length + 1, cursorPos.line, match2.length + 2), " "); + editBuilder.insert(lineBreakPos, `\n${match1}`); + } else { + // Insert "- [ ]" + // - [ ] item1| + // ↓ + // - [ ] item1 + // - [ ] | + editBuilder.insert(lineBreakPos, `\n${match1.replace('[x]', '[ ]')}`); + } + }).then(() => { + // Fix cursor position + if (modifiers == 'ctrl' && !cursorPos.isEqual(lineBreakPos)) { + let newCursorPos = cursorPos.with(line.lineNumber + 1, matches![1].length); + editor.selection = new Selection(newCursorPos, newCursorPos); + } + }).then(() => { editor.revealRange(editor.selection) }); + } else if ((matches = /^(\s*)([0-9]+)([.)])( +)((\[[ x]\] +)?)/.exec(textBeforeCursor)) !== null) { + // Ordered list + let config = workspace.getConfiguration('typst-companion.extension.orderedList').get('marker'); + let marker = '1'; + let leadingSpace = matches[1]; + let previousMarker = matches[2]; + let delimiter = matches[3]; + let trailingSpace = matches[4]; + let gfmCheckbox = matches[5].replace('[x]', '[ ]'); + let textIndent = (previousMarker + delimiter + trailingSpace).length; + if (config == 'ordered') { + marker = String(Number(previousMarker) + 1); + } + // Add enough trailing spaces so that the text is aligned with the previous list item, but always keep at least one space + trailingSpace = " ".repeat(Math.max(1, textIndent - (marker + delimiter).length)); + + const toBeAdded = leadingSpace + marker + delimiter + trailingSpace + gfmCheckbox; + return editor.edit( + editBuilder => { + editBuilder.insert(lineBreakPos, `\n${toBeAdded}`); + }, + { undoStopBefore: true, undoStopAfter: false } + ).then(() => { + // Fix cursor position + if (modifiers == 'ctrl' && !cursorPos.isEqual(lineBreakPos)) { + let newCursorPos = cursorPos.with(line.lineNumber + 1, toBeAdded.length); + editor.selection = new Selection(newCursorPos, newCursorPos); + } + }).then(() => fixMarker(editor)).then(() => { editor.revealRange(editor.selection); }); + } else { + return asNormal(editor, 'enter', modifiers); + } +} + +function onTabKey(modifiers?: IModifier) { + const editor = window.activeTextEditor!; + let cursorPos = editor.selection.start; + let lineText = editor.document.lineAt(cursorPos.line).text; + + let match = /^\s*([-+*]|[0-9]+[.)]) +(\[[ x]\] +)?/.exec(lineText); + if ( + match + && ( + modifiers === 'shift' + || !editor.selection.isEmpty + || editor.selection.isEmpty && cursorPos.character <= match[0].length + ) + ) { + if (modifiers === 'shift') { + return outdent(editor).then(() => fixMarker(editor)); + } else { + return indent(editor).then(() => fixMarker(editor)); + } + } else { + return asNormal(editor, 'tab', modifiers); + } +} + +function onBackspaceKey() { + const editor = window.activeTextEditor!; + let cursor = editor.selection.active; + let document = editor.document; + let textBeforeCursor = document.lineAt(cursor.line).text.substr(0, cursor.character); + + if (!editor.selection.isEmpty) { + return asNormal(editor, 'backspace').then(() => fixMarker(editor)); + } else if (/^\s+([-+*]|[0-9]+[.)]) $/.test(textBeforeCursor)) { + // e.g. textBeforeCursor === ` - `, ` 1. ` + return outdent(editor).then(() => fixMarker(editor)); + } else if (/^([-+*]|[0-9]+[.)]) $/.test(textBeforeCursor)) { + // e.g. textBeforeCursor === `- `, `1. ` + return editor.edit(editBuilder => { + editBuilder.replace(new Range(cursor.with({ character: 0 }), cursor), ' '.repeat(textBeforeCursor.length)) + }).then(() => fixMarker(editor)); + } else if (/^\s*([-+*]|[0-9]+[.)]) +(\[[ x]\] )$/.test(textBeforeCursor)) { + // e.g. textBeforeCursor === `- [ ]`, `1. [x]`, ` - [x]` + return deleteRange(editor, new Range(cursor.with({ character: textBeforeCursor.length - 4 }), cursor)).then(() => fixMarker(editor)); + } else { + return asNormal(editor, 'backspace'); + } +} + +function asNormal(editor: TextEditor, key: "backspace" | "enter" | "tab", modifiers?: IModifier) { + switch (key) { + case 'enter': + if (modifiers === 'ctrl') { + return commands.executeCommand('editor.action.insertLineAfter'); + } else { + return commands.executeCommand('type', { source: 'keyboard', text: '\n' }); + } + case 'tab': + if (modifiers === 'shift') { + return commands.executeCommand('editor.action.outdentLines'); + } else if ( + editor.selection.isEmpty + && workspace.getConfiguration('emmet').get('triggerExpansionOnTab') + ) { + return commands.executeCommand('editor.emmet.action.expandAbbreviation'); + } else { + return commands.executeCommand('tab'); + } + case 'backspace': + return commands.executeCommand('deleteLeft'); + } +} + +/** + * If + * + * 1. it is not the first line + * 2. there is a Markdown list item before this line + * + * then indent the current line to align with the previous list item. + */ +function indent(editor: TextEditor) { + if (workspace.getConfiguration("typst-companion.extension.list", editor.document.uri).get("indentationSize") === "adaptive") { + try { + const selection = editor.selection; + const indentationSize = tryDetermineIndentationSize(editor, selection.start.line, editor.document.lineAt(selection.start.line).firstNonWhitespaceCharacterIndex); + let edit = new WorkspaceEdit() + for (let i = selection.start.line; i <= selection.end.line; i++) { + if (i === selection.end.line && !selection.isEmpty && selection.end.character === 0) { + break; + } + if (editor.document.lineAt(i).text.length !== 0) { + edit.insert(editor.document.uri, new Position(i, 0), ' '.repeat(indentationSize)); + } + } + return workspace.applyEdit(edit); + } catch (error) { } + } + + return commands.executeCommand('editor.action.indentLines'); +} + +/** + * Similar to `indent`-function + */ +function outdent(editor: TextEditor) { + if (workspace.getConfiguration("typst-companion.extension.list", editor.document.uri).get("indentationSize") === "adaptive") { + try { + const selection = editor.selection; + const indentationSize = tryDetermineIndentationSize(editor, selection.start.line, editor.document.lineAt(selection.start.line).firstNonWhitespaceCharacterIndex); + let edit = new WorkspaceEdit() + for (let i = selection.start.line; i <= selection.end.line; i++) { + if (i === selection.end.line && !selection.isEmpty && selection.end.character === 0) { + break; + } + const lineText = editor.document.lineAt(i).text; + let maxOutdentSize: number; + if (lineText.trim().length === 0) { + maxOutdentSize = lineText.length; + } else { + maxOutdentSize = editor.document.lineAt(i).firstNonWhitespaceCharacterIndex; + } + if (maxOutdentSize > 0) { + edit.delete(editor.document.uri, new Range(i, 0, i, Math.min(indentationSize, maxOutdentSize))); + } + } + return workspace.applyEdit(edit); + } catch (error) { } + } + + return commands.executeCommand('editor.action.outdentLines'); +} + +function tryDetermineIndentationSize(editor: TextEditor, line: number, currentIndentation: number) { + while (--line >= 0) { + const lineText = editor.document.lineAt(line).text; + let matches; + if ((matches = /^(\s*)(([-+*]|[0-9]+[.)]) +)(\[[ x]\] +)?/.exec(lineText)) !== null) { + if (matches[1].length <= currentIndentation) { + return matches[2].length; + } + } + } + throw "No previous Markdown list item"; +} + +/** + * Returns the line index of the next ordered list item starting from the specified line. + * + * @param line + * Defaults to the beginning of the current primary selection (`editor.selection.start.line`) + * in order to find the first marker following either the cursor or the entire selected range. + */ +function findNextMarkerLineNumber(editor: TextEditor, line = editor.selection.start.line): number { + while (line < editor.document.lineCount) { + const lineText = editor.document.lineAt(line).text; + + if (lineText.startsWith('#')) { + // Don't go searching past any headings + return -1; + } + + if (/^\s*[0-9]+[.)] +/.exec(lineText) !== null) { + return line; + } + line++; + } + return -1; +} + +/** + * Looks for the previous ordered list marker at the same indentation level + * and returns the marker number that should follow it. + * + * @param currentIndentation treat tabs as if they were replaced by spaces with a tab stop of 4 characters + * + * @returns the fixed marker number + */ +function lookUpwardForMarker(editor: TextEditor, line: number, currentIndentation: number): number { + let prevLine = line; + while (--prevLine >= 0) { + const prevLineText = editor.document.lineAt(prevLine).text.replace(/\t/g, ' '); + let matches; + if ((matches = /^(\s*)(([0-9]+)[.)] +)/.exec(prevLineText)) !== null) { + // The previous line has an ordered list marker + const prevLeadingSpace: string = matches[1]; + const prevMarker = matches[3]; + if (currentIndentation < prevLeadingSpace.length) { + // yet to find a sibling item + continue; + } else if ( + currentIndentation >= prevLeadingSpace.length + && currentIndentation <= (prevLeadingSpace + prevMarker).length + ) { + // found a sibling item + return Number(prevMarker) + 1; + } else if (currentIndentation > (prevLeadingSpace + prevMarker).length) { + // found a parent item + return 1; + } else { + // not possible + } + } else if ((matches = /^(\s*)([-+*] +)/.exec(prevLineText)) !== null) { + // The previous line has an unordered list marker + const prevLeadingSpace: string = matches[1]; + if (currentIndentation >= prevLeadingSpace.length) { + // stop finding + break; + } + } else if ((matches = /^(\s*)\S/.exec(prevLineText)) !== null) { + // The previous line doesn't have a list marker + if (matches[1].length < 3) { + // no enough indentation for a list item + break; + } + } + } + return 1; +} + +/** + * Fix ordered list marker *iteratively* starting from current line + */ +export function fixMarker(editor: TextEditor, line?: number): Thenable | void { + if (!workspace.getConfiguration('typst-companion.extension.orderedList').get('autoRenumber')) return; + if (workspace.getConfiguration('typst-companion.extension.orderedList').get('marker') == 'one') return; + + if (line === undefined) { + line = findNextMarkerLineNumber(editor); + } + if (line < 0 || line >= editor.document.lineCount) { + return; + } + + let currentLineText = editor.document.lineAt(line).text; + let matches; + if ((matches = /^(\s*)([0-9]+)([.)])( +)/.exec(currentLineText)) !== null) { // ordered list + let leadingSpace = matches[1]; + let marker = matches[2]; + let delimiter = matches[3]; + let trailingSpace = matches[4]; + let fixedMarker = lookUpwardForMarker(editor, line, leadingSpace.replace(/\t/g, ' ').length); + let listIndent = marker.length + delimiter.length + trailingSpace.length; + let fixedMarkerString = String(fixedMarker); + + return editor.edit( + // fix the marker (current line) + editBuilder => { + if (marker === fixedMarkerString) { + return; + } + // Add enough trailing spaces so that the text is still aligned at the same indentation level as it was previously, but always keep at least one space + fixedMarkerString += delimiter + " ".repeat(Math.max(1, listIndent - (fixedMarkerString + delimiter).length)); + + editBuilder.replace(new Range(line!, leadingSpace.length, line!, leadingSpace.length + listIndent), fixedMarkerString); + }, + { undoStopBefore: false, undoStopAfter: false } + ).then(() => { + let nextLine = line! + 1; + while (editor.document.lineCount > nextLine) { + const nextLineText = editor.document.lineAt(nextLine).text; + if (/^\s*[0-9]+[.)] +/.test(nextLineText)) { + return fixMarker(editor, nextLine); + } else if ( + editor.document.lineAt(nextLine - 1).isEmptyOrWhitespace // This line is a block + && !nextLineText.startsWith(" ".repeat(3)) // and doesn't have enough indentation + && !nextLineText.startsWith("\t") // so terminates the current list. + ) { + return; + } else { + nextLine++; + } + } + }); + } +} + +function deleteRange(editor: TextEditor, range: Range): Thenable { + return editor.edit( + editBuilder => { + editBuilder.delete(range); + }, + // We will enable undoStop after fixing markers + { undoStopBefore: true, undoStopAfter: false } + ); +} + +function checkTaskList(): Thenable | void { + // - Look into selections for lines that could be checked/unchecked. + // - The first matching line dictates the new state for all further lines. + // - I.e. if the first line is unchecked, only other unchecked lines will + // be considered, and vice versa. + const editor = window.activeTextEditor!; + const uncheckedRegex = /^(\s*([-+*]|[0-9]+[.)]) +\[) \]/ + const checkedRegex = /^(\s*([-+*]|[0-9]+[.)]) +\[)x\]/ + let toBeToggled: Position[] = [] // all spots that have an "[x]" resp. "[ ]" which should be toggled + let newState: boolean | undefined = undefined // true = "x", false = " ", undefined = no matching lines + + // go through all touched lines of all selections. + for (const selection of editor.selections) { + for (let i = selection.start.line; i <= selection.end.line; i++) { + const line = editor.document.lineAt(i); + const lineStart = line.range.start; + + if (!selection.isSingleLine && (selection.start.isEqual(line.range.end) || selection.end.isEqual(line.range.start))) { + continue; + } + + let matches: RegExpExecArray | null; + if ( + (matches = uncheckedRegex.exec(line.text)) + && newState !== false + ) { + toBeToggled.push(lineStart.with({ character: matches[1].length })); + newState = true; + } else if ( + (matches = checkedRegex.exec(line.text)) + && newState !== true + ) { + toBeToggled.push(lineStart.with({ character: matches[1].length })); + newState = false; + } + } + } + + if (newState !== undefined) { + const newChar = newState ? 'x' : ' '; + return editor.edit(editBuilder => { + for (const pos of toBeToggled) { + let range = new Range(pos, pos.with({ character: pos.character + 1 })); + editBuilder.replace(range, newChar); + } + }); + } +} + +function onMoveLineUp() { + const editor = window.activeTextEditor!; + return commands.executeCommand('editor.action.moveLinesUpAction') + .then(() => fixMarker(editor)); +} + +function onMoveLineDown() { + const editor = window.activeTextEditor!; + return commands.executeCommand('editor.action.moveLinesDownAction') + .then(() => fixMarker(editor, findNextMarkerLineNumber(editor, editor.selection.start.line - 1))); +} + +function onCopyLineUp() { + const editor = window.activeTextEditor!; + return commands.executeCommand('editor.action.copyLinesUpAction') + .then(() => fixMarker(editor)); +} + +function onCopyLineDown() { + const editor = window.activeTextEditor!; + return commands.executeCommand('editor.action.copyLinesDownAction') + .then(() => fixMarker(editor)); +} + +function onIndentLines() { + const editor = window.activeTextEditor!; + return indent(editor).then(() => fixMarker(editor)); +} + +function onOutdentLines() { + const editor = window.activeTextEditor!; + return outdent(editor).then(() => fixMarker(editor)); +} + +export function deactivate() { } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 965a7b4..9fd6eeb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,13 @@ { - "compilerOptions": { - "module": "commonjs", - "target": "ES2020", - "lib": [ - "ES2020" - ], - "sourceMap": true, - "rootDir": "src", - "strict": true /* enable all strict type-checking options */ - /* Additional Checks */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ - } + "compilerOptions": { + "module": "commonjs", + "target": "es2020", + "lib": ["es2020"], + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "strict": true + }, + "include": ["src"], + "exclude": ["node_modules"] } diff --git a/vsc-extension-quickstart.md b/vsc-extension-quickstart.md deleted file mode 100644 index b2eb4a4..0000000 --- a/vsc-extension-quickstart.md +++ /dev/null @@ -1,47 +0,0 @@ -# Welcome to your VS Code Extension - -## What's in the folder - -* This folder contains all of the files necessary for your extension. -* `package.json` - this is the manifest file in which you declare your extension and command. - * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. -* `src/extension.ts` - this is the main file where you will provide the implementation of your command. - * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. - * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. - -## Setup - -* install the recommended extensions (amodio.tsl-problem-matcher and dbaeumer.vscode-eslint) - - -## Get up and running straight away - -* Press `F5` to open a new window with your extension loaded. -* Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. -* Set breakpoints in your code inside `src/extension.ts` to debug your extension. -* Find output from your extension in the debug console. - -## Make changes - -* You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. -* You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. - - -## Explore the API - -* You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. - -## Run tests - -* Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. -* Press `F5` to run the tests in a new window with your extension loaded. -* See the output of the test result in the debug console. -* Make changes to `src/test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. - * The provided test runner will only consider files matching the name pattern `**.test.ts`. - * You can create folders inside the `test` folder to structure your tests any way you want. - -## Go further - -* Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). -* [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace. -* Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration).