├── src ├── client │ ├── index.ts │ └── client.ts ├── reset.d.ts ├── index.ts ├── components │ ├── icons │ │ ├── index.ts │ │ ├── settings.tsx │ │ └── nodejs.tsx │ ├── preact-env.d.ts │ ├── ModalCloseButton.scss │ ├── ModalTitle.tsx │ ├── ModalOverlay.scss │ ├── SuggestionPanel.scss │ ├── ModalCloseButton.tsx │ ├── ModelHeader.tsx │ ├── ModalOverlay.tsx │ ├── Switch.scss │ ├── ModalBody.tsx │ ├── ModalContent.tsx │ ├── Spinner.tsx │ ├── Switch.tsx │ ├── DropdownWithInput.scss │ ├── DropdownWithInput.tsx │ ├── SuggestionPanel.tsx │ └── CopilotIcon.tsx ├── i18n │ ├── index.ts │ ├── t.spec.ts │ ├── t.ts │ ├── en.json │ └── zh-CN.json ├── patches │ ├── index.ts │ ├── typora.ts │ ├── promise.spec.ts │ ├── jquery.ts │ └── promise.ts ├── errors │ ├── index.ts │ ├── CommandError.ts │ ├── NoFreePortError.ts │ └── PlatformError.ts ├── logging.ts ├── utils │ ├── tools.proof.ts │ ├── random.ts │ ├── function.ts │ ├── observable.ts │ ├── dom.ts │ ├── diff.ts │ ├── cli-tools.ts │ ├── stream.ts │ ├── tools.ts │ ├── lsp.ts │ └── logging.ts ├── styles.scss ├── constants.ts ├── modules │ ├── path.spec.ts │ ├── url.spec.ts │ └── url.ts ├── footer.scss ├── types │ └── tools.ts ├── mac-server.ts ├── settings.ts ├── completion.ts └── footer.tsx ├── docs ├── screenshot.png ├── toolbar-icon.png ├── screenshot.zh-CN.png └── toolbar-icon.zh-CN.png ├── .githooks └── commit-msg ├── .vscode └── settings.json ├── tsconfig.build.json ├── stylelint.config.js ├── test └── setup.ts ├── .gitignore ├── prettier.config.cjs ├── .editorconfig ├── vitest.config.ts ├── pre-commit.ts ├── tsconfig.json ├── install.ps1 ├── LICENSE ├── install.sh ├── .github └── workflows │ └── ci.yml ├── rollup.config.ts ├── bin ├── uninstall_linux.sh ├── uninstall_macos.sh ├── uninstall_windows.ps1 ├── install_linux.sh ├── install_windows.ps1 └── install_macos.sh ├── commitlint.config.js ├── eslint.config.js ├── package.json └── README.zh-CN.md /src/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./client"; 2 | -------------------------------------------------------------------------------- /src/reset.d.ts: -------------------------------------------------------------------------------- 1 | import "@total-typescript/ts-reset"; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "./patches"; 2 | 3 | import "./main"; 4 | -------------------------------------------------------------------------------- /src/components/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./nodejs"; 2 | export * from "./settings"; 3 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Snowflyt/typora-copilot/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export { t, pathOf } from "./t"; 2 | 3 | export type { PathOf } from "./t"; 4 | -------------------------------------------------------------------------------- /.githooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx --no -- commitlint --edit "$1" 3 | npx tsx pre-commit.ts 4 | -------------------------------------------------------------------------------- /docs/toolbar-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Snowflyt/typora-copilot/HEAD/docs/toolbar-icon.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["reqnode"], 3 | "stylelint.validate": ["css", "scss"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/screenshot.zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Snowflyt/typora-copilot/HEAD/docs/screenshot.zh-CN.png -------------------------------------------------------------------------------- /docs/toolbar-icon.zh-CN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Snowflyt/typora-copilot/HEAD/docs/toolbar-icon.zh-CN.png -------------------------------------------------------------------------------- /src/components/preact-env.d.ts: -------------------------------------------------------------------------------- 1 | type FC

> = import("preact").FunctionalComponent

; 2 | -------------------------------------------------------------------------------- /src/patches/index.ts: -------------------------------------------------------------------------------- 1 | // Make sure patches to Typora are imported before other patches 2 | import "./typora"; 3 | 4 | import "./promise"; 5 | 6 | import "./jquery"; 7 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { CommandError } from "./CommandError"; 2 | export { NoFreePortError } from "./NoFreePortError"; 3 | export { PlatformError } from "./PlatformError"; 4 | -------------------------------------------------------------------------------- /src/components/ModalCloseButton.scss: -------------------------------------------------------------------------------- 1 | .modal-close-button { 2 | font-size: 1.2rem !important; 3 | opacity: 0.5 !important; 4 | } 5 | 6 | .modal-close-button:hover { 7 | opacity: 1 !important; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/ModalTitle.tsx: -------------------------------------------------------------------------------- 1 | const ModalTitle: FC = ({ children }) => { 2 | return {children}; 3 | }; 4 | 5 | export default ModalTitle; 6 | -------------------------------------------------------------------------------- /src/errors/CommandError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Error thrown when a command execution fails. 3 | */ 4 | export class CommandError extends Error { 5 | constructor(message: string) { 6 | super(message); 7 | this.name = "CommandError"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/errors/NoFreePortError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Error thrown when no free port is found. 3 | */ 4 | export class NoFreePortError extends Error { 5 | constructor(message: string) { 6 | super(message); 7 | this.name = "NoFreePortError"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/errors/PlatformError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Error thrown when a platform is not supported. 3 | */ 4 | export class PlatformError extends Error { 5 | constructor(message: string) { 6 | super(message); 7 | this.name = "PlatformError"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from "./utils/logging"; 2 | 3 | /** 4 | * Logger used across the plugin. 5 | */ 6 | export const logger = createLogger({ 7 | prefix: `%cCopilot plugin:%c `, 8 | styles: ["font-weight: bold", "font-weight: normal"], 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "dist", 6 | "skipLibCheck": true 7 | }, 8 | "include": ["src"], 9 | "exclude": ["src/**/*.spec.ts", "src/**/*.proof.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ModalOverlay.scss: -------------------------------------------------------------------------------- 1 | .modal-overlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | z-index: 99999; 8 | background: rgba(0, 0, 0, 50%); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | } 13 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @satisfies {import("stylelint").Config} */ 4 | const config = { 5 | extends: "stylelint-config-standard-scss", 6 | rules: { 7 | "color-function-alias-notation": "with-alpha", 8 | "color-function-notation": "legacy", 9 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /src/components/SuggestionPanel.scss: -------------------------------------------------------------------------------- 1 | .suggestion-panel { 2 | position: absolute; 3 | z-index: 9999; 4 | pointer-events: none; 5 | white-space: pre-wrap; 6 | border: 1px solid #ccc; 7 | display: flex; 8 | flex-direction: column; 9 | padding: 0.5em; 10 | border-radius: 5px; 11 | box-shadow: 0 4px 8px rgba(0, 0, 0, 50%); 12 | } 13 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { Window } from "happy-dom"; 2 | 3 | import "@/patches/typora"; 4 | 5 | const window = new Window(); 6 | Object.assign(window, { 7 | isWin: false, 8 | dirname: "/usr/share/typora/resources", 9 | _options: { 10 | appLocale: "en", 11 | appVersion: "1.7.6", 12 | }, 13 | }); 14 | global.window = window as unknown as typeof global.window; 15 | -------------------------------------------------------------------------------- /src/utils/tools.proof.ts: -------------------------------------------------------------------------------- 1 | import { describe, equal, expect, it } from "typroof"; 2 | 3 | import { omit } from "./tools"; 4 | 5 | describe("omit", () => { 6 | it("should omit keys from an object", () => { 7 | expect(omit({ a: 1, b: 2, c: 3 }, "a")).to(equal<{ b: number; c: number }>); 8 | expect(omit({ a: 1, b: 2, c: 3 }, "a", "c")).to(equal<{ b: number }>); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/settings.json 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /src/utils/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate a UUID. 3 | * @returns 4 | */ 5 | export function generateUUID(): string { 6 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { 7 | // eslint-disable-next-line sonarjs/pseudo-random 8 | const r = (Math.random() * 16) | 0; 9 | const v = c === "x" ? r : (r & 0x3) | 0x8; 10 | return v.toString(16); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | .text-gray { 2 | color: gray !important; 3 | } 4 | 5 | .font-italic { 6 | font-style: italic !important; 7 | } 8 | 9 | .unset-button { 10 | all: unset; 11 | display: inline-block; 12 | cursor: pointer; 13 | text-align: center; 14 | font: inherit; 15 | color: inherit; 16 | background: none; 17 | border: none; 18 | padding: 0; 19 | margin: 0; 20 | box-sizing: border-box; 21 | } 22 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import * as path from "@modules/path"; 2 | 3 | import { TYPORA_RESOURCE_DIR } from "./typora-utils"; 4 | import { setGlobalVar } from "./utils/tools"; 5 | 6 | /** 7 | * Plugin version. 8 | */ 9 | export const VERSION = "0.3.11"; 10 | 11 | /** 12 | * Copilot plugin directory. 13 | */ 14 | export const PLUGIN_DIR = path.join(TYPORA_RESOURCE_DIR, "copilot"); 15 | setGlobalVar("__copilotDir", PLUGIN_DIR); 16 | -------------------------------------------------------------------------------- /src/components/ModalCloseButton.tsx: -------------------------------------------------------------------------------- 1 | import "./ModalCloseButton.scss"; 2 | 3 | export interface ModalCloseButtonProps { 4 | onClick?: () => void; 5 | } 6 | 7 | const ModalCloseButton: FC = ({ onClick }) => { 8 | return ( 9 | 12 | ); 13 | }; 14 | 15 | export default ModalCloseButton; 16 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @satisfies {import("prettier").Config} */ 4 | const config = { 5 | arrowParens: "always", 6 | bracketSameLine: true, 7 | bracketSpacing: true, 8 | experimentalTernaries: true, 9 | plugins: ["prettier-plugin-packagejson"], 10 | printWidth: 100, 11 | semi: true, 12 | singleQuote: false, 13 | tabWidth: 2, 14 | trailingComma: "all", 15 | }; 16 | 17 | module.exports = config; 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,css,scss,json}] 14 | charset = utf-8 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /src/components/ModelHeader.tsx: -------------------------------------------------------------------------------- 1 | const ModalHeader: FC = ({ children }) => { 2 | return ( 3 | <> 4 |

11 | {children} 12 |
13 |
14 | 15 | ); 16 | }; 17 | 18 | export default ModalHeader; 19 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | import { defineConfig } from "vitest/config"; 4 | 5 | export default defineConfig({ 6 | resolve: { 7 | alias: { 8 | "@modules": path.resolve(__dirname, "src/modules"), 9 | "@": path.resolve(__dirname, "src"), 10 | "@test": path.resolve(__dirname, "test"), 11 | }, 12 | }, 13 | test: { 14 | setupFiles: ["./test/setup.ts"], 15 | environment: "happy-dom", 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/ModalOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { createPortal } from "preact/compat"; 2 | import "./ModalOverlay.scss"; 3 | 4 | export interface ModalOverlayProps { 5 | onClose?: () => void; 6 | } 7 | 8 | const ModalOverlay: FC = ({ children, onClose }) => { 9 | return createPortal( 10 | , 13 | document.body, 14 | ); 15 | }; 16 | 17 | export default ModalOverlay; 18 | -------------------------------------------------------------------------------- /src/components/Switch.scss: -------------------------------------------------------------------------------- 1 | .switch { 2 | width: 36px; 3 | height: 20px; 4 | border-radius: 20px; 5 | position: relative; 6 | cursor: pointer; 7 | transition: background-color 0.2s; 8 | } 9 | 10 | .switch .toggle { 11 | width: 16px; 12 | height: 16px; 13 | background-color: white; 14 | border-radius: 50%; 15 | position: absolute; 16 | top: 2px; 17 | left: 2px; 18 | transition: left 0.2s; 19 | } 20 | 21 | .switch.on .toggle { 22 | left: 18px; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ModalBody.tsx: -------------------------------------------------------------------------------- 1 | export interface ModalBodyProps { 2 | className?: string; 3 | style?: preact.JSX.CSSProperties; 4 | } 5 | 6 | const ModalBody: FC = ({ children, className, style }) => { 7 | return ( 8 | // eslint-disable-next-line @typescript-eslint/no-misused-spread 9 |
10 | {children} 11 |
12 | ); 13 | }; 14 | 15 | export default ModalBody; 16 | -------------------------------------------------------------------------------- /src/components/ModalContent.tsx: -------------------------------------------------------------------------------- 1 | const ModalContent: FC = ({ children }) => { 2 | return ( 3 |
{ 13 | e.stopPropagation(); 14 | }}> 15 | {children} 16 |
17 | ); 18 | }; 19 | 20 | export default ModalContent; 21 | -------------------------------------------------------------------------------- /src/utils/function.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cache the result of a function. 3 | * @param fn The function to cache. 4 | * @returns 5 | */ 6 | export const cache = unknown>(fn: F): F => { 7 | const cache = new Map(); 8 | const result = ((...args) => { 9 | const key = JSON.stringify(args); 10 | if (cache.has(key)) return cache.get(key); 11 | const result = fn(...args); 12 | cache.set(key, result); 13 | return result; 14 | }) as F; 15 | Object.defineProperty(result, "name", { 16 | value: fn.name, 17 | writable: false, 18 | enumerable: false, 19 | configurable: true, 20 | }); 21 | return result; 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/observable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A lightweight observable implementation. 3 | */ 4 | export class Observable { 5 | private observers: ((value: T) => void)[] = []; 6 | 7 | subscribe(observer: (value: T) => void): () => void { 8 | this.observers.push(observer); 9 | return () => { 10 | this.observers = this.observers.filter((o) => o !== observer); 11 | }; 12 | } 13 | 14 | subscribeOnce(observer: (value: T) => void): void { 15 | const unsubscribe = this.subscribe((value) => { 16 | unsubscribe(); 17 | observer(value); 18 | }); 19 | } 20 | 21 | next(value: T): void { 22 | this.observers.forEach((observer) => observer(value)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pre-commit.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | 3 | import { replaceInFileSync } from "replace-in-file"; 4 | 5 | import packageJSON from "./package.json"; 6 | 7 | const CONSTANTS_FILE_PATHNAME = "./src/constants.ts"; 8 | 9 | const { version } = packageJSON; 10 | 11 | const options = { 12 | files: CONSTANTS_FILE_PATHNAME, 13 | from: /VERSION = ".*"/g, 14 | to: `VERSION = "${version}"`, 15 | }; 16 | 17 | if (fs.readFileSync(CONSTANTS_FILE_PATHNAME, "utf-8").includes(options.to)) process.exit(0); 18 | 19 | try { 20 | replaceInFileSync(options); 21 | console.log("Plugin VERSION updated:", version); 22 | } catch (error) { 23 | console.error("Error occurred while updating plugin VERSION:", error); 24 | process.exit(1); 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ES2020", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "Bundler", 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | 14 | /* JSX */ 15 | "jsx": "react-jsx", 16 | "jsxImportSource": "preact", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "checkJs": true, 21 | "allowUmdGlobalAccess": true, 22 | "noUnusedLocals": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedIndexedAccess": true, 25 | 26 | /* Path aliases */ 27 | "baseUrl": ".", 28 | "paths": { 29 | "@/*": ["src/*"], 30 | "@modules/*": ["src/modules/*"], 31 | "@test/*": ["test/*"] 32 | } 33 | }, 34 | "include": ["src", "test", "rollup.config.ts", "pre-commit.ts", "vitest.config.ts"] 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/path.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import * as path from "./path"; 4 | 5 | describe("basename", () => { 6 | it("should return the last portion of a path", () => { 7 | expect(path.basename("/foo/bar/baz/asdf/quux.html")).toBe("quux.html"); 8 | expect(path.basename("/foo/bar/baz/asdf/quux.html", ".html")).toBe("quux"); 9 | expect(path.basename("/foo")).toBe("foo"); 10 | expect(path.basename("")).toBe(""); 11 | expect(path.basename("C:\\")).toBe("C:\\"); 12 | }); 13 | }); 14 | 15 | describe("dirname", () => { 16 | it("should return the directory name of a path", () => { 17 | expect(path.dirname("/foo/bar/baz/asdf/quux")).toBe("/foo/bar/baz/asdf"); 18 | expect(path.dirname("/foo/bar/baz/asdf/quux.html")).toBe("/foo/bar/baz/asdf"); 19 | expect(path.dirname("/foo/bar/baz/asdf/quux/")).toBe("/foo/bar/baz/asdf"); 20 | expect(path.dirname("/foo/bar")).toBe("/foo"); 21 | expect(path.dirname("/foo")).toBe("/"); 22 | expect(path.dirname("/")).toBe("/"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Snowflyt/typora-copilot/releases/latest" 3 | Invoke-WebRequest -Uri $latestRelease.assets[0].browser_download_url -OutFile "typora-copilot-$($latestRelease.tag_name).zip" 4 | If (Test-Path "typora-copilot-$($latestRelease.tag_name)") { 5 | Remove-Item "typora-copilot-$($latestRelease.tag_name)" -Recurse -Force 6 | } 7 | New-Item -ItemType Directory -Path "typora-copilot-$($latestRelease.tag_name)" 8 | Expand-Archive -Path "typora-copilot-$($latestRelease.tag_name).zip" -DestinationPath "typora-copilot-$($latestRelease.tag_name)" 9 | Remove-Item "typora-copilot-$($latestRelease.tag_name).zip" 10 | Set-Location "typora-copilot-$($latestRelease.tag_name)" 11 | Write-Host "Trying to uninstall the previous version (if any)..." 12 | .\bin\uninstall_windows.ps1 -Silent 13 | Write-Host "Trying to install the new version..." 14 | .\bin\install_windows.ps1 15 | Set-Location .. 16 | Remove-Item "typora-copilot-$($latestRelease.tag_name)" -Recurse -Force 17 | -------------------------------------------------------------------------------- /src/modules/url.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { fileURLToPath, pathToFileURL } from "./url"; 4 | 5 | describe("fileURLToPath", () => { 6 | it("should return a platform-specific path", () => { 7 | const isWinBefore = Files.isWin; 8 | 9 | Files.isWin = true; 10 | expect(fileURLToPath("file:///C:/path/")).toBe("C:\\path\\"); 11 | expect(fileURLToPath("file://nas/foo.txt")).toBe("\\\\nas\\foo.txt"); 12 | Files.isWin = false; 13 | expect(fileURLToPath("file:///你好.txt")).toBe("/你好.txt"); 14 | expect(fileURLToPath("file:///hello world")).toBe("/hello world"); 15 | 16 | Files.isWin = isWinBefore; 17 | }); 18 | }); 19 | 20 | describe("pathToFileURL", () => { 21 | it("should return a file URL object", () => { 22 | expect(pathToFileURL("/foo#1")).toEqual(new URL("file:///foo%231")); 23 | expect(pathToFileURL("/some/path%.c")).toEqual(new URL("file:///some/path%25.c")); 24 | expect(pathToFileURL("C:\\foo\\bar\\test.py")).toEqual(new URL("file:///C:/foo/bar/test.py")); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Snowflyt 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 | -------------------------------------------------------------------------------- /src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | export interface SpinnerProps { 2 | color?: string; 3 | } 4 | 5 | /** 6 | * An svg spinner. 7 | * @returns 8 | */ 9 | const Spinner: FC = ({ color = "gray" }) => { 10 | return ( 11 | 18 | .spinner_aj0A { 19 | transform-origin: center; 20 | animation: spinner_KYSC 0.75s infinite linear; 21 | } 22 | @keyframes spinner_KYSC { 23 | 100% { 24 | transform: rotate(360deg); 25 | } 26 | } 27 | 28 | 33 | `, 34 | }} 35 | /> 36 | ); 37 | }; 38 | 39 | export default Spinner; 40 | -------------------------------------------------------------------------------- /src/components/icons/settings.tsx: -------------------------------------------------------------------------------- 1 | export const SettingsIcon: FC<{ size?: number }> = ({ size = 24 }) => { 2 | return ( 3 | 4 | 8 | 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/Switch.tsx: -------------------------------------------------------------------------------- 1 | import { getLuminance } from "color2k"; 2 | import "./Switch.scss"; 3 | 4 | export interface SwitchProps { 5 | value: boolean; 6 | onChange: (value: boolean) => void; 7 | } 8 | 9 | const Switch: FC = ({ onChange, value }) => { 10 | const isDark = getLuminance(window.getComputedStyle(document.body).backgroundColor) < 0.5; 11 | 12 | // The following colors and shadows are extracted from naive-ui 13 | // https://www.naiveui.com/en-US/os-theme/components/switch 14 | const switchBackgroundColor = { 15 | dark: { on: "#2a947d", off: "#464649" }, 16 | light: { on: "#18a058", off: "#dbdbdb" }, 17 | }[isDark ? "dark" : "light"][value ? "on" : "off"]; 18 | const toggleBoxShadow = { 19 | dark: "0 2px 4px 0 rgba(0, 0, 0, 40%)", 20 | light: "0 1px 4px 0 rgba(0, 0, 0, 30%), inset 0 0 1px 0 rgba(0, 0, 0, 5%)", 21 | }[isDark ? "dark" : "light"]; 22 | 23 | return ( 24 |
{ 28 | onChange(!value); 29 | }}> 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default Switch; 36 | -------------------------------------------------------------------------------- /src/patches/typora.ts: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////// 2 | /// Global patches for Typora to make TS happy /// 3 | ////////////////////////////////////////////////// 4 | 5 | // The same function as `@/utils/tools` 6 | // Defined here instead of importing to ensure the following code executes 7 | // before any other code. 8 | const setGlobalVar = )>( 9 | name: K, 10 | value: K extends keyof typeof globalThis ? (typeof globalThis)[K] : unknown, 11 | ) => { 12 | try { 13 | Object.defineProperty(global, name, { value }); 14 | } catch { 15 | Object.defineProperty(globalThis, name, { value }); 16 | } 17 | }; 18 | 19 | // Typora extends `window.File` with additional properties (e.g., `File.editor`). 20 | // In a pure JS application, we could directly use `window.File`. 21 | // However, in TS, these additional properties are not recognized by the type system. 22 | // TS doesn’t allow direct modifications to the type of a global variable. 23 | // As a workaround, we create an alias for the global variable and declare types for the alias. 24 | // This is what we’re doing here. 25 | setGlobalVar("Files", File as typeof Files); 26 | 27 | // Similarly, create an alias for `window.Node`. 28 | setGlobalVar("Nodes", Node as typeof Nodes); 29 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$(id -u)" -ne 0 ]; then 4 | echo "Please run as root" 5 | exit 1 6 | fi 7 | 8 | latest_release=$(curl -s https://api.github.com/repos/Snowflyt/typora-copilot/releases/latest) 9 | download_url=$(echo "$latest_release" | grep '"browser_download_url"' | head -n 1 | sed -E 's/.*"browser_download_url": "(.*)".*/\1/') 10 | tag_name=$(echo "$latest_release" | grep '"tag_name"' | head -n 1 | sed -E 's/.*"tag_name": "(.*)".*/\1/') 11 | curl -L "$download_url" -o "typora-copilot-$tag_name.zip" 12 | if [ -d "typora-copilot-$tag_name" ]; then 13 | rm -rf "typora-copilot-$tag_name" 14 | fi 15 | mkdir "typora-copilot-$tag_name" 16 | unzip "typora-copilot-$tag_name.zip" -d "typora-copilot-$tag_name" 17 | rm "typora-copilot-$tag_name.zip" 18 | cd "typora-copilot-$tag_name" || exit 19 | if [[ "$OSTYPE" == "darwin"* ]]; then 20 | echo "Trying to uninstall the previous version (if any)..." 21 | chmod +x ./bin/uninstall_macos.sh 22 | ./bin/uninstall_macos.sh --silent 23 | echo "Trying to install the new version..." 24 | chmod +x ./bin/install_macos.sh 25 | ./bin/install_macos.sh 26 | elif [[ "$OSTYPE" == "linux-gnu"* ]]; then 27 | echo "Trying to uninstall the previous version (if any)..." 28 | chmod +x ./bin/uninstall_linux.sh 29 | ./bin/uninstall_linux.sh --silent 30 | echo "Trying to install the new version..." 31 | chmod +x ./bin/install_linux.sh 32 | ./bin/install_linux.sh 33 | else 34 | echo "Unsupported OS: $OSTYPE" 35 | exit 1 36 | fi 37 | cd .. 38 | rm -rf "typora-copilot-$tag_name" 39 | -------------------------------------------------------------------------------- /src/footer.scss: -------------------------------------------------------------------------------- 1 | #footer-copilot { 2 | margin-left: 8px; 3 | margin-right: 0; 4 | padding: 0; 5 | opacity: 0.75; 6 | cursor: pointer; 7 | display: flex; 8 | flex-direction: row; 9 | align-items: center; 10 | justify-content: center; 11 | } 12 | 13 | #footer-copilot:hover { 14 | opacity: 1; 15 | } 16 | 17 | .footer-copilot-icon { 18 | height: 100%; 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | padding: 0 6px; 23 | cursor: pointer; 24 | } 25 | 26 | .footer-copilot-menu-button { 27 | height: 100%; 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | padding: 0 4px; 32 | cursor: pointer; 33 | 34 | &:hover { 35 | background-color: rgba(128, 128, 128, 10%); 36 | } 37 | } 38 | 39 | #footer-copilot-panel { 40 | left: auto; 41 | right: 4px; 42 | top: auto; 43 | padding-top: 8px; 44 | padding-bottom: 8px; 45 | display: flex; 46 | flex-direction: column; 47 | overflow-x: hidden; 48 | min-width: 160px; 49 | } 50 | 51 | .footer-copilot-panel-hint { 52 | padding: 0 16px; 53 | padding-bottom: 6px; 54 | font-size: 8pt; 55 | font-weight: normal; 56 | line-height: 1.8; 57 | } 58 | 59 | .footer-copilot-panel-btn { 60 | border: none !important; 61 | border-radius: 0 !important; 62 | padding: 3px 16px !important; 63 | font-size: 10pt !important; 64 | font-weight: normal !important; 65 | line-height: 1.8 !important; 66 | } 67 | 68 | .footer-copilot-panel-btn:hover { 69 | background-color: var(--item-hover-bg-color); 70 | color: var(--item-hover-text-color); 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get coordinates of the caret. 3 | * @returns 4 | */ 5 | export const getCaretCoordinate = (): { x: number; y: number } | null => { 6 | const sel = window.getSelection(); 7 | if (sel?.rangeCount) { 8 | const range = sel.getRangeAt(0).cloneRange(); 9 | const caret = document.createElement("span"); 10 | range.insertNode(caret); 11 | const rect = caret.getBoundingClientRect(); 12 | if (caret.parentNode) caret.parentNode.removeChild(caret); 13 | return { x: rect.left, y: rect.top }; 14 | } 15 | return null; 16 | }; 17 | 18 | /** 19 | * Get the CSS styles of the given class names. 20 | * @param classNames The CSS class names. 21 | * @returns 22 | */ 23 | export const getCSSClassStyles = (() => { 24 | const cachedElements = new Map(); 25 | 26 | const createElementWithClasses = (classNames: string[]): HTMLDivElement => { 27 | const element = document.createElement("div"); 28 | element.style.height = "0"; 29 | element.style.width = "0"; 30 | element.style.position = "absolute"; 31 | element.style.left = "0"; 32 | element.style.top = "0"; 33 | element.classList.add(...classNames); 34 | return element; 35 | }; 36 | 37 | return (...classNames: string[]): CSSStyleDeclaration => { 38 | const key = classNames.join(" "); 39 | if (cachedElements.has(key)) return window.getComputedStyle(cachedElements.get(key)!); 40 | const element = createElementWithClasses(classNames); 41 | document.body.appendChild(element); 42 | cachedElements.set(key, element); 43 | return window.getComputedStyle(element); 44 | }; 45 | })(); 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | typecheck: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout the repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Typecheck 28 | run: npm run typecheck 29 | 30 | lint: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout the repository 34 | uses: actions/checkout@v4 35 | 36 | - name: Set up Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: lts/* 40 | 41 | - name: Install dependencies 42 | run: npm ci 43 | 44 | - name: Lint 45 | run: npm run lint 46 | 47 | test: 48 | runs-on: ubuntu-latest 49 | 50 | strategy: 51 | matrix: 52 | node-version: [18.x, 20.x, 22.x] 53 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 54 | 55 | steps: 56 | - name: Checkout the repository 57 | uses: actions/checkout@v4 58 | 59 | - name: Set up Node.js ${{ matrix.node-version }} 60 | uses: actions/setup-node@v4 61 | with: 62 | node-version: ${{ matrix.node-version }} 63 | cache: npm 64 | 65 | - name: Install dependencies 66 | run: npm ci --ignore-scripts 67 | 68 | - name: Test types 69 | run: npm run test-types 70 | 71 | - name: Test 72 | run: npm test 73 | -------------------------------------------------------------------------------- /src/i18n/t.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, describe, expect, it, vi } from "vitest"; 2 | 3 | import { t } from "./t"; 4 | 5 | vi.mock("./en.json", () => ({ default: { a: { b: { c: "foo" } } } })); 6 | 7 | describe("t", () => { 8 | const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); 9 | 10 | afterAll(() => { 11 | warn.mockReset(); 12 | }); 13 | 14 | it("should return the correct translation", () => { 15 | expect(t("a.b.c" as never)).toBe("foo"); 16 | }); 17 | 18 | it("should warn when the translation is not found", () => { 19 | expect(t("d" as never)).toBe("d"); 20 | expect(warn).toHaveBeenCalledWith('Cannot find translation for "d": "d" not found.'); 21 | warn.mockClear(); 22 | 23 | expect(t("d.c" as never)).toBe("d.c"); 24 | expect(warn).toHaveBeenCalledWith('Cannot find translation for "d.c": "d" not found.'); 25 | warn.mockClear(); 26 | 27 | expect(t("a.d.c" as never)).toBe("a.d.c"); 28 | expect(warn).toHaveBeenCalledWith('Cannot find translation for "a.d.c": "d" not found in "a".'); 29 | warn.mockClear(); 30 | 31 | expect(t("a.b.c.d" as never)).toBe("a.b.c.d"); 32 | expect(warn).toHaveBeenCalledWith( 33 | 'Cannot find translation for "a.b.c.d": "a.b.c" is not an object.', 34 | ); 35 | warn.mockClear(); 36 | 37 | expect(t("" as never)).toBe(""); 38 | expect(warn).toHaveBeenCalledWith("Empty path is not allowed."); 39 | warn.mockClear(); 40 | 41 | expect(t("a.b.d" as never)).toBe("a.b.d"); 42 | expect(warn).toHaveBeenCalledWith( 43 | 'Cannot find translation for "a.b.d": "d" not found in "a.b".', 44 | ); 45 | warn.mockClear(); 46 | 47 | expect(t("a.b" as never)).toBe("a.b"); 48 | expect(warn).toHaveBeenCalledWith('Cannot find translation for "a.b": "a.b" is not a string.'); 49 | warn.mockClear(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/icons/nodejs.tsx: -------------------------------------------------------------------------------- 1 | export const NodejsIcon: FC<{ size?: number }> = ({ size = 24 }) => { 2 | return ( 3 | 9 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/types/tools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Construct a type with a set of readonly properties `K` of type `T`. 3 | */ 4 | export type ReadonlyRecord = { readonly [P in K]: T }; 5 | 6 | /** 7 | * Check if two types are equal. 8 | */ 9 | export type Equals = 10 | (() => G extends T ? 1 : 2) extends () => G extends U ? 1 : 2 ? true : false; 11 | 12 | /** 13 | * Tell TS to evaluate an object type immediately. Actually does nothing, but 14 | * it's useful for debugging or make type information more readable. 15 | * 16 | * Sometimes strange things happen when you try to use it with a _generic type_, 17 | * so avoid that if possible. 18 | */ 19 | export type _Id = T extends infer U ? { [K in keyof U]: U[K] } : never; 20 | 21 | export type _IdDeep = 22 | T extends infer U ? 23 | U extends object ? 24 | { [K in keyof U]: _IdDeep } 25 | : U 26 | : never; 27 | 28 | /** 29 | * Merge two object types, preferring the second type when keys overlap. 30 | * 31 | * @example 32 | * ```typescript 33 | * type A = { a: number; b: number; c?: number; d?: number; e?: number; } 34 | * type B = { b: string; c: string; d?: string; f: string; g?: string; } 35 | * type R = Merge; 36 | * // ^ { a: number; b: string; c: string; d?: string; e?: number; f: string; g?: string; } 37 | * ``` 38 | */ 39 | export type Merge = _Id< 40 | Pick> & 41 | Pick>> & 42 | Pick, keyof L>> & 43 | _SpreadProperties & keyof L> 44 | >; 45 | type _OptionalPropertyNames = { 46 | [K in keyof T]-?: NonNullable extends { [P in K]: T[K] } ? K : never; 47 | }[keyof T]; 48 | type _SpreadProperties = { 49 | [P in K]: L[P] | Exclude; 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/DropdownWithInput.scss: -------------------------------------------------------------------------------- 1 | .dropdown-with-input > input[type="text"] { 2 | padding: 0.75rem !important; 3 | box-sizing: border-box !important; 4 | border-width: 1px !important; 5 | border-style: solid !important; 6 | border-color: #edf2f7 !important; // border-gray-100 7 | border-radius: 0.375rem !important; 8 | transition: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform !important; 9 | transition-duration: 200ms !important; 10 | outline: none !important; 11 | } 12 | 13 | .dropdown-with-input.passed > input[type="text"] { 14 | box-shadow: #38a169 0 0 0 1px !important; // border-green-600 15 | border-color: #68d391 !important; // border-green-400 16 | } 17 | 18 | .dropdown-with-input.failed > input[type="text"] { 19 | box-shadow: #e53e3e 0 0 0 1px !important; // border-red-600 20 | border-color: #fc8181 !important; // border-red-400 21 | } 22 | 23 | .dropdown-with-input:not(.passed, .failed).focus > input[type="text"], 24 | .dropdown-with-input:not(.passed, .failed, .focus) > input[type="text"]:focus { 25 | box-shadow: #3182ce 0 0 0 1px !important; // border-blue-600 26 | border-color: #63b3ed !important; // border-blue-400 27 | } 28 | 29 | .dropdown-with-input > ul { 30 | position: absolute !important; 31 | top: 100% !important; 32 | left: 0 !important; 33 | right: 0 !important; 34 | padding: 0 !important; 35 | list-style: none !important; 36 | border: 1px solid #edf2f7 !important; // border-gray-100 37 | border-radius: 0.375rem !important; 38 | box-shadow: 0 4px 8px rgba(0, 0, 0, 10%) !important; 39 | max-height: 12rem !important; 40 | overflow-y: auto !important; 41 | z-index: 1000 !important; 42 | } 43 | 44 | .dropdown-with-input > ul > li { 45 | cursor: pointer !important; 46 | font-size: 0.875rem !important; 47 | transition: background 0.2s !important; 48 | } 49 | 50 | .dropdown-with-input > ul > li:first-child { 51 | padding: 0.5rem 0.75rem 0.3125rem !important; 52 | } 53 | 54 | .dropdown-with-input > ul > li:last-child { 55 | padding: 0.3125rem 0.75rem 0.5rem !important; 56 | } 57 | 58 | .dropdown-with-input > ul > li:not(:first-child, :last-child) { 59 | padding: 0.3125rem 0.75rem !important; 60 | } 61 | -------------------------------------------------------------------------------- /src/patches/promise.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import "./promise"; 4 | 5 | // Utility to simulate delays 6 | const delay = (ms: number, value?: unknown, shouldReject = false) => 7 | new Promise((resolve, reject) => 8 | // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors 9 | setTimeout(() => (shouldReject ? reject(value) : resolve(value)), ms), 10 | ); 11 | 12 | // Main tests 13 | describe("Promise.orderedFirstResolved", () => { 14 | it("should return the first resolved promise in order", async () => { 15 | const promises = [delay(100, "A"), delay(200, "B"), delay(300, "C")]; 16 | const result = await Promise.orderedFirstResolved(promises); 17 | expect(result).toBe("A"); 18 | }); 19 | 20 | it("should return the second resolved promise if the first is rejected", async () => { 21 | const promises = [delay(100, "Error A", true), delay(200, "B"), delay(300, "C")]; 22 | const result = await Promise.orderedFirstResolved(promises); 23 | expect(result).toBe("B"); 24 | }); 25 | 26 | it("should reject if all promises are rejected", async () => { 27 | const promises = [ 28 | delay(100, "Error A", true), 29 | delay(200, "Error B", true), 30 | delay(300, "Error C", true), 31 | ]; 32 | await expect(Promise.orderedFirstResolved(promises)).rejects.toThrow( 33 | "All promises were rejected", 34 | ); 35 | }); 36 | 37 | it("should reject if the iterable is empty", async () => { 38 | const promises: Promise[] = []; 39 | await expect(Promise.orderedFirstResolved(promises)).rejects.toThrow( 40 | "All promises were rejected", 41 | ); 42 | }); 43 | 44 | it("should return the first resolved value in order even if others resolve first", async () => { 45 | const promises = [delay(300, "A"), delay(100, "B"), delay(200, "C")]; 46 | const result = await Promise.orderedFirstResolved(promises); 47 | expect(result).toBe("A"); 48 | }); 49 | 50 | it("should handle mixed resolutions and rejections correctly", async () => { 51 | const promises = [ 52 | delay(300, "A"), // First resolves after 300ms 53 | delay(100, "Error B", true), // Second rejects first 54 | delay(200, "C"), // Third resolves but is irrelevant 55 | ]; 56 | const result = await Promise.orderedFirstResolved(promises); 57 | expect(result).toBe("A"); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/mac-server.ts: -------------------------------------------------------------------------------- 1 | import { fork } from "child_process"; 2 | import net from "net"; 3 | import type { ChildProcessWithoutNullStreams } from "node:child_process"; 4 | 5 | import { WebSocketServer } from "ws"; 6 | 7 | if (!process.argv[2] || !process.argv[3]) { 8 | console.log("Usage: node mac-server.cjs "); 9 | process.exit(1); 10 | } 11 | 12 | const port = Number.parseInt(process.argv[2]); 13 | if (Number.isNaN(port)) { 14 | console.log(`Invalid port "${process.argv[2]}"`); 15 | process.exit(1); 16 | } 17 | 18 | console.log("Process PID:", process.pid); 19 | 20 | const server = fork(process.argv[3], ["--stdio", "true"], { 21 | silent: true, 22 | }) as ChildProcessWithoutNullStreams; 23 | console.log("Copilot LSP server started. PID:", server.pid); 24 | 25 | const startWebSocketServer = () => { 26 | const wss = new WebSocketServer({ port }); 27 | 28 | wss.on("connection", (ws) => { 29 | console.log(`➕➕ Connection (${wss.clients.size})`); 30 | 31 | ws.once("close", () => { 32 | console.log("🚨 WebSocket Server shutting down..."); 33 | wss.close(); 34 | process.exit(0); 35 | }); 36 | 37 | ws.on("message", (data) => { 38 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 39 | const payload = data.toString("utf-8"); 40 | console.debug("📥", payload); 41 | server.stdin.write(payload); 42 | }); 43 | 44 | server.stdout.on("data", (data) => { 45 | const message: string = data.toString("utf-8"); 46 | console.debug("📤", message); 47 | ws.send(message); 48 | }); 49 | }); 50 | 51 | console.log(`✅ WebSocket Server listening on ws://localhost:${port}`); 52 | 53 | const cleanupServer = (() => { 54 | let called = false; 55 | return () => { 56 | if (called) return; 57 | called = true; 58 | console.log("🚨 WebSocket Server shutting down..."); 59 | wss.close((err) => { 60 | if (err) console.error(err); 61 | process.exit(0); 62 | }); 63 | }; 64 | })(); 65 | 66 | process.on("exit", cleanupServer); 67 | process.on("SIGINT", cleanupServer); 68 | process.on("SIGTERM", cleanupServer); 69 | process.on("SIGUSR1", cleanupServer); 70 | process.on("SIGUSR2", cleanupServer); 71 | process.on("uncaughtException", cleanupServer); 72 | }; 73 | 74 | const testServer = net.createServer(); 75 | testServer.once("error", (err) => { 76 | if ((err as unknown as { code: string }).code === "EADDRINUSE") { 77 | console.error(`🚨 Port ${port} is busy`); 78 | process.exit(1); 79 | } 80 | }); 81 | 82 | testServer.once("listening", () => { 83 | testServer.close(startWebSocketServer); 84 | }); 85 | 86 | testServer.listen(port); 87 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { mapValues } from "radash"; 2 | import { kebabCase } from "string-ts"; 3 | 4 | export type Settings = typeof defaultSettings; 5 | const defaultSettings = { 6 | /* General */ 7 | disableCompletions: false, 8 | useInlineCompletionTextInSource: true, 9 | useInlineCompletionTextInPreviewCodeBlocks: false, 10 | 11 | /* Node.js runtime */ 12 | nodePath: null as string | null, 13 | }; 14 | 15 | export const settings = (() => { 16 | const changeListeners = new Map void)[]>(); 17 | const onChange = (key: K, callback: (value: Settings[K]) => void) => { 18 | const listener = () => callback(settings[key]); 19 | if (!changeListeners.has(key)) changeListeners.set(key, []); 20 | changeListeners.get(key)?.push(listener); 21 | return () => { 22 | const listeners = changeListeners.get(key); 23 | if (!listeners) return; 24 | const index = listeners.indexOf(listener); 25 | if (index !== -1) listeners.splice(index, 1); 26 | }; 27 | }; 28 | 29 | const clear = (key: keyof Settings) => { 30 | if (localStorage.getItem(kebabCase(key)) === null) return; 31 | const changed = JSON.stringify(settings[key]) !== JSON.stringify(defaultSettings[key]); 32 | localStorage.removeItem(kebabCase(key)); 33 | if (changed) changeListeners.get(key)?.forEach((listener) => listener()); 34 | }; 35 | 36 | const localStorageKeys = mapValues(defaultSettings, (_, key) => kebabCase(key)); 37 | return new Proxy( 38 | {} as Settings & { readonly onChange: typeof onChange; readonly clear: typeof clear }, 39 | { 40 | get(_target, prop, _receiver) { 41 | if (prop === "onChange") return onChange; 42 | if (prop === "clear") return clear; 43 | if (!(prop in defaultSettings)) throw new Error(`Unknown setting: ${String(prop)}`); 44 | const unparsedValue = localStorage.getItem(localStorageKeys[prop as keyof Settings]); 45 | if (unparsedValue === null) return defaultSettings[prop as keyof Settings]; 46 | return JSON.parse(unparsedValue); 47 | }, 48 | set(_target, prop, value, _receiver) { 49 | if (prop === "onChange") return false; 50 | if (prop === "clear") return false; 51 | if (!(prop in defaultSettings)) return false; 52 | const jsonifiedValue = JSON.stringify(value); 53 | if ( 54 | jsonifiedValue === 55 | (localStorage.getItem(localStorageKeys[prop as keyof Settings]) ?? 56 | JSON.stringify(defaultSettings[prop as keyof Settings])) 57 | ) 58 | // No change 59 | return true; 60 | localStorage.setItem(localStorageKeys[prop as keyof Settings], jsonifiedValue); 61 | changeListeners.get(prop as keyof Settings)?.forEach((listener) => listener()); 62 | return true; 63 | }, 64 | }, 65 | ); 66 | })(); 67 | -------------------------------------------------------------------------------- /src/utils/diff.ts: -------------------------------------------------------------------------------- 1 | import diff from "fast-diff"; 2 | 3 | import type { Range } from "@/types/lsp"; 4 | 5 | export const computeTextChanges = ( 6 | oldStr: string, 7 | newStr: string, 8 | lastCaretPosition?: { line: number; character: number } | null, 9 | ): { range: Range; text: string }[] => { 10 | const result: { range: Range; text: string }[] = []; 11 | 12 | const diffs = diff( 13 | oldStr, 14 | newStr, 15 | lastCaretPosition ? 16 | oldStr 17 | .split(Files.useCRLF ? "\r\n" : "\n") 18 | .slice(0, lastCaretPosition.line) 19 | .reduce((acc, line) => acc + line.length + (Files.useCRLF ? 2 : 1), 0) + 20 | lastCaretPosition.character 21 | : 0, 22 | true, 23 | ).reverse(); 24 | 25 | let line = 0; 26 | let character = 0; 27 | 28 | let change: { range: Range; text: string } | null = null; 29 | 30 | let part: diff.Diff | undefined; 31 | while ((part = diffs.pop())) { 32 | const [operation, text] = part; 33 | 34 | const linesToAdd = text.split(Files.useCRLF ? "\r\n" : "\n").length - 1; 35 | 36 | switch (operation) { 37 | case diff.EQUAL: 38 | if (change !== null) { 39 | result.push(change); 40 | change = null; 41 | } 42 | line += linesToAdd; 43 | character = 44 | linesToAdd === 0 ? 45 | character + text.length 46 | : text.length - text.lastIndexOf(Files.useCRLF ? "\r\n" : "\n") - 1; 47 | break; 48 | 49 | case diff.DELETE: 50 | if (change === null) { 51 | change = { 52 | range: { 53 | start: { line, character }, 54 | end: { 55 | line: line + linesToAdd, 56 | character: 57 | linesToAdd === 0 ? 58 | character + text.length 59 | : text.length - text.lastIndexOf(Files.useCRLF ? "\r\n" : "\n") - 1, 60 | }, 61 | }, 62 | text: "", 63 | }; 64 | } else { 65 | change.range.end.line += linesToAdd; 66 | change.range.end.character = 67 | linesToAdd === 0 ? 68 | character + text.length 69 | : text.length - text.lastIndexOf(Files.useCRLF ? "\r\n" : "\n") - 1; 70 | } 71 | line += linesToAdd; 72 | character = 73 | linesToAdd === 0 ? 74 | character + text.length 75 | : text.length - text.lastIndexOf(Files.useCRLF ? "\r\n" : "\n") - 1; 76 | break; 77 | 78 | case diff.INSERT: 79 | if (change === null) { 80 | change = { 81 | range: { 82 | start: { line, character }, 83 | end: { line, character }, 84 | }, 85 | text, 86 | }; 87 | } else { 88 | change.text += text; 89 | } 90 | break; 91 | } 92 | } 93 | 94 | if (change !== null) result.push(change); 95 | 96 | return result; 97 | }; 98 | -------------------------------------------------------------------------------- /src/modules/url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This function ensures the correct decodings of percent-encoded characters as 3 | * well as ensuring a cross-platform valid absolute path string. 4 | * 5 | * ```javascript 6 | * import { fileURLToPath } from "@modules/url"; 7 | * 8 | * const __filename = fileURLToPath(import.meta.url); 9 | * 10 | * new URL("file:///C:/path/").pathname; // Incorrect: /C:/path/ 11 | * fileURLToPath("file:///C:/path/"); // Correct: C:\path\ (Windows) 12 | * 13 | * new URL("file://nas/foo.txt").pathname; // Incorrect: /foo.txt 14 | * fileURLToPath("file://nas/foo.txt"); // Correct: \\nas\foo.txt (Windows) 15 | * 16 | * new URL("file:///你好.txt").pathname; // Incorrect: /%E4%BD%A0%E5%A5%BD.txt 17 | * fileURLToPath("file:///你好.txt"); // Correct: /你好.txt (POSIX) 18 | * 19 | * new URL("file:///hello world").pathname; // Incorrect: /hello%20world 20 | * fileURLToPath("file:///hello world"); // Correct: /hello world (POSIX) 21 | * ``` 22 | * @param url The file URL string or URL object to convert to a path. 23 | * @returns The fully-resolved platform-specific Node.js file path. 24 | */ 25 | export const fileURLToPath = (url: string) => { 26 | let parsedUrl: URL; 27 | 28 | try { 29 | parsedUrl = new URL(url); 30 | } catch (error) { 31 | throw new TypeError(`Invalid URL: ${url}`); 32 | } 33 | 34 | if (parsedUrl.protocol !== "file:") { 35 | throw new TypeError(`Invalid URL protocol: ${parsedUrl.protocol}`); 36 | } 37 | 38 | let path = decodeURIComponent(parsedUrl.pathname); 39 | 40 | if (Files.isWin) { 41 | // For Windows: replace forward slashes with backslashes and remove the leading slash for local paths 42 | path = path.replace(/\//g, "\\"); 43 | 44 | // Handle local paths like "C:\path" 45 | if (path.startsWith("\\") && !parsedUrl.host) { 46 | path = path.slice(1); 47 | } 48 | 49 | // Handle network paths like "\\nas\foo.txt" 50 | if (parsedUrl.host) { 51 | path = `\\\\${parsedUrl.host}${path}`; 52 | } 53 | } else { 54 | // For POSIX: Ensure the leading slash is present 55 | if (!path.startsWith("/")) { 56 | path = `/${path}`; 57 | } 58 | } 59 | 60 | return path; 61 | }; 62 | 63 | /** 64 | * This function ensures that `path` is resolved absolutely, and that the URL 65 | * control characters are correctly encoded when converting into a File URL. 66 | * 67 | * ```javascript 68 | * import { pathToFileURL } from "@modules/url"; 69 | * 70 | * new URL("/foo#1", "file:"); // Incorrect: file:///foo#1 71 | * pathToFileURL("/foo#1"); // Correct: file:///foo%231 (POSIX) 72 | * 73 | * new URL("/some/path%.c", "file:"); // Incorrect: file:///some/path%.c 74 | * pathToFileURL("/some/path%.c"); // Correct: file:///some/path%25.c (POSIX) 75 | * ``` 76 | * @param path The path to convert to a File URL. 77 | * @returns The file URL object. 78 | */ 79 | export const pathToFileURL = (path: string) => { 80 | const url = new URL("file:///"); 81 | url.pathname = encodeURI(path.replace(/\\/g, "/")); 82 | return url; 83 | }; 84 | -------------------------------------------------------------------------------- /src/patches/jquery.ts: -------------------------------------------------------------------------------- 1 | /************************ 2 | * Custom jQuery events * 3 | ************************/ 4 | $(function () { 5 | /************* 6 | * caretMove * 7 | *************/ 8 | (() => { 9 | /** 10 | * Get the current caret position. 11 | * @returns 12 | */ 13 | const getCaretPosition = (): { node: Node; offset: number } | null => { 14 | const selection = window.getSelection(); 15 | if (selection === null) return null; 16 | if (selection.rangeCount) { 17 | const range = selection.getRangeAt(0); 18 | return { 19 | node: range.startContainer, 20 | offset: range.startOffset, 21 | }; 22 | } 23 | return null; 24 | }; 25 | 26 | const eventsToBind = [ 27 | "keypress", 28 | "keyup", 29 | "keydown", 30 | "mouseup", 31 | "mousedown", 32 | "mousemove", 33 | "touchend", 34 | "touchstart", 35 | "touchmove", 36 | "focus", 37 | "blur", 38 | "input", 39 | "paste", 40 | "cut", 41 | "copy", 42 | "select", 43 | "selectstart", 44 | "selectionchange", 45 | "drag", 46 | "dragend", 47 | "dragenter", 48 | "dragexit", 49 | "dragleave", 50 | "dragover", 51 | "dragstart", 52 | "drop", 53 | "scroll", 54 | "wheel", 55 | "animationstart", 56 | "animationend", 57 | "animationiteration", 58 | "transitionstart", 59 | "transitionend", 60 | "transitionrun", 61 | "transitioncancel", 62 | ]; 63 | 64 | $.event.special.caretMove = { 65 | setup() { 66 | let lastCaretPosition = getCaretPosition(); 67 | const onCaretMove = (event: Event) => { 68 | const selection = window.getSelection(); 69 | 70 | if (selection === null) { 71 | if (lastCaretPosition !== null) { 72 | lastCaretPosition = null; 73 | if (event.target) $(event.target).trigger("caretMove", [null]); 74 | return; 75 | } 76 | return; 77 | } 78 | 79 | if (selection.rangeCount) { 80 | const range = selection.getRangeAt(0); 81 | const caretPosition = { 82 | node: range.startContainer, 83 | offset: range.startOffset, 84 | }; 85 | 86 | if ( 87 | !lastCaretPosition || 88 | !lastCaretPosition.node.isSameNode(caretPosition.node) || 89 | caretPosition.offset !== lastCaretPosition.offset 90 | ) { 91 | lastCaretPosition = caretPosition; 92 | if (event.target) $(event.target).trigger("caretMove", [caretPosition]); 93 | } 94 | } 95 | }; 96 | 97 | $.data(this, "caretMoveHandler", onCaretMove); 98 | 99 | for (const event of eventsToBind) this.addEventListener(event, onCaretMove, true); 100 | 101 | return false; 102 | }, 103 | teardown() { 104 | const onCaretMove = $.data(this, "caretMoveHandler"); 105 | if (!onCaretMove) return false; 106 | for (const event of eventsToBind) this.removeEventListener(event, onCaretMove, true); 107 | $.removeData(this, "caretMoveHandler"); 108 | }, 109 | }; 110 | })(); 111 | }); 112 | 113 | export {}; 114 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import commonjs from "@rollup/plugin-commonjs"; 5 | import json from "@rollup/plugin-json"; 6 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 7 | import typescript from "@rollup/plugin-typescript"; 8 | import type { InputPluginOption } from "rollup"; 9 | import { defineConfig } from "rollup"; 10 | import postcss from "rollup-plugin-postcss"; 11 | 12 | const plugins = [ 13 | typescript({ 14 | tsconfig: "./tsconfig.build.json", 15 | }), 16 | nodeResolve({ 17 | extensions: [".js", ".jsx", ".ts", ".tsx"], 18 | }), 19 | json(), 20 | commonjs(), 21 | { 22 | name: "clean", 23 | transform: (code) => 24 | code 25 | .replace(/\n?^\s*\/\/ @ts-.+$/gm, "") 26 | .replace(/\n?^\s*\/\/\/ 42 | css.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$"); 43 | 44 | const themeSwitch = ` 45 | // Highlight.js theme switcher 46 | (function() { 47 | let styleElement = null; 48 | let currentTheme = null; 49 | 50 | const themes = { 51 | light: \`${escapeCSS(lightThemeCSS)}\`, 52 | dark: \`${escapeCSS(darkThemeCSS)}\` 53 | }; 54 | 55 | window.setHighlightjsTheme = function(theme) { 56 | if (!themes[theme]) { 57 | console.error('Invalid theme: ' + theme + '. Use "light" or "dark"'); 58 | return; 59 | } 60 | 61 | if (currentTheme === theme) return; 62 | currentTheme = theme; 63 | 64 | if (!styleElement) { 65 | styleElement = document.createElement('style'); 66 | styleElement.id = 'highlightjs-dynamic-theme'; 67 | document.head.appendChild(styleElement); 68 | } 69 | 70 | styleElement.textContent = themes[theme]; 71 | }; 72 | })();`; 73 | 74 | return code + themeSwitch; 75 | } 76 | return code; 77 | }, 78 | }, 79 | ] satisfies InputPluginOption; 80 | 81 | export default defineConfig([ 82 | { 83 | input: "src/index.ts", 84 | output: { 85 | file: "dist/index.js", 86 | format: "iife", 87 | }, 88 | plugins: [ 89 | ...plugins, 90 | postcss({ 91 | inject: true, 92 | // Disable Dart Sass deprecated legacy JS API warning until rollup-plugin-postcss is updated 93 | // to support modern Sass API: https://github.com/egoist/rollup-plugin-postcss/issues/463 94 | use: { 95 | sass: { 96 | silenceDeprecations: ["legacy-js-api"], 97 | }, 98 | } as never, 99 | }), 100 | ], 101 | }, 102 | { 103 | input: "src/mac-server.ts", 104 | output: { 105 | file: "dist/mac-server.cjs", 106 | format: "cjs", 107 | }, 108 | plugins, 109 | }, 110 | ]); 111 | -------------------------------------------------------------------------------- /src/utils/cli-tools.ts: -------------------------------------------------------------------------------- 1 | import { CommandError, NoFreePortError, PlatformError } from "@/errors"; 2 | import type { ReadonlyRecord } from "@/types/tools"; 3 | 4 | /** 5 | * Run a command from shell and return its output. 6 | * @param command Command to run. 7 | * @returns 8 | * @throws {CommandError} If the command fails. 9 | */ 10 | export const runCommand = (() => { 11 | if (Files.isNode) { 12 | const { exec } = window.reqnode!("child_process"); 13 | 14 | return async function runCommand(command: string, options?: { cwd?: string }): Promise { 15 | const { cwd } = options ?? {}; 16 | 17 | return new Promise((resolve, reject) => { 18 | exec(command, { cwd }, (error, stdout, stderr) => { 19 | if (error) reject(error); 20 | else if (stderr) reject(new CommandError(stderr)); 21 | else resolve(stdout); 22 | }); 23 | }); 24 | }; 25 | } 26 | 27 | if (Files.isMac) 28 | return async function runCommand(command: string, options?: { cwd?: string }): Promise { 29 | const { cwd } = options ?? {}; 30 | 31 | return new Promise((resolve, reject) => { 32 | window.bridge!.callHandler( 33 | "controller.runCommand", 34 | { args: command, ...(cwd ? { cwd } : {}) }, 35 | ([success, stdout, stderr]) => { 36 | if (success) resolve(stdout); 37 | else reject(new CommandError(stderr)); 38 | }, 39 | ); 40 | }); 41 | }; 42 | 43 | throw new PlatformError("Unsupported platform for `runCommand`"); 44 | })(); 45 | 46 | /** 47 | * Get the environment variables. 48 | * @returns 49 | */ 50 | export const getEnv: () => Promise> = (() => { 51 | if (Files.isNode) 52 | // eslint-disable-next-line @typescript-eslint/require-await 53 | return async function getEnv() { 54 | return process.env; 55 | }; 56 | 57 | if (Files.isMac) { 58 | let cache: 59 | | ReadonlyRecord 60 | | Promise> 61 | | null = null; 62 | return async function getEnv() { 63 | if (cache) return cache; 64 | cache = runCommand("printenv") 65 | .then((output) => 66 | output 67 | .trim() 68 | .split("\n") 69 | .map((line) => line.trim()) 70 | .filter(Boolean), 71 | ) 72 | .then((lines) => { 73 | const env: Record = {}; 74 | for (const line of lines) { 75 | const [key, value] = line.split("="); 76 | env[key!] = value; 77 | } 78 | return env; 79 | }) 80 | .catch(() => ({})); 81 | return cache; 82 | }; 83 | } 84 | 85 | throw new PlatformError("Unsupported platform for `getEnv`"); 86 | })(); 87 | 88 | /** 89 | * Find a free localhost port. 90 | * 91 | * **⚠️ Warning:** This function only works on macOS and Linux. 92 | * @throws {NoFreePortError} If no free port is found. 93 | * @returns 94 | */ 95 | export const findFreePort = async (startAt = 6190): Promise => { 96 | const command = /* sh */ ` 97 | for port in {${startAt}..${startAt + 100 > 65535 ? 65535 : startAt + 100}}; do 98 | nc -z localhost $port &>/dev/null || { echo $port; break; } 99 | done 100 | `; 101 | const output = await runCommand(command); 102 | const port = Number.parseInt(output.trim()); 103 | if (Number.isNaN(port)) throw new NoFreePortError("Cannot find free port"); 104 | return port; 105 | }; 106 | -------------------------------------------------------------------------------- /src/utils/stream.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse an SSE (Server-Sent Events) stream and handle messages. 3 | * @param stream The ReadableStream to read from. 4 | * @param onMessage The callback to handle each message. 5 | * @param onError The callback to handle errors for each message (optional). 6 | */ 7 | export async function parseSSEStream( 8 | stream: ReadableStream, 9 | onMessage: (data: T) => void, 10 | onError?: (error: Error, rawData?: string) => void, 11 | signal?: AbortSignal, 12 | ): Promise { 13 | const reader = stream.getReader(); 14 | const decoder = new TextDecoder(); 15 | let buffer = ""; 16 | 17 | try { 18 | if (signal?.aborted) { 19 | await reader.cancel(); 20 | throw new DOMException("Stream reading was aborted", "AbortError"); 21 | } 22 | 23 | const abortController = new AbortController(); 24 | const localSignal = abortController.signal; 25 | 26 | if (signal) { 27 | const abortListener = () => { 28 | abortController.abort(); 29 | void reader.cancel(); 30 | }; 31 | 32 | signal.addEventListener("abort", abortListener, { once: true }); 33 | 34 | localSignal.addEventListener( 35 | "abort", 36 | () => { 37 | signal.removeEventListener("abort", abortListener); 38 | }, 39 | { once: true }, 40 | ); 41 | } 42 | 43 | while (!localSignal.aborted) { 44 | if (signal?.aborted) { 45 | await reader.cancel(); 46 | break; 47 | } 48 | 49 | const { done, value } = await reader.read(); 50 | if (done) break; 51 | 52 | buffer += decoder.decode(value, { stream: true }); 53 | 54 | let processBuffer = true; 55 | while (processBuffer) { 56 | const messageEnd = buffer.indexOf("\n\n"); 57 | if (messageEnd === -1) { 58 | processBuffer = false; 59 | continue; 60 | } 61 | 62 | const message = buffer.substring(0, messageEnd).trim(); 63 | buffer = buffer.substring(messageEnd + 2); 64 | 65 | if (message && !message.includes("[DONE]")) { 66 | const dataPrefix = "data: "; 67 | if (message.startsWith(dataPrefix)) { 68 | const jsonStr = message.substring(dataPrefix.length).trim(); 69 | try { 70 | const data = JSON.parse(jsonStr) as T; 71 | onMessage(data); 72 | } catch (e) { 73 | onError?.(e instanceof Error ? e : new Error(String(e)), jsonStr); 74 | } 75 | } 76 | } 77 | } 78 | 79 | if (!signal?.aborted && buffer.trim() && buffer.trim() !== "[DONE]") { 80 | try { 81 | const data = JSON.parse(buffer.trim()) as T; 82 | onMessage(data); 83 | } catch (e) { 84 | onError?.(e instanceof Error ? e : new Error(String(e)), buffer.trim()); 85 | } 86 | } 87 | } 88 | 89 | // Process any remaining buffer content 90 | if (buffer.trim() && buffer.trim() !== "[DONE]") 91 | try { 92 | const data = JSON.parse(buffer.trim()) as T; 93 | onMessage(data); 94 | } catch (e) { 95 | onError?.(e instanceof Error ? e : new Error(String(e)), buffer.trim()); 96 | } 97 | } catch (error) { 98 | if (signal?.aborted && error instanceof DOMException && error.name === "AbortError") return; 99 | 100 | if (onError) onError(error instanceof Error ? error : new Error(String(error))); 101 | else throw error; 102 | } finally { 103 | decoder.decode(); 104 | 105 | try { 106 | await reader.cancel(); 107 | } catch (e) { 108 | // Ignore 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /bin/uninstall_linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Parse arguments -path or -p 4 | while [[ "$#" -gt 0 ]]; do 5 | case $1 in 6 | -p | --path) 7 | custom_path="$2" 8 | shift 9 | ;; 10 | -s | --silent) 11 | silent=true 12 | shift 13 | ;; 14 | *) 15 | echo "Unknown parameter passed: $1" 16 | exit 1 17 | ;; 18 | esac 19 | shift 20 | done 21 | 22 | # Possible Typora installation paths on Linux 23 | paths=( 24 | "/usr/share/typora" 25 | "/usr/local/share/typora" 26 | "/opt/typora" 27 | "/opt/Typora" 28 | "$HOME/.local/share/Typora" 29 | "$HOME/.local/share/typora" 30 | ) 31 | if [[ -n "$custom_path" ]]; then 32 | paths=("$custom_path") 33 | fi 34 | 35 | script_to_remove_after_candidates=( 36 | '' 37 | '' 38 | ) 39 | script_to_remove='' 40 | 41 | escape_for_sed() { 42 | echo "$1" | sed -E 's/[]\/$*.^|[]/\\&/g' 43 | } 44 | 45 | # Find `window.html` in Typora installation path 46 | path_found=false 47 | success=false 48 | 49 | for path in "${paths[@]}"; do 50 | window_html_path_candidates=( 51 | "$path/resources/app/window.html" 52 | "$path/resources/appsrc/window.html" 53 | "$path/resources/window.html" 54 | ) 55 | 56 | for window_html_path in "${window_html_path_candidates[@]}"; do 57 | # If found, insert script 58 | if [[ -f "$window_html_path" ]]; then 59 | path_found=true 60 | echo "Found Typora \"window.html\" at \"$window_html_path\"." 61 | content=$(cat "$window_html_path") 62 | 63 | for script_to_remove_after in "${script_to_remove_after_candidates[@]}"; do 64 | if echo "$content" | grep -qF "$script_to_remove_after"; then 65 | if echo "$content" | grep -qF "$script_to_remove"; then 66 | echo "Removing Copilot plugin script after \"$script_to_remove_after\"..." 67 | 68 | escaped_script_to_remove=$(escape_for_sed "$script_to_remove") 69 | new_content=$(echo "$content" | sed -E "s/[[:space:]]*$escaped_script_to_remove//") 70 | 71 | # Remove script 72 | echo "$new_content" >"$window_html_path" 73 | 74 | # Remove `/copilot/` directory 75 | copilot_dir=$(dirname "$window_html_path")/copilot 76 | if [[ -d "$copilot_dir" ]]; then 77 | echo "Removing Copilot plugin directory \"$copilot_dir\"..." 78 | rm -rf "$copilot_dir" 79 | fi 80 | 81 | echo "Successfully uninstalled Copilot plugin in Typora." 82 | 83 | success=true 84 | break 85 | else 86 | if ! $silent; then 87 | echo "Warning: Copilot plugin has not been installed in Typora." 88 | fi 89 | 90 | # Remove `/copilot/` directory regardless of script presence 91 | copilot_dir=$(dirname "$window_html_path")/copilot 92 | if [[ -d "$copilot_dir" ]]; then 93 | echo "Detected Copilot plugin directory but no script reference. This might be leftover from a previous installation." 94 | echo "Removing Copilot plugin directory \"$copilot_dir\"..." 95 | rm -rf "$copilot_dir" 96 | echo "Uninstallation complete." 97 | fi 98 | 99 | success=true 100 | break 101 | fi 102 | fi 103 | 104 | if $success; then break; fi 105 | done 106 | fi 107 | 108 | if $success; then break; fi 109 | done 110 | 111 | if $success; then break; fi 112 | done 113 | 114 | # If not found, prompt user to check installation path 115 | if ! $path_found; then 116 | echo "Error: Could not find Typora installation path. Please check if Typora is installed and try again." >&2 117 | elif ! $success; then 118 | echo "Error: Uninstallation failed." >&2 119 | fi 120 | -------------------------------------------------------------------------------- /src/components/DropdownWithInput.tsx: -------------------------------------------------------------------------------- 1 | import { darken, getLuminance, lighten } from "color2k"; 2 | import { useRef, useState } from "preact/hooks"; 3 | 4 | import "./DropdownWithInput.scss"; 5 | 6 | export interface DropdownWithInputProps { 7 | options: string[]; 8 | 9 | value: string; 10 | onChange: (option: string) => void; 11 | 12 | onOpenDropdown?: () => void; 13 | onCloseDropdown?: () => void; 14 | 15 | type?: "default" | "passed" | "failed"; 16 | forceFocus?: boolean; 17 | placeholder?: string; 18 | dropdownMarginTop?: string; 19 | } 20 | 21 | const DropdownWithInput: FC = ({ 22 | dropdownMarginTop = "0.375rem", 23 | forceFocus = false, 24 | onChange, 25 | onCloseDropdown, 26 | onOpenDropdown, 27 | options, 28 | placeholder, 29 | type = "default", 30 | value, 31 | }) => { 32 | const backgroundColor = window.getComputedStyle(document.body).backgroundColor; 33 | 34 | const [isOpen, setIsOpen] = useState(false); 35 | const [filteredOptions, setFilteredOptions] = useState(options); 36 | 37 | const dropdownRef = useRef(null); 38 | 39 | const handleOptionClick = (option: string) => { 40 | onChange(option); // Set the selected value 41 | setIsOpen(false); // Close the dropdown 42 | onCloseDropdown?.(); 43 | }; 44 | 45 | const handleBlur = (e: React.FocusEvent) => { 46 | if (!dropdownRef.current?.contains(e.relatedTarget as Node)) { 47 | setIsOpen(false); // Close dropdown on blur 48 | onCloseDropdown?.(); 49 | } 50 | }; 51 | 52 | const handleInputChange = (e: React.ChangeEvent) => { 53 | const inputValue = (e.target! as unknown as { value: string }).value; 54 | onChange(inputValue); 55 | setFilteredOptions( 56 | options.filter((option) => option.toLowerCase().includes(inputValue.toLowerCase())), 57 | ); // Filter options based on input 58 | setIsOpen(true); // Open dropdown if user is typing 59 | onOpenDropdown?.(); 60 | }; 61 | 62 | const handleKeyDown = (e: React.KeyboardEvent) => { 63 | if (e.key === "Enter") { 64 | setIsOpen(false); // Close dropdown when pressing Enter 65 | onCloseDropdown?.(); 66 | } 67 | }; 68 | 69 | return ( 70 |
80 | {/* Input Field */} 81 | { 89 | setIsOpen(true); // Open dropdown when input is focused 90 | onOpenDropdown?.(); 91 | setFilteredOptions(options); // Reset filtered options on focus 92 | }} 93 | /> 94 | 95 | {/* Dropdown Menu */} 96 | {isOpen && filteredOptions.length > 0 && ( 97 |
    102 | {filteredOptions.map((option, index) => ( 103 |
  • { 106 | handleOptionClick(option); 107 | }} 108 | onMouseEnter={(e) => { 109 | e.currentTarget.style.background = 110 | getLuminance(backgroundColor) > 0.5 ? 111 | darken(backgroundColor, 0.05) 112 | : lighten(backgroundColor, 0.05); 113 | }} 114 | onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}> 115 | {option} 116 |
  • 117 | ))} 118 |
119 | )} 120 |
121 | ); 122 | }; 123 | 124 | export default DropdownWithInput; 125 | -------------------------------------------------------------------------------- /bin/uninstall_macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Parse arguments -path or -p 4 | while [[ "$#" -gt 0 ]]; do 5 | case $1 in 6 | -p | --path) 7 | custom_path="$2" 8 | shift 9 | ;; 10 | -s | --silent) 11 | silent=true 12 | shift 13 | ;; 14 | *) 15 | echo "Unknown parameter passed: $1" 16 | exit 1 17 | ;; 18 | esac 19 | shift 20 | done 21 | 22 | # Possible Typora installation paths 23 | paths=( 24 | "/Applications/Typora.app" 25 | "$HOME/Applications/Typora.app" 26 | "/usr/local/bin/Typora" 27 | "/opt/Typora" 28 | ) 29 | if [[ -n "$custom_path" ]]; then 30 | paths=("$custom_path") 31 | fi 32 | 33 | script_to_remove_after_candidates=( 34 | '' 35 | '' 36 | '' 37 | '' 38 | ) 39 | script_to_remove='' 40 | 41 | escape_for_sed() { 42 | echo "$1" | sed -E 's/[]\/$*.^|[]/\\&/g' 43 | } 44 | 45 | # Find `index.html` in Typora installation path 46 | path_found=false 47 | success=false 48 | 49 | for path in "${paths[@]}"; do 50 | index_html_path_candidates=( 51 | "$path/Contents/Resources/TypeMark/index.html" 52 | "$path/Contents/Resources/app/index.html" 53 | "$path/Contents/Resources/appsrc/index.html" 54 | "$path/resources/app/index.html" 55 | "$path/resources/appsrc/index.html" 56 | "$path/resources/TypeMark/index.html" 57 | "$path/resources/index.html" 58 | ) 59 | 60 | for index_html_path in "${index_html_path_candidates[@]}"; do 61 | # If found, insert script 62 | if [[ -f "$index_html_path" ]]; then 63 | path_found=true 64 | echo "Found Typora \"index.html\" at \"$index_html_path\"." 65 | content=$(cat "$index_html_path") 66 | 67 | for script_to_remove_after in "${script_to_remove_after_candidates[@]}"; do 68 | if echo "$content" | grep -qF "$script_to_remove_after"; then 69 | if echo "$content" | grep -qF "$script_to_remove"; then 70 | echo "Removing Copilot plugin script after \"$script_to_remove_after\"..." 71 | 72 | escaped_script_to_remove=$(escape_for_sed "$script_to_remove") 73 | new_content=$(echo "$content" | sed -E "s/[[:space:]]*$escaped_script_to_remove//") 74 | 75 | # Remove script 76 | echo "$new_content" >"$index_html_path" 77 | 78 | # Remove `/copilot/` directory 79 | copilot_dir=$(dirname "$index_html_path")/copilot 80 | if [[ -d "$copilot_dir" ]]; then 81 | echo "Removing Copilot plugin directory \"$copilot_dir\"..." 82 | rm -rf "$copilot_dir" 83 | fi 84 | 85 | echo "Successfully uninstalled Copilot plugin in Typora." 86 | 87 | success=true 88 | break 89 | else 90 | if ! $silent; then 91 | echo "Warning: Copilot plugin has not been installed in Typora." 92 | fi 93 | 94 | # Remove `/copilot/` directory regardless of script presence 95 | copilot_dir=$(dirname "$index_html_path")/copilot 96 | if [[ -d "$copilot_dir" ]]; then 97 | echo "Detected Copilot plugin directory but no script reference. This might be leftover from a previous installation." 98 | echo "Removing Copilot plugin directory \"$copilot_dir\"..." 99 | rm -rf "$copilot_dir" 100 | echo "Uninstallation complete." 101 | fi 102 | 103 | success=true 104 | break 105 | fi 106 | fi 107 | 108 | if $success; then break; fi 109 | done 110 | fi 111 | 112 | if $success; then break; fi 113 | done 114 | 115 | if $success; then break; fi 116 | done 117 | 118 | # If not found, prompt user to check installation path 119 | if ! $path_found; then 120 | echo "Error: Could not find Typora installation path. Please check if Typora is installed and try again." >&2 121 | elif ! $success; then 122 | echo "Error: Uninstallation failed." >&2 123 | fi 124 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @typedef {object} Parsed 5 | * @property {?string} emoji The emoji at the beginning of the commit message. 6 | * @property {?string} type The type of the commit message. 7 | * @property {?string} scope The scope of the commit message. 8 | * @property {?string} subject The subject of the commit message. 9 | */ 10 | 11 | const emojiEnum = /** @type {const} */ ([ 12 | 2, 13 | "always", 14 | { 15 | "🎉": ["init", "Project initialization"], 16 | "✨": ["feat", "Adding new features"], 17 | "🐞": ["fix", "Fixing bugs"], 18 | "📃": ["docs", "Modify documentation only"], 19 | "🌈": [ 20 | "style", 21 | "Only the spaces, formatting indentation, commas, etc. were changed, not the code logic", 22 | ], 23 | "🦄": ["refactor", "Code refactoring, no new features added or bugs fixed"], 24 | "🎈": ["perf", "Optimization-related, such as improving performance, experience"], 25 | "🧪": ["test", "Adding or modifying test cases"], 26 | "🔧": [ 27 | "build", 28 | "Dependency-related content, such as Webpack, Vite, Rollup, npm, package.json, etc.", 29 | ], 30 | "🐎": ["ci", "CI configuration related, e.g. changes to k8s, docker configuration files"], 31 | "🐳": ["chore", "Other modifications, e.g. modify the configuration file"], 32 | "↩": ["revert", "Rollback to previous version"], 33 | }, 34 | ]); 35 | 36 | /** @satisfies {import("@commitlint/types").UserConfig} */ 37 | const config = { 38 | parserPreset: { 39 | parserOpts: { 40 | headerPattern: 41 | /^(?\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]) (?\w+)(?:\((?.*)\))?!?: (?(?:(?!#).)*(?:(?!\s).))$/, 42 | headerCorrespondence: ["emoji", "type", "scope", "subject"], 43 | }, 44 | }, 45 | plugins: [ 46 | { 47 | rules: { 48 | "header-match-git-commit-message-with-emoji-pattern": (parsed) => { 49 | const { emoji, scope, subject, type } = /** @type {Parsed} */ ( 50 | /** @type {unknown} */ (parsed) 51 | ); 52 | if (emoji === null && type === null && scope === null && subject === null) 53 | return [ 54 | false, 55 | 'header must be in format " (?): ", e.g:\n' + 56 | " - 🎉 init: Initial commit\n" + 57 | " - ✨ feat(assertions): Add assertions\n" + 58 | " ", 59 | ]; 60 | return [true, ""]; 61 | }, 62 | "emoji-enum": (parsed, _, value) => { 63 | const { emoji } = /** @type {Parsed} */ (/** @type {unknown} */ (parsed)); 64 | const emojisObject = /** @type {typeof emojiEnum[2]} */ (/** @type {unknown} */ (value)); 65 | if (emoji && !Object.keys(emojisObject).includes(emoji)) { 66 | return [ 67 | false, 68 | "emoji must be one of:\n" + 69 | Object.entries(emojisObject) 70 | .map(([emoji, [type, description]]) => ` ${emoji} ${type} - ${description}`) 71 | .join("\n") + 72 | "\n ", 73 | ]; 74 | } 75 | return [true, ""]; 76 | }, 77 | }, 78 | }, 79 | ], 80 | rules: { 81 | "header-match-git-commit-message-with-emoji-pattern": [2, "always"], 82 | "body-leading-blank": [2, "always"], 83 | "footer-leading-blank": [2, "always"], 84 | "header-max-length": [2, "always", 72], 85 | "scope-case": [2, "always", ["lower-case", "upper-case"]], 86 | "subject-case": [2, "always", "sentence-case"], 87 | "subject-empty": [2, "never"], 88 | "subject-exclamation-mark": [2, "never"], 89 | "subject-full-stop": [2, "never", "."], 90 | "emoji-enum": emojiEnum, 91 | "type-case": [2, "always", "lower-case"], 92 | "type-empty": [2, "never"], 93 | "type-enum": [ 94 | 2, 95 | "always", 96 | [ 97 | "init", 98 | "feat", 99 | "fix", 100 | "docs", 101 | "style", 102 | "refactor", 103 | "perf", 104 | "test", 105 | "build", 106 | "ci", 107 | "chore", 108 | "revert", 109 | ], 110 | ], 111 | }, 112 | }; 113 | 114 | export default config; 115 | -------------------------------------------------------------------------------- /src/utils/tools.ts: -------------------------------------------------------------------------------- 1 | import type { EOL, Range } from "@/types/lsp"; 2 | import type { ReadonlyRecord } from "@/types/tools"; 3 | 4 | /** 5 | * Assert that the value is never (i.e., this statement should never be reached). 6 | * @param value The value to assert. 7 | */ 8 | export const assertNever = (value: never): never => { 9 | throw new Error(`Unexpected value: ${JSON.stringify(value)}`); 10 | }; 11 | 12 | /** 13 | * Omit keys from an object 14 | * @param obj The object to omit keys from. 15 | * @param keys The keys to omit. 16 | * @returns 17 | * 18 | * @example 19 | * ```javascript 20 | * omit({ a: 1, b: 2, c: 3 }, "a"); // => { b: 2, c: 3 } 21 | * omit({ a: 1, b: 2, c: 3 }, "a", "c"); // => { b: 2 } 22 | * ``` 23 | */ 24 | export const omit = , KS extends (keyof O)[]>( 25 | obj: O, 26 | ...keys: KS 27 | ): Omit extends infer U ? { [K in keyof U]: U[K] } : never => { 28 | const result: Record = {}; 29 | for (const key in obj) if (!keys.includes(key)) result[key] = obj[key]; 30 | return result as never; 31 | }; 32 | 33 | /** 34 | * A stricter version of {@linkcode Object.keys} that makes TS happy. 35 | * @param obj The object to get keys from. 36 | * @returns 37 | */ 38 | export const keysOf = (obj: O): readonly `${keyof O & (string | number)}`[] => 39 | Object.keys(obj) as readonly `${keyof O & (string | number)}`[]; 40 | 41 | /** 42 | * A stricter version of {@linkcode Object.values} that makes TS happy. 43 | * @param obj The object to get values from. 44 | * @returns 45 | */ 46 | export const valuesOf = (obj: O): readonly O[keyof O][] => 47 | Object.values(obj) as readonly O[keyof O][]; 48 | 49 | /** 50 | * A stricter version of {@linkcode Object.entries} that makes TS happy. 51 | * @param obj The object to get entries from. 52 | * @returns 53 | */ 54 | export const entriesOf = ( 55 | obj: O, 56 | ): O extends O ? readonly (readonly [`${keyof O & (string | number)}`, O[keyof O]])[] : never => 57 | Object.entries(obj) as unknown as O extends O ? 58 | readonly (readonly [`${keyof O & (string | number)}`, O[keyof O]])[] 59 | : never; 60 | 61 | export const isKeyOf = (obj: O, key: PropertyKey): key is keyof O => key in obj; 62 | 63 | /** 64 | * Get a global variable. 65 | * @param name The name of the global variable. 66 | * @returns The value of the global variable. 67 | */ 68 | export const getGlobalVar = )>( 69 | name: K, 70 | ): K extends keyof typeof globalThis ? (typeof globalThis)[K] : unknown => { 71 | try { 72 | return global[name as keyof typeof globalThis]; 73 | } catch { 74 | return globalThis[name as keyof typeof globalThis]; 75 | } 76 | }; 77 | /** 78 | * Set a global variable. 79 | * @param name The name of the global variable. 80 | * @param value The value of the global variable to set. 81 | */ 82 | export const setGlobalVar = )>( 83 | name: K, 84 | value: K extends keyof typeof globalThis ? (typeof globalThis)[K] : unknown, 85 | ) => { 86 | try { 87 | Object.defineProperty(global, name, { value }); 88 | } catch { 89 | Object.defineProperty(globalThis, name, { value }); 90 | } 91 | }; 92 | 93 | /** 94 | * Replace `text` in `range` with `newText`. 95 | * @param text The text to replace. 96 | * @param range The range to replace. 97 | * @param newText The new text. 98 | * @param eol The end of line character. Defaults to `"\n"`. 99 | * @returns 100 | */ 101 | export const replaceTextByRange = ( 102 | text: string, 103 | range: Range, 104 | newText: string, 105 | eol: EOL = "\n", 106 | ) => { 107 | const { end, start } = range; 108 | const lines = text.split(eol); 109 | const startLine = lines[start.line]!; 110 | 111 | if (start.line === end.line) 112 | return [ 113 | ...lines.slice(0, start.line), 114 | startLine.slice(0, start.character) + newText + startLine.slice(end.character), 115 | ...lines.slice(end.line + 1), 116 | ].join(eol); 117 | 118 | const endLine = lines[end.line]!; 119 | return [ 120 | ...lines.slice(0, start.line), 121 | startLine.slice(0, start.character) + newText, 122 | ...lines.slice(start.line + 1, end.line), 123 | endLine.slice(end.character), 124 | ].join(eol); 125 | }; 126 | -------------------------------------------------------------------------------- /bin/uninstall_windows.ps1: -------------------------------------------------------------------------------- 1 | # Allow custom path (-Path or -p) and silence warning (-Silent or -s) 2 | param ( 3 | [Parameter(Mandatory = $false)] 4 | [Alias('p')] 5 | [string] $Path = '', 6 | 7 | [Parameter(Mandatory = $false)] 8 | [Alias('s')] 9 | [switch] $Silent = $false 10 | ) 11 | 12 | # Possible Typora installation paths 13 | $paths = @( 14 | 'C:\Program Files\Typora' 15 | 'C:\Program Files (x86)\Typora' 16 | "$env:LOCALAPPDATA\Programs\Typora" 17 | ) 18 | if ($Path -ne '') { $paths = @($Path) } 19 | 20 | $scriptToRemoveAfterCandidates = @( 21 | '' 22 | '' 23 | ) 24 | $scriptToRemove = '' 25 | 26 | # Find `window.html` in Typora installation path 27 | $pathFound = $false 28 | $success = $false 29 | 30 | foreach ($path in $paths) { 31 | $windowHtmlPathCandiates = @( 32 | Join-Path -Path $path -ChildPath 'resources\app\window.html' 33 | Join-Path -Path $path -ChildPath 'resources\appsrc\window.html' 34 | Join-Path -Path $path -ChildPath 'resources\window.html' 35 | ) 36 | 37 | foreach ($windowHtmlPath in $windowHtmlPathCandiates) { 38 | # If found, remove script 39 | if (Test-Path $windowHtmlPath) { 40 | $pathFound = $true 41 | Write-Host "Found Typora ""window.html"" at ""$windowHtmlPath""." 42 | $content = Get-Content $windowHtmlPath -Raw -Encoding UTF8 43 | 44 | foreach ($scriptToRemoveAfter in $scriptToRemoveAfterCandidates) { 45 | if ($content.Contains($scriptToRemoveAfter)) { 46 | if ($content.Contains($scriptToRemove)) { 47 | Write-Host "Removing Copilot plugin script after ""$scriptToRemoveAfter""..." 48 | 49 | # Calculate indent of the script to remove 50 | $row = $content.Split("`n") | Where-Object { $_ -match $scriptToRemove } 51 | $rowContentBeforeScriptToRemove = $row -replace "$scriptToRemoveAfter(.*)", '' 52 | $indent = $rowContentBeforeScriptToRemove -replace $rowContentBeforeScriptToRemove.TrimEnd(), '' 53 | 54 | # Remove script 55 | $newContent = $content -replace ($indent + $scriptToRemove), '' 56 | Set-Content -Path $windowHtmlPath -Value $newContent -Encoding UTF8 57 | 58 | # Remove `\copilot\` directory 59 | $copilotPath = Join-Path -Path (Split-Path -Path $windowHtmlPath -Parent) -ChildPath 'copilot' 60 | if (Test-Path $copilotPath) { 61 | Write-Host "Removing Copilot plugin directory ""$copilotPath""..." 62 | Remove-Item -Path $copilotPath -Recurse -Force 63 | } 64 | 65 | Write-Host "Successfully uninstalled Copilot plugin in Typora." 66 | 67 | $success = $true 68 | break 69 | } 70 | else { 71 | if (-not $Silent) { 72 | Write-Warning "Copilot plugin script has not been found in Typora." 73 | } 74 | 75 | # Remove `\copilot\` directory regardless of script presence 76 | $copilotPath = Join-Path -Path (Split-Path -Path $windowHtmlPath -Parent) -ChildPath 'copilot' 77 | if (Test-Path $copilotPath) { 78 | Write-Host "Detected Copilot plugin directory but no script reference. This might be leftover from a previous installation." 79 | Write-Host "Removing Copilot plugin directory ""$copilotPath""..." 80 | Remove-Item -Path $copilotPath -Recurse -Force 81 | Write-Host "Uninstallation complete." 82 | $success = $true 83 | } 84 | 85 | $success = $true 86 | break 87 | } 88 | } 89 | 90 | if ($success) { break } 91 | } 92 | } 93 | 94 | if ($success) { break } 95 | } 96 | } 97 | 98 | # If not found, prompt user to check installation path 99 | if (-not $pathFound) { 100 | Write-Error "Could not find Typora installation path. Please check if Typora is installed and try again." 101 | } 102 | elseif (-not $success) { 103 | Write-Error "Uninstallation failed." 104 | } 105 | -------------------------------------------------------------------------------- /bin/install_linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Parse arguments -path or -p 4 | while [[ "$#" -gt 0 ]]; do 5 | case $1 in 6 | -p | --path) 7 | custom_path="$2" 8 | shift 9 | ;; 10 | *) 11 | echo "Unknown parameter passed: $1" 12 | exit 1 13 | ;; 14 | esac 15 | shift 16 | done 17 | 18 | # Possible Typora installation paths 19 | paths=( 20 | "/usr/share/typora" 21 | "/usr/local/share/typora" 22 | "/opt/typora" 23 | "/opt/Typora" 24 | "$HOME/.local/share/Typora" 25 | "$HOME/.local/share/typora" 26 | ) 27 | if [[ -n "$custom_path" ]]; then 28 | paths=("$custom_path") 29 | fi 30 | 31 | script_to_insert_after_candidates=( 32 | '' 33 | '' 34 | ) 35 | script_to_insert='' 36 | 37 | escape_for_sed() { 38 | echo "$1" | sed -E 's/[]\/$*.^|[]/\\&/g' 39 | } 40 | 41 | # Find `window.html` in Typora installation path 42 | path_found=false 43 | success=false 44 | 45 | for path in "${paths[@]}"; do 46 | window_html_path_candidates=( 47 | "$path/resources/app/window.html" 48 | "$path/resources/appsrc/window.html" 49 | "$path/resources/window.html" 50 | ) 51 | 52 | for window_html_path in "${window_html_path_candidates[@]}"; do 53 | # If found, insert script 54 | if [[ -f "$window_html_path" ]]; then 55 | path_found=true 56 | echo "Installation directory: \"$(dirname "$window_html_path")/copilot/\"" 57 | echo "Found Typora \"index.html\" at \"$window_html_path\"." 58 | content=$(cat "$window_html_path") 59 | 60 | if [[ "$content" != *"$script_to_insert"* ]]; then 61 | echo 'Installing Copilot plugin in Typora...' 62 | for script_to_insert_after in "${script_to_insert_after_candidates[@]}"; do 63 | if echo "$content" | grep -qF "$script_to_insert_after"; then 64 | echo "Inserting Copilot plugin script after \"$script_to_insert_after\"..." 65 | 66 | # Calculate indent of the script to insert 67 | escaped_script_to_insert_after=$(escape_for_sed "$script_to_insert_after") 68 | escaped_script_to_insert=$(escape_for_sed "$script_to_insert") 69 | indent=$(echo "$content" | while IFS= read -r line; do 70 | if [[ "$line" == *"$script_to_insert_after"* ]]; then 71 | echo "$line" | sed -E 's/^([[:space:]]*).*/\1/' 72 | break 73 | fi 74 | done) 75 | if [[ -z "$indent" ]]; then 76 | replacement="$escaped_script_to_insert_after$escaped_script_to_insert" 77 | else 78 | replacement="$escaped_script_to_insert_after\n$indent$escaped_script_to_insert" 79 | fi 80 | new_content=$(echo "$content" | sed "s|$escaped_script_to_insert_after|$replacement|") 81 | 82 | # Insert script 83 | echo "$new_content" >"$window_html_path" 84 | 85 | # Copy `/../` to `/copilot/` directory 86 | copilot_path=$(dirname "$window_html_path")/copilot 87 | if [[ ! -d "$copilot_path" ]]; then 88 | echo "Copying Copilot plugin files to \"$copilot_path\"..." 89 | mkdir -p "$copilot_path" 90 | cp -r "$(dirname "$0")/../" "$copilot_path" 91 | fi 92 | 93 | echo "Successfully installed Copilot plugin in Typora." 94 | 95 | success=true 96 | break 97 | fi 98 | done 99 | 100 | if $success; then break; fi 101 | else 102 | # Script tag already present; validate installation integrity 103 | copilot_path="$(dirname "$window_html_path")/copilot" 104 | index_js_path="$copilot_path/index.js" 105 | if [[ ! -f "$index_js_path" ]]; then 106 | echo "Warning: Corrupted Copilot installation detected. Expected \"$index_js_path\" but it does not exist." 107 | echo "Please delete the entire \"$copilot_path\" directory and re-run this installer." 108 | echo "Do NOT place the release inside Typora's installation folder (especially not under \"copilot\")." 109 | echo "Download/extract it to any other folder and run this script from there." 110 | break 111 | fi 112 | 113 | echo "Warning: Copilot plugin has already been installed in Typora." 114 | success=true 115 | break 116 | fi 117 | fi 118 | done 119 | 120 | if $success; then break; fi 121 | done 122 | 123 | # If not found, prompt user to check installation path 124 | if ! $path_found; then 125 | echo "Error: Could not find Typora installation path. Please check if Typora is installed and try again." >&2 126 | elif ! $success; then 127 | echo "Error: Installation failed." >&2 128 | fi 129 | -------------------------------------------------------------------------------- /src/completion.ts: -------------------------------------------------------------------------------- 1 | import * as path from "@modules/path"; 2 | 3 | import type { Completion, CompletionResult, CopilotClient } from "./client"; 4 | import type { ResponsePromise } from "./client/general-client"; 5 | import { logger } from "./logging"; 6 | import type { Position } from "./types/lsp"; 7 | import { Observable } from "./utils/observable"; 8 | 9 | /** 10 | * Options for {@link CompletionTaskManager}. 11 | */ 12 | export interface CompletionTaskManagerOptions { 13 | workspaceFolder: string; 14 | activeFilePathname: string; 15 | } 16 | 17 | /** 18 | * A manager for GitHub Copilot completion tasks that makes sure exactly one completion task is 19 | * active at a time. 20 | */ 21 | export default class CompletionTaskManager { 22 | public workspaceFolder: string; 23 | public activeFilePathname: string; 24 | 25 | private _state: "idle" | "requesting" | "pending" = "idle"; 26 | 27 | private activeRequest: 28 | | (ResponsePromise & { cleanup?: Observable<"accepted" | "rejected"> }) 29 | | null = null; 30 | 31 | constructor( 32 | private copilot: CopilotClient, 33 | options: CompletionTaskManagerOptions, 34 | ) { 35 | this.workspaceFolder = options.workspaceFolder; 36 | this.activeFilePathname = options.activeFilePathname; 37 | } 38 | 39 | get state(): "idle" | "requesting" | "pending" { 40 | return this._state; 41 | } 42 | 43 | rejectCurrentIfExist(): void { 44 | if (this.activeRequest) { 45 | if (this.activeRequest.status === "pending") this.activeRequest.cancel(); 46 | this.activeRequest.cleanup?.next("rejected"); 47 | this.activeRequest = null; 48 | } 49 | this._state = "idle"; 50 | if (this.copilot.status === "InProgress") this.copilot.status = "Normal"; 51 | } 52 | 53 | start( 54 | position: Position, 55 | { 56 | onCompletion, 57 | }: { 58 | /** 59 | * Callback invoked when a task completion is received. 60 | * 61 | * This can optionally return an {@linkcode Observable} representing a cleanup action. 62 | * The observable will be: 63 | * - Subscribed to initially for internal cleanup. 64 | * - Triggered later by the class itself when a new task starts, or by external triggers 65 | * (e.g., the user manually invoking `.next()` for cleanup). 66 | */ 67 | onCompletion?: (completion: Completion) => Observable<"accepted" | "rejected"> | void; 68 | }, 69 | ): void { 70 | if (this.activeRequest) { 71 | if (this.activeRequest.status === "pending") this.activeRequest.cancel(); 72 | this.activeRequest.cleanup?.next("rejected"); 73 | } 74 | 75 | this._state = "requesting"; 76 | 77 | const request = this.copilot.request.getCompletions({ 78 | position, 79 | languageId: "markdown", 80 | path: this.activeFilePathname, 81 | relativePath: 82 | this.workspaceFolder ? 83 | path.relative(this.workspaceFolder, this.activeFilePathname) 84 | : this.activeFilePathname, 85 | }); 86 | this.activeRequest = request; 87 | 88 | request 89 | .then(({ cancellationReason, completions }): void => { 90 | if (this.activeRequest !== request) { 91 | // The request has been cancelled or a new task has started since this task was started, 92 | // so we should ignore this task's completion 93 | return; 94 | } 95 | 96 | if (cancellationReason || completions.length === 0) { 97 | if (this.copilot.status === "InProgress") this.copilot.status = "Normal"; 98 | this._state = "idle"; 99 | return; 100 | } 101 | 102 | this._state = "pending"; 103 | 104 | const completion = completions[0]!; 105 | 106 | const cleanup = onCompletion?.(completion) ?? new Observable<"accepted" | "rejected">(); 107 | cleanup.subscribeOnce((acceptedOrRejected) => { 108 | if (acceptedOrRejected === "accepted") { 109 | this.copilot.notification.notifyAccepted({ uuid: completion.uuid }); 110 | logger.debug("Accepted completion"); 111 | } else { 112 | this.copilot.notification.notifyRejected({ uuids: [completion.uuid] }); 113 | logger.debug("Rejected completion", completion.uuid); 114 | } 115 | this._state = "idle"; 116 | if (this.activeRequest === request) this.activeRequest = null; 117 | }); 118 | this.activeRequest.cleanup = cleanup; 119 | }) 120 | .catch(() => { 121 | if (this.activeRequest !== request) { 122 | // The request has been cancelled or a new task has started since this task was started, 123 | // so we should ignore this task's completion 124 | return; 125 | } 126 | 127 | this._state = "idle"; 128 | this.activeRequest = null; 129 | }); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/patches/promise.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface PromiseConstructor { 3 | /** 4 | * Get the first resolved promise in order. 5 | * 6 | * Suppose we have `[, , ]`, when it turns to 7 | * `[, , ]`, we can directly return the resolved value since it's 8 | * the first one. However, when it turns to `[, , ]`, we have to 9 | * wait for the first one to settle, if it finally turns to 10 | * `[, , ]`, we can directly return the resolved value (the 2nd), 11 | * or if it turns to `[, , ]`, we should return the first one. 12 | * @param values An array or iterable of Promises. 13 | * @returns A new Promise. 14 | */ 15 | orderedFirstResolved(values: T): Promise>; 16 | /** 17 | * Get the first resolved promise in order. 18 | * 19 | * Suppose we have `[, , ]`, when it turns to 20 | * `[, , ]`, we can directly return the resolved value since it's 21 | * the first one. However, when it turns to `[, , ]`, we have to 22 | * wait for the first one to settle, if it finally turns to 23 | * `[, , ]`, we can directly return the resolved value (the 2nd), 24 | * or if it turns to `[, , ]`, we should return the first one. 25 | * @param values An array or iterable of Promises. 26 | * @returns A new Promise. 27 | */ 28 | orderedFirstResolved(values: Iterable>): Promise>; 29 | } 30 | } 31 | 32 | Promise.orderedFirstResolved = function orderedFirstResolved( 33 | values: Iterable>, 34 | ): Promise { 35 | const states: ("pending" | "resolved" | "rejected")[] = []; 36 | const resolvedValues: T[] = []; 37 | const errors: unknown[] = []; 38 | let rejectedPointer = 0; 39 | 40 | return new Promise((resolve, reject) => { 41 | const tryResolve = () => { 42 | while (states[rejectedPointer] === "rejected") 43 | if (++rejectedPointer === states.length) { 44 | const message = "All promises were rejected"; 45 | reject( 46 | "AggregateError" in window ? 47 | (new (window.AggregateError as any)(errors, message) as Error) 48 | : new Error(message), 49 | ); 50 | return; 51 | } 52 | if (states[rejectedPointer] === "resolved") resolve(resolvedValues[rejectedPointer]!); 53 | }; 54 | 55 | let i = 0; 56 | for (const value of values) { 57 | const index = i++; 58 | states.push("pending"); 59 | 60 | Promise.resolve(value).then( 61 | (resolvedValue) => { 62 | states[index] = "resolved"; 63 | resolvedValues[index] = resolvedValue; 64 | tryResolve(); 65 | }, 66 | () => { 67 | states[index] = "rejected"; 68 | errors[index] = new Error("Promise rejected"); 69 | tryResolve(); 70 | }, 71 | ); 72 | } 73 | 74 | if (i === 0) { 75 | const message = "All promises were rejected"; 76 | reject( 77 | "AggregateError" in window ? 78 | (new (window.AggregateError as any)([], message) as Error) 79 | : new Error(message), 80 | ); 81 | } 82 | }); 83 | }; 84 | 85 | declare global { 86 | interface PromiseConstructor { 87 | /** 88 | * Defer an operation to the next tick. Using `process.nextTick` if available, otherwise 89 | * `Promise.resolve().then`. 90 | * @param factory A callback used to initialize the promise. 91 | * @returns A new Promise. 92 | */ 93 | defer(factory: () => T): Promise>; 94 | /** 95 | * Defer an operation to the next tick. Using `process.nextTick` if available, otherwise 96 | * `Promise.resolve().then`. 97 | * @param factory A callback used to initialize the promise. 98 | * @returns A new Promise. 99 | */ 100 | defer(factory: () => T | PromiseLike): Promise>; 101 | } 102 | } 103 | 104 | Promise.defer = function defer(factory: () => T | PromiseLike): Promise { 105 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 106 | if (globalThis.process && typeof process.nextTick === "function") { 107 | const isThenable = (value: unknown): value is PromiseLike => 108 | value !== null && 109 | (typeof value === "object" || typeof value === "function") && 110 | typeof (value as PromiseLike).then === "function"; 111 | 112 | return new Promise((resolve, reject) => 113 | process.nextTick(() => { 114 | const result = factory(); 115 | if (isThenable(result)) result.then(resolve, reject); 116 | else resolve(result); 117 | }), 118 | ); 119 | } 120 | 121 | return Promise.resolve().then(factory); 122 | }; 123 | 124 | export {}; 125 | -------------------------------------------------------------------------------- /bin/install_windows.ps1: -------------------------------------------------------------------------------- 1 | # Allow custom path (-Path or -p) 2 | param ( 3 | [Parameter(Mandatory = $false)] 4 | [Alias('p')] 5 | [string] $Path = '' 6 | ) 7 | 8 | # Possible Typora installation paths 9 | $paths = @( 10 | 'C:\Program Files\Typora' 11 | 'C:\Program Files (x86)\Typora' 12 | "$env:LOCALAPPDATA\Programs\Typora" 13 | ) 14 | if ($Path -ne '') { $paths = @($Path) } 15 | 16 | $scriptToInsertAfterCandidates = @( 17 | '' 18 | '' 19 | ) 20 | $scriptToInsert = '' 21 | 22 | # Find `window.html` in Typora installation path 23 | $pathFound = $false 24 | $success = $false 25 | 26 | foreach ($path in $paths) { 27 | $windowHtmlPathCandiates = @( 28 | Join-Path -Path $path -ChildPath 'resources\app\window.html' 29 | Join-Path -Path $path -ChildPath 'resources\appsrc\window.html' 30 | Join-Path -Path $path -ChildPath 'resources\window.html' 31 | ) 32 | 33 | foreach ($windowHtmlPath in $windowHtmlPathCandiates) { 34 | # If found, insert script 35 | if (Test-Path $windowHtmlPath) { 36 | $pathFound = $true 37 | Write-Host "Installation directory: ""$(Split-Path -Path $windowHtmlPath -Parent)\copilot\""" 38 | Write-Host "Found Typora ""window.html"" at ""$windowHtmlPath""." 39 | $content = Get-Content $windowHtmlPath -Raw -Encoding UTF8 40 | 41 | if (!($content.Contains($scriptToInsert))) { 42 | Write-Host 'Installing Copilot plugin in Typora...' 43 | foreach ($scriptToInsertAfter in $scriptToInsertAfterCandidates) { 44 | if ($content.Contains($scriptToInsertAfter)) { 45 | Write-Host "Inserting Copilot plugin script after ""$scriptToInsertAfter""..." 46 | 47 | # Calculate indent of the script to insert 48 | $row = $content.Split("`n") | Where-Object { $_ -match $scriptToInsertAfter } 49 | $rowContentBeforeScriptToInsertAfter = $row -replace "$scriptToInsertAfter(.*)", '' 50 | $indent = $rowContentBeforeScriptToInsertAfter -replace $rowContentBeforeScriptToInsertAfter.TrimEnd(), '' 51 | 52 | # Insert script 53 | $newContent = $content -replace $scriptToInsertAfter, ( 54 | $scriptToInsertAfter + 55 | $(If (($rowContentBeforeScriptToInsertAfter -ne '') -and ($indent -eq '')) { '' } Else { "`n" + $indent }) + 56 | $scriptToInsert 57 | ) 58 | Set-Content -Path $windowHtmlPath -Value $newContent -Encoding UTF8 59 | 60 | # Copy `\..\` to `\copilot\` directory 61 | $copilotPath = Join-Path -Path (Split-Path -Path $windowHtmlPath -Parent) -ChildPath 'copilot' 62 | if (-not (Test-Path $copilotPath)) { 63 | Write-Host "Copying Copilot plugin files to ""$copilotPath""..." 64 | Copy-Item -Path (Join-Path -Path $PSScriptRoot -ChildPath '..\') -Destination $copilotPath -Recurse 65 | } 66 | 67 | Write-Host "Successfully installed Copilot plugin in Typora." 68 | 69 | $success = $true 70 | break 71 | } 72 | } 73 | 74 | if ($success) { break } 75 | } 76 | else { 77 | # Script tag already present; validate installation integrity 78 | $copilotPath = Join-Path -Path (Split-Path -Path $windowHtmlPath -Parent) -ChildPath 'copilot' 79 | $indexJsPath = Join-Path -Path $copilotPath -ChildPath 'index.js' 80 | if (-not (Test-Path $indexJsPath)) { 81 | Write-Warning "Corrupted Copilot installation detected. Expected ""$indexJsPath"" but it does not exist." 82 | Write-Host "Please delete the entire ""$copilotPath"" directory and re-run this installer." 83 | Write-Host "Do NOT place the release inside Typora's installation folder (especially not under ""copilot"")." 84 | Write-Host "Download/extract it to any other folder and run this script from there." 85 | break 86 | } 87 | 88 | Write-Warning "Copilot plugin has already been installed in Typora." 89 | $success = $true 90 | break 91 | } 92 | } 93 | } 94 | } 95 | 96 | # If not found, prompt user to check installation path 97 | if (-not $pathFound) { 98 | Write-Error "Could not find Typora installation path. Please check if Typora is installed and try again." 99 | } 100 | elseif (-not $success) { 101 | Write-Error "Installation failed." 102 | } 103 | -------------------------------------------------------------------------------- /bin/install_macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Parse arguments -path or -p 4 | while [[ "$#" -gt 0 ]]; do 5 | case $1 in 6 | -p | --path) 7 | custom_path="$2" 8 | shift 9 | ;; 10 | *) 11 | echo "Unknown parameter passed: $1" 12 | exit 1 13 | ;; 14 | esac 15 | shift 16 | done 17 | 18 | # Possible Typora installation paths 19 | paths=( 20 | "/Applications/Typora.app" 21 | "$HOME/Applications/Typora.app" 22 | "/usr/local/bin/Typora" 23 | "/opt/Typora" 24 | ) 25 | if [[ -n "$custom_path" ]]; then 26 | paths=("$custom_path") 27 | fi 28 | 29 | script_to_insert_after_candidates=( 30 | '' 31 | '' 32 | '' 33 | '' 34 | ) 35 | script_to_insert='' 36 | 37 | escape_for_sed() { 38 | echo "$1" | sed -E 's/[]\/$*.^|[]/\\&/g' 39 | } 40 | 41 | # Find `index.html` in Typora installation path 42 | path_found=false 43 | success=false 44 | 45 | for path in "${paths[@]}"; do 46 | index_html_path_candidates=( 47 | "$path/Contents/Resources/TypeMark/index.html" 48 | "$path/Contents/Resources/app/index.html" 49 | "$path/Contents/Resources/appsrc/index.html" 50 | "$path/resources/app/index.html" 51 | "$path/resources/appsrc/index.html" 52 | "$path/resources/TypeMark/index.html" 53 | "$path/resources/index.html" 54 | ) 55 | 56 | for index_html_path in "${index_html_path_candidates[@]}"; do 57 | # If found, insert script 58 | if [[ -f "$index_html_path" ]]; then 59 | path_found=true 60 | echo "Installation directory: \"$(dirname "$index_html_path")/copilot/\"" 61 | echo "Found Typora \"index.html\" at \"$index_html_path\"." 62 | content=$(cat "$index_html_path") 63 | 64 | if [[ "$content" != *"$script_to_insert"* ]]; then 65 | echo 'Installing Copilot plugin in Typora...' 66 | for script_to_insert_after in "${script_to_insert_after_candidates[@]}"; do 67 | if echo "$content" | grep -qF "$script_to_insert_after"; then 68 | echo "Inserting Copilot plugin script after \"$script_to_insert_after\"..." 69 | 70 | # Calculate indent of the script to insert 71 | escaped_script_to_insert_after=$(escape_for_sed "$script_to_insert_after") 72 | escaped_script_to_insert=$(escape_for_sed "$script_to_insert") 73 | indent=$(echo "$content" | while IFS= read -r line; do 74 | if [[ "$line" == *"$script_to_insert_after"* ]]; then 75 | echo "$line" | sed -E 's/^([[:space:]]*).*/\1/' 76 | break 77 | fi 78 | done) 79 | if [[ -z "$indent" ]]; then 80 | replacement="$escaped_script_to_insert_after$escaped_script_to_insert" 81 | else 82 | replacement="$escaped_script_to_insert_after\n$indent$escaped_script_to_insert" 83 | fi 84 | new_content=$(echo "$content" | sed "s|$escaped_script_to_insert_after|$replacement|") 85 | 86 | # Insert script 87 | echo "$new_content" >"$index_html_path" 88 | 89 | # Copy `/../` to `/copilot/` directory 90 | copilot_path=$(dirname "$index_html_path")/copilot 91 | if [[ ! -d "$copilot_path" ]]; then 92 | echo "Copying Copilot plugin files to \"$copilot_path\"..." 93 | mkdir -p "$copilot_path" 94 | cp -r "$(dirname "$0")/../" "$copilot_path" 95 | fi 96 | 97 | echo "Successfully installed Copilot plugin in Typora." 98 | 99 | success=true 100 | break 101 | fi 102 | done 103 | 104 | if $success; then break; fi 105 | else 106 | # Script tag already present; validate installation integrity 107 | copilot_path="$(dirname "$index_html_path")/copilot" 108 | index_js_path="$copilot_path/index.js" 109 | if [[ ! -f "$index_js_path" ]]; then 110 | echo "Warning: Corrupted Copilot installation detected. Expected \"$index_js_path\" but it does not exist." 111 | echo "Please delete the entire \"$copilot_path\" directory and re-run this installer." 112 | echo "Do NOT place the release inside Typora's installation folder (especially not under \"copilot\")." 113 | echo "Download/extract it to any other folder and run this script from there." 114 | break 115 | fi 116 | 117 | echo "Warning: Copilot plugin has already been installed in Typora." 118 | success=true 119 | break 120 | fi 121 | fi 122 | done 123 | 124 | if $success; then break; fi 125 | done 126 | 127 | # If not found, prompt user to check installation path 128 | if ! $path_found; then 129 | echo "Error: Could not find Typora installation path. Please check if Typora is installed and try again." >&2 130 | elif ! $success; then 131 | echo "Error: Installation failed." >&2 132 | fi 133 | -------------------------------------------------------------------------------- /src/i18n/t.ts: -------------------------------------------------------------------------------- 1 | import en from "./en.json"; 2 | import zhCN from "./zh-CN.json"; 3 | 4 | /** 5 | * Path of an object joined by dots. 6 | * 7 | * @example 8 | * ```typescript 9 | * type R = PathOf<{ a: { b: { c: string }, d: string } }>; 10 | * // ^?: "a.b.c" | "a.d" 11 | * ``` 12 | */ 13 | export type PathOf = keyof { 14 | [P in keyof O & (string | number) as O[P] extends string ? P 15 | : O[P] extends readonly string[] ? P 16 | : `${P}.${PathOf}`]: void; 17 | }; 18 | 19 | interface LocaleMap { 20 | [key: string]: string | readonly string[] | LocaleMap; 21 | } 22 | 23 | const isStringArray = (x: unknown): x is readonly string[] => 24 | Array.isArray(x) && x.every((v) => typeof v === "string"); 25 | 26 | class TranslationError extends Error {} 27 | 28 | /** 29 | * Get locale string by path. 30 | * @param path The path of the locale string. 31 | * @returns The locale string. 32 | * 33 | * @example 34 | * ```typescript 35 | * // en.json 36 | * {"a": {"b": {"c": "foo"}}} 37 | * 38 | * // Example 39 | * t("a.b.c"); // => "foo" 40 | * t("d"); // => "d". Warning: Cannot find translation for "d": "d" not found. 41 | * t("d.c"); // => "d.c". Warning: Cannot find translation for "d.c": "d" not found. 42 | * t("a.d.c"); // => "a.d.c". Warning: Cannot find translation for "a.d.c": "d" not found in "a". 43 | * t("a.b.c.d"); // => "a.b.c.d". Warning: Cannot find translation for "a.b.c.d": "a.b.c" is not an object. 44 | * t(""); // => "". Warning: Empty path is not allowed. 45 | * t("a.b.d"); // => "a.b.d". Warning: Cannot find translation for "a.b.d": "d" not found in "a.b". 46 | * t("a.b"); // => "a.b". Warning: Cannot find translation for "a.b": "a.b" is not a string. 47 | * ``` 48 | */ 49 | export const t = Object.assign( 50 | (path: PathOf): string => { 51 | try { 52 | return _t(path); 53 | } catch (e) { 54 | if (e instanceof TranslationError) console.warn(e.message); 55 | else console.error(e); 56 | return path; 57 | } 58 | }, 59 | { 60 | /** 61 | * Test if the path exists. 62 | * @param path The path of the locale string. 63 | * @returns 64 | */ 65 | test: (path: string): boolean => { 66 | try { 67 | _t(path); 68 | return true; 69 | } catch { 70 | return false; 71 | } 72 | }, 73 | /** 74 | * The unsafe version of {@linkcode t} without type checking. 75 | * @param path The path of the locale string. 76 | * @returns 77 | */ 78 | tran: (path: string): string => { 79 | try { 80 | return _t(path); 81 | } catch (e) { 82 | if (e instanceof TranslationError) console.warn(e.message); 83 | else console.error(e); 84 | return path; 85 | } 86 | }, 87 | }, 88 | ); 89 | const _t = (path: string): string => { 90 | const locale = 91 | window._options.userLocale || 92 | window._options.appLocale || 93 | navigator.languages[0] || 94 | navigator.language || 95 | ("userLanguage" in navigator && 96 | typeof navigator.userLanguage === "string" && 97 | navigator.userLanguage) || 98 | "en"; 99 | const keys = path.split(".").filter(Boolean); 100 | const localeMap: LocaleMap = (() => { 101 | if (locale === "en" || locale.startsWith("en-")) return en; 102 | if (locale === "zh-CN" || locale === "zh-Hans") return zhCN; 103 | return en; 104 | })(); 105 | let tmp = localeMap; 106 | 107 | const visitedKeys: string[] = []; 108 | for (const key of keys.slice(0, -1)) { 109 | const x = tmp[key]; 110 | if (x === undefined) 111 | throw new TranslationError( 112 | `Cannot find translation for "${path}": "${key}" not found` + 113 | (visitedKeys.length > 0 ? ` in "${visitedKeys.join(".")}".` : "."), 114 | ); 115 | if (typeof x === "string" || isStringArray(x)) 116 | throw new TranslationError( 117 | `Cannot find translation for "${path}": "${[...visitedKeys, key].join(".")}" is not an object.`, 118 | ); 119 | tmp = x; 120 | visitedKeys.push(key); 121 | } 122 | const lastKey = keys.at(-1); 123 | if (lastKey === undefined) throw new TranslationError("Empty path is not allowed."); 124 | const res = tmp[lastKey]; 125 | if (res === undefined) 126 | throw new TranslationError( 127 | `Cannot find translation for "${path}": "${lastKey}" not found` + 128 | (visitedKeys.length > 0 ? ` in "${visitedKeys.join(".")}".` : "."), 129 | ); 130 | if (typeof res !== "string" && !isStringArray(res)) 131 | throw new TranslationError(`Cannot find translation for "${path}": "${path}" is not a string.`); 132 | return typeof res === "string" ? res : res.join("\n"); 133 | }; 134 | 135 | /** 136 | * Get the all possible paths of an object. 137 | * @param o The object. 138 | * @returns 139 | * 140 | * @example 141 | * ```typescript 142 | * pathOf({ a: { b: { c: "foo" }, d: "bar" } }); // => ["a.b.c", "a.d"] 143 | * ``` 144 | */ 145 | export const pathOf = (o: O): PathOf[] => 146 | Object.entries(o).flatMap(([k, v]) => 147 | typeof v === "string" ? [k] : pathOf(v as never).map((x) => `${k}.${x}`), 148 | ) as unknown as PathOf[]; 149 | -------------------------------------------------------------------------------- /src/utils/lsp.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LSPAny, 3 | LSPArray, 4 | LSPObject, 5 | Message, 6 | NotificationMessage, 7 | RequestMessage, 8 | ResponseError, 9 | ResponseMessage, 10 | decimal, 11 | integer, 12 | uinteger, 13 | } from "@/types/lsp"; 14 | import { ErrorCodes } from "@/types/lsp"; 15 | 16 | /************** 17 | * Base types * 18 | **************/ 19 | /** 20 | * Check if a value is an integer. 21 | * @param value The value to check. 22 | * @returns 23 | * 24 | * @see {@link integer} 25 | */ 26 | export const isInteger = (value: unknown): value is integer => Number.isInteger(value); 27 | /** 28 | * Check if a value is an unsigned integer. 29 | * @param value The value to check. 30 | * @returns 31 | * 32 | * @see {@link uinteger} 33 | */ 34 | export const isUInteger = (value: unknown): value is uinteger => isInteger(value) && value >= 0; 35 | /** 36 | * Check if a value is a decimal. 37 | * @param value The value to check. 38 | * @returns 39 | * 40 | * @see {@link decimal} 41 | */ 42 | export const isDecimal = (value: unknown): value is decimal => 43 | typeof value === "number" && !isNaN(value); 44 | 45 | /** 46 | * Check if a value is an LSP any. 47 | * @param value The value to check. 48 | * @returns 49 | * 50 | * @see {@link LSPAny} 51 | */ 52 | export const isLSPAny = (value: unknown): value is LSPAny => 53 | typeof value === "string" || 54 | typeof value === "boolean" || 55 | value === null || 56 | isInteger(value) || 57 | isDecimal(value) || 58 | isLSPArray(value) || 59 | isLSPObject(value); 60 | 61 | /** 62 | * Check if a value is an LSP object. 63 | * @param value The value to check. 64 | * @returns 65 | * 66 | * @see {@link LSPObject} 67 | */ 68 | export const isLSPObject = (value: unknown): value is LSPObject => { 69 | if (typeof value !== "object" || value === null) return false; 70 | for (const key in value) if (!isLSPAny(value[key as keyof typeof value])) return false; 71 | return true; 72 | }; 73 | 74 | /** 75 | * Check if a value is an LSP array. 76 | * @param value The value to check. 77 | * @returns 78 | * 79 | * @see {@link LSPArray} 80 | */ 81 | export const isLSPArray = (value: unknown): value is LSPArray => 82 | Array.isArray(value) && value.every(isLSPAny); 83 | 84 | /***************** 85 | * Base Protocol * 86 | *****************/ 87 | /** 88 | * Check if a value is a message. 89 | * @param value The value to check. 90 | * @returns 91 | */ 92 | export const isMessage = (value: unknown): value is Message => { 93 | if (typeof value !== "object" || value === null) return false; 94 | return !(!("jsonrpc" in value) || typeof value.jsonrpc !== "string"); 95 | }; 96 | 97 | /** 98 | * Check if a value is a request message. 99 | * @param value The value to check. 100 | * @returns 101 | */ 102 | export const isRequestMessage = (value: unknown): value is RequestMessage => { 103 | if (!isMessage(value)) return false; 104 | if ( 105 | !("id" in value) || 106 | (!isInteger(value.id) && typeof value.id !== "string" && value.id !== null) 107 | ) 108 | return false; 109 | if (!("method" in value) || typeof value.method !== "string") return false; 110 | return !("params" in value && !isLSPArray(value.params) && !isLSPObject(value.params)); 111 | }; 112 | 113 | /** 114 | * Check if a value is a response message. 115 | * @param value The value to check. 116 | * @returns 117 | */ 118 | export const isResponseMessage = (value: unknown): value is ResponseMessage => { 119 | if (!isMessage(value)) return false; 120 | if ( 121 | !("id" in value) || 122 | (!isInteger(value.id) && typeof value.id !== "string" && value.id !== null) 123 | ) 124 | return false; 125 | if ( 126 | "result" in value && 127 | ("error" in value || 128 | (typeof value.result !== "string" && 129 | typeof value.result !== "number" && 130 | typeof value.result !== "boolean" && 131 | !isLSPArray(value.result) && 132 | !isLSPObject(value.result) && 133 | value.result !== null)) 134 | ) 135 | return false; 136 | if ("error" in value && !isResponseError(value.error)) return false; 137 | return !(!("result" in value) && !("error" in value)); 138 | }; 139 | 140 | /** 141 | * Check if a value is a response error. 142 | * @param value The value to check. 143 | * @returns 144 | */ 145 | export const isResponseError = (value: unknown): value is ResponseError => { 146 | if (typeof value !== "object" || value === null) return false; 147 | if (!("code" in value) || !isInteger(value.code)) return false; 148 | if (!("message" in value) || typeof value.message !== "string") return false; 149 | return !( 150 | "data" in value && 151 | typeof value.data !== "string" && 152 | typeof value.data !== "number" && 153 | typeof value.data !== "boolean" && 154 | !isLSPArray(value.data) && 155 | !isLSPObject(value.data) && 156 | value.data !== null 157 | ); 158 | }; 159 | 160 | export const toJSError = (error: ResponseError) => { 161 | const ErrorClass = class extends Error {}; 162 | let errorName = getErrorCodeName(error.code) ?? "UnknownError"; 163 | if (!errorName.endsWith("Error")) errorName += "Error"; 164 | Object.defineProperty(ErrorClass, "name", { 165 | value: errorName, 166 | writable: false, 167 | enumerable: false, 168 | configurable: true, 169 | }); 170 | Object.defineProperty(ErrorClass.prototype, "name", { 171 | value: errorName, 172 | writable: true, 173 | enumerable: false, 174 | configurable: true, 175 | }); 176 | return Object.assign(new ErrorClass(error.message), { code: error.code, data: error.data }); 177 | }; 178 | 179 | /** 180 | * Get the name of an error code. 181 | * @param errorCode The error code. 182 | * @returns 183 | */ 184 | export const getErrorCodeName = (errorCode: integer) => 185 | Object.entries(ErrorCodes).find(([, v]) => v === errorCode)?.[0] ?? null; 186 | 187 | /** 188 | * Check if a value is a notification message. 189 | * @param value The value to check. 190 | * @returns 191 | */ 192 | export const isNotificationMessage = (value: unknown): value is NotificationMessage => { 193 | if (!isMessage(value)) return false; 194 | if ("id" in value && value.id !== null) return false; 195 | if (!("method" in value) || typeof value.method !== "string") return false; 196 | return !("params" in value && !isLSPArray(value.params) && !isLSPObject(value.params)); 197 | }; 198 | -------------------------------------------------------------------------------- /src/components/SuggestionPanel.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "preact"; 2 | import { useEffect, useRef } from "preact/hooks"; 3 | 4 | import { t } from "@/i18n"; 5 | import { getCaretCoordinate } from "@/utils/dom"; 6 | 7 | import CopilotIcon from "./CopilotIcon"; 8 | 9 | import "./SuggestionPanel.scss"; 10 | 11 | export interface SuggestionPanelProps { 12 | x: number; 13 | y: number; 14 | mode?: NonNullable | null; 15 | text: string; 16 | textColor?: string; 17 | fontSize?: string | null; 18 | backgroundColor?: string | null; 19 | cm?: CodeMirror.Editor; 20 | } 21 | 22 | /** 23 | * Create and attach a suggestion panel to the DOM. The element is placed right below caret. 24 | * @param text The text to be displayed in the suggestion panel. 25 | * @returns A function that can be used to remove the suggestion panel from the DOM. 26 | */ 27 | export const attachSuggestionPanel = ( 28 | text: string, 29 | mode: NonNullable | null = null, 30 | options?: { backgroundColor?: string | null; fontSize?: string | null; cm?: CodeMirror.Editor }, 31 | ) => { 32 | const pos = 33 | options?.cm ? 34 | { x: options.cm.cursorCoords().left, y: options.cm.cursorCoords().top } 35 | : getCaretCoordinate(); 36 | if (!pos) return () => {}; 37 | 38 | const container = document.createElement("div"); 39 | document.body.appendChild(container); 40 | 41 | const { x, y } = pos; 42 | render( 43 | , 52 | container, 53 | ); 54 | 55 | // Adjust position when scrolling 56 | const scrollListener = () => { 57 | const pos = 58 | options?.cm ? 59 | { x: options.cm.cursorCoords().left, y: options.cm.cursorCoords().top } 60 | : getCaretCoordinate(); 61 | if (!pos) return; 62 | $(".suggestion-panel").css("top", `calc(${pos.y}px + 1.5em)`); 63 | }; 64 | if (options?.cm) options.cm.on("scroll", scrollListener); 65 | $("content").on("scroll", scrollListener); 66 | 67 | return () => { 68 | if (options?.cm) options.cm.off("scroll", scrollListener); 69 | $("content").off("scroll", scrollListener); 70 | render(null, container); 71 | container.remove(); 72 | }; 73 | }; 74 | 75 | /** 76 | * A suggestion panel that displays the text generated by Copilot. 77 | * @returns 78 | */ 79 | const SuggestionPanel: FC = ({ 80 | backgroundColor, 81 | cm, 82 | fontSize, 83 | mode, 84 | text, 85 | textColor = "gray", 86 | x, 87 | y, 88 | }) => { 89 | const containerRef = useRef(null); 90 | const codeAreaRef = useRef(null); 91 | 92 | const maxAvailableWidth = 93 | (cm ? cm.getWrapperElement() : Files.editor!.writingArea).getBoundingClientRect().width - 94 | (x - (cm ? cm.getWrapperElement() : Files.editor!.writingArea).getBoundingClientRect().left) - 95 | 30; 96 | 97 | // Calculate actual width after mount, and adjust position 98 | useEffect(() => { 99 | const actualWidth = containerRef.current!.getBoundingClientRect().width; 100 | 101 | containerRef.current!.style.left = `min(${x}px, calc(${x}px + ${maxAvailableWidth}px - ${actualWidth}px))`; 102 | 103 | // Highlight syntax using CodeMirror 104 | const cm = CodeMirror.fromTextArea(codeAreaRef.current!, { 105 | lineWrapping: true, 106 | mode: mode || "gfm", 107 | theme: mode ? "inner null-scroll" : "typora-default", 108 | maxHighlightLength: Infinity, 109 | // @ts-expect-error - Extracted from Typora. I don't really know if this prop is used, 110 | // but to be safe, I just keep it like original 111 | styleActiveLine: true, 112 | visibleSpace: true, 113 | autoCloseTags: true, 114 | resetSelectionOnContextMenu: false, 115 | lineNumbers: false, 116 | dragDrop: false, 117 | }); 118 | 119 | // Adjust cm styles 120 | const panelBackgroundColor = 121 | backgroundColor || window.getComputedStyle(document.body).backgroundColor; 122 | codeAreaRef.current!.style.backgroundColor = panelBackgroundColor; 123 | cm.getWrapperElement().style.backgroundColor = panelBackgroundColor; 124 | $(cm.getWrapperElement()).children().css("backgroundColor", panelBackgroundColor); 125 | cm.getWrapperElement().style.padding = "0"; 126 | if (fontSize) cm.getWrapperElement().style.fontSize = fontSize; 127 | if (backgroundColor) { 128 | cm.getWrapperElement().style.padding = "0.5rem"; 129 | cm.getWrapperElement().style.borderRadius = "0.375rem"; 130 | } 131 | $(cm.getWrapperElement()).find(".CodeMirror-hscrollbar").remove(); 132 | $(cm.getWrapperElement()).find(".CodeMirror-activeline-background").remove(); 133 | 134 | // Set visibility to visible 135 | containerRef.current!.style.removeProperty("visibility"); 136 | // eslint-disable-next-line react-hooks/exhaustive-deps 137 | }, []); 138 | 139 | return ( 140 |